DevOps

Docker im produktiven Einsatz: Images, Betrieb und Fallstricke

Was einen produktiven Container von einem Entwicklungscontainer unterscheidet: Multi-Stage-Builds, Digest-Pinning, PID 1, Secrets, Volumes und Ressourcenlimits.

OSSDL Lab Veröffentlicht 14 Min. Lesezeit

Voraussetzungen

Benötigt

  • Docker Engine oder Podman auf einem Linux-Host
  • Grundverständnis von Images, Containern und Volumes

Getestet mit

  • Docker Engine 28.x
  • Docker Compose v2
  • Podman 5.x

Ein Container, der auf dem Laptop startet, beweist wenig. Im Entwicklungsalltag genügt es, dass der Prozess läuft, einen Port öffnet und beim Speichern neu lädt. Im Produktionsbetrieb gelten andere Kriterien: Das Image muss in sechs Monaten unverändert erneut ausgerollt werden können, ein SIGTERM muss innerhalb der Grace Period zu einem geordneten Shutdown führen, kein Zugangstoken darf in einem Layer liegen, und der Prozess sollte nicht mit UID 0 auf einem Host laufen, der weitere Workloads trägt.

Die Kernantwort in zwei Sätzen: Ein produktives Image ist klein, weil es nur Laufzeitartefakte enthält – gebaut in mehreren Stages, mit einem Basis-Image ohne Compiler, ohne Paketmanager-Cache und ohne Shell, wenn es sich vermeiden lässt. Und es ist reproduzierbar, weil es über einen Digest referenziert wird, nicht über latest, weil Secrets zur Build- und Laufzeit von außen kommen, und weil der Hauptprozess als PID 1 Signale korrekt verarbeitet.

Dieser Artikel geht die Stationen der Reihe nach durch: Build (Multi-Stage, Layer-Reihenfolge, Basis-Image), Identität und Rechte, Referenzierung und Registry, Betrieb (Signale, Healthchecks, Limits), Secrets, Zustand und Logs sowie die Absicherung der Lieferkette. Alle Beispiele sind gegen Docker Engine 28.x mit BuildKit und Docker Compose v2 formuliert; die Dockerfile-Beispiele funktionieren mit Podman 5.x ebenso.

Was einen produktiven Container von einem Entwicklungscontainer unterscheidet

Der Unterschied ist nicht graduell, sondern eine andere Zielfunktion. Ein Entwicklungscontainer optimiert auf kurze Feedbackzeit: Quellcode als Bind-Mount, Watcher, Debugger, Shell zum Hineinspringen. Ein produktives Image optimiert auf Nachvollziehbarkeit, kleine Angriffsfläche und vorhersagbares Verhalten unter Last und beim Neustart.

Aspekt Entwicklung Produktion
Quellcode Bind-Mount vom Host im Image, unveränderlich
Abhängigkeiten inklusive Dev-Dependencies nur Laufzeit
Referenz myapp:dev myapp@sha256:...
Benutzer häufig root dedizierte UID, read_only Rootfs
Prozess über Shell-Wrapper gestartet ENTRYPOINT in Exec-Form
Secrets .env-Datei im Projekt Secret-Mount oder Secret-Store
Neustart manuell Restart-Policy plus Healthcheck

Wer diese Spalten in einem einzigen Dockerfile vermischt, bekommt beides nur halb. Zwei Targets in einem Dockerfile – ein dev-Stage und ein Default-Stage für die Produktion – lösen das Problem sauber, weil beide dieselbe Basis und dieselbe Abhängigkeitsauflösung teilen.

Images bauen: Multi-Stage, Layer-Reihenfolge, Basis-Image

Warum Multi-Stage-Builds die Angriffsfläche verkleinern

Ein Build braucht Compiler, Header, Paketmanager, Git und oft ein halbes SDK. Nichts davon wird zur Laufzeit benötigt, und jedes davon ist Code, der im Produktionsimage mitgeliefert, mitgescannt und mitgepatcht werden muss. Multi-Stage-Builds trennen das: Der letzte FROM-Block definiert das Ergebnis, alle vorherigen Stages sind Werkzeugkasten und werden nicht mit ausgeliefert.

Ein typisches Anti-Beispiel, das in vielen Repositories steht:

