Compose-Datei: jede Zeile erklärt

Zielgruppe: alle, die eine docker-compose.yml lesen können, ohne dass jede einzelne Zeile Sinn ergibt. Diese Seite erklärt die Syntax selbst, nicht die Infrastruktur dahinter - für "was startet hier und warum" siehe docker-compose-guide.md und reverse-proxy.md. Beispiele stammen ausschließlich aus den beiden echten Dateien dieses Setups: infra/docker-compose.yml (dieses Projekt) und shared/enterprise-infrastructure/docker-compose.yml (projektübergreifend, siehe shared/enterprise-infrastructure/README.md).

Dieses Projekt nutzt Podman. Das Compose-Dateiformat ist mit Docker identisch - podman compose (bzw. das separate podman-compose) liest dieselbe docker-compose.yml; der Dateiname bleibt bewusst erhalten.

Was ist eine docker-compose.yml überhaupt?

Eine einzige Textdatei, die beschreibt: "diese Container sollen laufen, mit diesen Einstellungen, so verbunden." podman compose up -d liest die Datei und setzt genau das um - Container erstellen, Netzwerke anlegen, alles verbinden. Ohne diese Datei müsstest du dieselbe Konfiguration als lange podman run ...-Befehlszeile mit zig Flags von Hand eingeben, für JEDEN Container einzeln, jedes Mal neu.

Die Datei ist YAML (sprich "Jamel") - ein Format, das Struktur über EINRÜCKUNG ausdrückt (wie Python), nicht über Klammern wie JSON. Zwei Regeln reichen für 90 % des Verständnisses:

services:          # <- Schlüssel "services" auf oberster Ebene
  postgres:         # <- eingerückt = gehört zu "services", ist selbst ein Schlüssel ("postgres")
    image: postgres:16-alpine   # <- noch weiter eingerückt = gehört zu "postgres"
    ports:                       # <- Schlüssel "ports", Wert ist eine LISTE (siehe naechste Zeile)
      - "5432:5432"              # <- "-" davor = ein Listenelement

Die drei obersten Ebenen

Jede docker-compose.yml in diesem Setup hat dieselbe Grundstruktur - drei Blöcke auf oberster Ebene (keine Einrückung):

networks:   # WELCHE Netzwerke sollen existieren, wie heißen sie
  ...

volumes:    # WELCHE dauerhaften Datenspeicher soll es geben (siehe docker-compose-guide.md,
  ...       # "Was passiert mit meinen Daten?" fuer den vollen Kontext zu Volumes)

services:   # DIE EIGENTLICHEN CONTAINER - der groesste, wichtigste Block
  ...

networks: - welche Netzwerke soll es geben

# infra/docker-compose.yml, Zeilen 22-32
networks:
  library-net:
    driver: bridge
  proxy:
    external: true
    name: proxy

volumes: - welche dauerhaften Datenspeicher

# infra/docker-compose.yml, Zeilen 34-38
volumes:
  pgdata:
  kafka-data:
  rabbitmq-data:
  grafana-data:

Jede Zeile ist ein Name, sonst nichts (kein Doppelpunkt-Wert dahinter nötig) - Compose legt ein Volume mit diesem Namen an (ein von Podman verwalteter Ordner auf der Platte, unabhängig vom Container-Lebenszyklus). Erst weiter unten, bei den einzelnen Services, wird festgelegt, WELCHER Container dieses Volume WOHIN einhängt (siehe volumes: auf Service-Ebene weiter unten - gleicher Schlüsselname, andere Bedeutung, siehe dort).

Jeder Service - Schlüssel für Schlüssel

Ab hier folgt jeder mögliche Schlüssel innerhalb eines Service-Blocks, in der Reihenfolge, wie sie am häufigsten vorkommen. Jeder Abschnitt zeigt eine ECHTE Fundstelle.

image: - ein fertiges Image aus einer Container-Registry (Docker Hub, Quay.io) verwenden

# infra/docker-compose.yml, Zeile 47
image: postgres:16-alpine

<name>:<tag> - postgres ist der Image-Name (bei offiziellen Images ohne Namespace-Präfix), 16-alpine der Tag (meist eine Version). Podman lädt dieses fertige, bereits gebaute Image herunter, falls es nicht schon lokal vorhanden ist - kein eigener Bauschritt, im Gegensatz zu build: (nächster Abschnitt).

build: - selbst aus einem Dockerfile bauen

# infra/docker-compose.yml, Zeilen 251-257 (catalog-service)
build:
  context: ../library-platform
  dockerfile: Dockerfile
  args:
    MODULE: catalog-service
    PORT: 8081

