Docker-Image-Größe reduzieren: Ein praktischer Leitfaden für schnellere Builds
Machen Sie Docker-Images kleiner und Builds schneller mit Multi-Stage-Builds, cache-freundlichen Dockerfiles und sauberen Build-Kontexten.
Docker-Image-Größe reduzieren: Ein praktischer Leitfaden für schnellere Builds
Die Reduzierung der Docker-Image-Größe bedeutet nicht, die kleinste Zahl bei docker image ls zu jagen. Das sinnvolle Ziel ist ein Build, der schnell, wiederholbar, einfach zu patchen und klein genug ist, um CI, Deployments oder die lokale Entwicklung nicht zu verlangsamen.
Ein aufgeblähtes Image entsteht meist durch einige gewöhnliche Gewohnheiten: das zu frühe Kopieren des gesamten Repositorys, das Installieren von Build-Tools in das Runtime-Image, das Hinterlassen von Paketmanager-Caches, die Verwendung von latest-Tags und das Senden eines riesigen Build-Kontexts an Docker. Beheben Sie diese Gewohnheiten, und die meisten Teams sehen sauberere Builds, ohne etwas Exotisches tun zu müssen.
Beginnen Sie damit, das aktuelle Image zu messen:
docker image ls my-app
docker history --no-trunc my-app:latest
docker history zeigt, welche Dockerfile-Anweisungen zur Größe beigetragen haben. Wenn ein RUN-Schritt eine große Menge hinzufügt, überprüfen Sie diesen Schritt, bevor Sie alles andere ändern. Für einen tieferen Einblick können Tools wie dive anzeigen, welche Dateien in jeder Ebene hinzugefügt und entfernt wurden, aber die integrierten Befehle reichen für den ersten Durchgang aus.
Wählen Sie ein Basis-Image zuerst nach Kompatibilität, dann nach Größe
Die Wahl des Basis-Images ist wichtig, aber das kleinste Image ist nicht immer das beste Image. Alpine ist klein, weil es musl libc und einen minimalen Userspace verwendet. Das funktioniert gut für viele Go-, Node-, Python- und Utility-Images, kann aber Reibung mit nativen Erweiterungen, vorgebauten Binärdateien, DNS-Verhalten oder Paketen, die glibc erwarten, verursachen.
Eine praktische Reihenfolge ist:
- Verwenden Sie ein offizielles Image für Ihre Runtime.
- Bevorzugen Sie einen versionierten Tag wie
python:3.12-slimgegenüberpython:latest. - Probieren Sie
slim, bevor Sie zu Alpine springen, wenn Ihre App native Abhängigkeiten hat. - Verwenden Sie distroless oder
scratch, wenn Sie bereits genau wissen, was die Runtime benötigt.
Zum Beispiel kann ein Python-Dienst mit psycopg, Bildbibliotheken oder Kryptographie-Paketen auf python:3.12-slim einfacher zu warten sein als auf python:3.12-alpine. Das Alpine-Image mag anfangs kleiner sein, aber wenn Sie Compiler und Kompatibilitätspakete hinzufügen, um Abhängigkeiten zu bauen, ist das Endergebnis möglicherweise nicht einfacher oder viel kleiner.
Für Go-Dienste sind scratch oder distroless hervorragend geeignet, da eine statisch kompilierte Binärdatei zur Laufzeit möglicherweise sehr wenig benötigt. Denken Sie auch dann an TLS-Zertifikate, Zeitzonendaten und Nicht-Root-Benutzer. Ein winziges Image, das keine HTTPS-Anfragen stellen kann, ist kein nützliches Produktions-Image.
Verwenden Sie Multi-Stage-Builds, um Build-Tools aus der Produktion herauszuhalten
Multi-Stage-Builds sind der zuverlässigste Weg, um die Docker-Image-Größe für kompilierte Apps und Frontend-Builds zu reduzieren. Die Builder-Stage kann groß sein. Sie kann Compiler, Paketmanager, Testtools und Quelldateien enthalten. Die finale Stage sollte nur das enthalten, was die App zum Ausführen benötigt.
Ein Go-Beispiel:
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Der wichtige Teil ist nicht das genaue Basis-Image. Der wichtige Teil ist die Grenze: Quellcode, Modul-Cache und Compiler bleiben in builder; das finale Image erhält die Binärdatei.
Eine Node-App hat eine ähnliche Form, aber Sie müssen Entwicklungsabhängigkeiten von Produktionsabhängigkeiten trennen:
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Für Frontend-Assets, die von nginx bereitgestellt werden, benötigt das finale Image überhaupt kein Node:
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
Dieses Muster entfernt node_modules, den Quellbaum und Build-Tools aus dem Runtime-Image.
Lassen Sie den Docker-Cache für sich arbeiten
Die Docker-Cache-Invalidierung ist einfach, aber unnachgiebig. Wenn sich eine Anweisung ändert, baut Docker diese Ebene und jede Ebene danach neu. Wenn Sie das gesamte Projekt kopieren, bevor Sie Abhängigkeiten installieren, kann jede Codeänderung eine Neuinstallation der Abhängigkeiten erzwingen.
Das ist langsam:
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
Das ist normalerweise schneller:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Die Abhängigkeitsebene wird nur neu erstellt, wenn sich requirements.txt ändert. Quellcodeänderungen bauen nur die spätere COPY . .-Ebene neu.
Die gleiche Regel gilt für Maven, Gradle, npm, pnpm, Cargo und Go-Module. Kopieren Sie zuerst die Abhängigkeitsmanifeste, installieren oder laden Sie Abhängigkeiten herunter, dann kopieren Sie den Rest des Quellcodes.
Halten Sie den Build-Kontext mit .dockerignore klein
Docker sendet den Build-Kontext an den Daemon, bevor er mit dem Bauen beginnt. Wenn Ihr Kontext .git, lokale node_modules, Coverage-Berichte, Videos, Screenshots, Testdatenbanken und Build-Artefakte enthält, ist Ihr Build langsamer, bevor das Dockerfile überhaupt ausgeführt wird.
Eine grundlegende .dockerignore könnte so aussehen:
.git
.gitignore
.env
.env.*
node_modules
coverage
dist
build
target
.pytest_cache
__pycache__
*.log
.DS_Store
Seien Sie vorsichtig mit zu breiten Ignorierungen. Wenn Sie dist ignorieren, Ihr Dockerfile aber erwartet, ein vorgebautes dist zu kopieren, wird der Build fehlschlagen. Wenn Sie .env ignorieren, ist das normalerweise gut für Geheimnisse, aber Ihr Build sollte nicht davon abhängen. Der Build sollte erforderliche Werte durch explizite Build-Argumente oder CI-Konfiguration erhalten.
Sie können die Kontextgröße am Anfang der klassischen Docker-Build-Ausgabe sehen, und die BuildKit-Ausgabe macht auch große Kontextübertragungen sichtbar. Wenn der erste Schritt lange dauert, bevor eine Dockerfile-Anweisung ausgeführt wird, überprüfen Sie .dockerignore.
Bereinigen Sie Paketmanager-Caches in derselben Ebene
Ein häufiger Fehler ist das Installieren von Paketen in einer Ebene und das Löschen von Caches in einer späteren Ebene. Die Dateien verschwinden aus der endgültigen Dateisystemansicht, aber sie können immer noch in der Image-Historie existieren.
Für Debian- und Ubuntu-Images:
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
Für Alpine:
RUN apk add --no-cache ca-certificates curl
Für Python:
RUN pip install --no-cache-dir -r requirements.txt
Für npm:
RUN npm ci --omit=dev && npm cache clean --force
Übernehmen Sie keine Bereinigungsbefehle blind. Verwenden Sie die Bereinigung, die zum Paketmanager und Basis-Image passt. Vermeiden Sie auch apt-get upgrade in den meisten Anwendungs-Dockerfiles. Es macht Builds weniger vorhersagbar und kann mehr Änderungen mit sich bringen als beabsichtigt. Patchen Sie, indem Sie regelmäßig von gewarteten Basis-Images neu bauen und die erwartete Image-Familie festlegen.
Bevorzugen Sie COPY, es sei denn, Sie benötigen ADD
COPY kopiert Dateien aus dem Build-Kontext. ADD hat zusätzliches Verhalten: Es kann in einigen Fällen lokale tar-Archive entpacken und entfernte URLs abrufen. Dieses zusätzliche Verhalten kann Maintainer überraschen und das Cache-Verhalten schwerer nachvollziehbar machen.
Verwenden Sie COPY für normale Anwendungsdateien:
COPY ./src ./src
Verwenden Sie ADD nur, wenn Sie bewusst dessen Archiv-Extraktionsverhalten wünschen. Für Remote-Downloads bevorzugen Sie curl oder wget in einem RUN-Schritt, wo Sie Prüfsummen verifizieren und klar fehlschlagen können.
Reduzieren Sie, was Sie installieren, nicht nur, was Sie löschen
Der beste Cache-Cleanup ist, unnötige Dateien gar nicht erst zu installieren. Verwenden Sie --no-install-recommends mit apt-get. Vermeiden Sie die Installation von Editoren, Shells, Paketmanagern und Debugging-Tools in Produktions-Images, es sei denn, Sie haben einen Grund.
Entfernen Sie jedoch nicht jedes Tool, wenn es den Betrieb erschwert. Ein Produktions-Image kann minimal sein, aber dennoch beobachtbar. Für einige Teams ist ein slim-Image mit einer Shell ein besserer Kompromiss als ein distroless-Image, weil das Debuggen im Bereitschaftsdienst einfacher ist. Für andere Teams mit starkem Logging, Tracing und ephemeren Debug-Containern ist distroless eine gute Wahl.
Die ehrliche Antwort ist workload-spezifisch. Kleinere Images pullen normalerweise schneller und setzen weniger Pakete dem Patchen aus, aber die Wartbarkeit ist dennoch wichtig.
Verwenden Sie BuildKit-Cache-Mounts für Abhängigkeits-Downloads
BuildKit kann Paket-Caches außerhalb des finalen Images halten und sie über Builds hinweg wiederverwenden. Dies unterscheidet sich vom Belassen von Caches im Image.
Für npm:
# syntax=docker/dockerfile:1.7
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
Für apt können Cache-Mounts in CI-Umgebungen helfen, in denen wiederholte Builds auf demselben Builder stattfinden, obwohl viele CI-Systeme frische Worker verwenden, es sei denn, Sie konfigurieren einen persistenten Cache. BuildKit-Funktionen sind leistungsstark, aber halten Sie das Dockerfile lesbar für das Team, das es warten muss.
Überprüfen Sie den Inhalt des finalen Images
Beweisen Sie nach Änderungen, was sich verbessert hat:
docker build -t my-app:optimized .
docker image ls my-app
docker history my-app:optimized
Führen Sie dann das Image so aus, wie es in der Produktion ausgeführt wird. Ein kleineres Image, das fehlschlägt, weil es Zertifikate, Locale-Daten, Zeitzonendateien oder ein beschreibbares Verzeichnis vermissen lässt, ist keine Verbesserung.
Nützliche Smoke-Tests umfassen:
docker run --rm my-app:optimized --version
docker run --rm -p 8080:8080 my-app:optimized
curl -f http://localhost:8080/health
Wenn die App Dateien schreibt, testen Sie diesen Pfad. Wenn sie HTTPS-Dienste aufruft, testen Sie TLS. Wenn sie als Nicht-Root ausgeführt wird, testen Sie Berechtigungen.
Eine praktische Optimierungsreihenfolge
Wenn ich ein übermäßig großes Docker-Image überprüfe, verwende ich diese Reihenfolge:
- Fügen Sie
.dockerignorehinzu oder korrigieren Sie es. - Ordnen Sie das Dockerfile so um, dass die Abhängigkeitsinstallation gecached werden kann.
- Führen Sie Multi-Stage-Builds ein.
- Wechseln Sie von vollständigen Basis-Images zu
slim, Alpine, distroless oderscratch, wo kompatibel. - Entfernen Sie Paketmanager-Caches und Entwicklungsabhängigkeiten in derselben Ebene, in der sie erstellt wurden.
- Überprüfen Sie mit
docker historyoderdiveund wiederholen Sie nur dort, wo die Beweise darauf hindeuten.
Diese Reihenfolge vermeidet vorzeitige Raffinesse. Sie hält das Dockerfile auch verständlich, was wichtiger ist, als die letzten paar Megabyte herauszuholen.