← Alle Beiträge

Container Best Practices — Jenseits der Grundlagen

Matthias Bruns · · 5 Min. Lesezeit
docker containers cloud-native engineering

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:

StackZielgrößeBase Image
Go< 20MBdistroless/static oder scratch
Node.js< 150MBnode:22-alpine
Java< 200MBeclipse-temurin:21-jre-alpine
Python< 150MBpython: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
  • .dockerignore deckt .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.

Lesebarkeit

Schriftgröße