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.
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 NULLfără default, pentru că vechiul cod n-o setează. - Adăugarea unui index:
CREATE INDEX CONCURRENTLYîn Postgres, în afara unei tranzacții. UnCREATE INDEXsimplu 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, apoiVALIDATE 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.
-- 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”.
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 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 NULLfă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. E în mare parte checklist-ul de mai sus și o Lambda.