FROM node:latest
COPY . /app
WORKDIR /app
RUN npm install
ENV DATABASE_PASSWORD=geheim
EXPOSE 3000
CMD npm start

Sechs Probleme in sieben Zeilen: latest ist kein Versionsbezug; COPY . /app vor npm install zerstört den Layer-Cache bei jeder Codeänderung; npm install statt npm ci ignoriert das Lockfile; Dev-Dependencies und Build-Toolchain landen im Image; das Passwort ist als Layer-Metadatum dauerhaft im Image gespeichert und über docker image inspect lesbar; CMD npm start startet eine Shell, die Signale nicht weitergibt.

Die produktionsnahe Variante für eine Node-Anwendung:

# syntax=docker/dockerfile:1
FROM node:24.9.0-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:24.9.0-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
ENTRYPOINT ["node", "dist/server.js"]

Für statisch gelinkte Sprachen geht es noch weiter. Ein Go-Binary braucht zur Laufzeit weder libc noch Shell:

# syntax=docker/dockerfile:1
FROM golang:1.24-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
USER nonroot:nonroot
ENTRYPOINT ["/api"]

Das Ergebnis liegt im einstelligen Megabyte-Bereich und enthält kein Programm, das ein Angreifer nach einer Remote-Code-Execution aufrufen könnte – kein sh, kein curl, kein apt. Der Preis: Debugging erfordert entweder ein zweites Image mit gleicher Codebasis oder einen Debug-Container, der den Namespace des laufenden Containers teilt.

Layer-Caching und die Reihenfolge der Anweisungen

BuildKit cacht pro Instruktion. Der Cache-Key einer COPY-Anweisung enthält die Inhalte der kopierten Dateien, der Key einer RUN-Anweisung den Befehlstext plus den Zustand der vorherigen Layer. Sobald ein Layer invalidiert wird, sind alle folgenden ebenfalls hinfällig. Daraus folgt eine einfache Regel: Was sich selten ändert, kommt nach oben; was sich bei jedem Commit ändert, nach unten. Manifest und Lockfile also vor dem Anwendungscode.

In CI fehlt der lokale Layer-Cache. docker buildx build --cache-to type=registry,ref=... und --cache-from legen ihn in der Registry ab, sodass Pipeline-Runs voneinander profitieren. Wie sich das in eine Pipeline einbettet, behandeln wir ausführlicher in den Grundlagen zu CI/CD.

Die Wahl des Basis-Images: slim, Alpine und die musl-Frage

Die Basis entscheidet über Größe, Patch-Aufwand und Kompatibilität. Ein grober Vergleich der üblichen Kandidaten:

Basis-Image Größe (grob) libc Shell / Paketmanager Sinnvoll für
debian:13-slim ca. 30 MB glibc ja Standardfall, breite Binärkompatibilität
ubuntu:24.04 ca. 80 MB glibc ja wenn bestimmte Distro-Pakete gebraucht werden
alpine:3.22 ca. 8 MB musl ja (BusyBox) kleine Tools, statische Sprachen
gcr.io/distroless/base-debian12 ca. 20 MB glibc nein dynamisch gelinkte Binaries
gcr.io/distroless/static-debian12 ca. 2 MB nein statische Go- oder Rust-Binaries

Alpine ist attraktiv klein, tauscht aber glibc gegen musl. Das ist keine Kleinigkeit: Viele vorkompilierte Artefakte im Ökosystem sind gegen glibc gebaut. Python-Wheels nach manylinux funktionieren nicht, es braucht musllinux-Wheels oder eine Kompilierung aus dem Quellcode, was Build-Zeit und Toolchain zurück ins Image holt. Native Node-Module verhalten sich ähnlich. Dazu kommen Unterschiede im DNS-Resolver – musl fragt Nameserver parallel ab und behandelt search-Domains sowie TCP-Fallback anders als glibc, was in Cluster-Umgebungen zu schwer reproduzierbaren Auflösungsfehlern führt. Und die Allokator-Charakteristik von musl kann bei stark thread- und malloc-lastigen Workloads messbar Leistung kosten.

Praktische Konsequenz: Wer eine statisch gelinkte Anwendung ausliefert, nimmt distroless oder scratch. Wer eine dynamische Laufzeit mitbringt – Node, Python, Java, Ruby –, fährt mit einem -slim-Debian-Image in der Regel besser als mit Alpine, weil Kompatibilitätsprobleme teurer sind als 20 MB Image.

