Zum Inhalt springen
DevOps

Docker Multi-Stage Builds: Schlanke Images systematisch bauen

9 min Lesezeit
Docker Container CI/CD

Ein typisches Node.js-Image mit node:20 als Base: 1.1 GB. Dasselbe Projekt mit Multi-Stage Build und node:20-alpine als Runtime: 180 MB. Faktor sechs. Und das ist noch ein konservatives Beispiel — bei Go oder Rust landet man mit Distroless oder Scratch bei unter 20 MB. Die Frage ist nicht ob Multi-Stage Builds sinnvoll sind, sondern warum sie nicht laengst Standard in jedem Projekt sind.

Das Problem: Build-Tools in Production

Die meisten Dockerfiles folgen einem simplen Muster: Base-Image waehlen, Dependencies installieren, Code kopieren, builden, CMD setzen. Das Ergebnis ist ein Image, das alles enthält — Compiler, Dev-Dependencies, Build-Artefakte, Testframeworks. Alles, was in Production nicht gebraucht wird, aber trotzdem mitgeschleppt wird.

Klassisches Single-Stage Dockerfile (Anti-Pattern) dockerfile
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]

Was hier passiert: npm install zieht alle Dependencies — inklusive TypeScript, ESLint, Jest und was sonst noch in devDependencies steht. Der TypeScript-Compiler bleibt im Image, obwohl er nach dem Build nie wieder gebraucht wird. Build-Caches, Source Maps, Test-Fixtures — alles dabei. Das Image ist gross, die Angriffsfläche riesig.

Multi-Stage Builds: Das Konzept

Die Idee ist einfach: Mehrere FROM-Anweisungen in einem Dockerfile. Jede definiert eine eigene Stage mit eigenem Base-Image. Nur die Artefakte, die tatsächlich gebraucht werden, wandern per COPY --from in die finale Stage. Alles andere bleibt zurück.

Praxis: Node.js mit Multi-Stage

Ein realistisches Beispiel für eine Node.js-Anwendung mit TypeScript:

Node.js Multi-Stage Build dockerfile
# Stage 1: Dependencies installieren
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --include=dev

# Stage 2: Build
FROM deps AS build
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
RUN npm prune --production

# Stage 3: Production Runtime
FROM node:20-alpine AS runtime
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./

USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]

Drei Stages, drei klare Verantwortlichkeiten. deps installiert alle Dependencies inklusive Dev-Dependencies. build kompiliert TypeScript und entfernt danach die Dev-Dependencies mit npm prune --production. runtime bekommt nur das kompilierte JavaScript und die Production-Dependencies. TypeScript, ESLint, Jest — nichts davon ist im finalen Image.

Der Größenunterschied ist drastisch. Statt 1.1 GB landen wir bei unter 200 MB. Bei Frameworks wie Next.js mit Standalone-Output sogar unter 100 MB.

Praxis: Go mit Scratch

Bei kompilierten Sprachen wird der Vorteil noch deutlicher. Go produziert statisch gelinkte Binaries, die keine Runtime brauchen. Das ideale Szenario für Multi-Stage:

Go Multi-Stage Build mit Distroless dockerfile
# Stage 1: Build
FROM golang:1.23-alpine AS build
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w" \
    -o /app/server ./cmd/server

# Stage 2: Minimales Runtime-Image
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]

Das Build-Image mit dem Go-Compiler ist über 300 MB gross. Das finale Image mit Distroless: unter 15 MB. Kein Shell, kein Package Manager, keine Utilities — nichts, was ein Angreifer nutzen könnte. Die -ldflags="-s -w" entfernen Debug-Informationen und Symbol-Tabellen, was die Binary nochmal um 30-40% verkleinert.

Layer Caching optimal nutzen

Multi-Stage Builds ändern die Caching-Strategie fundamental. Jede Stage hat ihren eigenen Layer-Cache, und die Reihenfolge der Anweisungen bestimmt, wie effektiv dieser genutzt wird.

Die goldene Regel: Was sich selten ändert, kommt zuerst. Dependencies ändern sich seltener als Quellcode. Deshalb COPY package*.json vor COPY . . — so wird npm ci nur wiederholt, wenn sich package.json oder package-lock.json tatsächlich geändert haben.

Cache-optimierte Java Multi-Stage Build dockerfile
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app

