edu@dorner-it
WerkzeugeFortgeschritten25 min

Docker Fortgeschritten: Multi-Stage, BuildKit & Security

Schlanke, schnelle und sichere Images bauen: Multi-Stage-Patterns, BuildKit-Features (Cache- & Secret-Mounts, Multi-Arch), Distroless, Healthchecks und der Unterschied zwischen ENTRYPOINT und CMD.

Zuletzt aktualisiert: 12. Mai 2026

docker-basics hat dir Image, Container und ein erstes Dockerfile gezeigt. In der Praxis wachsen Images, Build-Zeiten explodieren und Security-Reports werden länger. Hier kommen die Werkzeuge, mit denen du das in den Griff bekommst.

Lernziele

  • Du baust schlanke Images mit klar getrennten Stages
  • Du nutzt BuildKit für Cache-Mounts, Secrets und Multi-Arch-Builds
  • Du kennst den Unterschied zwischen ENTRYPOINT und CMD genau
  • Du härtest Container gegen die häufigsten Security-Fallen

Multi-Stage richtig nutzen

Multi-Stage ist nicht nur „Build + Runtime trennen". Du kannst beliebig viele Stages haben und gezielt Artefakte zwischen ihnen kopieren.

# syntax=docker/dockerfile:1.7
 
# 1. Dependencies separat (cached, wenn lockfile sich nicht ändert)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
 
# 2. Build (mit Dev-Deps)
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
# 3. Dependencies nur für Produktion
FROM node:20-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
 
# 4. Runtime — minimal
FROM gcr.io/distroless/nodejs20-debian12 AS runtime
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build    /app/dist          ./dist
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]

Trick: Du kannst eine Stage gezielt bauen (docker build --target build .), z. B. um Tests in der build-Stage laufen zu lassen, ohne das Runtime-Image zu erzeugen.

BuildKit — was du nicht missen willst

BuildKit ist seit langem Standard, viele Features lassen sich aber nur mit der # syntax=docker/dockerfile:1.7-Direktive nutzen.

Cache-Mounts

Persistente Caches außerhalb des Layers — überleben Build-Aufrufe, landen aber nicht im fertigen Image.

# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

Funktioniert genauso für apt, go mod, pip, cargo, maven etc.

Secret-Mounts

Geheimnisse während des Builds verfügbar machen, ohne sie ins Image zu brennen.

# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --registry=https://registry.privat
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

Im finalen Image ist von .npmrc keine Spur — kein Layer enthält das Secret.

Multi-Arch Builds mit buildx

Ein Image, das auf amd64 und arm64 läuft (z. B. M-Macs, Graviton):

docker buildx create --use --name multi
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t registry.example.com/myapp:1.0 \
  --push .

ENTRYPOINT vs CMD — der saubere Vertrag

Eine der häufigsten Verwirrungen in Dockerfiles:

Direktive Rolle
ENTRYPOINT Was ausgeführt wird (das Programm)
CMD Welche Argumente standardmäßig anhängen
ENTRYPOINT ["node", "dist/server.js"]
CMD ["--port", "3000"]
docker run myapp                  # node dist/server.js --port 3000
docker run myapp --port 8080      # node dist/server.js --port 8080
docker run --entrypoint sh myapp  # sh   (CMD wird ignoriert)

Faustregel: Programme mit fester Identität → ENTRYPOINT setzen. Einfache Tools / Skripte → nur CMD, dann kann der User komplett überschreiben.

Shell- vs Exec-Form: Immer ["binary", "arg"] (Exec-Form) nutzen. Die Shell-Form (CMD node dist/server.js) startet eine Subshell und fängt Signale wie SIGTERM nicht sauber ab.

Healthchecks

Ein laufender Prozess ist nicht zwangsläufig gesund. Docker (und Orchestratoren) können das prüfen:

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -qO- http://localhost:3000/healthz || exit 1
docker ps
# STATUS                  PORTS
# Up 2 minutes (healthy)  3000/tcp

In Compose und Kubernetes ist das die Grundlage für depends_on mit Bedingung bzw. readinessProbe.

