Ein Go-Service, dessen Binärdatei 12 MB misst, landet in einem naiv gebauten Image schnell bei über einem Gigabyte: Compiler, Modul-Cache und Build-Tools werden mit ausgeliefert. Multi-Stage Builds trennen die Build-Umgebung sauber vom Laufzeit-Image. Dieses Tutorial zeigt den Umbau an einem lauffähigen Dockerfile, inklusive Cache-Mounts, Zielstufen und Verifikation.
Was ist ein Docker Multi-Stage Build?
Ein Docker Multi-Stage Build ist ein Dockerfile mit mehreren FROM-Anweisungen, bei dem jede Stage ein eigenes Zwischen-Image erzeugt und nur ausgewählte Artefakte per COPY --from=<stage> in die finale Stage übernommen werden.
Alles, was in einer früheren Stage installiert oder kompiliert wurde und nicht explizit kopiert wird, taucht im Ergebnis-Image nicht mehr auf. Compiler, Header-Dateien, Dev-Dependencies und Paket-Caches bleiben zurück. Das Feature existiert seit Docker 17.05 und wurde mit BuildKit, dem Standard-Builder seit Docker 23.0, um parallele Stage-Ausführung und Cache-Mounts erweitert.
Voraussetzungen
- Docker Engine 23.0 oder neuer, geprüft mit
docker --version - Ein Linux-Host mit
sudo-Rechten oder Mitgliedschaft in der Gruppedocker, zum Beispiel eine skalierbare Cloud-VM für Build-Runner - Ein Beispielprojekt in Go 1.24 mit
go.mod,go.sumundcmd/api/main.go - Etwa 3 GB freier Speicherplatz für Zwischen-Images und Build-Cache
Die Technik selbst ist sprachunabhängig. Die Beispiele nutzen Go, weil der Unterschied dort am deutlichsten ausfällt. Für Node.js, Java oder Rust gilt dasselbe Muster.
Ausgangslage: das Single-Stage-Image
Das typische erste Dockerfile baut und startet die Anwendung im selben Image:
FROM golang:1.24
WORKDIR /src
COPY . .
RUN go build -o /bin/api ./cmd/api
EXPOSE 8080
CMD ["/bin/api"]Der Build funktioniert, das Ergebnis ist jedoch unnötig groß:
$ docker build -t api:single .
$ docker images api:single --format '{{.Repository}}:{{.Tag}} {{.Size}}'
api:single 1.13GBIn diesen 1,13 GB stecken der Go-Compiler, die Standardbibliothek im Quelltext, der heruntergeladene Modul-Cache unter /go/pkg/mod und der komplette Anwendungsquellcode. Zur Laufzeit wird davon nichts benötigt. Jedes zusätzliche Paket vergrößert außerdem die Angriffsfläche und die Zahl der CVEs, die ein Scanner meldet.
Passende Infrastruktur bei centron
Zum Mitmachen braucht es keine eigene Hardware: ccloud³ VMs mit vollem Root-Zugriff, stundengenau abgerechnet und in Sekunden startklar. Cloud-Server mieten →
Dockerfile in Stages aufteilen
Teile das Dockerfile in zwei Stages: eine Build-Stage mit der vollständigen Toolchain und eine Runtime-Stage, die nur das fertige Artefakt enthält.
# syntax=docker/dockerfile:1
# ---------- Stage 1: Build ----------
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/api ./cmd/api
# ---------- Stage 2: Runtime ----------
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=build /out/api /usr/local/bin/api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/api"]Drei Details sind entscheidend:
AS buildbenennt die Stage. Ohne Namen müsstest du sie über ihren Index ansprechen (COPY --from=0), was bei jeder Umsortierung bricht.CGO_ENABLED=0erzwingt eine statisch gelinkte Binärdatei. Nur so läuft sie in einem Image ohne glibc.-ldflags="-s -w"entfernt Symboltabelle und DWARF-Debug-Informationen und spart je nach Projekt 20 bis 30 Prozent der Binärgröße.
Nur die Artefakte kopieren
COPY --from=build akzeptiert Pfade und Wildcards wie ein normales COPY. Kopiere gezielt einzelne Dateien, nicht ganze Verzeichnisse:
COPY --from=build /out/api /usr/local/bin/api
COPY --from=build /src/web/dist /srv/staticDie Quelle muss keine eigene Stage sein. Auch ein externes Image lässt sich direkt anzapfen, etwa für CA-Zertifikate oder ein einzelnes Binary:
COPY --from=ghcr.io/example/tools:1.4 /usr/bin/migrate /usr/bin/migrateWarum wird das Image kleiner?
Das Image wird kleiner, weil Docker nur die Layer der letzten Stage in das Ergebnis-Image schreibt. Alle Layer der Build-Stage bleiben Zwischenergebnisse im Build-Cache und werden nie Teil des veröffentlichten Manifests.
graph TD
A["Quellcode, go.mod, go.sum"] --> B["Stage build: golang:1.24-alpine"]
B --> C["Compiler, Modul-Cache, Objektdateien"]
B --> D["Binärdatei /out/api"]
D -->|"COPY --from=build"| E["Stage runtime: distroless static"]
C -.->|"bleibt im Build-Cache"| F["Nicht Teil des Images"]
E --> G["Finales Image: ca. 14 MB"]
Die Wahl des Runtime-Basis-Image bestimmt den Rest der Größe:
| Basis-Image | Ungefähre Größe | Enthält Shell | Typischer Einsatz |
|---|---|---|---|
golang:1.24 |
815 MB | ja | nur zum Bauen |
debian:12-slim |
75 MB | ja | dynamisch gelinkte Binaries |
alpine:3.20 |
8 MB | ja | musl-Binaries, Debugging nötig |
gcr.io/distroless/static-debian12 |
2 MB | nein | statische Binaries |
scratch |
0 MB | nein | vollständig statisch, ohne Zertifikate |
distroless ist in der Praxis der beste Kompromiss: klein, ohne Shell und Paketmanager, aber mit CA-Zertifikaten, /etc/passwd und Zeitzonendaten an Bord. Wenn du Images regelmäßig automatisiert baust und ausrollst, lohnt es sich, Build-Runner und Registry-Speicher auf einer Cloud-VM mit dediziertem CPU-Anteil zu betreiben, damit parallele Stages nicht um Rechenzeit konkurrieren.
Build-Cache gezielt nutzen
Die Reihenfolge der Anweisungen entscheidet über die Build-Dauer. COPY go.mod go.sum ./ vor COPY . . sorgt dafür, dass go mod download nur dann erneut läuft, wenn sich die Abhängigkeiten ändern, nicht bei jeder Codeänderung.
Mit BuildKit lässt sich der Paket-Cache zusätzlich aus dem Layer herauslösen:
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/api ./cmd/apiDer Cache überlebt einzelne Builds, wird aber nie in ein Layer geschrieben. Dasselbe Muster funktioniert mit /root/.npm, /root/.m2 oder /var/cache/apt.
In CI-Systemen ohne persistenten Build-Cache exportierst du ihn in die Registry:
$ docker buildx build \
--cache-to type=registry,ref=registry.example.net/api:buildcache,mode=max \
--cache-from type=registry,ref=registry.example.net/api:buildcache \
-t registry.example.net/api:1.4.0 --push .mode=max speichert auch die Layer der Zwischenstages. Ohne diese Option landet nur die finale Stage im Cache, was den Nutzen bei Multi-Stage Builds fast aufhebt.
Einzelne Stages gezielt bauen
docker build --target <stage> stoppt den Build nach der angegebenen Stage. Das ist nützlich, um die Build-Umgebung zu inspizieren:
$ docker build --target build -t api:builder .
$ docker run --rm -it api:builder shDasselbe Prinzip trägt eine dedizierte Test-Stage, die in der CI läuft, aber nie ausgeliefert wird:
FROM build AS test
RUN go vet ./... && go test ./... -race -count=1$ docker build --target test .Schlägt ein Test fehl, bricht der Build mit Exit-Code ungleich null ab. Ohne --target baut Docker immer bis zur letzten Stage im Dockerfile, die Test-Stage wird dann übersprungen, sofern keine spätere Stage per COPY --from=test darauf verweist.
Verifikation
Vergleiche beide Images direkt:
$ docker build -t api:multi .
$ docker images 'api' --format 'table {{.Tag}}\t{{.Size}}'
TAG SIZE
multi 14.2MB
single 1.13GBPrüfe anschließend, welche Layer im finalen Image liegen. Erwartet werden die Layer des Basis-Image plus genau ein Layer für das kopierte Binary:
$ docker history api:multi --no-trunc --format '{{.Size}}\t{{.CreatedBy}}'
12.4MB COPY /out/api /usr/local/bin/api # buildkit
0B USER nonroot:nonrootZum Schluss ein Funktionstest des Containers:
$ docker run --rm -d -p 8080:8080 --name api-test api:multi
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/healthz
200
$ docker rm -f api-testWenn Größe, Layer-Liste und HTTP-Antwort stimmen, ist der Umbau abgeschlossen.
Troubleshooting
exec /usr/local/bin/api: no such file or directory trotz vorhandener Datei: Die Binärdatei ist dynamisch gelinkt, der Loader fehlt im Runtime-Image. Prüfe das mit docker run --rm api:builder ldd /out/api. Meldet der Befehl "not a dynamic executable", ist alles korrekt. Andernfalls setze CGO_ENABLED=0 oder wechsle auf gcr.io/distroless/base-debian12.
x509: certificate signed by unknown authority bei ausgehenden HTTPS-Verbindungen: Dem Image fehlen die CA-Zertifikate. Bei scratch musst du sie explizit kopieren:
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/Falsche Zeitstempel oder unknown time zone Europe/Berlin: Die Zeitzonendatenbank fehlt. Kopiere /usr/share/zoneinfo aus der Build-Stage oder binde in Go das Paket time/tzdata ein.
Der Build lädt bei jeder Änderung alle Abhängigkeiten neu: COPY . . steht vor dem Dependency-Schritt. Ziehe go.mod/go.sum beziehungsweise package-lock.json vor und lasse den Rest des Quellcodes erst danach folgen.
Fazit
Zwei FROM-Anweisungen und ein COPY --from reduzieren ein Go-Image von 1,13 GB auf rund 14 MB. Der Effekt ist nicht nur kosmetisch: kürzere Pull-Zeiten beim Rollout, weniger Registry-Speicher und deutlich weniger Pakete, die ein Scanner bemängeln kann. Prüfe nach jedem Umbau mit docker history, dass wirklich nur die gewünschten Artefakte im finalen Image liegen, und ergänze eine test-Stage, damit der Build bei fehlgeschlagenen Tests abbricht.
Weitere Docker-Themen
- Docker-Volumes mit Restic verschlüsselt sichern
- Ollama im Docker-Container betreiben
- Nextcloud mit Docker Compose betreiben
Testen Sie Ihr Setup auf ccloud³
Registrieren Sie sich in der ccloud³ und erhalten Sie 200 € Startguthaben für Ihr Projekt – z. B. für eine PostgreSQL-VM mit automatischen Backups.