Container Best Practices — Jenseits der Grundlagen
Dein Dockerfile ist wahrscheinlich kaputt
Nicht kaputt im Sinne von “baut nicht.” Kaputt im Sinne von: aufgebläht, unsicher, langsam beim Bauen, unmöglich zu debuggen und läuft als Root in Produktion. Die meisten Teams kopieren ein Dockerfile von Stack Overflow aus 2019 und fassen es nie wieder an. So wird das gefixt.
Multi-Stage Builds sind Pflicht
Wenn Dein finales Image gcc, npm oder go build Toolchains enthält, lieferst Du die Werkstatt gleich mit aus. Multi-Stage Builds trennen Build-Abhängigkeiten von der Runtime.
# Build-Stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
# Runtime-Stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
Die Build-Stage zieht die gesamte Go-Toolchain rein — ungefähr 300MB. Das finale Image? Unter 15MB. Das ist keine Mikrooptimierung. Es ist der Unterschied zwischen einem 3-Sekunden- und einem 45-Sekunden-Pull auf kalten Nodes. Multiplizier das mit 50 Pods, die während eines Traffic-Spikes hochskalieren, und die Rechnung wird ernst.
Das gleiche Muster funktioniert für Node.js, Rust, Java — jede kompilierte oder gebündelte Ausgabe. Das Prinzip: In einer Stage bauen, Artefakte in eine minimale Runtime kopieren.
Hör auf, als Root zu laufen
Die Docker-Dokumentation sagt es seit Jahren, trotzdem laufen die meisten Produktions-Container immer noch als root. Ein Container-Escape, und der Angreifer besitzt den Host.
# Schlecht: impliziter Root
FROM node:22-alpine
COPY . /app
CMD ["node", "/app/index.js"]
# Besser: expliziter Non-Root-User
FROM node:22-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["node", "index.js"]
Noch besser: Distroless Images verwenden, die einen nonroot-User mitbringen und null Shell-Utilities enthalten. Keine Shell bedeutet keine Shell-Exploits.
Image-Größe zählt tatsächlich
“Speicher ist billig” ist das falsche Denkmodell für Container-Images. Image-Größe beeinflusst:
- Pull-Zeit. Jeder neue Node, jedes Scale-Up, jedes Rollout zieht das Image. In Autoscaling-Szenarien wirkt sich Pull-Zeit direkt auf die Antwortlatenz aus.
- Angriffsfläche. Jede Binary in Deinem Image ist eine potenzielle Schwachstelle. Der Sysdig 2025 Container Security Report zeigt, dass über 80% der Container-CVEs von Paketen stammen, die die Anwendung nie benutzt.
- Build-Cache-Effizienz. Kleinere Layer bedeuten schnellere Cache-Invalidierung und weniger Bandbreite zwischen CI und Registry.
Größenziele, die in der Praxis funktionieren:
| Stack | Zielgröße | Base Image |
|---|---|---|
| Go | < 20MB | distroless/static oder scratch |
| Node.js | < 150MB | node:22-alpine |
| Java | < 200MB | eclipse-temurin:21-jre-alpine |
| Python | < 150MB | python:3.12-slim |
Layer-Reihenfolge ist eine Build-Cache-Strategie
Docker cacht Layer von oben nach unten. Wenn sich ein Layer ändert, wird alles darunter neu gebaut. Das bedeutet: Dependency-Installation sollte vor dem Quellcode kommen:
# Dependencies zuerst (ändert sich selten)
COPY package.json package-lock.json ./
RUN npm ci --production
# Quellcode danach (ändert sich oft)
COPY src/ ./src/
Dreh die Reihenfolge um, und jede Code-Änderung triggert ein volles npm ci. Bei einem Projekt mit 800 Dependencies sind das 90 Sekunden Verschwendung pro Build.
Scannen vor dem Ausliefern
Container-Scanning ist keine Option mehr. Es ist Standard. Tools, die sich lohnen:
- Trivy — Open-Source, schnell, deckt OS-Pakete und Sprach-Dependencies ab. In CI einbinden:
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
- Grype — Anchores Open-Source-Scanner, gute Alternative zu Trivy mit SBOM-Integration.
- Snyk Container — kommerziell, aber mit kostenlosem Tier. Integriert sich mit GitHub und liefert Fix-Vorschläge.
Der Schlüssel: Build bei HIGH/CRITICAL-CVEs fehlschlagen lassen. Keine Reports generieren, die keiner liest. Als Gate in CI verdrahten.
Lokale Entwicklung sollte nicht Produktion spiegeln
Ein häufiger Fehler: das gleiche Dockerfile für lokale Entwicklung und Produktion nutzen. Produktion will minimale, unveränderliche Images. Lokale Entwicklung will Hot Reload, Debugger und schnelles Feedback.
Nutze eine docker-compose.override.yml für lokal:
# docker-compose.yml (Basis)
services:
app:
image: myapp:latest
ports:
- "8080:8080"
# docker-compose.override.yml (lokale Entwicklung, automatisch geladen)
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- ./src:/app/src
environment:
- NODE_ENV=development
Docker Compose merged die Override-Datei automatisch. Produktion nutzt die Basis. Entwicklung bekommt Volume Mounts und ein dev-freundliches Dockerfile. Keine bedingte Logik, keine Build Args, kein “if development then” im Dockerfile.
.dockerignore ist Deine erste Verteidigungslinie
Ohne .dockerignore schickt Docker Dein gesamtes Projektverzeichnis als Build Context. Das beinhaltet .git (potenziell hunderte MB), node_modules, Test-Fixtures, lokale Env-Dateien und alles andere, was rumliegt.
.git
node_modules
*.md
.env*
coverage/
dist/
.vscode/
Bei einem mittelgroßen Projekt kann ein ordentliches .dockerignore den Build Context von 500MB auf 5MB reduzieren. Das sind nicht nur schnellere Builds — es sind auch weniger Secrets, die versehentlich in Layer eingebacken werden.
Health Checks gehören ins Image
Verlass Dich nicht nur auf Kubernetes Liveness/Readiness Probes. Eine HEALTHCHECK-Instruktion im Dockerfile bietet eine Baseline, die überall funktioniert — Docker Compose, Swarm, ECS und einfaches docker run:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD ["/app/healthcheck"]
Verwende eine kompilierte Binary für den Health Check, nicht curl oder wget. Diese Tools existieren möglicherweise nicht in Deinem minimalen Image, und sie vergrößern die Angriffsfläche unnötig.
Base Images pinnen
# Schlecht: Floating Tag, anderes Image bei jedem Build
FROM node:22-alpine
# Besser: auf Digest pinnen
FROM node:22-alpine@sha256:<64-char-sha256-digest>
Floating Tags bedeuten, dass Dein Montag-Build und Dein Freitag-Build unterschiedliche Base Images nutzen könnten. Pinning auf einen Digest garantiert Reproduzierbarkeit. Den Digest gezielt aktualisieren, als Teil eines Dependency-Update-Workflows — nicht versehentlich, weil Docker Hub ein neues 22-alpine gepusht hat.
Tools wie Renovate können Digest-Pinning-Updates automatisieren — Reproduzierbarkeit ohne Veralten.
Die Checkliste
Bevor ein Container in Produktion geht:
- Multi-Stage Build mit Trennung von Build und Runtime
- Läuft als Non-Root-User
- Base Image auf Digest gepinnt
-
.dockerignoredeckt.git,node_modules, Env-Dateien ab - Image in CI auf HIGH/CRITICAL-CVEs gescannt
- Health Check definiert
- Finales Image unter der Zielgröße für den Stack
- Keine Secrets in Build Args oder Layern
Nichts davon ist revolutionär. Es ist Hygiene. Aber Hygiene ist das, was Container, die jahrelang zuverlässig laufen, von Containern trennt, die zu Security-Incidents werden.