Docker-Images mit Multi-Stage-Builds optimieren: Ein umfassender Leitfaden

Meistern Sie Docker-Multi-Stage-Builds, um Ihre Image-Größen drastisch zu reduzieren, Bereitstellungen zu beschleunigen und die Sicherheit zu erhöhen. Dieser umfassende Leitfaden bietet Schritt-für-Schritt-Anleitungen, praktische Beispiele für Go und Node.js sowie wesentliche Best Practices. Erfahren Sie, wie Sie Ihre Dockerfiles optimieren, indem Sie Build-Abhängigkeiten trennen und sicherstellen, dass nur notwendige Komponenten in Ihr endgültiges Laufzeit-Image gelangen. Pflichtlektüre für alle, die effiziente und sichere containerisierte Anwendungen erstellen möchten.

Docker-Images mit Multi-Stage-Builds optimieren: Ein umfassender Leitfaden

Multi-Stage-Builds lösen ein sehr alltägliches Docker-Problem: Die Werkzeuge, die Sie zum Erstellen einer Anwendung benötigen, sind normalerweise nicht die Werkzeuge, die Sie zum Ausführen benötigen.

Ein Go-Compiler, Node-Paket-Cache, Maven-Repository, Test-Framework und Build-Header sind während des Image-Builds nützlich. Im Laufzeit-Image sind sie totes Gewicht. Sie verlangsamen Pulls, erhöhen die Menge an Software, die Sie patchen müssen, und erschweren das Verständnis dessen, was tatsächlich in der Produktion läuft.

Mit einem Multi-Stage-Dockerfile bauen Sie in einer Stufe und kopieren nur das fertige Artefakt in eine kleinere Laufzeitstufe. Das endgültige Image erbt die Build-Stufe nicht, es sei denn, Sie kopieren explizit Dateien daraus.

Das Problem mit Single-Stage-Images

Betrachten Sie eine typische Go-Anwendung. Sie benötigen die Go-Toolchain, um sie zu kompilieren. Sobald Sie ein Linux-Binary haben, wird der Compiler nicht mehr benötigt. Ein Single-Stage-Image behält ihn trotzdem:

FROM golang:1.21-alpine

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp

EXPOSE 8080
CMD ["./myapp"]

Das funktioniert, aber das endgültige Image enthält immer noch die Go-Toolchain und den Build-Cache. Das gleiche Muster zeigt sich bei Node, Java, Rust, Python-Paketen mit nativen Erweiterungen und Frontend-Builds.

Die Kosten sind praktisch:

  • Erhöhte Image-Größe: Mehr Layer, mehr Daten zum Ziehen und Speichern.
  • Längere Bereitstellungszeiten: Größere Images benötigen mehr Zeit für die Übertragung.
  • Sicherheitsrisiken: Eine größere Angriffsfläche durch unnötige Software.
  • Unklare Laufzeitumgebung: Erschwert das Verständnis, was wirklich benötigt wird.

Kleinere Images sind nicht automatisch schneller zur Laufzeit, aber sie sind schneller durch CI, Registries und Bereitstellungssysteme zu bewegen. Sie machen auch die Sicherheitsüberprüfung weniger verrauscht.

Was Multi-Stage-Builds tun

Jede FROM-Anweisung startet eine neue Stufe. Sie können eine Stufe benennen und später Dateien daraus kopieren:

FROM golang:1.21-alpine AS builder
# Dateien hier erstellen

FROM alpine:3.20
COPY --from=builder /app/myapp /app/myapp

Die zweite Stufe startet frisch. Sie enthält kein /usr/local/go, keine Quellcodedateien, Paket-Caches oder Build-Tools aus der ersten Stufe, es sei denn, Sie kopieren sie.

Ein sauberes Go-Beispiel

Hier ist eine kleine Anwendung:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hallo von optimiertem Docker-Image!")
}

