Design wechseln

Docker Multi-Stage-Build in der Praxis: Go-/Java-/Rust-Images von GB auf MB schlanken

Easton editorial illustration: performance tuning console

Ein 650-MB-Image als Weckruf

Freitagnachmittag, ich starre auf das K8s-Dashboard: Ein Pod zieht seit fünf Minuten ein Image. Eine simple Spring-Boot-App – Docker-Image: 650 MB. Ein Praktikant fragt: „Warum ist das so groß?” Ich merke: Ich habe mir die Frage selten gestellt.

Abends recherchiert, morgens dasselbe Image: 89 MB. Deployment von 5 Minuten auf unter 1 Minute. Der Praktikant schaut mich anders an.

98%
Go-Image-Optimierung
295 MB → 6,47 MB
86%
Java-Image-Optimierung
650 MB → 89 MB
99,4%
Rust-Image-Optimierung
2 GB → 11 MB
89 MB
Optimierte Image-Größe
Von 650 MB auf 89 MB – Deployment von 5 Minuten auf unter 1 Minute

Nur ein paar Zeilen im Dockerfile geändert. Die Technik heißt Multi-Stage-Build.

Anfangs wirkte das harmlos. Unsere Go-Images: 295 MB, Java oft 500 MB+ – man gewöhnt sich daran. Bis jemand ein Rust-Image von 2 GB auf 11 MB drückte. Dann wurde klar: Wir packen falsch.

Kern des Problems: Kompilierte Sprachen brauchen Compiler und Build-Tools – zur Laufzeit nicht. Klassische Dockerfiles packen Maven, Gradle und Go-Compiler mit. Wie beim Umzug Bohrmaschine und Zement mitnehmen.

Heute zeige ich Go-, Java- und Rust-Beispiele:

  • Go: 295 MB → 6,47 MB (-98 %)
  • Java Spring Boot: 650 MB → 89 MB (-86 %)
  • Rust: 2 GB → 11,2 MB (-99,4 %)

Nur eine Sprache relevant? Direkt zum passenden Abschnitt springen – jedes Beispiel ist vollständig und kopierbar.

Warum ist Ihr Image so aufgebläht?

Vergleichstabelle aus echten Tests:

SpracheKlassischer Single-Stage-BuildMulti-Stage-BuildReduktion
Go-App295 MB6,47 MB98 %
Java Spring Boot650 MB89 MB86 %
Rust-App2,1 GB11,2 MB99,4 %

Beim Rust-Wert war ich sprachlos: 2 GB auf 11 MB – das wirkt wie Magie.

Die Analyse zeigt: Kompilierte Sprachen brauchen Compiler. Go braucht den Go-Compiler, Java Maven oder Gradle, Rust Cargo. Diese Tools sind groß:

  • Go-Compiler: ca. 300 MB
  • Maven + OpenJDK: ca. 500 MB
  • Rust-Toolchain: ca. 1,5 GB

Klassisches Dockerfile:

FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]

Sieht harmlos aus – ist es nicht. Das gesamte golang:1.21-Image (295 MB) landet im finalen Image: Compiler, Build-Tools, Debug-Tools. Nach dem Build bleibt alles drin.

Wie ein Rohbau: Die Handwerker renovieren, danach bleiben Zement, Bohrmaschine und Werkzeugkisten im Zimmer – und Sie ziehen ein.

Zur Laufzeit brauchen Sie nur das kompilierte Binary. Go-Binaries: wenige MB. Java-JARs: einige Dutzend MB. Rust-Binaries: ebenfalls klein. Die restlichen hunderte MB sind Compiler und Basis-Image.

Die Folgen gehen über Speicher hinaus:

  1. Langsames Pullen: In CI/CD muss jedes Deployment das Image ziehen. 650 MB bei schlechtem Netz – frustrierend
  2. Sicherheitsrisiko: Compiler, Quellcode und Build-Tools in Produktion – Werkzeugkiste für Angreifer
  3. Cache-Verschwendung: Eine Codezeile ändert sich, das ganze Image wird neu gebaut – Compiler und Code sind gekoppelt

Einmal bei Alibaba Cloud: Fünf Personen deployen gleichzeitig, jedes Image 500 MB+ – das interne Netz war voll. Seitdem: Image-Optimierung.

Multi-Stage-Build: Ein Dockerfile, zwei Umgebungen