Identität und Rechte: nicht als root im Container laufen

Ohne USER-Anweisung läuft der Prozess als root. In der Standardkonfiguration ohne User-Namespace-Remapping ist das dieselbe UID 0 wie auf dem Host, nur durch Capabilities, Namespaces und seccomp eingeschränkt. Jede Lücke in dieser Kette wird damit unnötig folgenreich.

RUN groupadd --system --gid 10001 app \
 && useradd --system --uid 10001 --gid app --no-create-home app
USER 10001:10001

Eine numerische UID ist einem Namen vorzuziehen, weil Orchestratoren und Volume-Berechtigungen mit Zahlen arbeiten und ein Name ohne passenden Eintrag in /etc/passwd je nach Laufzeit unterschiedlich behandelt wird. Ports unter 1024 sind für unprivilegierte Prozesse gesperrt – die Anwendung lauscht deshalb auf 8080 und nicht auf 80; das Mapping übernimmt der Reverse Proxy oder die Port-Publikation.

Zusätzlich gehören drei Schalter in jede produktive Definition: read_only: true für das Root-Dateisystem mit tmpfs für Schreibpfade, cap_drop: ALL mit gezieltem Wiederhinzufügen, und no-new-privileges, das setuid-Eskalation innerhalb des Containers verhindert. Die Absicherung des Hosts selbst – Kernel-Updates, SSH, Firewall, Audit – bleibt davon unberührt und ist in Linux-Server absichern beschrieben.

Referenzierung und Registry: Tags, Digests, Aufbewahrung

Image-Digest

Der SHA-256-Hash über das Image-Manifest, geschrieben als repository@sha256:.... Er identifiziert einen Image-Inhalt eindeutig und unveränderlich: Derselbe Digest liefert immer dieselben Layer. Ein Tag dagegen ist nur ein beweglicher Zeiger, der jederzeit auf ein anderes Manifest umgehängt werden kann.

latest ist kein Versionsbezug, sondern ein Standardname. Zwei docker pull im Abstand von einer Woche können unterschiedliche Software liefern, ohne dass sich irgendwo eine Zeile geändert hat. Für Deployments gilt daher: sprechender, unveränderlicher Tag für Menschen, Digest für Maschinen.

# Digest eines lokal vorhandenen Images auslesen
docker image inspect --format '{{index .RepoDigests 0}}' registry.example.org/team/api:1.4.2

# Digest ohne Pull direkt aus der Registry ermitteln
docker buildx imagetools inspect registry.example.org/team/api:1.4.2

Zur Aufbewahrung: Images wachsen schnell in den dreistelligen Gigabyte-Bereich, wenn jeder Commit einen Tag erzeugt und nichts gelöscht wird. Eine brauchbare Regel ist, Release-Tags dauerhaft zu behalten, Commit- und Branch-Tags nach 30 bis 90 Tagen zu verwerfen und die Bereinigung als geplanten Job zu betreiben, nicht als Handarbeit. Wichtig ist dabei, dass keine Referenz eines laufenden Deployments gelöscht wird – Garbage Collection über Digests, die noch produktiv im Einsatz sind, ist ein selbstverschuldeter Ausfall.

Betrieb: PID 1, Healthchecks und Ressourcenlimits

Signalbehandlung und der Prozess mit PID 1

CMD npm start wird in Shell-Form ausgeführt und startet /bin/sh -c. Die Shell wird PID 1, das eigentliche Programm ein Kindprozess. Beim Stoppen sendet die Laufzeit SIGTERM an PID 1 – die Shell leitet es nicht weiter, die Anwendung erfährt nichts vom Shutdown, und nach Ablauf der Grace Period (bei Docker standardmäßig 10 Sekunden) folgt SIGKILL. Ergebnis: abgebrochene Verbindungen, halb geschriebene Dateien, keine Chance auf einen Connection Drain.

Die Exec-Form vermeidet die Shell und macht den Anwendungsprozess selbst zu PID 1:

ENTRYPOINT ["node", "dist/server.js"]

