TL;DR — Kurzzusammenfassung
Docker Multi-Stage Builds reduzieren die Image-Größe und entfernen Build-Tools aus der Produktion und verbessern die Sicherheit für Node.js, Go und .NET.
Docker-Produktions-Images, die Compiler, Test-Frameworks und Gigabytes an Entwicklungsabhängigkeiten enthalten, sind eine Belastung. Sie verlangsamen den Container-Start, verbrauchen unnötigen Speicherplatz in Registries und exponieren eine größere Angriffsfläche gegenüber potenziellen Exploits. Docker Multi-Stage Builds lösen dieses Problem, indem sie es ermöglichen, in einer vollständigen Build-Umgebung zu kompilieren und zu testen und dann nur die fertigen Artefakte in ein schlankes, minimales Runtime-Image zu kopieren — alles aus einem einzigen Dockerfile. Dieser Leitfaden behandelt Multi-Stage Builds für Node.js, Go und .NET, einschließlich Caching-Strategien, Sicherheitshärtung und GitHub Actions-Integration.
Voraussetzungen
- Docker Engine 24+ installiert (
docker --version) - Grundlegende Kenntnisse in Dockerfiles (
FROM,RUN,COPY,CMD) - Eine funktionierende Anwendung in Node.js, Go oder .NET (Beispiele bereitgestellt)
- Docker BuildKit aktiviert (Standard in Docker 23+; setzen Sie
DOCKER_BUILDKIT=1für ältere Versionen) - Optional: GitHub Actions-Workflow für CI/CD-Integration
Das Problem mit einstufigen Builds
Ein typisches Node.js-Dockerfile ohne Multi-Stage Builds sieht so aus:
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
Dieses Image enthält die gesamte Node.js-Entwicklungsumgebung: npm, native Compiler-Toolchains, das node_modules-Verzeichnis einschließlich devDependencies, Quelldateien und Test-Utilities. Das finale Image kann für eine bescheidene Anwendung leicht 1,2 GB überschreiten. Jede Schicht wird in die Registry gepusht, bei jeder Bereitstellung heruntergeladen und auf CVEs in all diesen Tools geprüft, die Sie in der Produktion nie ausführen.
Wie Multi-Stage Builds funktionieren
Multi-Stage Builds verwenden mehrere FROM-Anweisungen in einem einzigen Dockerfile. Jedes FROM beginnt eine neue Stage mit einem sauberen Dateisystem. Sie benennen Stages mit AS und referenzieren sie in nachfolgenden COPY --from=<Stage>-Anweisungen:
# Stage 1: Build-Umgebung
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
# Stage 2: Produktions-Runtime
FROM node:22-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
Docker baut alle Stages sequentiell, behält aber nur die finale Stage im Ausgabe-Image. Jedes Tool, jede temporäre Datei und jedes Build-Artefakt aus früheren Stages wird verworfen. Der --from=builder-Flag in COPY greift auf das Dateisystem der benannten Stage zu, um bestimmte Pfade zu extrahieren.
Multi-Stage Build für Node.js
Hier ist ein produktionsreifes Multi-Stage Dockerfile für eine Node.js TypeScript-Anwendung:
# ── Stage 1: alle Abhängigkeiten installieren ──────────────────
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --frozen-lockfile
# ── Stage 2: TypeScript kompilieren ───────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ── Stage 3: Produktions-Runtime ──────────────────────────────
FROM node:22-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
# Nicht-Root-Benutzer erstellen
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# devDependencies aus node_modules entfernen
RUN npm prune --production
USER appuser
EXPOSE 8080
CMD ["node", "dist/server.js"]
Das Drei-Stage-Muster trennt Verantwortlichkeiten klar. Die deps-Stage installiert alles einschließlich devDependencies. Die builder-Stage kompiliert TypeScript zu JavaScript. Die production-Stage beginnt von vorne, kopiert nur die kompilierte Ausgabe und node_modules, entfernt devDependencies und läuft als Nicht-Root-Benutzer. Das resultierende Image ist ungefähr 180 MB gegenüber 1,2 GB für einen einstufigen Build.
Multi-Stage Build für Go
Go ist ein idealer Kandidat für Multi-Stage Builds, da der Compiler ein statisch verlinktes Binary produziert, das auf praktisch jedem Linux-Dateisystem ausgeführt werden kann:
# ── Stage 1: kompilieren ──────────────────────────────────────
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /app/server ./cmd/server
# ── Stage 2: minimale Runtime ─────────────────────────────────
FROM gcr.io/distroless/static-debian12 AS production
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
CGO_ENABLED=0 produziert ein vollständig statisches Binary ohne Abhängigkeiten von gemeinsam genutzten Bibliotheken. Die -ldflags="-s -w" entfernen die Symboltabelle und Debug-Informationen und reduzieren die Binary-Größe um 20-30%. Das finale Image verwendet distroless/static-debian12, das nur das Minimum zum Ausführen eines statischen Binaries enthält — kein Shell, kein Paketmanager, kein /bin/sh. Das Ergebnis ist ein 10-20 MB Image gegenüber 600 MB für die Build-Stage.
Multi-Stage Build für .NET
.NET trennt klar zwischen SDK (zum Bauen) und Runtime (zum Ausführen). Das Runtime-Image ist ungefähr ein Viertel so groß wie das SDK-Image:
# ── Stage 1: NuGet-Pakete wiederherstellen ─────────────────────
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS restore
WORKDIR /src
COPY *.sln .
COPY src/**/*.csproj ./
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore
# ── Stage 2: Build und Publish ─────────────────────────────────
FROM restore AS builder
COPY . .
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish src/MyApp/MyApp.csproj \
-c Release \
-o /app/publish \
--no-restore
# ── Stage 3: Produktions-Runtime ───────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/publish .
USER appuser
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]
Eine typische .NET 9 API schrumpft von 1,1 GB (SDK-Image) auf etwa 130 MB (Alpine-Runtime-Image).
Caching-Strategien
BuildKit-Mount-Caches (--mount=type=cache) sind die wirkungsvollste Optimierung für die Rebuild-Geschwindigkeit. Im Gegensatz zum Docker-Layer-Cache bleiben Mount-Caches zwischen Builds erhalten, ohne in das Image eingeschlossen zu werden:
# npm
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Go — Modul-Downloads und Build-Cache separat cachen
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build ./...
# NuGet
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore
Die Layer-Reihenfolge ist ebenso wichtig. Kopieren Sie nur die Abhängigkeits-Manifest-Dateien (package.json, go.mod, *.csproj) bevor Sie den Quellcode kopieren. Docker invalidiert den Cache für alle Layer nach dem ersten geänderten Layer:
# Richtig: Manifeste zuerst kopieren, Quellcode danach
COPY package*.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build
Image-Größenvergleich
| Anwendung | Einstufiges Image | Multi-Stage Image | Reduktion |
|---|---|---|---|
| Node.js TypeScript API | 1.240 MB | 185 MB | 85% |
| Go REST-Service | 612 MB | 12 MB | 98% |
| .NET 9 ASP.NET Core API | 1.080 MB | 130 MB | 88% |
| Python Flask-App | 945 MB | 180 MB | 81% |
| Java Spring Boot (JRE) | 880 MB | 250 MB | 72% |
Praxisszenario
Sie bauen und deployen eine Node.js REST API über GitHub Actions. Derzeit dauert jede CI-Ausführung 8 Minuten, um ein 1,2 GB Image zu bauen. Mit Multi-Stage Builds und BuildKit-Layer-Caching können Sie dies bei gecachten Läufen auf unter 2 Minuten reduzieren.
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
target: production
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache,mode=max
Fallstricke und Randfälle
ARG-Werte überschreiten keine Stage-Grenzen. Wenn Sie ARG VERSION=1.0 vor dem ersten FROM definieren, ist es global verfügbar. Wenn es innerhalb einer Stage definiert wird, existiert es nur in dieser Stage. Deklarieren Sie ARG in jeder Stage neu, die es benötigt.
Der Build-Kontext wird über alle Stages hinweg geteilt. Verwenden Sie eine .dockerignore-Datei, um node_modules, .git und Test-Fixtures aus dem Kontext auszuschließen, um die Übertragung zu beschleunigen und zu verhindern, dass sensible Dateien in Zwischenlayern erscheinen.
Der --target-Flag stoppt den Build bei einer benannten Stage. Verwenden Sie ihn zum Testen einzelner Stages:
docker build --target builder -t myapp:debug .
docker run --rm -it myapp:debug sh
Fehlerbehebung
Das finale Image enthält immer noch Quelldateien — Sie haben vergessen, COPY --from=builder zu verwenden und verwenden stattdessen ein einfaches COPY . . in der Produktions-Stage. Überprüfen Sie jedes COPY in Ihrer Produktions-Stage.
Das Go-Binary schlägt mit “exec format error” auf Alpine fehl — Sie haben mit CGO_ENABLED=1 kompiliert, aber eine Stage ohne C-Runtime verwendet. Setzen Sie CGO_ENABLED=0 explizit, oder wechseln Sie die Runtime-Stage zu alpine:3.19.
Mount-Caches bleiben im CI nicht erhalten — --mount=type=cache ist eine lokale BuildKit-Funktion. Verwenden Sie im CI stattdessen die Registry-Cache-Strategie (cache-from/cache-to).
Die Image-Größe hat sich nicht wesentlich verringert — Stellen Sie sicher, dass Ihre Produktions-Stage keine unnötigen Verzeichnisse kopiert. Führen Sie docker history myimage:latest aus, um zu sehen, welche Layer am meisten zur Größe beitragen.
Zusammenfassung
- Einstufige Builds beinhalten Compiler, Entwicklungstools und Quellcode im finalen Image — Multi-Stage Builds eliminieren all das
- Verwenden Sie
COPY --from=<Stage>, um nur kompilierte Artefakte in ein minimales Runtime-Basis-Image zu kopieren - Node.js-Apps schrumpfen um 85%, Go-Apps um 98% und .NET-Apps um 88% mit Multi-Stage Builds
- Hängen Sie Paketmanager-Caches mit
--mount=type=cacheein, um inkrementelle Rebuilds zu beschleunigen, ohne das Image aufzublähen - Kopieren Sie Abhängigkeits-Manifeste vor dem Quellcode, um die Docker-Layer-Cache-Wiederverwendung zu maximieren
- Führen Sie Container als Nicht-Root-Benutzer aus und verwenden Sie schreibgeschützte Dateisysteme, wo möglich
- In GitHub Actions verwenden Sie
cache-from/cache-tomit einem Registry-Cache, um BuildKit-Layer zwischen Läufen zu teilen - Verwenden Sie
--target, um spezifische Stages zu bauen und zu debuggen, ohne den vollständigen Build auszuführen