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
ENTRYPOINTundCMDgenau - 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 derbuild-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 ciFunktioniert 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.privatdocker 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 wieSIGTERMnicht 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 1docker ps
# STATUS PORTS
# Up 2 minutes (healthy) 3000/tcpIn 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 myappOder 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 \
myappDiese 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 appOder 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 Shell —
docker exec shgeht nicht. Das ist ein Feature, kein Bug. Für Debug-Builds gibt es*:debug-Varianten.
Read-only Filesystem
docker run --read-only --tmpfs /tmp myappWenn 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 MaterialsIn 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
.vscodeOhne .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_modulesins 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 MalLö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:
- Zerlege es in
deps,build,runtime-Stages. - Wechsle auf ein Distroless- oder
*-slim-Runtime-Image. - Füge einen nicht-root User hinzu.
- Baue mit
--mount=type=cachefür deinen Paket-Manager. - Ergänze einen
HEALTHCHECK. - Vergleiche Größe (
docker images) und Scan-Ergebnisse vorher/nachher.
Weiterführendes
- Dockerfile-Referenz: https://docs.docker.com/reference/dockerfile/
- BuildKit-Frontend-Syntax: https://docs.docker.com/build/dockerfile/frontend/
- Distroless: https://github.com/GoogleContainerTools/distroless
- Snyk „10 Best Practices": https://snyk.io/blog/10-docker-image-security-best-practices/