Damit übernimmt die Anwendung zwei Aufgaben von init: Sie muss SIGTERM behandeln und Zombie-Prozesse ernten. Ersteres ist Anwendungscode – Listener schließen, laufende Requests abarbeiten, dann beenden. Letzteres ist nur relevant, wenn der Prozess selbst Kinder erzeugt. Wo das der Fall ist oder wo ein Wrapper-Skript nötig bleibt, gehört ein minimales init dazwischen: docker run --init beziehungsweise init: true in Compose stellt tini als PID 1 voran, das Signale weitergibt und verwaiste Kinder abräumt.

Healthchecks, die eine Aussage treffen

Ein Healthcheck, der nur prüft, ob der Port offen ist, meldet „gesund“ auch dann, wenn die Datenbankverbindung seit zehn Minuten abgerissen ist. Nützlich ist ein Endpunkt, der die kritischen Abhängigkeiten flach prüft und schnell antwortet, ohne bei jedem Aufruf teure Arbeit auszulösen. Zwei getrennte Endpunkte sind sauberer als einer: Liveness beantwortet „läuft der Prozess noch sinnvoll?“, Readiness beantwortet „darf Verkehr auf diese Instanz geleitet werden?“.

services:
  api:
    image: registry.example.org/team/api@sha256:3f8c1d2e4b5a6978c0d1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7
    init: true
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    environment:
      NODE_ENV: production
    secrets:
      - db_password
    healthcheck:
      test: ["CMD", "/usr/bin/healthcheck"]
      interval: 10s
      timeout: 2s
      retries: 3
      start_period: 20s
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 512M
    volumes:
      - api-data:/var/lib/api
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"

volumes:
  api-data:

secrets:
  db_password:
    file: ./secrets/db_password

Ressourcenlimits sind keine Feinjustierung

Ohne Limit kann ein einzelner Container den gesamten Host-Speicher beanspruchen; der OOM-Killer des Kernels wählt dann ein Opfer, und das ist nicht zwingend der Verursacher. Ein memory-Limit begrenzt den Schaden auf den fehlerhaften Container. Wichtig ist der Hinweis für JVM- und Node-Workloads: Der Prozess muss das cgroup-Limit kennen, sonst dimensioniert er seinen Heap nach dem Host-Speicher und wird zuverlässig vom OOM-Killer beendet. Moderne JVMs lesen cgroup-v2-Limits von selbst, bei Node ist --max-old-space-size gegen das Limit abzustimmen. CPU-Limits wirken als Bandbreite pro Periode: Ein zu knappes Limit erzeugt Throttling und damit Latenz-Ausreißer, die in Mittelwerten unsichtbar bleiben.

Secrets, persistente Daten und Logs

Secrets gehören nicht ins Image und nicht in ENV

Ein Layer ist unveränderlich. Was einmal per COPY oder ENV hineingeraten ist, bleibt im Image – auch wenn eine spätere Instruktion die Datei löscht, denn der frühere Layer existiert weiter und ist über die Registry abrufbar. Umgebungsvariablen sind zusätzlich in der Container-Konfiguration lesbar, tauchen in Crash-Dumps und häufig in Logs von Child-Prozessen auf.

BuildKit löst den Build-Fall über Secret-Mounts, die nur für die Dauer einer RUN-Anweisung als Datei existieren und in keinem Layer landen:

docker build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  -t registry.example.org/team/api:1.4.2 .
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,mode=0400 npm ci

Zur Laufzeit ist die Datei der bessere Träger als die Variable: Compose-Secrets werden unter /run/secrets/<name> eingehängt, ein externer Secret-Store liefert sie über einen Agenten oder eine Projektion. Die Anwendung liest den Pfad, nicht die Umgebung – das erlaubt außerdem Rotation ohne Neustart, wenn der Code die Datei bei Bedarf erneut liest.

Zustand gehört in benannte Volumes

Das Container-Dateisystem ist flüchtig; die Overlay-Schreibschicht verschwindet mit dem Container. Alles, was einen Neustart überleben muss, gehört in ein benanntes Volume oder auf externen Speicher. Bind-Mounts sind in der Entwicklung praktisch, koppeln aber Pfad und Berechtigungen an den konkreten Host – im Betrieb ist ein benanntes Volume die robustere Wahl, weil die Laufzeit Eigentümerschaft und Lebenszyklus verwaltet. Und: Ein Volume ist kein Backup. Ein pg_dump in ein versioniertes Ziel bleibt erforderlich, samt geprüfter Wiederherstellung.

