# Zero-Downtime-Deploys sind kein Plattform-Feature. Sie sind eine Disziplin bei Schemamigrationen.

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.

<div class="article-figure">
<svg viewBox="0 0 900 300" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Zeitstrahl der Umbenennung einer Spalte über vier Deploys, wobei sich alter und neuer Code bei jedem Rollout überlappen. Deploy 1, expand: phone_number hinzufügen, nullable; alter Code ignoriert sie. Deploy 2: Code schreibt beide Spalten und liest die neue mit Fallback; ein Backfill-Job kopiert phone in Batches nach phone_number. Deploy 3: Code liest und schreibt nur phone_number; die alte Spalte ist noch da, ungenutzt. Deploy 4, contract: phone löschen; nichts liest sie. Zu jedem Zeitpunkt funktioniert das laufende Schema für das vorige und das nächste Release.">
<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">phone → phone_number umbenennen, mit überlappendem altem und neuem Code bei jedem 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 · expand</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">alter Code ignoriert sie · sicher unter 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 · dual write</text><text x="340" y="110" text-anchor="middle" fill="#f1f3ff">beide schreiben · neu ?? alt lesen</text><text x="340" y="124" text-anchor="middle" fill="#9aa3c7" font-size="10">Backfill-Job kopiert Zeilen in Batches</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 · umschalten</text><text x="560" y="110" text-anchor="middle" fill="#f1f3ff">nur phone_number lesen und schreiben</text><text x="560" y="124" text-anchor="middle" fill="#9aa3c7" font-size="10">alte Spalte vorhanden, ungenutzt</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">nichts liest sie mehr</text>
<text x="20" y="166" fill="#9aa3c7">laufender Code während jedes Rollouts:</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 · beide ok mit einer zusätzlichen nullable Spalte</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 · beide schreiben, beide lesen mit 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 · alte Spalte von keinem genutzt</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 · das Löschen ist für beide unsichtbar</text>
<text x="450" y="250" text-anchor="middle" fill="#ffd166">Vier Deploys statt einem. Jeder lässt sich per Image-Tag zurückrollen, weil das Schema für das Release davor funktioniert.</text>
<text x="450" y="272" text-anchor="middle" fill="#9aa3c7">Das Zwischenschema lebt Tage. Das ist der Preis, und der einzige.</text>
</g>
</svg>
</div>

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 NULL` ohne Default, weil der alte Code sie nicht setzt.
- **Einen Index hinzufügen:** `CREATE INDEX CONCURRENTLY` in Postgres, außerhalb einer Transaktion. Ein schlichtes `CREATE INDEX` sperrt 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 Schritt `VALIDATE 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.

```sql
-- 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.

<div class="article-figure">
<svg viewBox="0 0 900 200" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Deploy-Ablauf mit dem Migrationsschritt. Images bauen, dann die schema-migrate-Lambda synchron aufrufen: sie nimmt eine Advisory Lock, wendet ausstehende Migrationen in Reihenfolge an, hält sie in einer Migrationstabelle fest, gibt die Sperre frei. Nur wenn das Erfolg zurückgibt, rollt cdk deploy die App-Runner-Services auf das neue Image. Dann die Nach-Deploy-Prüfung. Scheitert die Lambda, stoppt der Deploy, bevor neuer Code Traffic bedient.">
<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">Images bauen</text><text x="95" y="100" text-anchor="middle" fill="#9aa3c7" font-size="11">neuer Code existiert, läuft nicht</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">schema-migrate-Lambda · im VPC</text><text x="335" y="82" text-anchor="middle" fill="#f1f3ff" font-size="11">pg_advisory_lock · ausstehende in Reihenfolge</text><text x="335" y="100" text-anchor="middle" fill="#f1f3ff" font-size="11">in schema_migrations festhalten · unlock</text><text x="335" y="118" text-anchor="middle" fill="#9aa3c7" font-size="11">eigene DB-Rolle mit DDL · 5-Minuten-Timeout</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">alter und neuer Code überlappen, Schema passt zu beiden</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">prüfen</text><text x="800" y="100" text-anchor="middle" fill="#9aa3c7" font-size="11">Health · Release-SHA</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">bei Fehler: hier stoppen · kein neuer Code hat einen Request bedient · vorwärts reparieren</text>
</g>
</svg>
</div>

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](/de/blog/preview-environments-per-pull-request-on-aws) 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 NULL` ohne 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](/contact). Es ist im Wesentlichen die Checkliste oben und eine Lambda.
