Das Basis-Image, in dem ein Go-Service gebaut wird, wiegt 241 MB. Das Basis-Image, in dem er laufen soll, 2,21 MB. Zwischen diesen beiden Zahlen liegt der ganze Sinn eines Production-Dockerfile: In die Produktion darf nichts mitfahren, was nur beim Bauen gebraucht wurde. Alle Größen unten sind ein docker images-Messwert auf linux/amd64.
Multi-Stage: bauen hier, laufen dort
Multi-Stage wird meist als Mittel zum kleineren Image verkauft. Die Verkleinerung ist ein Nebeneffekt. Entscheidend ist, dass die letzte Schicht auf einer sauberen Basis beginnt und genau das enthält, was von Hand kopiert wurde: Compiler, Paketmanager, Header-Dateien und Shell landen nicht darin, weil sie niemand kopiert hat.
# syntax=docker/dockerfile:1
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /bin/app ./cmd/app
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /bin/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
Die erste Zeile ist keine Zierde: Sie holt das aktuelle Dockerfile-Frontend statt des im Daemon eingebauten, und von dessen Version hängt ab, ob --mount und --link aus den nächsten Abschnitten überhaupt verfügbar sind.
| Basis | Größe | Wann sie passt |
|---|---|---|
distroless/static-debian13 | 2,21 MB | statisch gelinkte Binaries: Go mit CGO_ENABLED=0, Rust |
alpine:3.24 | 8,42 MB | Shell, busybox oder Paketmanager werden gebraucht |
distroless/base-debian13 | 24,3 MB | dynamisches Linken gegen glibc |
debian:trixie-slim | 78,6 MB | Systemabhängigkeiten, die es unter musl nicht gibt |
Ein Tag ohne Distributions-Suffix zeigt derzeit auf -debian13, upstream warnt aber, dass er irgendwann auf das nächste Debian wechselt. Das Suffix schreibt man besser aus.
Cache: Die Reihenfolge der Schichten wiegt schwerer als Flags
Eine Regel: Was sich selten ändert, wird zuerst kopiert. Das Abhängigkeitsmanifest in einem eigenen COPY, dann die Installation und erst danach der Quellcode. Andersherum invalidiert die Änderung einer einzigen Codezeile die Paketinstallation.
BuildKit legt darüber noch Cache-Mounts, die eine invalidierte Schicht überleben: Das Verzeichnis liegt auf dem Host und wird in den Build eingehängt.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install --no-install-recommends -y gcc
sharing=locked ist hier Pflicht — apt überlebt zwei parallele Builds in einem Verzeichnis nicht. Das zweite unterschätzte Flag: COPY --link legt das Kopierte auf eine eigene Schicht, unabhängig von den vorherigen, und die wird selbst nach einem Neubau der Basis wiederverwendet.
Ein Secret in einer Schicht bleibt in dieser Schicht
Ein Image ist kein Archiv, sondern ein Stapel unveränderlicher Schichten. Ein Token, das in einem RUN geschrieben und im nächsten gelöscht wird, ist nirgendwo hin: rm hat es nur aus der obersten Sicht auf das Dateisystem entfernt.
docker history --no-trunc my-app:latest
docker save my-app:latest | tar -xO | strings | grep -i 'token\|secret'
Herausholen kann es jeder mit pull-Zugriff, ohne einen einzigen Exploit. Es gibt genau einen Weg, der funktioniert: --mount=type=secret hängt die Datei für die Dauer der Instruktion ein, und sie landet weder in einer Schicht noch im Manifest.
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
ARG taugt dafür nicht einmal in der Builder-Stage: Der Wert bleibt in deren Schichten und im Build-Cache auf dem Host, und Multi-Stage schützt nur das finale Image. Laufzeit-Secrets sind eine eigene Aufgabe, dort arbeiten External Secrets Operator und Workload Identity, nicht der Build.
Non-root und Signale
USER mit numerischer UID ist keine Kosmetik. Kubernetes mit runAsNonRoot: true startet keinen Container, in dessen Image-Konfiguration ein Benutzername statt einer Zahl steht: Das kubelet kann nicht prüfen, ob sich der Name nicht doch zu root auflöst. Die nonroot-Tags von distroless nutzen 65532.
Der verbreitete Glaube, USER müsse vor CMD stehen, sonst starte der Container als root, stimmt nicht. Zwei Images mit umgekehrter Reihenfolge dieser Instruktionen liefern dieselbe uid=1001: Der Wert stammt aus der letzten USER-Instruktion der Datei, die Position relativ zu CMD spielt keine Rolle. Die Prüfung dauert eine Minute und lohnt sich, bevor man diese Regel aus einer fremden Checkliste in die eigene übernimmt.
Kaputt geht etwas anderes — die Schreibweise des Entrypoints. Die Shell-Form ENTRYPOINT app startet /bin/sh -c, und PID 1 bekommt die Shell: Das Signal erreicht den Prozess nicht, docker stop sitzt das Timeout ab und killt den Container. Die Docker-Dokumentation misst den Unterschied — 10,19 Sekunden gegen 0,20. Die Exec-Form ENTRYPOINT ["/app"] hat das Problem nicht.
Attestierungen kommen von selbst, SBOMs nicht
BuildKit fügt standardmäßig eine Provenance-Attestierung der Stufe mode=min hinzu: Der Nachweis, wer woraus gebaut hat, ist da, auch wenn niemand danach gefragt hat. Ein SBOM entsteht nicht von allein.
docker buildx build --provenance=mode=max --sbom=true --push -t app:1.0 .
Für einen internen Service ist das Hygiene, für ein Produkt auf dem EU-Markt eine Anforderung des Cyber Resilience Act mit Frist im September 2026. Provenance aus BuildKit ist Vorarbeit für SLSA-Stufen, kein Ersatz: Signatur und Verifikation bleiben bei der Pipeline.
Was distroless kostet
Im Image gibt es keine Shell — damit funktioniert weder ein HEALTHCHECK mit curl noch das gewohnte docker exec sh. Die Health-Prüfung wandert zum kubelet, das Debugging in einen Ephemeral Container (kubectl debug --target) oder auf den Tag :debug, der busybox mitbringt. Braucht die Anwendung zur Laufzeit eine Shell, passt distroless schlicht nicht, und das ist ein normales Ergebnis: alpine mit 8,42 MB schleppt auch keinen Compiler in die Produktion.
Checkliste
- Multi-Stage; in die finale Schicht werden nur Artefakte kopiert.
- Die Basis ist per Digest
@sha256:…festgenagelt, nicht nur per Tag. - Abhängigkeiten werden vor dem Quellcode kopiert und installiert; schwere Caches laufen über
--mount=type=cache. - Kein einziges Secret über
ARGoderENV— nur--mount=type=secret. USERmit numerischer UID,ENTRYPOINTin Exec-Form..dockerignoredeckt.git,.env,node_modulesund den Terraform-State ab.--sbom=trueist bewusst an, nicht „wenn jemand fragt".
Kein Punkt braucht ein neues Werkzeug: Alles davon ist BuildKit, seit Docker Engine 23.0 standardmäßig aktiv. Bleibt, ein Dutzend Zeilen in der richtigen Reihenfolge zu schreiben.