Jede Container-Plattform wird Ihnen sagen, dass sie Zero-Downtime-Deployments beherrscht. App Runner, Fargate, Cloud Run, Kubernetes: alle starten neue Instanzen, warten, bis sie gesund sind, verlagern den Traffic und stoppen die alten. Die Anwendung ist nie down. Dieser Teil stimmt, und er ist auch der einfache Teil.
Der schwere Teil ist die Datenbank, denn die Datenbank rollt nicht. In dem Moment, in dem der neue Code Requests bedient, bedient der alte Code ebenfalls noch Requests, und beide sprechen mit demselben Schema. Braucht der neue Code eine Spalte, die der alte nicht kennt, oder braucht der alte Code eine Spalte, die der neue gerade gelöscht hat, bekommt jemand einen Fehler, und das Rolling Deploy der Plattform hat exakt null Ausfallzeit an ein System geliefert, das trotzdem kaputt ist.
Die Disziplin liegt also nicht in der Plattform. Sie liegt darin, wie Sie das Schema ändern. Das hier ist der Regelsatz, den wir befolgen, das Muster, das ihn umsetzt, und die Lambda, die ihn anwendet.
Die eine Regel, aus der alles folgt
Jede Migration muss mit dem Code kompatibel sein, der gerade läuft, und jede Codeänderung muss mit dem Schema kompatibel sein, das gerade deployt ist. Weil ein Rolling Deploy bedeutet, dass das vorige und das nächste Release minutenlang gleichzeitig gegen ein Schema laufen, muss das Schema für beide funktionieren. Das ist alles. Jede andere Regel ist diese eine, auf einen konkreten Fall angewandt.
Die Konsequenz, gegen die sich Leute sträuben: eine Änderung, die in einer Entwicklungsdatenbank ein Schritt ist, „Spalte phone in phone_number umbenennen“, sind in Produktion drei oder vier Deploys, und das Schema ist tagelang in einem Zwischenzustand. Das ist kein Zeichen, dass Sie etwas falsch machen. Es ist, was die Regel kostet, und es ist viel billiger als die Alternative.
Expand, migrate, contract
Das Muster ist alt und immer noch die ganze Antwort. Jede inkompatible Änderung wird in einen erweiternden Schritt aufgeteilt, der nur hinzufügt, eine Migration von Daten oder Code, die beide Formen nutzt, und einen kontrahierenden Schritt, der nur entfernt, mit einem Deploy zwischen jedem.
Einige Fälle ausbuchstabiert, weil das Muster leicht abzunicken und im Detail leicht falsch zu machen ist:
- Eine Spalte hinzufügen: nullable oder mit Default, in einem Schritt. Nie
NOT NULLohne Default, weil der alte Code sie nicht setzt. - Einen Index hinzufügen:
CREATE INDEX CONCURRENTLYin Postgres, außerhalb einer Transaktion. Ein schlichtesCREATE INDEXsperrt die Tabelle für Schreibzugriffe, solange es dauert, was auf einer großen Tabelle genau der Ausfall ist, den das Rolling Deploy verhindern sollte. - Irgendetwas umbenennen: die vier Schritte oben. Es gibt keine Abkürzung.
- Einen Typ ändern: neue Spalte mit dem neuen Typ hinzufügen, dual write, Backfill, umschalten, löschen. Dieselben vier Schritte.
- Eine Spalte löschen: erst nachdem ein Release ausgeliefert ist, das sie nicht referenziert, und nachdem Sie in Produktion bestätigt haben, dass nichts es tut. ORMs mit
SELECT *überraschen einen hier. - Ein Constraint hinzufügen: zuerst
NOT VALID, was es nur für neue Zeilen durchsetzt, dann in einem späteren SchrittVALIDATE CONSTRAINT, sobald der Backfill die bestehenden Zeilen konform gemacht hat.
Migrationen sind keine Backfills
Eine Migration ändert Struktur. Sie läuft in Sekunden. Ein Backfill ändert Daten. Er kann Stunden laufen. Das zweite in das erste zu stecken ist der Weg zu einem Deploy, der vierzig Minuten lang eine Sperre auf der Bestelltabelle hält, während jeder Request wartet.
Unsere Migrationen sind reines DDL, plus höchstens eine Datenänderung, die garantiert eine begrenzte Zeilenzahl berührt (eine Lookup-Tabelle, eine Config-Zeile). Alles, was „alle Zeilen einer großen Tabelle“ berührt, ist ein Backfill, und ein Backfill ist ein Job: eine Lambda oder ein Worker, der einen Batch verarbeitet, seinen Checkpoint festhält und erneut aufgerufen wird, mit Nebenläufigkeit eins. Er ist idempotent, kann gestoppt und fortgesetzt werden und läuft zwischen Deploy 2 und Deploy 3 so lange, wie er braucht, während der Dual-Write-Code neue Zeilen korrekt hält.
-- Migration 0042, läuft in Sekunden
ALTER TABLE customers ADD COLUMN phone_number text;
-- Backfill, läuft als Job in Batches von 5.000, bis er null Zeilen meldet
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);
Wo die Migration läuft: eine Lambda im Deploy
Die Migration muss gegen die Produktionsdatenbank laufen, von etwas, das sie erreichen kann (sie liegt in einem privaten Subnetz), mit Zugangsdaten, die das Schema ändern dürfen (die Rolle der Anwendung darf das absichtlich nicht), bevor der neue Code startet, aber nachdem das neue Image existiert. Das ist ein sehr spezifisches Anforderungsset, und es passt auf genau eine Sache in unserem Stack: eine Lambda im VPC, mit eigener Datenbankrolle, vom Deploy-Skript zwischen „Images gebaut“ und „Service-Rollout“ aufgerufen.
Die Lambda hat etwa achtzig Zeilen. Sie öffnet eine Verbindung mit der Migrationsrolle, nimmt pg_advisory_lock(42), damit zwei Deploys nicht um die Wette laufen, liest die Tabelle schema_migrations, wendet jede ausstehende Datei in Reihenfolge in ihrer eigenen Transaktion an (außer den CONCURRENTLY-Migrationen, die nicht in einer Transaktion laufen können und entsprechend markiert sind), hält jede fest und gibt die Sperre frei. Sie wird vom Deploy-Skript synchron aufgerufen, das nicht zum Rollout übergeht, wenn der Aufruf keinen Erfolg meldet. Ein Fünf-Minuten-Timeout ist die Durchsetzung von „Migrationen sind keine Backfills“: dauert es länger, war es ein Backfill, und der Deploy scheitert, bevor er jemandem schadet.
Dieselbe Lambda, mit demselben Code, läuft gegen die Datenbank jedes Pull Requests bei der Stack-Erstellung und gegen Staging bei jedem Merge nach main. Bis eine Migration Produktion erreicht, ist sie ein Dutzend Mal gelaufen.
Rollback, und warum wir keine „Down“-Migrationen schreiben
Ein Code-Rollback ist eine Tag-Änderung: den Service auf das vorige Image zeigen lassen, vier Minuten, fertig. Ein Schema-Rollback ist keine Tag-Änderung, und so zu tun, als wäre er es, indem man für jede Migration ein down() schreibt, erzeugt eine falsche Sicherheit, denn DROP COLUMN in einer Down-Migration zerstört die Daten, die seit der Up-Migration angekommen sind.
Also schreiben wir keine. Die Sicherheit kommt stattdessen aus der Disziplin: weil jede Migration mit dem vorigen Release kompatibel ist, ist das Zurückrollen des Codes immer sicher, und das Zurückrollen des Schemas ist nie nötig. Ist eine Migration selbst falsch, ist die Lösung eine neue Migration, die vorwärts geht. In achtzehn Monaten wollten wir genau null Mal eine Down-Migration, und wir haben Code sechsmal zurückgerollt, jedes Mal ereignislos.
Die Checkliste in unserem Pull-Request-Template
- Funktioniert diese Migration, wenn der aktuell deployte Code weiter dagegen läuft? Wenn nein, aufteilen.
- Funktioniert der Code in diesem PR gegen das aktuell deployte Schema? Wenn nein, geht die Migration zuerst raus, in einem eigenen PR.
- Irgendein
NOT NULLohne Default, eine Umbenennung, eine Typänderung, ein Drop? Dann ist es eine Expand/Contract-Sequenz: welcher Schritt ist das? - Ein Index auf einer Tabelle über einer Million Zeilen?
CONCURRENTLY, außerhalb einer Transaktion. - Eine Anweisung, die mehr Zeilen berührt, als Sie zählen können? Das ist ein Backfill; in einen Job verlagern.
- Läuft das in Produktion unter einer Minute? Im Zweifel gegen einen Staging-Snapshot stoppen.
Sechs Fragen. Sie kosten einen Schema-PR etwa zehn Minuten und haben die Vorfallskategorie beseitigt, in der die Plattform ihre Arbeit perfekt gemacht hat und die Nutzer trotzdem Fehler sahen.
Wenn Ihre Deploys zero-downtime sind, bis sie die Datenbank berühren, helfen wir Ihnen, die Disziplin aufzusetzen. Es ist im Wesentlichen die Checkliste oben und eine Lambda.