Multi-Stage-Builds gibt es seit Docker 17.05. Kernidee: Mehrere Phasen in einem Dockerfile – frühere kompilieren, spätere laufen, nur nötige Artefakte weitergeben.

Einfach gesagt: Phase 1 renoviert, Phase 2 bekommt nur das fertige Haus – Zement und Bohrmaschine bleiben in Phase 1.

Minimalbeispiel:

# Phase 1: Build
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

# Phase 2: Runtime
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]

Die Schlüsselpunkte:

  1. Erstes FROM mit AS builder – Phase benennen
  2. Zweites FROM startet neue Phase mit leichtem Alpine-Image
  3. COPY —from=builder kopiert nur das Binary – der Rest wird verworfen

Phase 1: golang:1.21 mit 295 MB. Finales Image: Alpine (~5 MB) plus Binary – insgesamt wenige MB.

Der Aha-Moment: Nur das Ergebnis, nicht den Prozess. Compiler, Quellcode und Zwischendateien sind Prozess – das Binary ist Ergebnis. Docker verwirft Phase 1, behält Phase 2.

Debugging in Phase 1?

docker build --target builder -t myapp:debug .

--target builder baut nur bis zur builder-Phase – ideal zum Debuggen.

Mehrere Phasen sind möglich:

FROM node:18 AS frontend-builder
# Frontend-Build

FROM golang:1.21 AS backend-builder
# Backend-Build

FROM nginx:alpine
# Frontend- und Backend-Artefakte zusammenführen
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --from=backend-builder /app/api /usr/local/bin/api

Jede Phase erledigt ihre Aufgabe, die Runtime-Phase fasst zusammen. Die Dockerfile-Logik wird klarer.

Heute nutze ich bei kompilierten Sprachen standardmäßig Multi-Stage-Builds – Gewohnheit.

Go-Apps maximal schlank

Go ist ideal für Image-Optimierung: Statisch gelinkte Binaries, keine Systembibliotheken nötig – direkt in ein leeres Image.

Klassisch (nicht nachmachen):

FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]

Build:

docker build -t myapp:old .
docker images myapp:old
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        old    abc123def456   295MB

295 MB für einen einfachen HTTP-Service.

Multi-Stage-Version:

# Build-Phase
FROM golang:1.21-alpine AS builder
WORKDIR /app

# Abhängigkeiten zuerst (Cache nutzen)
COPY go.mod go.sum ./
RUN go mod download

# Quellcode kopieren und kompilieren
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o myapp .

# Runtime-Phase
FROM scratch
WORKDIR /app
COPY --from=builder /app/myapp .
EXPOSE 8080
CMD ["./myapp"]

Erneut bauen:

docker build -t myapp:new .
docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    def456ghi789   6.47MB

6,47 MB – von 295 MB, minus 98 %.

Schlüsselpunkte:

1. CGO_ENABLED=0

Deaktiviert CGO – rein statisches Binary. Bei C-Bibliotheken (z. B. SQLite) funktioniert das nicht; dann anderes Basis-Image.

2. -ldflags=“-w -s”

Compiler-Optimierung:

  • -w: Debug-Infos entfernen
  • -s: Symboltabelle entfernen

20–30 % kleineres Binary. In Produktion meist unnötig.

3. FROM scratch

scratch ist das leere Docker-Image – 0 Byte. Go-Static-Binaries laufen direkt darin.

4. Abhängigkeits-Cache

Zuerst go.mod und go.sum kopieren, dann go mod download. Solange diese Dateien gleich bleiben, wird die Abhängigkeits-Layer gecacht. Code-Änderungen triggern keinen erneuten Download.

Mit diesem Trick: Rebuild nach Code-Änderung von 2 Minuten auf 15 Sekunden.

Fortgeschritten: Zeitzone und CA-Zertifikate

scratch enthält weder Zeitzonendaten noch CA-Zertifikate. Für HTTPS oder Zeitzonen aus der builder-Phase kopieren:

FROM scratch
WORKDIR /app
# Zeitzonendaten
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# CA-Zertifikate
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/myapp .
ENV TZ=Asia/Shanghai
CMD ["./myapp"]

Oder gcr.io/distroless/static-debian11 statt scratch – ca. 2 MB, mit Zeitzonen und CA-Zertifikaten:

FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /app/myapp
CMD ["/app/myapp"]

Ich nutze meist distroless – weniger Aufwand.

Java/Spring Boot elegant schlanken

Java-Optimierung ist komplexer: JRE nötig, kein scratch. Mit der richtigen Methode lässt sich das Volumen deutlich drücken.

Klassisch (viele Java-Projekte):

FROM maven:3.8-openjdk-17
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests
CMD ["java", "-jar", "target/myapp.jar"]

Ergebnis: 650 MB. Maven-Image allein ~500 MB, plus JAR und Caches.

Multi-Stage-Version:

# Build-Phase
FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app

# pom.xml zuerst, Abhängigkeiten cachen
COPY pom.xml .
RUN mvn dependency:go-offline -B

# Quellcode und Paketierung
COPY src ./src
RUN mvn package -DskipTests

# Runtime-Phase
FROM openjdk:17-jre-slim
WORKDIR /app

# Nur JAR kopieren
COPY --from=builder /app/target/*.jar app.jar

# JVM-Tuning
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"

EXPOSE 8080
CMD java $JAVA_OPTS -jar app.jar

Ergebnis:

docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    xyz789abc012   89MB

650 MB → 89 MB, minus 86 %.

Schlüsselpunkte:

1. JDK vs. JRE

Build: openjdk-17 (mit Compiler). Runtime: openjdk-17-jre-slim (nur Laufzeit). JRE ist deutlich kleiner:

  • OpenJDK 17: ca. 500 MB
  • OpenJDK 17 JRE: ca. 200 MB
  • OpenJDK 17 JRE Slim: ca. 80 MB

2. Maven-Abhängigkeits-Cache

Zuerst pom.xml, dann mvn dependency:go-offline. Solange pom.xml gleich bleibt, wird die Layer gecacht. Code-Änderungen triggern keinen erneuten Download.

Ohne diesen Trick: Jede Codezeile → Dependencies neu laden. Maven-Repos im Ausland – oft 10+ Minuten Wartezeit. Jetzt: Sekunden.

3. JVM-Parameter

-XX:+UseContainerSupport: JVM erkennt Container-Memory-Limits. Ohne das: Heap nach Host-RAM – OOM-Gefahr.

-Xms128m -Xmx512m: Heap-Bereich. An die App anpassen, nicht blind 1 GB setzen.

Gradle-Variante

Mit Gradle leicht angepasst:

FROM gradle:8.5-jdk17 AS builder
WORKDIR /app

# Gradle-Konfiguration
COPY build.gradle settings.gradle ./
COPY gradle ./gradle

# Abhängigkeiten
RUN gradle dependencies --no-daemon

# Quellcode und Build
COPY src ./src
RUN gradle bootJar --no-daemon

FROM openjdk:17-jre-slim
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"
CMD java $JAVA_OPTS -jar app.jar

Pitfall: Spring Boot Layered JAR

Spring Boot 2.3+ unterstützt JAR-Layering – Dependencies und Business-Code getrennt, besserer Cache:

FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests
RUN java -Djarmode=layertools -jar target/*.jar extract

FROM openjdk:17-jre-slim
WORKDIR /app
# Layer-Reihenfolge: Dependencies ändern sich selten
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
CMD ["java", "org.springframework.boot.loader.JarLauncher"]

Code-Änderungen invalidieren die Dependency-Layer nicht – schnellerer Build. Ich nutze das selten; der einfache Ansatz reicht meist.

Rust: Minimales Deployment

Rust liefert den stärksten Effekt: Toolchain 1,5 GB+, Binary winzig. 2,1 GB → 11,2 MB – beeindruckend.

Klassisch:

FROM rust:1.75
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/myapp"]

Ergebnis: 2,1 GB. rustc, cargo, Dependencies – alles im Image.

Multi-Stage-Version:

# Build-Phase
FROM rust:1.75 AS builder
WORKDIR /app

# Abhängigkeiten zuerst kompilieren (Cache)
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src

# Echten Code kompilieren
COPY src ./src
RUN touch src/main.rs  # Timestamp aktualisieren, Rebuild triggern
RUN cargo build --release

# Runtime-Phase
FROM gcr.io/distroless/cc-debian11
WORKDIR /app
COPY --from=builder /app/target/release/myapp .
CMD ["./myapp"]

Ergebnis:

docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    rst345uvw678   11.2MB

11,2 MB – minus 99,4 %.

Rust-spezifischer Cache

Dependency-Builds dauern oft 10+ Minuten. Trick: Fake-main.rs erzeugen, Dependencies kompilieren, löschen, echten Code kopieren und neu bauen.

Solange Cargo.toml und Cargo.lock gleich bleiben, wird die Dependency-Layer gecacht. Code-Änderungen kompilieren nur den App-Code.

In einem mittelgroßen Projekt: Rebuild von 15 Minuten auf 2 Minuten.

Static vs. Dynamic Linking

Rust-Binaries können glibc brauchen. Bei reinem Rust ohne C-Bibliotheken: musl für statisches Binary, dann scratch:

FROM rust:1.75 AS builder
WORKDIR /app

# musl-Toolchain
RUN rustup target add x86_64-unknown-linux-musl

COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN rm -rf src

COPY src ./src
RUN touch src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl

# Runtime-Phase
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
CMD ["/myapp"]

Noch etwas kleiner – vielleicht 5–8 MB.

Ich nutze meist gcr.io/distroless/cc – bessere Kompatibilität, nur wenige MB mehr.

Pitfall: Cargo-Cache-Verzeichnis

Manche Tutorials cachen /usr/local/cargo. Nicht tun. Viele Zwischenartefakte – riesige Cache-Layer, langsamer statt schneller. Mein Fehler: 800-MB-Cache-Layer, lange Wartezeiten.

Build schneller und stabiler machen

Grundlagen erklärt – jetzt Tipps für Geschwindigkeit und Stabilität.

1. .dockerignore

Oft übersehen, aber wichtig. Wie .gitignore: Dateien vom Build-Context ausschließen.

Ohne .dockerignore: Docker packt das ganze Projekt (node_modules, .git, target) und sendet es an den Daemon. Hunderte MB, allein der Context-Upload dauert.

Jedes Projekt bekommt .dockerignore:

# Versionskontrolle
.git
.gitignore

# Abhängigkeitsverzeichnisse
node_modules
target
dist
build

# IDE
.vscode
.idea
*.swp

# Tests und Docs
**/*_test.go
**/*_test.rs
*.md
docs/

# Umgebungsvariablen und Secrets
.env
.env.local
*.key
*.pem

Build 3–5× schneller – in CI/CD besonders spürbar.

2. Multi-Stage-Build debuggen

--target im Detail. Build scheitert in builder-Phase?

# Nur bis builder bauen
docker build --target builder -t myapp:debug .

# In Image einsteigen
docker run -it myapp:debug sh

# Build-Befehle manuell ausführen

Praktisch: Go-Build schlug fehl – mit --target builder und manuellem go build war klar: Go-Version inkompatibel mit Dependency.

3. Phasen sinnvoll benennen

Aussagekräftige Namen statt stage1, stage2:

# Schlecht
FROM golang:1.21 AS stage1
FROM node:18 AS stage2
FROM nginx AS stage3

# Gut
FROM golang:1.21 AS backend-builder
FROM node:18 AS frontend-builder
FROM nginx AS runtime

Monate später danken Sie sich dafür.

4. BuildKit parallel bauen

BuildKit: Parallele Builds, besserer Cache. Aktivieren:

export DOCKER_BUILDKIT=1
docker build .

Oder einmalig:

DOCKER_BUILDKIT=1 docker build .

Vorteile:

  • Unabhängige Phasen parallel
  • Intelligenteres Caching
  • Klarere Build-Ausgabe

Ich nutze BuildKit standardmäßig, auch in CI/CD mit DOCKER_BUILDKIT=1.

5. Basis-Image-Version fixieren

Nicht FROM golang:latest – Produktion braucht feste Versionen:

# Schlecht – unvorhersehbar
FROM golang:latest

# Gut – explizite Version
FROM golang:1.21.5-alpine3.18

# Besser – SHA256
FROM golang@sha256:abc123...

Einmal Deployment-Fehler: golang:latest hatte sich geändert, neue Version inkompatibel. Seitdem: feste Versionen.

6. COPY-Reihenfolge für Cache

Selten geänderte Dateien zuerst, oft geänderte zuletzt:

# Gute Reihenfolge
FROM golang:1.21-alpine AS builder
WORKDIR /app

# 1. Abhängigkeiten (selten geändert)
COPY go.mod go.sum ./
RUN go mod download

# 2. Quellcode (oft geändert)
COPY . .
RUN go build -o myapp

# Schlechte Reihenfolge
COPY . .  # Alles auf einmal
RUN go mod download && go build -o myapp

Erste Variante: Code-Änderung triggert keinen erneuten Dependency-Download. Zweite: Jede Änderung lädt Dependencies neu.

Kernprinzip des Cache-Tunings – in allen Beispielen oben.

Basis-Image-Auswahl

Alpine, Slim, Distroless, Scratch – was wählen? Vergleich aus der Praxis:

Image-TypGrößeInhaltVorteileNachteileEinsatz
scratch0 MBLeerMinimal, kleinste AngriffsflächeKein Shell, kein Debug, keine CA-ZertsGo/Rust static
distroless2–20 MBRuntime-Libs, CA-ZertsKein Shell, sicher, kleinSchwer debuggbarGo, Java, Rust, Node.js
alpine5–40 MBmusl libc, PaketmanagerKlein, Shell vorhandenmusl-Kompatibilität, DNS-ProblemeApps ohne glibc
slim70–120 MBDebian/Ubuntu minimalglibc, kompatibelEtwas größerC-Library-Abhängigkeiten
Vollständig200 MB+Komplettes SystemAlle ToolsGroß, unsicherNicht für Produktion

Meine Strategie:

Go

  • Erste Wahl: gcr.io/distroless/static-debian11 (mit CA-Zerts)
  • Alternative: scratch (CA und Zeitzone manuell kopieren)
  • Mit CGO: gcr.io/distroless/base-debian11 oder alpine

Java

  • Erste Wahl: openjdk:17-jre-slim (vollständiges JRE, kompatibel)
  • Fortgeschritten: gcr.io/distroless/java17-debian11 (kleiner, schwerer debuggbar)
  • Vermeiden: openjdk:17-alpine (JVM-Bugs auf Alpine)

Rust

  • Erste Wahl: gcr.io/distroless/cc-debian11 (C-Runtime)
  • Static: scratch
  • Vermeiden: volles rust-Image in Produktion

Alpine-Pitfalls

Alpine ist beliebt, aber:

  1. musl libc: Nicht glibc – Java/Python zeigen manchmal seltsame Bugs
  2. DNS: Go auf Alpine manchmal DNS-Timeouts, Spezialkonfiguration nötig
  3. Zeitzone: Keine Zeitzonendaten standardmäßig – tzdata installieren

Unbekannt mit Alpine? -slim ist sicherer. Etwas größer, weniger Überraschungen.

Distroless

Google-Minimal-Images: kein Shell, kein Paketmanager – nur Runtime-Essentials.

Vorteile:

  • Minimale Angriffsfläche (kein Shell → schwer Befehle ausführen)
  • Klein
  • Offiziell gepflegt, Security-Patches

Nachteile:

  • Debugging schwierig, kein docker exec -it
  • Alles in der Build-Phase vorbereiten

Produktion: meist Distroless. Debug: --target builder. Runtime: Distroless.

Entscheidungsbaum

Welche Sprache?
├─ Go
│  ├─ Reines Go → distroless/static oder scratch
│  └─ Mit CGO → distroless/base oder alpine
├─ Java
│  ├─ Stabilität → openjdk:jre-slim
│  └─ Größe → distroless/java
├─ Rust
│  ├─ Reines Rust → distroless/cc oder scratch
│  └─ Mit C-Libs → distroless/cc
└─ Sonstiges
   └─ Erst slim, bei Problemen wechseln

Aus echten Projekten – deckt ca. 90 % der Fälle ab.

Das Wesentliche in drei Punkten

Multi-Stage-Builds sind einfach:

  1. Build-Phase: Vollständiges Image kompiliert
  2. Runtime-Phase: Schlankes Image führt aus
  3. COPY —from: Nur nötige Artefakte weitergeben

Effekt: 70–90 % kleiner, 2–3× schneller mit Cache, deutlich sicherer.

Neue Projekte: Multi-Stage-Dockerfile als Erstes – Go, Java, Rust. Gewohnheit.

Handlungsempfehlungen:

Heute umsetzbar:

  • Bestehendes Projekt mit Multi-Stage testen
  • docker images vorher/nachher vergleichen
  • Bei Erfolg auf andere Projekte ausrollen

Vertiefen:

  • Docker-Dokumentation Best Practices
  • dive für Layer-Analyse (docker run --rm -it wagoodman/dive:latest your-image)
  • BuildKit-Features

Langfristig:

  • Image-Cache in CI/CD
  • Basis-Images regelmäßig aktualisieren
  • Security-Scan mit Trivy o. Ä.

Zum Schluss: Technisch ist Image-Optimierung nicht schwer – schwer ist das Bewusstsein. Viele Teams lassen Images jahrelang wachsen, bis Deployments unerträglich werden.

Nicht warten. Multi-Stage heute ausprobieren – Images können so klein sein, Builds so schnell.

In den Kommentaren: Wie groß sind Ihre Images? Was hat bei der Optimierung geholfen – und wo sind Sie reingelaufen?

Vollständiger Optimierungs-Workflow für Docker Multi-Stage-Builds

Go-/Java-/Rust-Images von GB auf MB schlanken – mit Dockerfiles und Praxis-Pitfalls

⏱️ Estimated time: 1 hr

  1. 1

    Step 1: Multi-Stage-Prinzip verstehen

    Multi-Stage-Prinzip:
    • Kompilierte Sprachen brauchen Compiler und Build-Tools, um Quellcode in Binaries zu übersetzen
    • Zur Laufzeit sind diese Tools nicht nötig
    • Klassische Dockerfiles packen Maven, Gradle und Go-Compiler mit
    • Wie beim Umzug Bohrmaschine und Zement mitnehmen – völlig unnötig

    Kern des Problems:
    • Kompilierte Sprachen brauchen Compiler und Build-Tools
    • Zur Laufzeit sind diese nicht nötig

    Multi-Stage-Schritte:
    • Phase 1 (Build): Vollständiges Build-Image mit Compiler und Tools, Quellcode kompilieren
    • Phase 2 (Runtime): Minimales Runtime-Image, nur das Binary aus Phase 1 kopieren
    • Finales Image enthält nur Runtime und ausführbare Datei
  2. 2

    Step 2: Go Multi-Stage-Build in der Praxis

    Go Multi-Stage-Build:

    Phase 1 (Build):
    • FROM golang:1.21 AS builder
    • WORKDIR /app
    • COPY go.mod go.sum ./
    • RUN go mod download
    • COPY . .
    • RUN go build -o app

    Phase 2 (Runtime):
    • FROM alpine:latest
    • RUN apk --no-cache add ca-certificates
    • WORKDIR /root/
    • COPY --from=builder /app/app .
    • CMD ["./app"]

    Optimierung:
    • Nur das kompilierte Binary kopieren
    • Image von 295 MB auf 6,47 MB (-98 %)
    • Deployment von 5 Minuten auf unter 1 Minute
    • Deutlich kleinere Images
  3. 3

    Step 3: Java- und Rust Multi-Stage-Builds in der Praxis

    Java Multi-Stage-Build:

    Phase 1 (Build):
    • FROM maven:3.9 AS builder
    • WORKDIR /app
    • COPY pom.xml .
    • RUN mvn dependency:go-offline
    • COPY src ./src
    • RUN mvn clean package -DskipTests

    Phase 2 (Runtime):
    • FROM eclipse-temurin:17-jre-alpine
    • WORKDIR /app
    • COPY --from=builder /app/target/app.jar app.jar
    • CMD ["java", "-jar", "app.jar"]

    Optimierung: Image von 650 MB auf 89 MB (-86 %)

    Rust Multi-Stage-Build:

    Phase 1 (Build):
    • FROM rust:1.75 AS builder
    • WORKDIR /app
    • COPY Cargo.toml Cargo.lock .
    • RUN cargo fetch
    • COPY src ./src
    • RUN cargo build --release

    Phase 2 (Runtime):
    • FROM alpine:latest
    • RUN apk --no-cache add ca-certificates
    • WORKDIR /root/
    • COPY --from=builder /app/target/release/app .
    • CMD ["./app"]

    Optimierung: Image von 2 GB auf 11 MB (-99,4 %)
  4. 4

    Step 4: Best Practices und langfristige Optimierung

    Best Practices:
    1. Passendes Runtime-Image wählen
    • Go: distroless/static oder scratch
    • Java: openjdk:jre-slim oder distroless/java
    • Rust: distroless/cc oder scratch

    2. .dockerignore für unnötige Dateien

    3. Build-Cache nutzen (Abhängigkeiten zuerst, dann Quellcode)

    4. Basis-Images regelmäßig aktualisieren

    Langfristig:
    • Image-Cache in CI/CD-Pipeline
    • Basis-Images regelmäßig aktualisieren
    • Security-Scan mit Trivy o. Ä.

    Technisch ist Image-Optimierung nicht schwer – schwer ist das Bewusstsein. Viele Teams lassen Images jahrelang wachsen, bis Deployments unerträglich langsam werden.

FAQ

Was ist Docker Multi-Stage-Build? Warum braucht man es?
Multi-Stage-Prinzip: Kompilierte Sprachen brauchen Compiler und Build-Tools, zur Laufzeit nicht. Klassische Dockerfiles packen Maven, Gradle und Go-Compiler mit – wie beim Umzug Bohrmaschine und Zement mitnehmen.

Kern: Compiler und Build-Tools sind nur für den Build nötig, nicht zur Laufzeit.

Schritte:
• Phase 1: Vollständiges Build-Image kompiliert Quellcode
• Phase 2: Minimales Runtime-Image, nur Binary kopieren
• Finales Image: Runtime + ausführbare Datei
Wie stark optimiert Multi-Stage-Build?
Ergebnisse:
• Go: 295 MB → 6,47 MB (-98 %)
• Java Spring Boot: 650 MB → 89 MB (-86 %)
• Rust: 2 GB → 11 MB (-99,4 %)
• Deployment: 5 Minuten → unter 1 Minute

Beispiel: Spring-Boot-App als Docker-Image 650 MB, mit Multi-Stage nur 89 MB – Deployment von 5 Minuten auf unter 1 Minute.
Wie setzt man Go Multi-Stage-Build um?
Go Multi-Stage-Build:

Phase 1 mit golang:1.21:
• FROM golang:1.21 AS builder
• WORKDIR /app
• COPY go.mod go.sum ./
• RUN go mod download
• COPY . .
• RUN go build -o app

Phase 2 mit alpine:latest:
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/app .
• CMD ["./app"]

Nur das Binary kopieren – von 295 MB auf 6,47 MB (-98 %). Deployment von 5 Minuten auf unter 1 Minute.
Wie setzt man Java- und Rust Multi-Stage-Build um?
Java Multi-Stage-Build:

Phase 1 mit maven:3.9:
• FROM maven:3.9 AS builder
• WORKDIR /app
• COPY pom.xml .
• RUN mvn dependency:go-offline
• COPY src ./src
• RUN mvn clean package -DskipTests

Phase 2 mit eclipse-temurin:17-jre-alpine:
• FROM eclipse-temurin:17-jre-alpine
• WORKDIR /app
• COPY --from=builder /app/target/app.jar app.jar
• CMD ["java", "-jar", "app.jar"]

650 MB → 89 MB (-86 %)

Rust Multi-Stage-Build:

Phase 1 mit rust:1.75:
• FROM rust:1.75 AS builder
• WORKDIR /app
• COPY Cargo.toml Cargo.lock .
• RUN cargo fetch
• COPY src ./src
• RUN cargo build --release

Phase 2 mit alpine:latest:
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/target/release/app .
• CMD ["./app"]

2 GB → 11 MB (-99,4 %)
Was sind Best Practices für Multi-Stage-Builds?
Best Practices:
1) Passendes Runtime-Image:
• Go: distroless/static oder scratch
• Java: openjdk:jre-slim oder distroless/java
• Rust: distroless/cc oder scratch

2) .dockerignore nutzen

3) Build-Cache (Abhängigkeiten zuerst, dann Quellcode)

4) Basis-Images regelmäßig aktualisieren

Langfristig:
• Image-Cache in CI/CD
• Basis-Images aktualisieren
• Security-Scan mit Trivy

Technisch nicht schwer – schwer ist das Bewusstsein. Viele Teams optimieren erst, wenn Deployments unerträglich langsam werden.

11 Min. Lesezeit · Veröffentlicht am: 17. Dez. 2025 · Aktualisiert am: 14. Juli 2026

Ähnliche Beiträge

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog