Init-Container-Pattern: Datenbank-Migrationen vor dem App-Start in Docker Compose
AI generated
FROM
RUN
Docker · Compose · Datenbank
Init-Container fuer Migrationen
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.

16 Min. Lesezeit depends_on condition service_completed_successfully Migrations-Strategie

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.

11. FAQ: Init-Container fuer Migrationen: Das Wichtigste auf einen Blick

1Was ist der Vorteil eines Init-Containers gegenueber Migrationen im App-Entrypoint?
Der Init-Container verhindert parallele Migrationsversuche bei mehreren App-Instanzen und stellt sicher, dass der App-Container bei einer fehlgeschlagenen Migration gar nicht erst startet, statt in einem inkonsistenten Zustand weiterzulaufen.
2Wie funktioniert condition: service_completed_successfully genau?
Compose wartet, bis der referenzierte Service seinen Prozess beendet und dabei Exit-Code 0 zurueckgibt. Erst dann startet der abhaengige Service. Bei einem Exit-Code ungleich null startet der abhaengige Service nicht.
3Muss der Migrations-Container dasselbe Image wie die App nutzen?
Nicht zwingend, aber praktisch oft sinnvoll, da so dieselben Abhaengigkeiten und derselbe Code-Stand fuer Migration und Anwendung garantiert sind. Ein separates, schlankeres Migrations-Image ist ebenfalls moeglich.
4Welche restart-Policy sollte der Migrations-Container haben?
In der Regel restart: no, da der Container genau einmal laufen und danach beendet bleiben soll. restart: on-failure mit begrenzter Versuchszahl ist eine Alternative fuer voruebergehende Verbindungsprobleme.
5Was passiert, wenn die Migration bei jedem docker compose up erneut laeuft?
Bei einem idempotenten Migrationstool ist das unproblematisch, da bereits angewendete Migrationen automatisch uebersprungen werden. Das Tool sollte eine eigene Statustabelle wie schema_migrations fuehren.
6Warum braucht der Migrations-Container einen Healthcheck der Datenbank als Voraussetzung?
Ohne condition: service_healthy fuer die Datenbank wartet der Migrations-Container nur auf den Containerstart, nicht darauf, dass die Datenbank tatsaechlich Verbindungen annimmt, was zu sporadisch fehlschlagenden Migrationen fuehrt.
7Ist set -euo pipefail bei jedem Migrationsskript notwendig?
Bei selbst geschriebenen Shell-Skripten dringend empfohlen, da ohne diese Optionen ein fehlgeschlagener Zwischenbefehl stillschweigend ignoriert werden kann und das Skript trotzdem mit Exit-Code 0 endet.
8Wie laesst sich das Muster in Kubernetes uebertragen?
Kubernetes bietet mit InitContainers ein natives, sehr aehnliches Konzept: mehrere Init-Container muessen nacheinander erfolgreich terminieren, bevor die Anwendungscontainer im selben Pod starten.
9Was tun, wenn eine Migration in Produktion fehlschlaegt?
Der App-Container startet nicht, was einen Ausfall bedeutet, bis das Problem behoben ist. Eine klare Rollback-Strategie, entweder automatische Rollback-Migrationen oder ein dokumentierter manueller Eingriff, sollte vorbereitet sein.
10Kann ein Seed-Container fuer Testdaten dasselbe Pattern nutzen?
Ja, ein Seed-Container kann per depends_on mit condition: service_completed_successfully von der erfolgreichen Migration abhaengen und sollte zusaetzlich per profiles auf Entwicklungsumgebungen beschraenkt werden, damit er nicht in Produktion laeuft.