Ein Dockerfile zu schreiben, das "irgendwie funktioniert", ist leicht. Eines zu schreiben, das schnell baut, klein bleibt, sicher ist und reproduzierbar funktioniert, erfordert Wissen über die Eigenheiten von Docker. Dieser Artikel sammelt die wichtigsten Best Practices, die den Unterschied zwischen einem hingerotzten und einem produktionsreifen Dockerfile ausmachen. Wenn du tiefer in das Verkleinern von Images einsteigen willst, ergänzt unser Artikel zum Optimieren der Docker-Image-Größe die hier gezeigten Grundlagen.
Verstehe das Layer-Prinzip
Jede Anweisung im Dockerfile (FROM, RUN, COPY usw.) erzeugt einen Layer – eine unveränderliche Schicht im Image. Docker cached diese Layer: Ändert sich an einer Anweisung und ihrem Kontext nichts, wird der gecachte Layer wiederverwendet, statt neu zu bauen. Sobald sich aber ein Layer ändert, müssen alle darunterliegenden ebenfalls neu gebaut werden.
Daraus folgt die wichtigste Regel überhaupt: Ordne Anweisungen vom Stabilen zum Veränderlichen. Was sich selten ändert (Base-Image, Systempakete), gehört nach oben. Was sich oft ändert (dein Quellcode), gehört nach unten. So bleibt der teure obere Teil im Cache, während du am Code arbeitest.
Abhängigkeiten vor dem Code kopieren
Der klassische Anwendungsfall dieser Regel: Kopiere zuerst nur die Dateien, die deine Abhängigkeiten beschreiben, installiere sie, und kopiere erst danach den restlichen Quellcode.
- Bei Node: erst
COPY package.json package-lock.json ./, dannRUN npm ci, dannCOPY . . - Bei Python: erst
COPY requirements.txt ./, dannRUN pip install -r requirements.txt, dann der Rest
So bleibt der zeitaufwändige Installations-Layer im Cache, solange sich deine Abhängigkeiten nicht ändern – auch wenn du den Code hundertmal anfasst. Wer stattdessen direkt alles mit COPY . . kopiert und dann installiert, baut bei jeder Code-Änderung die kompletten Abhängigkeiten neu.
RUN-Befehle sinnvoll bündeln
Jeder RUN-Befehl erzeugt einen eigenen Layer. Fasse zusammengehörige Schritte in einer einzigen RUN-Anweisung zusammen und räume im selben Schritt auf:
- Bei Debian/Ubuntu:
RUN apt-get update && apt-get install -y --no-install-recommends paket && rm -rf /var/lib/apt/lists/* - Bei Alpine:
RUN apk add --no-cache paket
Wichtig: Das Aufräumen muss im selben RUN stehen wie die Installation. Löschst du Caches in einem späteren Layer, bleiben sie im früheren erhalten und blähen das Image trotzdem auf – Layer sind additiv und ein späteres Entfernen verkleinert frühere Schichten nicht.
COPY statt ADD (meistens)
Es gibt zwei Anweisungen zum Hineinkopieren von Dateien, und sie sind nicht gleichwertig:
- COPY kopiert schlicht Dateien und Verzeichnisse vom Build-Kontext ins Image. Transparent und vorhersehbar.
- ADD kann mehr: Es entpackt automatisch lokale Tar-Archive und kann sogar URLs herunterladen. Genau diese "Magie" macht es unvorhersehbarer.
Die Empfehlung der Docker-Dokumentation ist klar: Nutze standardmäßig COPY. Greife nur zu ADD, wenn du dessen Spezialfunktion (automatisches Entpacken eines lokalen Archivs) wirklich brauchst. Für Downloads ist ein explizites RUN curl oder wget ohnehin nachvollziehbarer.
Niemals als root laufen
Standardmäßig läuft der Prozess im Container als root. Das ist ein erhebliches Sicherheitsrisiko: Bricht ein Angreifer aus dem Container aus, hat er potenziell Root-Rechte im Host-Kontext. Lege deshalb einen unprivilegierten Benutzer an und wechsle vor dem Start zu ihm:
- Einen Benutzer anlegen (z. B.
RUN adduser --disabled-password appuser) - Dateiberechtigungen entsprechend setzen
- Vor dem Start mit
USER appuserauf ihn wechseln
Viele offizielle Images bringen bereits einen non-root-Benutzer mit (etwa der node-User in den Node-Images), den du direkt verwenden kannst. Dieser eine Schritt schließt eine der häufigsten Container-Sicherheitslücken.
ENTRYPOINT vs. CMD richtig einsetzen
Beide legen fest, was beim Start des Containers ausgeführt wird, mit unterschiedlicher Bedeutung:
- ENTRYPOINT definiert den festen, immer ausgeführten Hauptbefehl. Er macht den Container zu einem ausführbaren Programm.
- CMD liefert Standardargumente, die leicht überschrieben werden können, oder den Standardbefehl, wenn kein ENTRYPOINT gesetzt ist.
Nutze außerdem die Exec-Form (mit eckigen Klammern, z. B. CMD ["node", "server.js"]) statt der Shell-Form. Die Exec-Form startet den Prozess direkt als PID 1, sodass er Signale wie SIGTERM korrekt empfängt – wichtig für einen sauberen Graceful Shutdown. Die Shell-Form startet stattdessen eine Shell, die die Signale abfängt und nicht weiterreicht.
Versionen pinnen für reproduzierbare Builds
Der Tag latest ist verlockend, aber gefährlich: Was heute Version 20 ist, kann morgen Version 22 sein, und plötzlich bricht dein Build oder verhält sich anders. Pinne deshalb Versionen so genau wie sinnvoll:
- Base-Image:
FROM node:20.11-alpinestattFROM node:latest - Abhängigkeiten: Lockfiles (
package-lock.json,poetry.lock) committen und im Build deterministisch installieren
So baut dein Image heute identisch wie in einem halben Jahr – die Grundlage für nachvollziehbare, debuggbare Deployments.
Healthcheck einbauen
Mit der HEALTHCHECK-Anweisung sagst du Docker, wie es prüfen soll, ob deine Anwendung wirklich gesund ist – nicht nur, ob der Prozess läuft. Ein Healthcheck ruft typischerweise periodisch einen HTTP-Endpunkt auf und meldet den Container als unhealthy, wenn er nicht antwortet. Orchestrierer können darauf reagieren und ungesunde Container neu starten oder aus dem Load Balancing nehmen. Das macht dein Deployment deutlich robuster.
Secrets gehören nicht ins Image
Ein häufiger und gefährlicher Fehler: Passwörter, API-Keys oder Tokens werden per ENV oder ARG ins Image gebacken. Das Problem: Diese Werte landen in der Image-Historie und sind später auslesbar – auch wenn du sie scheinbar wieder "entfernst", bleiben sie in einem früheren Layer erhalten.
Richtig ist: Secrets erst zur Laufzeit über Umgebungsvariablen, Docker Secrets oder einen Secret-Manager einspielen. Für den Build-Zeitpunkt bietet BuildKit mit --mount=type=secret einen Mechanismus, der Geheimnisse nutzbar macht, ohne sie in einem Layer zu verewigen. Starke Tokens und zufällige Geheimnisse für deine Konfiguration erzeugst du am besten mit unserem Passwort-Generator.
Weitere praktische Tipps
- .dockerignore pflegen: Halte
node_modules,.git, Logs und lokale.env-Dateien aus dem Build-Kontext heraus. Das beschleunigt Builds und verhindert versehentlich eingebaute Secrets. - WORKDIR statt cd: Setze das Arbeitsverzeichnis mit
WORKDIR /appstatt mitRUN cd ...– letzteres wirkt nur innerhalb desselben RUN-Befehls. - Labels nutzen: Mit
LABELdokumentierst du Metadaten wie Maintainer, Version oder Quell-Repository direkt im Image. - Multi-Stage-Builds: Trenne Build- und Laufzeitumgebung, um Compiler und Build-Tools aus dem finalen Image herauszuhalten.
- Linter einsetzen: Tools wie
hadolintprüfen dein Dockerfile automatisch gegen viele dieser Best Practices.
Häufige Fragen
Warum dauert mein Build jedes Mal so lange?
Meist liegt es an schlechter Layer-Reihenfolge: Wenn der Code vor den Abhängigkeiten kopiert wird, baut jede Code-Änderung die Abhängigkeiten neu. Kopiere erst die Abhängigkeitsdateien und installiere sie, bevor du den restlichen Code kopierst – dann greift der Cache.
Soll ich ENV für Konfiguration nutzen?
Für unkritische Standardwerte (Port, Log-Level) ja. Für Secrets niemals – die landen in der Image-Historie. Konfiguration, die sich je Umgebung unterscheidet, gehört zur Laufzeit eingespielt, nicht fest ins Image gebacken.
Was bringt ein non-root-User konkret?
Er begrenzt den Schaden, falls jemand die Anwendung kompromittiert oder aus dem Container ausbricht. Ein als root laufender Container ist ein deutlich attraktiveres und gefährlicheres Ziel. Es ist eine der wirkungsvollsten und gleichzeitig einfachsten Sicherheitsmaßnahmen.
Brauche ich für jedes Dockerfile einen Multi-Stage-Build?
Nicht zwingend. Für interpretierte Sprachen ohne Build-Schritt bringt er weniger. Bei kompilierten Sprachen oder Frontend-Builds, wo Build-Werkzeuge nicht in die Produktion gehören, ist er fast immer sinnvoll und reduziert Größe wie Angriffsfläche erheblich.