# Deploy-urile fără downtime nu sunt o funcție a platformei. Sunt o disciplină a migrărilor de schemă.

Orice platformă de containere îți va spune că face deployment-uri fără downtime. App Runner, Fargate, Cloud Run, Kubernetes: toate pornesc instanțe noi, așteaptă să fie sănătoase, mută traficul și le opresc pe cele vechi. Aplicația nu e niciodată jos. Partea asta e adevărată, și e și partea ușoară.

Partea grea e baza de date, pentru că baza de date nu face rolling. În momentul în care codul nou începe să servească request-uri, codul vechi servește în continuare request-uri, și amândouă vorbesc cu aceeași schemă. Dacă noul cod are nevoie de o coloană de care vechiul cod nu știe, sau vechiul cod are nevoie de o coloană pe care noul cod tocmai a șters-o, cineva primește o eroare, iar rolling deploy-ul platformei a livrat exact zero downtime unui sistem care e totuși stricat.

Deci disciplina nu e în platformă. E în felul în care schimbi schema. Ăsta e setul de reguli pe care le rulăm, pattern-ul care le rulează și Lambda care le aplică.

## Singura regulă din care decurge tot

**Fiecare migrare trebuie să fie compatibilă cu codul care rulează acum, și fiecare schimbare de cod trebuie să fie compatibilă cu schema desfășurată acum.** Pentru că un rolling deploy înseamnă că release-ul anterior și cel următor rulează în același timp, minute în șir, pe o singură schemă, schema trebuie să funcționeze pentru amândouă. Atât. Orice altă regulă e aceeași aplicată unui caz specific.

Consecința la care oamenii se opun: o schimbare care într-o bază de date de dezvoltare e un singur pas, „redenumește coloana `phone` în `phone_number`”, e în producție trei sau patru deploy-uri, iar schema e într-o stare intermediară zile în șir. Ăsta nu e un semn că faci ceva greșit. E ce costă regula, și e mult mai ieftin decât alternativa.

## Extinde, migrează, contractă

Pattern-ul e vechi și e în continuare tot răspunsul. Fiecare schimbare care rupe compatibilitatea e împărțită într-un pas de extindere care doar adaugă, o migrare de date sau de cod care folosește ambele forme, și un pas de contractare care doar elimină, cu un deploy între fiecare.

