Was ein schlechtes Dockerfile kostet
| |
Vier Zeilen, und jede kostet etwas. docker build führt npm install bei jeder Codeänderung erneut aus, weil COPY . . den Layer-Cache zerstört, bevor der Install-Schritt überhaupt eine Chance hat, wiederverwendet zu werden. Das fertige Image trägt die komplette Debian-Basis, den gesamten node_modules-Baum inklusive Dev-Abhängigkeiten und alle Build-Tools, die npm install zum Kompilieren nativer Module nachgeladen hat. Nichts davon wird verworfen. Der Container läuft als Root, weil ihm niemand etwas anderes sagt, und node:latest bedeutet, dass sich das Base-Image zwischen zwei Builds ändern kann, ohne dass irgendwo festgehalten wird, was Sie tatsächlich ausgeliefert haben.
Nichts davon fällt als Fehler auf. Das Image baut, der Container startet, die App antwortet. Die Kosten kommen später: ein fünfminütiger CI-Build, weil jeder Schritt bei null anfängt, und ein 1,1-GB-Image für eine App, deren eigener Code ein paar hundert Kilobyte groß ist. Jede Korrektur unten ist klein. Zusammen ändern sie, was ein Dockerfile Sie jeden Tag kostet.
Wie der Docker-Layer-Cache entscheidet, was neu gebaut wird
Docker baut ein Image Instruktion für Instruktion und speichert das Ergebnis jeder einzelnen als Layer im Cache. Beim nächsten Build durchläuft es das Dockerfile erneut und verwendet einen gecachten Layer wieder, solange die Instruktion identisch ist und der Layer davor in der Kette sich nicht geändert hat. Sobald eine Instruktion den Cache verfehlt, laufen auch alle nachfolgenden erneut. Cache-Hits verlängern eine Kette nur von oben. Falls Images, Layer und Container noch unklar sind: Was Docker ist und wie Container funktionieren deckt die Grundlagen ab, die dieser Artikel voraussetzt.
COPY und ADD invalidieren nach Inhalt, nicht nur nach dem Instruktionstext: Wenn sich eine kopierte Datei geändert hat, ist der Cache dieses Layers weg, und mit ihm alles danach. COPY . . kopiert den kompletten Build-Kontext, invalidiert also bei jeder Änderung an beliebiger Stelle im Projekt, bis hin zu einem umformulierten Kommentar in einer Datei, die mit Abhängigkeiten nichts zu tun hat. Steht RUN npm install direkt danach, läuft es bei jedem einzelnen Build neu.
Wie Sie Dockerfile-Instruktionen für Cache-Treffer ordnen
Kopieren Sie nur, was eine Instruktion tatsächlich braucht, in der Reihenfolge, in der sich die Dinge wirklich ändern: Abhängigkeits-Manifeste selten, Quellcode ständig.
| |
package.json und package-lock.json ändern sich, wenn Sie eine Abhängigkeit hinzufügen oder aktualisieren. RUN npm ci cacht jetzt nur noch gegen diese beiden Dateien. Ändern Sie eine Quelldatei und bauen Sie neu: Docker verwendet den gecachten Install-Layer wieder und führt nur das abschließende COPY und alles danach erneut aus. Der langsamste Schritt des Builds läuft nur, wenn er wirklich muss.
npm ci statt npm install ist Absicht. Es installiert exakt das, was im Lockfile steht, und schlägt fehl, wenn Lockfile und package.json nicht übereinstimmen, statt still neue Versionen in einen Build aufzulösen, den Sie für reproduzierbar hielten.
Dieselbe Reihenfolge gilt außerhalb von Node: Ein Python-Projekt kopiert requirements.txt und führt pip install vor dem restlichen Quellcode aus, ein Go-Projekt kopiert go.mod/go.sum und führt zuerst go mod download aus.
Wie Multi-Stage-Builds das fertige Image verkleinern
Umsortieren behebt den Cache. Es ändert nichts an einem Image, das weiterhin Dev-Abhängigkeiten, Build-Tools und alles mitschleppt, was npm ci zum Kompilieren nativer Module heruntergeladen hat, obwohl die App davon nichts zum Laufen braucht. Ein Multi-Stage-Build trennt, was zum Bauen der App nötig ist, von dem, was zum Ausführen nötig ist.
| |
Zwei FROM-Zeilen, zwei Stages. Die erste, build genannt, installiert alle Abhängigkeiten (einschließlich reiner Dev-Tools wie eines Bundlers oder TypeScript) und erzeugt dist/. Die zweite startet frisch vom selben Base-Image, installiert nur die Produktionsabhängigkeiten und holt sich mit COPY --from=build ein Verzeichnis aus der ersten Stage. Der TypeScript-Compiler, die .ts-Quelldateien, der npm-Cache: nichts davon erreicht das fertige Image, weil die Build-Stage nach Abschluss verworfen wird.
Der Größenunterschied ist der Punkt. node:latest, das vollständige Debian-basierte Image, liegt nahe an 1,1 GB, bevor Sie auch nur eine einzige Abhängigkeit hinzufügen. node:24-slim bleibt unter 300 MB, node:24-alpine unter 200 MB, sofern Ihre Abhängigkeiten keine glibc brauchen. Multiplizieren Sie das mit jedem Service, den Sie betreiben, und jedem CI-Runner, der das Image zieht, und aus der Differenz wird realer Speicherplatz und reale Zeit.
Der Effekt fällt bei einer kompilierten Sprache noch größer aus, wo das Binary außer sich selbst nichts braucht.
| |
Keine Shell, kein Paketmanager, keine Go-Toolchain im fertigen Image: nur das statische Binary plus die CA-Zertifikate und Zeitzonendaten, die distroless/static mitbringt. Ein Angreifer, der in diesem Container Codeausführung erlangt, findet keine sh zum Starten und kein apt, um eine zu installieren.
Was in die .dockerignore gehört
COPY . . schickt den kompletten Build-Kontext an den Docker-Daemon, noch bevor der Build beginnt, und dieser Kontext enthält Dateien, die nie ausgeliefert werden sollten: .git, ein lokales node_modules, .env-Dateien mit echten Zugangsdaten, Editor-Konfiguration. Eine .dockerignore neben dem Dockerfile schließt sie aus, mit derselben Pattern-Syntax wie .gitignore.
| |
node_modules auszuschließen geht über die Größe hinaus. Hat eine lokale Installation auf Ihrer Maschine native Module, die für macOS und arm64 kompiliert wurden, schleust COPY . . diese Binaries in einen Linux-Container, wo sie nicht laden. Lassen Sie den Container seine eigenen Abhängigkeiten installieren.
Wie Sie einen Container ohne Root-Rechte laufen lassen
Nichts in einem einfachen Dockerfile hindert einen Container daran, als Root zu laufen, also tut er es standardmäßig. Erlangt ein Angreifer darin Codeausführung, über eine Schwachstelle in einer Abhängigkeit oder einen Deserialisierungs-Bug, trennt Root im Container nur ein Kernel-Bug oder ein falsch konfiguriertes Mount von Root auf dem Host. Die meisten offiziellen Images bringen bereits einen Benutzer mit, zu dem Sie wechseln können.
| |
Das node-Image legt einen node-Benutzer an, USER node ist also die einzige Zeile, die Sie hinzufügen. Bringt ein Base-Image keinen solchen Benutzer mit, legen Sie vor dem Wechsel einen an:
| |
Das --chown zählt genauso wie das USER. Ohne es gehören die Dateien Root, während der Prozess, der sie liest, als app läuft, und alles, was die App zur Laufzeit schreibt, scheitert an einem Berechtigungsfehler, den Sie erst in Produktion entdecken. Dasselbe gilt für ein Docker-Volume, das in den Container gemountet wird: Sein Inhalt muss für die UID beschreibbar sein, zu der Sie gewechselt sind, sonst stirbt der erste Schreibvorgang beim Start.
Wie Sie Base-Image-Tag oder Digest fixieren
FROM node:latest löst sich zu dem auf, worauf latest an dem Tag zeigt, an dem Sie bauen. Bauen Sie dasselbe Dockerfile nächsten Monat neu, können Sie bei einer anderen Major-Version, einem anderen Debian-Release, anderen Systempaketen landen, ohne dass ein Diff in Ihrem Repository zeigt, was sich verändert hat. Fixieren Sie die Version:
| |
Für einen Build, der byte-genau reproduzierbar sein muss, fixieren Sie zusätzlich den Digest. Das bindet den Build an genau ein unveränderliches Image, egal was mit dem Tag passiert:
| |
Den Digest bekommen Sie mit docker pull node:24.9.0-slim, gefolgt von docker inspect --format='{{index .RepoDigests 0}}' node:24.9.0-slim. Tag-Fixierung deckt die meisten Projekte ab. Digest-Fixierung ist für Pipelines, bei denen “was genau haben wir ausgeliefert” auch Monate später noch beantwortbar sein muss.
Wann Sie mehrere RUN in einem Layer zusammenfassen
Jedes RUN erzeugt einen Layer, und ein Layer wächst nur. Eine Datei in einem späteren Layer zu löschen verkleinert das Image nicht, sondern versteckt die Datei hinter einem Whiteout-Marker, während der frühere Layer die Bytes weiterhin trägt. Deshalb bringt es nichts, Installation und Aufräumen auf zwei separate RUN-Befehle zu verteilen:
| |
Packen Sie das Aufräumen in denselben Layer wie die Installation:
| |
Das gilt für Installationen, die Dateien hinterlassen: Paketmanager-Caches, heruntergeladene Archive, Build-Artefakte, die schon woanders hinkopiert wurden. Es ist kein Argument dafür, jedes RUN in der Datei zusammenzulegen. Eine Handvoll lesbarer Layer lässt sich besser debuggen als eine 400 Zeichen lange Zeile, und die Anzahl der Layer allein ist nicht das, was Größe kostet.
Wann sich diese Praktiken nicht lohnen
Ein Einmal-Skript, das Sie lokal mit docker build -t scratch . && docker run --rm scratch ausführen, braucht weder einen Multi-Stage-Build noch einen fixierten Digest. Der Aufwand kostet mehr als das Risiko, das er vermeidet.
Alpines kleinerer Fußabdruck kommt von musl libc statt glibc, was native Node- und Python-Module bricht, die gegen glibc kompiliert wurden. Segfaults oder fehlende Symbole nach dem Wechsel zu -alpine sind meist genau das. Nutzen Sie -slim, wenn Sie sich nicht sicher sind.
Und Fixierung ist eine Entscheidung mit Kosten: Ein nicht fixiertes Base-Image holt sich Sicherheitspatches beim nächsten docker build --pull, ohne dass Sie etwas dafür tun. Wenn Sie das wollen, treffen Sie diese Entscheidung bewusst, im Wissen, dass ein Build anfangen kann, sich anders zu verhalten aus Gründen, die nicht in Ihrem Diff stehen. Den Tag aus Versehen offenzulassen ist nicht dieselbe Entscheidung.
Wie Sie Image-Größe und Build-Zeit messen
Bauen Sie beide Versionen derselben App und vergleichen Sie:
| |
docker images zeigt den Größenunterschied. docker history zeigt, welche Instruktion welchen Layer erzeugt hat und wie groß er ist, der schnellste Weg, das eine RUN zu finden, das noch etwas mitschleppt, das dort nicht hingehört. Nichts davon ändert sich, wenn Ihre App von Docker Compose gebaut wird: Ein Service mit build: . verwendet dasselbe Dockerfile, denselben Cache und denselben Build-Kontext, also erzielt docker compose build dieselben Gewinne.
Haben Sie ein Dockerfile geerbt, das älter ist als all das? Bauen Sie es einmal so, wie es ist, führen Sie docker history aus und beheben Sie den Layer, der am meisten überrascht. Dieser eine Layer macht meist den größten Teil der Differenz aus.