Alternative zu image: - statt ein fertiges Image zu laden, wird eines aus einem Dockerfile GEBAUT. context: ist das Verzeichnis, das dem Bauprozess als "Arbeitsbereich" zur Verfügung steht (alle Dateien darin sind für das Dockerfile sichtbar, sonst nichts) - hier bewusst der komplette library-platform-Reactor-Root, nicht nur catalog-service/, weil das Maven-Multi-Modul-Projekt Zugriff auf die anderen Module braucht (common-domain usw.), siehe Kommentar im Dockerfile selbst. dockerfile: benennt die konkrete Datei innerhalb von context:. args: reicht Build-Argumente durch, die im Dockerfile über ARG MODULE/ ARG PORT empfangen werden - hier wird EIN generisches Dockerfile für alle neun Services wiederverwendet, nur MODULE/PORT unterscheiden sich pro Service.

Ein image:-Eintrag KANN trotzdem daneben stehen (nicht in diesem Projekt genutzt) - er würde dann nur den NAMEN bestimmen, unter dem das selbst gebaute Image lokal gespeichert wird.

container_name: - der Name, den podman ps anzeigt

# infra/docker-compose.yml, Zeile 48
container_name: library-postgres

Ohne diese Zeile würde Compose selbst einen Namen vergeben (nach dem Muster <projektordner>-<servicename>-1) - hier bewusst überall explizit gesetzt, mit einem Projekt-Präfix (library-), damit podman ps sofort erkennen lässt, welcher Container zu welchem Projekt gehört (wichtig, sobald mehrere Projekte gleichzeitig laufen, siehe reverse-proxy.md).

environment: - Umgebungsvariablen in den Container geben

# infra/docker-compose.yml, Zeilen 259-266 (catalog-service, Auszug)
environment:
  SPRING_CONFIG_IMPORT: optional:configserver:http://config-server:8888
  SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/catalog_db
  SPRING_DATA_REDIS_HOST: redis

SCHLÜSSEL: Wert je Zeile - im Container als Umgebungsvariable sichtbar (echo $SPRING_DATASOURCE_URL würde diesen Wert zeigen). Bei Spring-Boot-Anwendungen übersetzt Spring selbst SPRING_DATASOURCE_URL automatisch in die Property spring.datasource.url (Punkte werden zu Unterstrichen, alles groß - "Relaxed Binding", eine Spring-Boot-eigene Konvention, nichts Compose-Spezifisches). Wichtig: die WERTE hier sind Hostnamen, die INNERHALB des Container-Netzwerks gelten (postgres, redis, config-server - die jeweiligen container_name- bzw. Service-Namen), nicht localhost - ein Container kann sich nicht selbst über localhost erreichen, wenn er einen ANDEREN Container meint.

ports: - Container-Ports auf dem Host sichtbar machen

# infra/docker-compose.yml, Zeile 53-54
ports:
  - "5432:5432"

Format "HOST-PORT:CONTAINER-PORT". Der Container lauscht intern auf Port 5432 (rechte Zahl) - ohne diese Zeile wäre das NUR für andere Container im selben Container-Netzwerk erreichbar. Die Zeile öffnet zusätzlich einen Port auf deinem Windows-Host (linke Zahl) - localhost:5432 funktioniert dann auch von außerhalb der Container (z. B. von einem Java-Programm, das direkt in IntelliJ läuft, siehe docker-compose-guide.md). Die beiden Zahlen müssen NICHT gleich sein - siehe

# infra/docker-compose.yml, RabbitMQ
- "5673:5672"   # Host-Port 5673, Container lauscht weiter auf dem Standard-Port 5672

(Grund: ein Portkonflikt mit einem anderen lokalen Projekt, siehe Kommentar an der Fundstelle).

volumes: (auf Service-Ebene) - was wird wohin eingehängt

Drei verschiedene Varianten, alle mit demselben Schlüsselwort:

# infra/docker-compose.yml, Zeilen 55-57 (postgres)
volumes:
  - pgdata:/var/lib/postgresql/data
  - ./postgres/init-multiple-dbs.sh:/docker-entrypoint-initdb.d/init-multiple-dbs.sh:ro

networks: (auf Service-Ebene) - welchem/welchen Netzwerk(en) beitreten

# infra/docker-compose.yml, Keycloak (nach Phase 12)
networks: [library-net, proxy]

Eckige Klammern mit Kommas sind YAMLs Kurzschreibweise für eine Liste - identisch zu

networks:
  - library-net
  - proxy