Logs nach stdout, nicht in Dateien

Ein Container schreibt auf stdout und stderr, die Laufzeit übernimmt Sammlung und Rotation. Logdateien im Container laufen sonst gegen die Plattengrenze des Hosts, weil nichts sie rotiert. Wer das json-file-Backend nutzt, sollte max-size und max-file setzen – ohne diese Optionen wachsen die Logs unbegrenzt, und ein voller /var/lib/docker legt sämtliche Container auf dem Host still. Strukturierte Zeilen als JSON mit Zeitstempel, Level und Korrelations-ID machen die Auswertung erst möglich; Details zum Aufbau einer solchen Pipeline gehören in den Bereich DevOps und Cloud.

Lieferkette: Scanning, SBOM und Signaturen

Ein Image bündelt fremden Code: Basis-Distribution, Sprachlaufzeit, hunderte transitive Abhängigkeiten. Ohne Inventar ist die Frage „sind wir von CVE-X betroffen?“ nicht in Minuten beantwortbar. BuildKit kann beim Bauen Attestierungen erzeugen – eine SBOM im SPDX-Format und einen Provenance-Eintrag, der Build-Kontext und Parameter festhält:

docker buildx build \
  --sbom=true --provenance=mode=max \
  -t registry.example.org/team/api:1.4.2 --push .
  1. Im Build eine SBOM und Provenance-Attestierung erzeugen und mit dem Image in die Registry schieben.
  2. Das Image scannen, bevor es einen Release-Tag erhält, und den Befund an den Digest binden, nicht an den Tag.
  3. Eine Schwellenregel festlegen: Welche Schwere blockiert den Release, welche erzeugt ein Ticket mit Frist, was wird mit Begründung akzeptiert.
  4. Das Image nach dem Scan signieren und die Signatur beim Deployment prüfen.
  5. Basis-Images regelmäßig neu bauen – ein unverändertes Dockerfile erzeugt nach zwei Monaten dennoch ein Image mit bekannten Lücken.
  6. Den Befund nach dem Release wiederholen: Neue CVEs betreffen Images, die längst produktiv laufen.

Der letzte Punkt wird am häufigsten übersehen. Scannen ist kein Gate, das man einmal passiert, sondern ein wiederkehrender Abgleich zwischen Inventar und Schwachstellendatenbank. Wie sich das zu Signaturprüfung und Abhängigkeitsverwaltung verhält, behandelt der Artikel zur Absicherung der Software-Lieferkette im Detail.

Fazit

Die Fallstricke im produktiven Container-Betrieb sind selten exotisch. Es sind latest statt Digest, root statt dedizierter UID, Shell-Form statt Exec-Form, Secrets in ENV, fehlende Limits und unrotierte Logs – jeder Punkt einzeln klein, in Summe der Unterschied zwischen einem Deployment, das man nachts anfassen kann, und einem, das man lieber nicht anfasst. Die Gegenmaßnahmen sind wohlbekannt und kosten hauptsächlich Disziplin: eine Handvoll Zeilen im Dockerfile, ein paar Schalter in der Compose-Datei, zwei zusätzliche Schritte in der Pipeline. Wer diese Basis legt, gewinnt zudem Freiheit bei der Laufzeit: Ein OCI-konformes Image läuft unter Docker, Podman und im Cluster gleichermaßen. Genau das ist der praktische Wert offener Standards an dieser Stelle – die Entscheidung über die Ausführungsumgebung bleibt revidierbar.

Quellen und weiterführende Dokumentation

  1. Docker Docs – Build und BuildKit
  2. Docker Docs – Dockerfile-Referenz
  3. Docker Docs – Compose-File-Referenz
  4. Podman Documentation
  5. OCI Image Format Specification
  6. NIST SP 800-190 – Application Container Security Guide

Themen

  • Docker
  • Container
  • DevOps
  • Security
  • Build

Passende Leistung

Dazu arbeiten wir konkret

Kontakt

Technische Herausforderung besprechen

Beschreiben Sie kurz Ihr System und die offene Frage. Wir antworten mit einer fachlichen Einschätzung – ohne Vertriebsschleife.