Docker-Image-Größe optimieren: schlanke Container bauen

Ein Docker-Image, das mehrere hundert Megabyte oder gar mehrere Gigabyte groß ist, kostet dich an jeder Stelle deiner Pipeline Zeit und Geld: längere Build-Zeiten, langsamere Pushes in die Registry, träge Deployments und eine größere Angriffsfläche. Dabei ist ein aufgeblähtes Image fast nie nötig. Mit ein paar gezielten Techniken bekommst du dasselbe Programm in einem Bruchteil der Größe lauffähig. Dieser Artikel zeigt dir die wichtigsten Hebel – von der Wahl des Base-Images bis zu Multi-Stage-Builds und Distroless-Containern.

Warum die Image-Größe überhaupt zählt

Bevor wir optimieren, lohnt sich der Blick darauf, warum kleine Images konkret besser sind:

Das richtige Base-Image wählen

Die größte einzelne Stellschraube ist das Base-Image. Wer mit FROM ubuntu oder FROM node (ohne Suffix) startet, schleppt ein komplettes Betriebssystem mit hunderten Paketen mit, von denen die Anwendung die wenigsten braucht.

Alpine als schlanke Standardwahl

Alpine Linux ist eine minimalistische Distribution, deren Base-Image nur etwa 5–8 MB groß ist. Viele offizielle Images bieten eine Alpine-Variante an, etwa node:20-alpine oder python:3.12-alpine. Statt 1 GB landest du oft bei 50–150 MB.

Achtung: Alpine nutzt musl libc statt der weit verbreiteten glibc. Die allermeisten Programme laufen problemlos, doch einige kompilierte Abhängigkeiten (besonders mit nativen Erweiterungen) machen Ärger. Teste deine Anwendung gründlich, bevor du Alpine in Produktion einsetzt.

Slim- und Distroless-Varianten

Wenn Alpine Kompatibilitätsprobleme macht, sind Slim-Images (z. B. python:3.12-slim) ein guter Mittelweg: glibc-basiert, aber ohne unnötige Tools und Doku. Noch radikaler sind Distroless-Images von Google: Sie enthalten nur die Laufzeitumgebung und deine Anwendung – keine Shell, keinen Paketmanager, kein ls. Das ist extrem sicher, erschwert aber das Debugging, weil du dich nicht per docker exec in den Container einloggen kannst.

Multi-Stage-Builds: Build-Werkzeuge draußen lassen

Der wirkungsvollste Trick für kompilierte Sprachen und Frontends ist der Multi-Stage-Build. Die Idee: In einer ersten Stage baust du die Anwendung mit allen Compilern, Build-Tools und Dev-Abhängigkeiten. In einer zweiten, schlanken Stage kopierst du nur das fertige Artefakt herüber. Alles, was nur zum Bauen nötig war, bleibt zurück.

Ein Beispiel für eine Node-Anwendung:

Das finale Image enthält weder den TypeScript-Compiler noch die Dev-Dependencies noch die Quelldateien. Bei einer typischen React- oder Vue-App schrumpft das Image so von über 1 GB auf unter 100 MB.

Bei Go oder Rust geht es noch weiter: Du baust eine statisch gelinkte Binärdatei und kopierst sie in ein scratch-Image (das komplett leere Base-Image). Das Ergebnis sind oft Images von unter 20 MB, die nur die eine Binärdatei enthalten.

Layer-Caching clever nutzen

Docker baut Images schichtweise. Jede Anweisung im Dockerfile erzeugt einen Layer, und Docker cached diese Layer. Ändert sich ein Layer, müssen alle darunterliegenden neu gebaut werden. Daraus folgt eine wichtige Regel: Ordne die Anweisungen vom seltensten zum häufigsten Wechsel.

Klassisches Beispiel bei Node: Kopiere zuerst nur package.json und package-lock.json und führe npm ci aus, bevor du den restlichen Quellcode kopierst. So bleibt der teure Installations-Layer im Cache, solange sich deine Abhängigkeiten nicht ändern – auch wenn du tausendmal am Code feilst.

Vermeide außerdem das häufige Anti-Pattern, mehrere RUN-Befehle für zusammengehörige Schritte zu nutzen. Fasse Paketinstallation und Aufräumen in einem einzigen RUN zusammen und lösche Caches im selben Schritt:

