Dockerfile Best Practices: sichere und schnelle Container

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.

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:

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:

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:

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:

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:

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

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.