Sauberer Start dank separatem, kurzlebigem Migrations-Container
Migrationslogik im Entrypoint des App-Containers zu verstecken, funktioniert eine Weile gut und faellt dann bei Skalierung, parallelen Starts oder fehlgeschlagenen Migrationen unangenehm auf. Ein eigener, kurzlebiger Init-Container mit klarer Erfolgsbedingung loest dieses Problem strukturell.
Inhaltsverzeichnis
- 1. Warum Migrationen im App-Entrypoint problematisch sind
- 2. Das Init-Container-Pattern: Trennung von Migration und Anwendung
- 3. Exit-Codes richtig behandeln
- 4. Restart-Verhalten des Migrations-Containers
- 5. Mehrstufige Abhaengigkeitsketten mit mehreren Bedingungen
- 6. Rollback-Strategie bei fehlgeschlagenen Migrationen
- 7. Uebertragung auf Produktions-Orchestrierung
- 8. Selektives depends_on: nicht jeder Service muss auf die Migration warten
- 9. Haeufige Fehler beim Einsatz des Musters
- 10. Zusammenfassung
- 11. FAQ
1. Warum Migrationen im App-Entrypoint problematisch sind
Der naheliegendste Ansatz ist, im Entrypoint-Skript des App-Containers vor dem eigentlichen Start des Webservers erst die Migrationen auszufuehren. Das funktioniert bei einer einzelnen Instanz zuverlaessig, wird aber sofort zum Problem, sobald mehrere Replicas gleichzeitig hochfahren, etwa hinter einem Load Balancer oder in einer Orchestrierungsumgebung mit mehreren Instanzen. Jede Instanz versucht dann parallel dieselbe Migration auszufuehren, was je nach Migrationstool zu Sperren, Race Conditions oder inkonsistenten Zwischenstaenden fuehren kann.
Ein weiteres Problem ist die Vermischung von Verantwortlichkeiten: Schlaegt die Migration fehl, startet der App-Container trotzdem oder haengt in einem unklaren Zustand, je nachdem wie robust das Entrypoint-Skript geschrieben ist. Healthchecks des App-Containers pruefen typischerweise nur, ob der Webserver antwortet, nicht ob die Datenbank tatsaechlich auf dem erwarteten Schema-Stand ist. Das Ergebnis: ein Container, der als 'healthy' markiert ist, aber Anfragen gegen ein veraltetes oder halb migriertes Schema verarbeitet.
2. Das Init-Container-Pattern: Trennung von Migration und Anwendung
Das Init-Container-Pattern loest beide Probleme, indem die Migration in einen eigenen, dedizierten Service ausgelagert wird, der dasselbe Image wie die App nutzen kann, aber mit einem anderen Kommando startet, laeuft, terminiert und danach nicht mehr existiert. Der App-Container startet erst, wenn dieser Migrations-Container erfolgreich mit Exit-Code 0 beendet wurde. Schlaegt die Migration fehl, terminiert der Container mit einem Fehlercode ungleich null, und der App-Container startet gar nicht erst, was einen inkonsistenten Zustand von vornherein verhindert.
Das Kernelement dafuer in Docker Compose ist die Kombination aus depends_on mit der Bedingung condition: service_completed_successfully. Anders als die Standardbedingung service_started, die nur prueft, ob ein Container gestartet wurde, wartet service_completed_successfully aktiv darauf, dass der referenzierte Service seinen Prozess beendet und dabei Exit-Code 0 zurueckgibt. Genau dieses Verhalten braucht ein Einmal-Job wie eine Migration.
services:
db:
image: postgres:16
environment:
POSTGRES_DB: myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
migrate:
image: myapp:latest
command: ["npm", "run", "migrate:up"]
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://postgres@db:5432/myapp
app:
image: myapp:latest
command: ["npm", "start"]
depends_on:
migrate:
condition: service_completed_successfully
ports:
- "3000:3000"
3. Exit-Codes richtig behandeln
Damit service_completed_successfully funktioniert, muss das Migrationsskript selbst zuverlaessig einen korrekten Exit-Code liefern. Viele Migrationstools wie Flyway, Liquibase oder framework-eigene Migrationsbefehle machen das standardmaessig richtig, aber ein selbst geschriebenes Shell-Skript muss explizit darauf achten, Fehler nicht zu verschlucken. Ein haeufiger Fehler ist ein Skript mit mehreren Befehlen, bei dem nur der letzte Befehl den finalen Exit-Code bestimmt, waehrend ein fehlgeschlagener frueherer Befehl stillschweigend ignoriert wird.
Die Zeile set -euo pipefail am Anfang eines Bash-basierten Migrationsskripts ist deshalb Pflicht: -e beendet das Skript sofort bei einem fehlgeschlagenen Befehl, -u behandelt unbekannte Variablen als Fehler, -o pipefail sorgt dafuer, dass eine Pipeline auch dann als fehlgeschlagen gilt, wenn nur ein Glied der Pipe fehlschlaegt, nicht nur das letzte. Ohne diese drei Optionen kann ein Migrationsskript einen stillen Fehler haben, der als Erfolg durchgereicht wird.
#!/bin/bash
set -euo pipefail
echo "Warte auf Datenbankverbindung..."
until pg_isready -h db -U postgres; do
sleep 1
done
echo "Fuehre Migrationen aus..."
npm run migrate:up
echo "Migration erfolgreich abgeschlossen."
exit 0
4. Restart-Verhalten des Migrations-Containers
Ein oft uebersehener Konfigurationsfehler ist ein gesetztes restart: always oder restart: unless-stopped beim Migrations-Service. Ein Init-Container soll genau einmal laufen und dann beendet bleiben, nicht bei jedem Exit erneut gestartet werden. Mit restart: on-failure und einer begrenzten Anzahl an Versuchen laesst sich immerhin ein voruebergehendes Problem wie eine noch nicht bereite Datenbankverbindung abfedern, ohne in eine Endlosschleife zu geraten. Der Standardwert no ist fuer die meisten Migrations-Container aber die richtige und sicherste Wahl, kombiniert mit einem depends_on auf die Datenbank mit condition: service_healthy, damit der Container erst gar nicht startet, bevor die Datenbank bereit ist.
Bei wiederholten Compose-Starts, etwa waehrend der lokalen Entwicklung mit haeufigem docker compose up, laeuft der Migrations-Container jedes Mal erneut, was bei idempotenten Migrationstools unproblematisch ist, weil bereits angewendete Migrationen automatisch uebersprungen werden. Wichtig ist deshalb, ein Migrationstool zu waehlen, das eine eigene Tabelle mit dem Anwendungsstatus fuehrt, etwa schema_migrations oder flyway_schema_history, statt eine eigene, fehleranfaellige Idempotenz-Logik von Hand zu bauen.
services:
migrate:
image: myapp:latest
command: ["npm", "run", "migrate:up"]
restart: "no" # explizit: laeuft genau einmal, kein Neustart
depends_on:
db:
condition: service_healthy
5. Mehrstufige Abhaengigkeitsketten mit mehreren Bedingungen
In realistischen Setups reicht eine einzelne Bedingung selten aus. Der Migrations-Container muss warten, bis die Datenbank per Healthcheck als bereit gilt (service_healthy), der App-Container muss warten, bis die Migration erfolgreich beendet wurde (service_completed_successfully), und ein nachgelagerter Seed-Container fuer Testdaten muss wiederum warten, bis sowohl Migration als auch App-Grundkonfiguration abgeschlossen sind. Diese Kette laesst sich in Compose direkt als gerichteter Graph aus mehreren depends_on-Eintraegen mit jeweils passender Bedingung abbilden, ohne externe Orchestrierung.
Wichtig ist, dass Compose bei zyklischen Abhaengigkeiten sofort mit einem Fehler abbricht, was in der Praxis hilfreich ist, um Designfehler frueh zu erkennen. Ein haeufiges Beispiel: Ein Seed-Container haengt faelschlich von der App ab, obwohl er eigentlich nur die Datenbank direkt befuellen sollte, ein Anzeichen dafuer, dass Verantwortlichkeiten in der Servicekette nicht sauber getrennt sind.
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
migrate:
image: myapp:latest
command: ["npm", "run", "migrate:up"]
depends_on:
db:
condition: service_healthy
seed:
image: myapp:latest
command: ["npm", "run", "seed:dev"]
profiles: ["dev"]
depends_on:
migrate:
condition: service_completed_successfully
app:
image: myapp:latest
command: ["npm", "start"]
depends_on:
migrate:
condition: service_completed_successfully
6. Rollback-Strategie bei fehlgeschlagenen Migrationen
Das Init-Container-Pattern verhindert zwar, dass die App mit einem inkonsistenten Schema startet, loest aber nicht automatisch das Problem einer fehlgeschlagenen Migration selbst. Bei einer fehlgeschlagenen Migration bleibt die Datenbank in einem Zwischenzustand, und der App-Container startet gar nicht, was fuer Produktionsumgebungen zwar sicherer ist als ein stiller Fehler, aber trotzdem einen Ausfall bedeutet, bis das Problem behoben ist. Deshalb gehoert eine klare Rollback-Strategie zu jedem Migrationstool: entweder automatische Rollback-Migrationen, die das Schema auf den vorherigen Stand zuruecksetzen, oder ein manueller Eingriff mit dokumentiertem Vorgehen.
Fuer produktionskritische Systeme empfiehlt sich zusaetzlich, den Migrations-Container mit einem klaren Logging-Format zu betreiben, das im Fehlerfall die exakte fehlgeschlagene Migration und die SQL-Fehlermeldung ausgibt, kombiniert mit einer Benachrichtigung, etwa ueber den Exit-Code der Pipeline, die den Deploy ausgeloest hat. Ein Init-Container, der zwar korrekt mit einem Fehlercode terminiert, dessen Fehlermeldung aber in einem nicht ueberwachten Log verschwindet, verzoegert die Fehlerbehebung unnoetig.
7. Uebertragung auf Produktions-Orchestrierung
Ausserhalb von Docker Compose, etwa in Kubernetes, existiert mit InitContainers ein natives, sehr aehnliches Konzept: Ein Pod kann eine Liste von Init-Containern definieren, die nacheinander erfolgreich terminieren muessen, bevor die eigentlichen Anwendungscontainer starten. Wer das Muster bereits in Docker Compose fuer die lokale Entwicklung etabliert, ueberfuehrt es damit nahezu 1:1 in eine Kubernetes-Umgebung, ohne die grundlegende Architektur der Anwendung aendern zu muessen.
In CI/CD-Pipelines ausserhalb von Kubernetes, etwa bei einem klassischen Deploy per SSH oder ueber einen Cloud-Dienst ohne Container-Orchestrierung, laesst sich dasselbe Prinzip als expliziter Pipeline-Schritt abbilden: ein Job fuehrt die Migration in einem Wegwerf-Container aus und bricht die Pipeline bei einem Fehler ab, bevor der eigentliche Deploy-Schritt ueberhaupt beginnt. Das Grundprinzip, Migration und App-Start strikt sequenziell und mit einer klaren Erfolgsbedingung zu trennen, bleibt ueber alle Umgebungen hinweg identisch.
# Manuelles Ausfuehren des Migrations-Containers ausserhalb von Compose
docker run --rm \
--network myapp_default \
-e DATABASE_URL=postgres://postgres@db:5432/myapp \
myapp:latest npm run migrate:up
echo "Exit-Code der Migration: $?"
8. Selektives depends_on: nicht jeder Service muss auf die Migration warten
Ein haeufiger Modellierungsfehler ist, alle Services eines Compose-Projekts pauschal von der Migration abhaengig zu machen, auch solche, die gar keine Verbindung zum Datenbankschema haben, etwa ein Redis-Cache, ein Reverse-Proxy oder ein reiner Log-Collector. Das verlangsamt den Gesamtstart unnoetig, weil Services warten muessen, die inhaltlich gar keinen Grund dazu haetten, und erschwert gleichzeitig das Verstaendnis der Abhaengigkeitskette fuer neue Teammitglieder, die aus einer langen depends_on-Liste nicht mehr ablesen koennen, welche Abhaengigkeit tatsaechlich notwendig ist.
Die saubere Regel lautet: Nur Services, die direkt Datenbankabfragen gegen das migrierte Schema ausfuehren, sollten von service_completed_successfully des Migrations-Containers abhaengen. Ein Reverse-Proxy, der nur HTTP-Anfragen weiterleitet, braucht diese Abhaengigkeit nicht, ein Hintergrund-Worker, der Datenbankzugriffe durchfuehrt, dagegen schon. Diese bewusste Trennung haelt die Abhaengigkeitskette schlank und macht auf einen Blick sichtbar, welche Services tatsaechlich vom Schema-Zustand abhaengen.
services:
# Braucht die Migration NICHT, reine HTTP-Weiterleitung
reverse-proxy:
image: nginx:alpine
depends_on:
app:
condition: service_started
# Braucht die Migration, da direkte DB-Zugriffe
app:
image: myapp:latest
depends_on:
migrate:
condition: service_completed_successfully
# Braucht die Migration NICHT, hat keinen DB-Zugriff
redis-cache:
image: redis:7-alpine
9. Haeufige Fehler beim Einsatz des Musters
Der wohl haeufigste Fehler ist ein fehlender oder falsch konfigurierter Healthcheck fuer die Datenbank selbst. Ohne service_healthy als Bedingung fuer den Datenbank-Service wartet der Migrations-Container nur, bis der Datenbank-Container gestartet wurde, nicht bis der Datenbankprozess tatsaechlich Verbindungen annimmt. Gerade bei Postgres oder MySQL vergehen nach dem Prozessstart oft noch ein bis zwei Sekunden, bis Verbindungen akzeptiert werden, was ohne Healthcheck zu sporadisch fehlschlagenden Migrationen fuehrt, die bei einem erneuten Start ploetzlich funktionieren und damit schwer reproduzierbar sind.
Ein zweiter haeufiger Fehler ist, den Migrations-Container mit demselben restart: always wie den App-Container zu konfigurieren, wodurch er nach jedem erfolgreichen Durchlauf sofort wieder neu gestartet wird und in eine Neustart-Schleife geraet, weil manche Migrationstools bei bereits angewendeten Migrationen einen Exit-Code ungleich null liefern, statt einfach nichts zu tun. Ein kurzer Blick in die Dokumentation des jeweiligen Migrationstools, wie es sich bei einem bereits aktuellen Schema verhaelt, spart hier viel Debugging-Zeit.
| depends_on Bedingung | Wartet auf | Typischer Einsatz | Fehlerverhalten |
|---|---|---|---|
service_started |
Containerstart, kein Healthcheck | Lose gekoppelte Services | Kein Warten auf Bereitschaft |
service_healthy |
Healthcheck meldet 'healthy' | Datenbank vor Migration/App | Wartet bis Verbindungen moeglich sind |
service_completed_successfully |
Exit-Code 0 des Containers | Migrations-Container vor App-Start | App startet nicht bei Fehler |
| Kein depends_on | Nichts, sofortiger Start | Unabhaengige Services | Race Conditions moeglich |
Mironsoft
Container-Infrastruktur, CI-Pipelines und Deployment-Automatisierung
Docker-Setups, die im Team und in Produktion tragfähig bleiben?
Wir prüfen bestehende Dockerfiles und Compose-Stacks auf Sicherheitslücken, aufgeblähte Images und fragile Build-Pipelines und bauen daraus eine Container-Infrastruktur, die schnell baut, sicher läuft und im Team nachvollziehbar bleibt.
Dockerfile-Review
Multi-Stage-Builds, Layer-Caching und Image-Größe systematisch optimieren.
Security-Audit
Container-Isolation, Secrets-Handling und Image-Scanning gegen echte Angriffsflächen absichern.
CI/CD-Integration
Build-Pipelines, Registries und Deployment-Strategien für reproduzierbare Releases aufbauen.
10. Zusammenfassung
Init-Container fuer Migrationen: Das Wichtigste auf einen Blick
Kernidee
Migration in eigenem, kurzlebigem Container statt im App-Entrypoint versteckt.
Schluesselmechanismus
depends_on mit condition: service_completed_successfully erzwingt Warten auf Exit-Code 0.
Exit-Codes
set -euo pipefail in Bash-Migrationsskripten verhindert stillschweigend verschluckte Fehler.
Restart-Policy
Migrations-Container mit restart: no, damit er nach Erfolg nicht endlos neu startet.