# Dependencies zuerst -- ändert sich selten
COPY pom.xml ./
COPY .mvn/ .mvn/
RUN ./mvnw dependency:resolve

# Quellcode -- ändert sich oft
COPY src/ ./src/
RUN ./mvnw package -DskipTests -q

# Runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring

COPY --from=build /app/target/*.jar app.jar

USER spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Beachte den Unterschied zwischen jdk und jre als Base-Image. Das JDK enthält den Compiler und Development-Tools — braucht niemand in Production. Das JRE reicht völlig aus. Die Ersparniss: circa 200 MB.

Security: Minimale Angriffsfläche

Multi-Stage Builds sind nicht nur ein Optimierungswerkzeug — sie sind eine Security-Maßnahme. Jede Software im Image ist ein potenzieller Angriffsvektor. Weniger Software bedeutet weniger CVEs, weniger Updates, weniger Risiko.

Drei Praktiken, die in jeden Multi-Stage Build gehören:

Non-Root User: Container laufen standardmäßig als Root. Das ist in Production inakzeptabel. Immer einen dedizierten User anlegen und per USER-Direktive wechseln.

Minimale Base-Images: Alpine statt Debian, Distroless statt Alpine, Scratch statt Distroless — je nach Anforderung so minimal wie möglich.

Keine Secrets im Build: Build-Args und Secrets gehören nicht in den Layer-Cache. Docker BuildKit bietet --mount=type=secret für genau diesen Zweck.

Secrets sicher im Build verwenden (BuildKit) dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
    npm ci
COPY . .
RUN npm run build

Das Secret wird als temporäres Mount bereitgestellt und erscheint in keinem Layer. Aufrufen mit docker build --secret id=npmrc,src=.npmrc ..

Named Stages und selektives Kopieren

Stages lassen sich benennen und gezielt ansprechen. Das ermöglicht komplexe Build-Pipelines, in denen verschiedene Artefakte aus verschiedenen Stages kombiniert werden.

Named Stages für parallele Builds dockerfile
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build

FROM golang:1.23-alpine AS backend-build
WORKDIR /backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=frontend-build /frontend/dist /static
COPY --from=backend-build /server /server
ENTRYPOINT ["/server"]

Docker BuildKit erkennt, dass frontend-build und backend-build unabhängig sind und führt sie parallel aus. Das halbiert die Build-Zeit bei Monorepo-Setups. Einzelne Stages lassen sich auch gezielt bauen mit docker build --target frontend-build . — nützlich für Debugging oder wenn nur ein Teil des Builds getestet werden soll.

Anti-Patterns und wie man sie vermeidet

Multi-Stage Builds sind kein Allheilmittel. Falsch eingesetzt, schaffen sie neue Probleme.

Zu viele Stages: Jede Stage erhoet die Komplexität des Dockerfiles. Drei bis vier Stages sind typisch. Wer bei acht oder mehr landet, sollte über Build-Tools wie Bazel oder Nx nachdenken, die den Build ausserhalb von Docker orchestrieren.

COPY . . zu früh: Wer den gesamten Build-Kontext früher als nötig kopiert, zerstoert den Layer-Cache. Jede Änderung an irgendeiner Datei invalidiert alle folgenden Layer. Immer zuerst Dependency-Definitionen kopieren, dann den Rest.

Fehlende .dockerignore: Ohne .dockerignore landen node_modules, .git, lokale Configs und möglicherweise Secrets im Build-Kontext. Eine gute .dockerignore ist Pflicht — sie beschleunigt den Build und verhindert versehentliche Leaks.

Kein BuildKit: Der Legacy-Builder unterstützt weder parallele Stages noch Secret-Mounts noch fortgeschrittenes Caching. BuildKit ist seit Docker 23.0 Standard — wer eine aeltere Version nutzt, sollte DOCKER_BUILDKIT=1 setzen.

Fazit

Multi-Stage Builds sind kein Nice-to-have. Sie sind die Grundlage für produktionstaugliche Container-Images: klein, sicher, reproduzierbar. Die Investment in ein sauber strukturiertes Dockerfile zahlt sich bei jedem Build, jedem Deploy und jedem Security-Audit aus. Wer heute noch Single-Stage Dockerfiles in Production schiebt, verschenkt Performance, Sicherheit und Bandbreite.