Wenn du das Aufräumen in einen separaten RUN packst, bleiben die gelöschten Dateien im vorherigen Layer erhalten und das Image bleibt groß – Layer sind additiv, ein späteres Löschen entfernt nichts aus früheren Schichten.

.dockerignore nicht vergessen

Eine oft übersehene, aber simple Maßnahme ist die Datei .dockerignore. Sie funktioniert wie .gitignore und verhindert, dass unnötige Dateien in den Build-Kontext und damit ins Image gelangen. Typische Kandidaten:

Das spart nicht nur Größe, sondern beschleunigt auch jeden Build, weil Docker weniger Daten zum Daemon übertragen muss. Wenn du ein verstecktes .env-File aus dem Build heraushalten willst, ist .dockerignore auch ein Sicherheitsgewinn – sonst landen womöglich Secrets in deinem Image.

Größe analysieren und messen

Optimieren ohne Messen ist Raten. Mit docker images siehst du die Gesamtgröße, mit docker history <image> bekommst du die Größe jedes einzelnen Layers aufgeschlüsselt. So erkennst du sofort den Layer, der die meisten Megabyte beisteuert.

Noch komfortabler ist das Open-Source-Tool dive, das dir interaktiv durch die Layer navigiert und sogar einen "Efficiency Score" anzeigt, der verschwendeten Speicher aufdeckt. Wenn du mit langen Hash-Werten oder Base64-codierten Tokens arbeitest, hilft dir nebenbei unser Webtools-Bereich mit praktischen Konvertern und Generatoren beim Aufbereiten von Konfigurationswerten.

Sicherheit als willkommener Nebeneffekt

Kleine Images sind fast immer auch sicherere Images. Weniger Pakete bedeuten weniger CVEs (bekannte Schwachstellen). Scanne deine Images regelmäßig mit Tools wie Trivy oder Grype, um Schwachstellen in den enthaltenen Paketen zu finden. Setze zudem einen nicht-root-Benutzer im Container, indem du mit USER appuser arbeitest – läuft der Prozess als root und bricht jemand aus, hat er sofort Root-Rechte im Host-Kontext.

Eine pragmatische Reihenfolge

Wenn du ein bestehendes, zu großes Image optimieren willst, gehe in dieser Reihenfolge vor:

  1. Lege ein sinnvolles .dockerignore an – schnellster Gewinn ohne Risiko.
  2. Wechsle auf eine slim- oder alpine-Variante des Base-Images und teste gründlich.
  3. Führe einen Multi-Stage-Build ein, um Build-Tools aus dem finalen Image zu verbannen.
  4. Optimiere die Layer-Reihenfolge für besseres Caching.
  5. Räume Caches im selben RUN auf und fasse Befehle zusammen.
  6. Erwäge Distroless oder scratch für maximale Schlankheit bei kompilierten Sprachen.

Häufige Fragen

Macht ein kleineres Image meine Anwendung schneller?

Nicht zur Laufzeit – ein laufender Container verhält sich gleich. Schneller wird der Start, weil das Image schneller heruntergeladen wird, und die gesamte CI/CD-Pipeline, weil weniger Daten bewegt werden. Bei Autoscaling, wo ständig neue Container hochfahren, ist das ein spürbarer Unterschied.

Ist Alpine immer die beste Wahl?

Nein. Alpine ist sehr klein, aber die musl-libc kann zu schwer auffindbaren Bugs bei nativen Abhängigkeiten führen, und manchmal sind Builds wegen Quellkompilierung sogar langsamer. Wenn du Probleme hast, ist ein slim-Image oft die unkompliziertere Wahl mit fast genauso gutem Größenvorteil.

Wie debugge ich ein Distroless- oder scratch-Image ohne Shell?

Du kannst während der Entwicklung eine Debug-Variante mit Shell nutzen (Google bietet :debug-Tags an) oder mit neueren Docker-Versionen docker debug beziehungsweise ephemere Debug-Container über kubectl debug einsetzen, die ein eigenes Toolset mitbringen, ohne das Produktions-Image zu verändern.

Lohnt sich der Aufwand für kleine Projekte?

Ein .dockerignore und ein slim-Base-Image kosten dich fünf Minuten und sind immer sinnvoll. Den vollen Multi-Stage-Aufwand betreibst du dort, wo es zählt: bei Images, die häufig gebaut, gepusht und deployed werden, oder die in großen Stückzahlen laufen.