Warum ein Docker-Image zehnmal größer ist als die App
Ein Node-Service mit ein paar hundert Kilobyte Quellcode landet als Image mit über 1 GB. Das ist das normale Ergebnis eines funktionierenden Builds, kein Fehler in einer bestimmten Zeile. Das Gewicht kommt von einem Basis-Image, das ein komplettes Betriebssystem mitschleppt, einer Build-Toolchain, die die laufende App nie aufruft, und einem Paketmanager-Cache, um den sich niemand kümmert.
Sie zahlen dafür bei jedem Deploy: docker pull wird langsamer, und ein Vulnerability-Scanner meldet mehrere hundert zusätzliche Pakete. Auch der Speicherplatz summiert sich, über jeden Tag, an dem Sie je gepusht haben. Das zu beheben bedeutet, das Dockerfile neu zu schreiben, nicht die App.
Welches Basis-Image: alpine, slim oder distroless
Die FROM-Zeile setzt den Ausgangspunkt. Wird sie schlecht gewählt, gleicht nichts anderes in der Datei den Unterschied aus.
| Basis-Image | Ungefähre Größe | Enthält |
|---|---|---|
node:26 | 1,77 GB | Vollständiges Debian, Compiler, mehrere Sprach-Runtimes, Dokumentation |
node:26-slim | 371 MB | Abgespecktes Debian, ohne Build-Toolchain |
node:26-alpine | 247 MB | musl libc, apk, etwa 50 Pakete |
gcr.io/distroless/nodejs22-debian12 | 212 MB | Nur Node-Runtime, keine Shell, kein Paketmanager, etwa 10 Pakete |
Das Muster zieht sich außerhalb von Node fort. debian:bookworm-slim liegt bei etwa 74 MB gegenüber 77 MB bei ubuntu:22.04. alpine:latest liegt bei rund 7 MB. gcr.io/distroless/static-debian12, gebaut für ein statisch gelinktes Go- oder Rust-Binary, das nichts außer CA-Zertifikaten braucht, liegt bei knapp 2 MB.
Alpine erreicht seine Größe, indem es glibc gegen musl libc tauscht, und dieser Tausch ist der Preis: native Module, die gegen glibc kompiliert wurden (manche npm-Pakete, die meisten pip-Pakete mit C-Erweiterungen), stürzen unter musl mit einem Segfault ab oder werfen Fehler wegen fehlender Symbole. Ein -slim-Tag umgeht das Risiko und verkleinert das Image trotzdem um zwei Drittel. Wechseln Sie erst zu Alpine, nachdem Sie bestätigt haben, dass Ihre Abhängigkeiten damit klarkommen.
Distroless geht weiter. Keine Shell, kein Paketmanager, nichts, was ein Angreifer nach erlangter Code-Ausführung nutzen könnte, und auch kein sh für Sie selbst, was zu echten Kosten wird, sobald sich ein Container in Produktion einmal daneben benimmt.
Layer-Reihenfolge, .dockerignore und die Mechanik eines Multi-Stage-Builds gehören zu Dockerfile Best Practices: Kleinere, schnellere Builds. Wir gehen hier davon aus, dass diese Trennung bereits existiert, und konzentrieren uns auf das, was ein Build-Stage allein nicht behebt.
Wie Sie Paketmanager-Caches aus dem Image heraushalten
Ein Multi-Stage-Build hält den Compiler aus dem Runtime-Stage heraus. Er tut nichts gegen den eigenen Cache des Paketmanagers, der in dem Layer landet, in dem die Installation lief: harmlos in einem Build-Stage, den Sie wegwerfen, totes Gewicht im finalen Stage, wenn dieser selbst etwas installiert.
BuildKit-Cache-Mounts lösen das ganz ohne Aufräumschritt:
| |
--mount=type=cache gibt diesem RUN ein Verzeichnis, das zwischen Builds bestehen bleibt und nie in einen Layer übernommen wird. Der npm-Cache beschleunigt den nächsten Build weiterhin; nichts davon landet im Image. Bei Python heißt das RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt, bei Go --mount=type=cache,target=/root/.cache/go-build.
Ohne BuildKit sagen Sie dem Installer, gar nicht erst zu cachen: pip install --no-cache-dir, oder npm ci && npm cache clean --force. Beide Hälften gehören in denselben RUN. Eine Datei in einem späteren Layer zu löschen versteckt sie hinter einem Whiteout-Marker und lässt die Bytes im darunterliegenden Layer liegen, sodass die auf zwei RUN-Befehle aufgeteilte Version den Cache trotzdem ausliefert. Ein Service, den Docker Compose mit build: . baut, führt dasselbe Dockerfile mit demselben Builder aus, Cache-Mounts funktionieren dort also unverändert.
Wie Sie Debug-Symbole und ungenutzte Dateien entfernen
Build-Abhängigkeiten sind nur die halbe Miete. Runtime-Abhängigkeiten bringen Dateien mit, die in Produktion nie gelesen werden: Testsuiten, .md-Dokumentation, kompilierte .pyc-Caches, Source Maps.
Bei Python überspringen Sie den pip-Cache und den Bytecode-Cache im installierenden Layer:
| |
Ein kompiliertes Binary trägt Debug-Informationen, die die Produktion nie liest. Ein Flag im Build-Stage entfernt sie:
| |
| |
-ldflags="-s -w" nimmt bei einem Go-Binary üblicherweise 20-30% weg. Bei einer kleinen CLI fällt das nicht auf, bei allem mit einem großen Abhängigkeitsgraphen sind es echte Bytes. Entfernte Symbole kosten Sie nur, wenn Sie einen Debugger genau an das in Produktion laufende Binary anhängen wollen. Bei einem Container ohne Shell ist das ohnehin nicht möglich.
Wie Sie herausfinden, welcher Layer das Image aufbläht
Docker hat aufgezeichnet, wo die Bytes hingegangen sind. Sie müssen es nur auslesen:
| |
Eine Zeile pro Layer, mit der Anweisung, die ihn erzeugt hat, und seiner Größe. Fügen Sie --no-trunc hinzu, wenn sich mehrere RUN-Zeilen in der abgeschnittenen Ansicht ähneln.
Das nennt die teure Anweisung, nicht die teuren Dateien darin. dive übernimmt die zweite Hälfte:
| |
Es öffnet eine Terminal-UI über die Layer, färbt hinzugefügte, geänderte und gelöschte Dateien ein und meldet einen Effizienzwert zusammen mit den insgesamt verschwendeten Bytes: Platz, der von Dateien belegt wird, die ein späterer Layer überschrieben oder gelöscht hat, ohne dass sie je aus dem Image verschwunden wären. CI=true dive myapp:latest führt denselben Check nicht-interaktiv aus und beendet sich mit einem Fehlercode, wenn das Image die Schwellenwerte aus einer .dive-ci-Datei verfehlt:
| |
Ein Image, das still gewachsen ist, hört auf, etwas zu sein, das ein Kollege Wochen später bemerkt, und wird zu einem fehlgeschlagenen Check auf genau der Pull Request, die es verursacht hat.
Wann sich das Verkleinern nicht lohnt
Ein Skript, das Sie einmal bauen und auf Ihrer eigenen Maschine ausführen, braucht weder eine distroless-Basis noch ein von Symbolen befreites Binary. Das Image verlässt nie Ihre Festplatte, und die Arbeit kostet mehr als das eingesparte Gigabyte. Dasselbe gilt für das Image eines kurzlebigen CI-Jobs, der bei jedem Lauf neu gebaut wird: 200 MB von etwas abzuziehen, das einmal gezogen und dann verworfen wird, bringt nichts.
Der Kompromiss beißt am unteren Ende am stärksten. Ein distroless- oder scratch-Image hat keine Shell, also schlägt docker exec -it myapp sh bei einem sich daneben benehmenden Container schlicht fehl. Sie debuggen dann aus Logs und einer lokalen Nachstellung, oder Sie halten sich genau dafür eine Debug-Variante bereit (distroless veröffentlicht -debug-Tags mit einer Busybox-Shell). Alpine gibt weniger von diesem Komfort auf und bringt dafür das musl-Risiko mit. Keiner dieser Kosten ist versteckt, und genau deshalb ist “überall die kleinstmögliche Basis” ein schlechterer Rat, als die Basis pro Service danach zu wählen, was er tatsächlich braucht.
Wo Sie bei einem bestehenden Image anfangen
Führen Sie docker history auf das aus, was Sie heute ausliefern, bevor Sie irgendetwas ändern. Der größte Layer ist meist eine offensichtliche Korrektur, sobald er einen Namen daneben stehen hat. Führen Sie dann dive mit den obigen Schwellenwerten auf demselben Image aus: Ein Lauf, der beim ersten Versuch fehlschlägt, gibt Ihnen eine Zahl zum Unterbieten und einen Bericht auf Dateiebene, wo die Verschwendung liegt, was besser ist als am Dockerfile zu raten, ausgehend davon, was Images üblicherweise aufbläht.