func main() {
	http.HandleFunc("/", handler)
	log.Println("Server startet auf :8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Das Multi-Stage-Dockerfile:

FROM golang:1.21-alpine AS builder

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

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o myapp

FROM alpine:3.20

WORKDIR /app
COPY --from=builder /app/myapp /app/myapp

EXPOSE 8080
CMD ["/app/myapp"]

Die Dateien go.mod und go.sum werden vor dem vollständigen Quellbaum kopiert, damit Docker die Abhängigkeits-Download-Ebene wiederverwenden kann, wenn sich nur der Anwendungscode ändert. CGO_ENABLED=0 ist nützlich, wenn Sie ein statisches Binary wünschen. Wenn Ihre Anwendung von C-Bibliotheken abhängt, benötigen Sie möglicherweise ein Laufzeit-Image, das diese Bibliotheken enthält, anstatt statische Builds zu erzwingen.

Build und Vergleich:

docker build -t go-app:multi-stage .
docker images go-app:multi-stage
docker history go-app:multi-stage

Verlassen Sie sich nicht auf die Beispielgröße eines Blogs. Überprüfen Sie Ihr eigenes Image. Abhängigkeitsentscheidungen, Basis-Image-Versionen, Debug-Symbole, Zertifikate, Zeitzonendaten und native Bibliotheken beeinflussen alle das Ergebnis.

Auswahl der Laufzeit-Basis-Images

alpine ist beliebt, weil es klein ist, aber klein ist nicht immer gleichbedeutend mit kompatibel. Alpine verwendet musl libc, während viele gängige Linux-Distributionen glibc verwenden. Die meisten statischen Go-Binaries laufen einwandfrei. Einige Python-, Node-, Java- oder native Pakete verhalten sich anders.

Häufige Laufzeitoptionen:

Laufzeitbasis Geeignet für Kompromiss
alpine Kleine Images, einfache Binaries musl-Kompatibilitätsunterschiede
debian:bookworm-slim Breite Linux-Kompatibilität Größer als Alpine
Distroless-Images Produktionslaufzeiten mit weniger Tools Schwerer im Container zu debuggen
scratch Nur statische Binaries Keine Shell, CA-Zertifikate oder Paketmanager, sofern nicht kopiert

Wenn die App HTTPS-Endpunkte aufruft, stellen Sie sicher, dass das endgültige Image CA-Zertifikate enthält. Ein scratch-Image ohne Zertifikate kann auf eine Weise fehlschlagen, die wie ein Netzwerkproblem aussieht.

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=builder /app/myapp /myapp
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/myapp"]

Multi-Stage-Builds für andere Sprachen/Frameworks

Die gleiche Idee funktioniert überall dort, wo es einen Build-Schritt gibt.

Für ein Node-Frontend:

FROM node:20-alpine AS builder

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html

Für eine Node-API kopieren Sie node_modules nicht aus einer Entwicklungsinstallation, wenn diese Dev-Abhängigkeiten enthält:

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]

Für Java:

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /src
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /src/target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]

Build-Cache ist ebenfalls wichtig

Multi-Stage-Builds reduzieren die endgültige Image-Größe, aber die Reihenfolge des Dockerfiles steuert immer noch das Cache-Verhalten. Platzieren Sie stabile Abhängigkeitsdateien vor flüchtigen Quelldateien. Verwenden Sie npm ci anstelle von npm install bei reproduzierbaren Builds. Fixieren Sie Basis-Image-Versionen anstatt sich in der Produktion auf latest zu verlassen.

Mit BuildKit können Cache-Mounts Paket-Downloads beschleunigen, ohne Caches in das endgültige Image zu backen:

# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -o myapp

Dieser Cache ist für den Build-Rechner bestimmt, nicht für das Laufzeit-Image.

Was kopieren und was nicht kopieren

Kopieren Sie den kleinstmöglichen vollständigen Laufzeitsatz. Für einen kompilierten Dienst kann das ein Binary plus Konfigurationsvorlagen und CA-Zertifikate sein. Für ein Frontend kann es ein dist-Verzeichnis sein. Für Java kann es ein Jar plus eine JRE sein.

Kopieren Sie keinen Quellcode, keine Paketmanager-Caches, Test-Fixtures, lokale .env-Dateien, SSH-Schlüssel oder Build-Ausgaben, die Sie nicht ausführen. Verwenden Sie eine .dockerignore-Datei, damit diese Dateien erst gar nicht in den Build-Kontext gelangen:

.git
node_modules
coverage
dist
*.log
.env

Die .dockerignore-Datei ersetzt keine sorgfältigen COPY-Anweisungen, verhindert aber versehentliche Kontextaufblähung und Geheimnislecks.

Debuggen von Multi-Stage-Builds

Benennen Sie Ihre Stufen. Eine benannte Stufe ist einfacher anzusprechen:

docker build --target builder -t app-builder .
docker run --rm -it app-builder sh

Dies ist nützlich, wenn der Build erfolgreich ist, das endgültige Image jedoch fehlschlägt, weil eine Datei in den falschen Pfad kopiert wurde oder eine Laufzeitbibliothek fehlt.

Sie können auch in das endgültige Image kopierte Dateien überprüfen:

docker run --rm -it --entrypoint sh my-image

Wenn das Image keine Shell hat, wechseln Sie vorübergehend die endgültige Stufe zu einer debug-freundlichen Basis, während Sie diagnostizieren, und setzen Sie dann die Produktionsbasis zurück.

Eine praktische Regel

Verwenden Sie eine Stufe für jede einzelne Aufgabe: Abhängigkeiten, Build, Test, Laufzeit. Halten Sie die Laufzeitstufe langweilig. Wenn jemand die endgültige Dockerfile-Stufe öffnet, sollte er schnell eine Frage beantworten können: Welche Dateien benötigt dieser Container tatsächlich zum Laufen?

Das ist der wahre Wert von Multi-Stage-Builds. Kleinere Images sind schön. Klare Laufzeitgrenzen sind besser.