<div class="article-figure">
<svg viewBox="0 0 900 300" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Cronologia redenumirii unei coloane pe parcursul a patru deploy-uri, cu codul vechi și cel nou suprapunându-se la fiecare rollout. Deploy 1, extindere: adaugă phone_number, nullable; codul vechi o ignoră. Deploy 2: codul scrie ambele coloane și o citește pe cea nouă cu fallback; un job de backfill copiază phone în phone_number în loturi. Deploy 3: codul citește și scrie doar phone_number; coloana veche e încă acolo, nefolosită. Deploy 4, contractare: șterge phone; nimic n-o citește. În fiecare moment schema care rulează funcționează atât pentru release-ul anterior, cât și pentru cel următor.">
<defs><marker id="arrM" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#9aa3c7"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="11">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Redenumire phone → phone_number, cu codul vechi și nou suprapuse la fiecare rollout</text>
<line x1="20" y1="60" x2="880" y2="60" stroke="#2a3150"/>
<rect x="20" y="70" width="200" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="120" y="92" text-anchor="middle" fill="#4fffb0" font-weight="700">1 · extinde</text><text x="120" y="110" text-anchor="middle" fill="#f1f3ff">ADD COLUMN phone_number NULL</text><text x="120" y="124" text-anchor="middle" fill="#9aa3c7" font-size="10">codul vechi o ignoră · sigur sub N-1</text>
<line x1="222" y1="100" x2="238" y2="100" stroke="#9aa3c7" stroke-width="1.5" marker-end="url(#arrM)"/>
<rect x="240" y="70" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="340" y="92" text-anchor="middle" fill="#7b8cff" font-weight="700">2 · scriere dublă</text><text x="340" y="110" text-anchor="middle" fill="#f1f3ff">scrie ambele · citește nou ?? vechi</text><text x="340" y="124" text-anchor="middle" fill="#9aa3c7" font-size="10">job-ul de backfill copiază rândurile în loturi</text>
<line x1="442" y1="100" x2="458" y2="100" stroke="#9aa3c7" stroke-width="1.5" marker-end="url(#arrM)"/>
<rect x="460" y="70" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="560" y="92" text-anchor="middle" fill="#7b8cff" font-weight="700">3 · comută</text><text x="560" y="110" text-anchor="middle" fill="#f1f3ff">citește și scrie doar phone_number</text><text x="560" y="124" text-anchor="middle" fill="#9aa3c7" font-size="10">coloana veche prezentă, nefolosită</text>
<line x1="662" y1="100" x2="678" y2="100" stroke="#9aa3c7" stroke-width="1.5" marker-end="url(#arrM)"/>
<rect x="680" y="70" width="200" height="60" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="780" y="92" text-anchor="middle" fill="#ff6b8a" font-weight="700">4 · contractă</text><text x="780" y="110" text-anchor="middle" fill="#f1f3ff">DROP COLUMN phone</text><text x="780" y="124" text-anchor="middle" fill="#9aa3c7" font-size="10">nimic n-o mai citește</text>
<text x="20" y="166" fill="#9aa3c7">codul care rulează în timpul fiecărui rollout:</text>
<rect x="20" y="176" width="100" height="18" rx="3" fill="#2a3150"/><rect x="120" y="176" width="100" height="18" rx="3" fill="#4fffb0" opacity="0.6"/><text x="120" y="209" text-anchor="middle" fill="#9aa3c7" font-size="10">v1 + v2 · ambele ok cu o coloană extra nullable</text>
<rect x="240" y="176" width="100" height="18" rx="3" fill="#4fffb0" opacity="0.6"/><rect x="340" y="176" width="100" height="18" rx="3" fill="#7b8cff" opacity="0.6"/><text x="340" y="209" text-anchor="middle" fill="#9aa3c7" font-size="10">v2 + v3 · ambele scriu, ambele citesc cu fallback</text>
<rect x="460" y="176" width="100" height="18" rx="3" fill="#7b8cff" opacity="0.6"/><rect x="560" y="176" width="100" height="18" rx="3" fill="#7b8cff"/><text x="560" y="209" text-anchor="middle" fill="#9aa3c7" font-size="10">v3 + v4 · coloana veche nefolosită de niciunul</text>
<rect x="680" y="176" width="100" height="18" rx="3" fill="#7b8cff"/><rect x="780" y="176" width="100" height="18" rx="3" fill="#ff6b8a" opacity="0.6"/><text x="780" y="209" text-anchor="middle" fill="#9aa3c7" font-size="10">v4 + v5 · ștergerea e invizibilă pentru ambele</text>
<text x="450" y="250" text-anchor="middle" fill="#ffd166">Patru deploy-uri în loc de unul. Fiecare poate fi dat înapoi mutând tag-ul imaginii, pentru că schema funcționează pentru release-ul dinainte.</text>
<text x="450" y="272" text-anchor="middle" fill="#9aa3c7">Schema intermediară trăiește zile. Ăsta e prețul, și e singurul preț.</text>
</g>
</svg>
</div>

Câteva cazuri, spuse explicit, pentru că pattern-ul e ușor de aprobat din cap și ușor de greșit în detaliu:

- **Adăugarea unei coloane:** nullable sau cu default, într-un singur pas. Niciodată `NOT NULL` fără default, pentru că vechiul cod n-o setează.
- **Adăugarea unui index:** `CREATE INDEX CONCURRENTLY` în Postgres, în afara unei tranzacții. Un `CREATE INDEX` simplu blochează tabela la scriere cât durează, ceea ce pe o tabelă mare e exact pana pe care rolling deploy-ul trebuia s-o prevină.
- **Redenumirea a orice:** cei patru pași de mai sus. Nu există scurtătură.
- **Schimbarea unui tip:** adaugi o coloană nouă cu noul tip, scriere dublă, backfill, comutare, ștergere. Aceiași patru pași.
- **Ștergerea unei coloane:** doar după ce a fost livrat un release care n-o mai referențiază, și după ce ai confirmat în producție că nimic n-o face. ORM-urile care fac `SELECT *` te vor surprinde aici.
- **Adăugarea unei constrângeri:** adaug-o întâi `NOT VALID`, ceea ce o impune doar pentru rândurile noi, apoi `VALIDATE CONSTRAINT` într-un pas ulterior, odată ce backfill-ul a făcut rândurile existente conforme.