Init-Prozess & Signal-Handling

Wenn dein Prozess nicht selbst SIGTERM & Zombies handhaben kann, brauchst du einen Init-Prozess (PID 1):

docker run --init myapp

Oder direkt im Image (z. B. tini). Symptom für ein fehlendes Init: docker stop braucht 10 Sekunden und kickt dann hart.

Resource Limits

Per Default darf ein Container alles verbrauchen. Im Alltag:

docker run -d \
  --memory=512m \
  --cpus="1.5" \
  --pids-limit=200 \
  myapp

Diese Limits sind nicht nur Schutz vor Bugs — Java/Node/Go-Runtimes lesen sie aus und passen ihre internen Pools daraufhin an.

Security-Hardening

Nie als root

RUN addgroup -S app && adduser -S app -G app
USER app

Oder direkt ein Distroless-Image mit eingebautem nonroot-User.

Minimale Basis-Images

Basis Größe (ca.) Wann
ubuntu 75 MB Wenn du wirklich apt brauchst
*-slim 30–80 MB Debian, ohne Doku/Locales
alpine 5–15 MB Klein, aber musl statt glibc
distroless 20–30 MB Nur Runtime — keine Shell, kein Paketmanager
scratch 0 B Statisch gelinkte Binaries (Go, Rust)

Distroless hat keine Shelldocker exec sh geht nicht. Das ist ein Feature, kein Bug. Für Debug-Builds gibt es *:debug-Varianten.

Read-only Filesystem

docker run --read-only --tmpfs /tmp myapp

Wenn dein Container das aushält, ist das eine starke Defense-in-Depth- Maßnahme.

Scanner & SBOMs

docker scout cves myapp:1.0
trivy image myapp:1.0
docker sbom myapp:1.0           # Software Bill of Materials

In der CI: bei HIGH/CRITICAL ohne Fix → Build brechen.

.dockerignore — Pflicht für Build-Performance

# Build-Output, Logs, Caches
node_modules
dist
.next
.cache
coverage
 
# Secrets & lokale Configs
.env*
*.pem
.aws
 
# VCS / IDE
.git
.gitignore
.idea
.vscode

Ohne .dockerignore schickt docker build deinen ganzen Workspace inklusive Git-Historie an den Daemon — langsam und gefährlich.

Häufige Stolperfallen

„Mein Image ist trotz Multi-Stage groß"

docker image history myapp zeigt die Layer-Größen. Häufige Täter:

  • Build-Tools in der falschen Stage (Compiler, JDK)
  • Caches (pip/npm/apt) nicht aufgeräumt
  • Lokale node_modules ins Image kopiert (→ .dockerignore)

„Build dauert ewig, obwohl nur eine Zeile geändert wurde"

Du hast den Cache zerstört. Klassisch:

COPY . .            # ← jede Änderung invalidiert den Cache
RUN npm ci          # ← ab hier neu, jedes Mal

Lösung: erst package*.json, dann npm ci, dann der Rest.

„Secrets sind im Image gelandet"

docker history zeigt jeden Layer-Befehl im Klartext. Selbst gelöschte Dateien sind in vorherigen Layern noch da. Immer --secret-Mounts nutzen — niemals ENV API_KEY=... oder COPY .env ..

„Container reagiert nicht auf docker stop"

PID 1 ignoriert SIGTERM (typisch bei Shell-Form-CMD oder fehlendem Init). Fix: Exec-Form für CMD/ENTRYPOINT oder --init.

Praxis-Übung

Nimm dein Image aus docker-basics und mache daraus ein produktions­ taugliches:

  1. Zerlege es in deps, build, runtime-Stages.
  2. Wechsle auf ein Distroless- oder *-slim-Runtime-Image.
  3. Füge einen nicht-root User hinzu.
  4. Baue mit --mount=type=cache für deinen Paket-Manager.
  5. Ergänze einen HEALTHCHECK.
  6. Vergleiche Größe (docker images) und Scan-Ergebnisse vorher/nachher.

Weiterführendes