Tutorials  /  Docker

Docker-Images mit mehrstufigen Builds verkleinern

LLudwig · August 2026 ·9 Min. Lesezeit ·Docker, Tutorial

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 Gruppe docker, zum Beispiel eine skalierbare Cloud-VM für Build-Runner
  • Ein Beispielprojekt in Go 1.24 mit go.mod, go.sum und cmd/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:

dockerfile
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ß:

Konsole
$ docker build -t api:single .
$ docker images api:single --format '{{.Repository}}:{{.Tag}} {{.Size}}'
api:single 1.13GB

In 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.

VM

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.

dockerfile
# 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 build benennt die Stage. Ohne Namen müsstest du sie über ihren Index ansprechen (COPY --from=0), was bei jeder Umsortierung bricht.
  • CGO_ENABLED=0 erzwingt 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:

dockerfile
COPY --from=build /out/api /usr/local/bin/api
COPY --from=build /src/web/dist /srv/static

Die Quelle muss keine eigene Stage sein. Auch ein externes Image lässt sich direkt anzapfen, etwa für CA-Zertifikate oder ein einzelnes Binary:

dockerfile
COPY --from=ghcr.io/example/tools:1.4 /usr/bin/migrate /usr/bin/migrate

Warum 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:

dockerfile
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/api

Der 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:

Konsole
$ 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:

Konsole
$ docker build --target build -t api:builder .
$ docker run --rm -it api:builder sh

Dasselbe Prinzip trägt eine dedizierte Test-Stage, die in der CI läuft, aber nie ausgeliefert wird:

dockerfile
FROM build AS test
RUN go vet ./... && go test ./... -race -count=1
Konsole
$ 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:

Konsole
$ docker build -t api:multi .
$ docker images 'api' --format 'table {{.Tag}}\t{{.Size}}'
TAG       SIZE
multi     14.2MB
single    1.13GB

Prü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:

Konsole
$ docker history api:multi --no-trunc --format '{{.Size}}\t{{.CreatedBy}}'
12.4MB  COPY /out/api /usr/local/bin/api # buildkit
0B  USER nonroot:nonroot

Zum Schluss ein Funktionstest des Containers:

Konsole
$ 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-test

Wenn 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:

dockerfile
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

Jetzt 200 € Guthaben sichern

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.

Ludwig Technische Redaktion

Schreibt bei centron über Linux-Administration, Container und Datenbanken – mit Fokus auf Anleitungen, die im Betrieb tatsächlich funktionieren.

Kategorie Docker
Teilen
Noch offene Fragen?

Our team will help you with your specific setup - in German or English, by people who run the platform themselves.

War dieses Tutorial hilfreich?

Your answer is stored anonymously and helps us improve our tutorials.

Kommentare

No comments yet - be the first to ask a question about this tutorial.

Sign in to comment

Comments are open to centron customers. Sign in to your account to ask a question about this tutorial.

Weiterlesen

Das könnte Sie auch interessieren

Jetzt kostenlos anfangen

Melden Sie sich an und erhalten Sie in den ersten 60 Tagen ein Guthaben von 200 € bei centron.

Dieses Werbeangebot gilt nur für neue Konten. Angebot ausschließlich für Gewerbetreibende.

Jetzt loslegen Sales kontaktieren