## Migrările nu sunt backfill-uri

O migrare schimbă structura. Rulează în secunde. Un backfill schimbă date. Poate rula ore. Să-l pui pe al doilea în interiorul primei e felul în care ajungi la un deploy care ține un lock pe tabela de comenzi patruzeci de minute în timp ce fiecare request așteaptă.

Migrările noastre sunt doar DDL, plus cel mult o schimbare de date garantat să atingă un număr mărginit de rânduri (o tabelă de lookup, un rând de config). Orice atinge „toate rândurile unei tabele mari” e un backfill, iar un backfill e un job: o Lambda sau un worker care procesează un lot, își înregistrează checkpoint-ul și e invocat din nou, cu o concurență de unu. E idempotent, poate fi oprit și reluat, și rulează *între* deploy-ul 2 și deploy-ul 3 cât are nevoie, în timp ce codul cu scriere dublă ține rândurile noi corecte.

```sql
-- migrarea 0042, rulează în secunde
ALTER TABLE customers ADD COLUMN phone_number text;

-- backfill, rulează ca job în loturi de 5.000 până raportează zero rânduri
UPDATE customers
   SET phone_number = phone
 WHERE id IN (SELECT id FROM customers WHERE phone_number IS NULL AND phone IS NOT NULL ORDER BY id LIMIT 5000);
```

## Unde rulează migrarea: o Lambda în deploy

Migrarea trebuie să ruleze pe baza de date de producție, de pe ceva care poate ajunge la ea (e într-un subnet privat), cu credențiale care pot modifica schema (rolul aplicației nu poate, intenționat), înainte ca noul cod să pornească, dar după ce noua imagine există. E un set foarte specific de cerințe și se mapează pe exact un lucru din stack-ul nostru: o Lambda în VPC, cu propriul rol de bază de date, invocată de scriptul de deploy între „imagini construite” și „rollout de serviciu”.

<div class="article-figure">
<svg viewBox="0 0 900 200" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Secvența de deploy cu pasul de migrare. Se construiesc imaginile, apoi se invocă sincron Lambda schema-migrate: ia un advisory lock, aplică migrările în așteptare în ordine, le înregistrează într-o tabelă de migrări, eliberează lock-ul. Doar dacă asta întoarce succes, cdk deploy rulează serviciile App Runner pe noua imagine. Apoi verificarea post-deploy. Dacă Lambda pică, deploy-ul se oprește înainte ca vreun cod nou să servească trafic.">
<defs><marker id="arrD" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="15" y="50" width="160" height="70" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="95" y="80" text-anchor="middle" fill="#f1f3ff" font-weight="700">build imagini</text><text x="95" y="100" text-anchor="middle" fill="#9aa3c7" font-size="11">codul nou există, nu rulează</text>
<line x1="177" y1="85" x2="213" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/>
<rect x="215" y="40" width="240" height="90" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="335" y="62" text-anchor="middle" fill="#ffd166" font-weight="700">Lambda schema-migrate · în VPC</text><text x="335" y="82" text-anchor="middle" fill="#f1f3ff" font-size="11">pg_advisory_lock · aplică în ordine</text><text x="335" y="100" text-anchor="middle" fill="#f1f3ff" font-size="11">înregistrează în schema_migrations · unlock</text><text x="335" y="118" text-anchor="middle" fill="#9aa3c7" font-size="11">rol DB propriu cu DDL · timeout 5 minute</text>
<line x1="457" y1="85" x2="493" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/><text x="475" y="72" text-anchor="middle" fill="#4fffb0" font-size="10">ok</text>
<rect x="495" y="50" width="180" height="70" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="585" y="80" text-anchor="middle" fill="#f1f3ff" font-weight="700">rollout</text><text x="585" y="100" text-anchor="middle" fill="#9aa3c7" font-size="11">codul vechi și nou se suprapun, schema le încape</text>
<line x1="677" y1="85" x2="713" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/>
<rect x="715" y="50" width="170" height="70" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="800" y="80" text-anchor="middle" fill="#f1f3ff" font-weight="700">verificare</text><text x="800" y="100" text-anchor="middle" fill="#9aa3c7" font-size="11">health · SHA de release</text>
<path d="M335,132 L335,160 L95,160 L95,122" fill="none" stroke="#ff6b8a" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrD)"/><text x="215" y="176" text-anchor="middle" fill="#ff6b8a" font-size="11">la eșec: stop aici · niciun cod nou n-a servit un request · repari înainte</text>
</g>
</svg>
</div>