nur kompakter. Ein Container kann an MEHREREN Netzwerken gleichzeitig hängen (wie hier) - er bekommt dann in JEDEM davon eine eigene IP-Adresse und kann Container in JEDEM davon erreichen. Genau das nutzt reverse-proxy.md: library-net für den internen Projekt-Datenverkehr (Postgres, Kafka, ...), zusätzlich proxy NUR damit Traefik (das selbst auch im proxy-Netz hängt, aber nicht in library-net) diesen Container erreichen kann.

depends_on: - Startreihenfolge

Zwei Varianten, unterschiedlich streng:

# infra/docker-compose.yml, kafka-ui
depends_on: [kafka]

Einfache Form: "warte, bis der Kafka-Container gestartet ist" (nicht: bis er wirklich BEREIT/funktionsfähig ist - ein Container kann "gestartet" sein und trotzdem noch Sekunden brauchen, bis der eigentliche Dienst darin Anfragen beantwortet).

# infra/docker-compose.yml, catalog-service
depends_on:
  postgres: { condition: service_healthy }

Strengere Form: wartet, bis der healthcheck: von postgres (siehe nächster Abschnitt) tatsächlich ERFOLGREICH war, nicht nur bis der Container läuft. { condition: ... } ist YAMLs Kurzschreibweise für eine eingerückte Zuordnung in einer Zeile, identisch zu

depends_on:
  postgres:
    condition: service_healthy

healthcheck: - wie prüft die Container-Laufzeit, ob ein Dienst wirklich bereit ist

# infra/docker-compose.yml, postgres
healthcheck:
  test: ["CMD-SHELL", "pg_isready -U library"]
  interval: 5s
  timeout: 5s
  retries: 10

podman ps zeigt das Ergebnis: (healthy), (unhealthy), oder (health: starting) neben dem Container-Status.

command: und entrypoint: - was beim Containerstart wirklich ausgeführt wird

Jedes Image hat einen eingebauten Standard-Startbefehl (im Dockerfile festgelegt, das das Image gebaut hat) - command: und entrypoint: überschreiben das:

# infra/docker-compose.yml, keycloak
command: ["start-dev", "--import-realm"]

Einfachster Fall: nur command: gesetzt - überschreibt die STANDARD-Argumente, der ENTRYPOINT des Images bleibt unverändert und bekommt diese Argumente übergeben.

# shared/enterprise-infrastructure/docker-compose.yml, rabbitmq (Stolperstein-Fix, siehe README.md)
entrypoint: ["sh", "-c"]
command:
  - |
    echo -n "$$RABBITMQ_ERLANG_COOKIE" > /var/lib/rabbitmq/.erlang.cookie
    chown rabbitmq:rabbitmq /var/lib/rabbitmq/.erlang.cookie
    chmod 400 /var/lib/rabbitmq/.erlang.cookie
    exec docker-entrypoint.sh rabbitmq-server

Selteneren, aber hier wichtigen Fall: BEIDE überschrieben. entrypoint: ["sh", "-c"] sagt "starte eine Shell und führe darin AUS, was in command: steht" - command: enthält hier ein MEHRZEILIGES Shell-Skript (das | am Zeilenanfang ist YAMLs Zeichen für "alles danach ist ein mehrzeiliger Text-Block, Zeilenumbrüche bleiben erhalten"). Das Skript repariert erst eigenhändig eine kaputte Datei-Berechtigung (siehe shared/enterprise-infrastructure/README.md, Abschnitt "Stolperstein: .erlang.cookie"), bevor es am Ende exec docker-entrypoint.sh rabbitmq-server aufruft - das ist der EIGENTLICHE, normale Einstiegspunkt des Original-Images, den wir sonst komplett ersetzt hätten. $$ (doppeltes Dollarzeichen) statt $: Compose selbst interpretiert EIN $ als "hier eine Compose-eigene Variable einsetzen" - $$ escaped das zu einem buchstäblichen $, der dann von der SHELL (nicht von Compose) als Umgebungsvariable $RABBITMQ_ERLANG_COOKIE gelesen wird.

profiles: - Dienste, die NICHT automatisch mitstarten

# shared/enterprise-infrastructure/docker-compose.yml, postgres
profiles: ["postgres"]

Ein Service OHNE profiles:-Zeile startet immer mit podman compose up -d mit. Ein Service MIT profiles: ["postgres"] startet NUR, wenn du dieses Profil explizit anforderst:

podman compose --profile postgres up -d