Lambda are vreo optzeci de linii. Deschide o conexiune cu rolul de migrare, ia `pg_advisory_lock(42)` ca două deploy-uri să nu se poată întrece, citește tabela `schema_migrations`, aplică fiecare fișier în așteptare în ordine, în propria tranzacție (cu excepția celor `CONCURRENTLY`, care nu pot fi într-o tranzacție și sunt marcate ca atare), înregistrează fiecare și eliberează lock-ul. E invocată sincron de scriptul de deploy, care nu trece la rollout decât dacă invocarea întoarce succes. Un timeout de cinci minute e impunerea regulii „migrările nu sunt backfill-uri”: dacă durează mai mult, a fost un backfill, iar deploy-ul pică înainte să facă rău cuiva.

Aceeași Lambda, cu același cod, rulează pe [baza de date a fiecărui pull request](/ro/blog/preview-environments-per-pull-request-on-aws) la crearea stack-ului și pe staging la fiecare merge pe `main`. Când o migrare ajunge în producție, a rulat deja de o duzină de ori.

## Rollback, și de ce nu scriem migrări „down”

Un rollback de cod e o schimbare de tag: îndrepți serviciul spre imaginea anterioară, patru minute, gata. Un rollback de schemă nu e o schimbare de tag, iar a pretinde că e, scriind un `down()` pentru fiecare migrare, produce un fals sentiment de siguranță, pentru că `DROP COLUMN` într-o migrare down distruge datele care au sosit de când a rulat migrarea up.

Așa că nu le scriem. Siguranța vine din disciplină: pentru că fiecare migrare e compatibilă cu release-ul anterior, rollback-ul *codului* e mereu sigur, iar rollback-ul *schemei* nu e niciodată necesar. Dacă o migrare în sine e greșită, reparația e o migrare nouă care merge înainte. În optsprezece luni am vrut o migrare down de exact zero ori, și am dat codul înapoi de șase ori, fiecare fără evenimente.

## Checklist-ul pe care l-am pus în template-ul de pull request

- Migrarea asta funcționează dacă codul desfășurat acum continuă să ruleze pe ea? Dacă nu, împarte-o.
- Codul din PR-ul ăsta funcționează pe schema desfășurată acum? Dacă nu, migrarea se livrează întâi, în propriul PR.
- Vreun `NOT NULL` fără default, vreo redenumire, vreo schimbare de tip, vreo ștergere? Atunci e o secvență expand/contract: care pas e ăsta?
- Vreun index pe o tabelă cu peste un milion de rânduri? `CONCURRENTLY`, în afara unei tranzacții.
- Vreo instrucțiune care atinge mai multe rânduri decât poți număra? Ăla e backfill; mută-l într-un job.
- Va rula în sub un minut în producție? Dacă nu ești sigur, cronometreaz-o pe un snapshot de staging.

Șase întrebări. Adaugă cam zece minute la un PR de schemă și au eliminat categoria de incident în care platforma și-a făcut treaba perfect și utilizatorii au văzut erori oricum.

Dacă deploy-urile tale sunt fără downtime până ating baza de date, [te ajutăm să trasezi disciplina](/contact). E în mare parte checklist-ul de mai sus și o Lambda.