Genutzt für alles, was nicht JEDER braucht, der Enterprise Infrastructure startet (Postgres, Keycloak, Prometheus/Grafana, RabbitMQ - alle optional, siehe shared/enterprise-infrastructure/README.md). Mehrere Profile gleichzeitig: --profile postgres --profile keycloak (nötig, wenn ein Dienst wie Keycloak einen ANDEREN profilgesteuerten Dienst als Voraussetzung hat, siehe dortiges depends_on:).

labels: - Metadaten, die Traefik automatisch ausliest

# infra/docker-compose.yml, keycloak (nach Phase 12)
labels:
  - "traefik.enable=true"
  - "traefik.http.routers.library-keycloak.rule=Host(`keycloak.library.localhost`)"
  - "traefik.http.services.library-keycloak.loadbalancer.server.port=8080"
  - "traefik.docker.network=proxy"

Für Podman selbst sind Labels nur beliebiger Freitext (Schlüssel-Wert-Paare, die an einem Container "kleben") - Traefik (siehe reverse-proxy.md) liest Labels mit dem Präfix traefik. gezielt aus und routet danach automatisch. Vollständige Erklärung dieser vier Zeilen dort - hier nur die Compose-Syntax-Seite: labels: ist wie ports:/networks: eine LISTE von Zeichenketten (- "..."), jede im Format schlüssel=wert.

Ein Service komplett gelesen (RabbitMQ, shared/enterprise-infrastructure/docker-compose.yml)

Zum Abschluss ein vollständiger, real existierender Service-Block mit JEDER Zeile kommentiert - bewusst RabbitMQ gewählt, weil er die meisten der oben erklärten Konzepte gleichzeitig zeigt:

rabbitmq:                                    # Service-Name (auch der DNS-Name im Netzwerk)
  image: rabbitmq:3.13-management-alpine     # fertiges Image aus einer Registry, kein eigener Bau
  container_name: shared-rabbitmq            # Name in "podman ps"
  profiles: ["rabbitmq"]                     # startet NUR mit "--profile rabbitmq"
  entrypoint: ["sh", "-c"]                   # eigene Shell statt des Standard-Einstiegspunkts
  command:                                   # das Shell-Skript, das die Shell ausfuehrt
    - |
      echo -n "$$RABBITMQ_ERLANG_COOKIE" > /var/lib/rabbitmq/.erlang.cookie
      chown rabbitmq:rabbitmq /var/lib/rabbitmq/.erlang.cookie
      chmod 400 /var/lib/rabbitmq/.erlang.cookie
      exec docker-entrypoint.sh rabbitmq-server
  environment:
    RABBITMQ_ERLANG_COOKIE: "shared-infra-local-only-cookie"   # von obigem Skript gelesen
  volumes:
    - rabbitmq-data:/var/lib/rabbitmq                            # benanntes Volume (Daten)
    - ./rabbitmq/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro     # Bind-Mount, nur lesbar
    - ./rabbitmq/definitions.json:/etc/rabbitmq/definitions.json:ro
  healthcheck:
    test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]  # "CMD" = direkt, keine Shell noetig
    interval: 15s          # alle 15s pruefen
    timeout: 30s           # bis zu 30s Zeit fuer EINEN Versuch (grosszuegig, siehe oben)
    retries: 5              # 5 Fehlversuche hintereinander = "unhealthy"
    start_period: 60s       # erste 60s zaehlen Fehlversuche nicht mit
  networks: [proxy]         # nur EIN Netzwerk noetig (kein "library-net"-Aequivalent hier)
  labels:
    - "traefik.enable=true"
    - "traefik.http.routers.rabbitmq.rule=Host(`rabbitmq.localhost`)"
    - "traefik.http.services.rabbitmq.loadbalancer.server.port=15672"

Kein depends_on:, build: oder ports: in diesem Block - nicht jeder Service braucht jeden Schlüssel, nur die, die für ihn sinnvoll sind (RabbitMQ hier braucht z. B. keinen Host-Port, weil er ausschließlich über Traefik erreicht wird, siehe reverse-proxy.md).

Wo geht's weiter?

Mit diesem Vokabular sind infra/docker-compose.yml und shared/enterprise-infrastructure/docker-compose.yml vollständig lesbar - jeder der neun Fachservice-Blöcke im Referenzprojekt folgt demselben Muster wie catalog-service oben, nur mit anderen MODULE-/ PORT-Werten und Umgebungsvariablen. Für das WARUM hinter den konkreten Entscheidungen (welche Ports, welche Netzwerke, welche Healthchecks) siehe docker-compose-guide.md und reverse-proxy.md.

⌂ Cockpit