Dockerfile Best Practices: Bessere Docker-Images bauen

Dockerfile Best Practices: Bessere Docker-Images bauen

Dockerfile Best Practices: Bessere Docker-Images bauen

Genau so, wie in der Programmierung nicht jeder funktionierende Quellcode gleich gut ist, gibt es auch bei Dockerfiles qualitative Unterschiede. Vor allem bei Docker-Images für große Teams und komplexe Software gilt es, Effizienz und Reproduzierbarkeit zu wahren.

1. Paket-Management

Ich beginne mit dem Paket-Management, das ein vergleichsweise einfaches und schnell umzusetzendes Thema ist.

Nur tatsächlich benötigte Pakete installieren

Anders als bei einer virtuellen Maschine, auf der im Laufe der Zeit alle möglichen Tools und Pakete installiert werden können, sollten unnötige Pakete in einem Container vermieden werden. Davon abgesehen, dass das Herunterladen und die Installation eines solchen Pakets den Build unnötig in die Länge zieht, schleppt man sich als Maintainer auch noch eine zusätzliche Abhängigkeit ein. Am Ende leiden darunter die Build-Performance, die Image-Größe, die Übersichtlichkeit im Dockerfile und die Reproduzierbarkeit, sollte es zu Problemen bei der Installation des Pakets kommen.

Zu installierende Pakete sortieren

Bei großen Systemen kommt es vor, dass 10 oder mehr Pakete installiert werden. Es zählt zum guten Stil, diese Pakete nach Namen zu sortieren, um die Übersichtlichkeit zu verbessern. Ich übernehme hier direkt das Beispiel von Docker selbst:

Code:
RUN apt-get update && apt-get install -y \
  bzr \
  cvs \
  git \
  mercurial \
  subversion
Version-Pinning für Pakete

Um bessere reproduzierbare Builds zu gewährleisten, empfiehlt Docker bei der Installation von Paketen das sogegannte Version Pinning. Dabei wird nicht nur der Paketname, sondern auch die zu installierende Version angegeben.

Code:
RUN apt-get update && apt-get install -y \
    git=2.26.*
Damit können Fehler, die durch Inkompatibilitäten mit einer neuen Major-Version auftreten, vermieden werden.

2. Image-Größe

Die Größe eines Docker-Images macht sich vor allem dann bemerkbar, wenn es von Entwicklern und Nutzern aus einem zentralen Image-Repository heruntergeladen wird.

Installation von nicht benötigten Paketen vermeiden

Dieser Punkt überschneidet sich mit dem Paket-Management, bei dem ich erwähnt habe, dass nur tatsächlich benötigte Pakete installiert werden sollten. Wer gezielt die Image-Größe reduzieren möchte, kann aber noch einen Schritt weiter gehen:

apt-get installiert mit jedem Paket automatisch alle Pakete, die zu diesem Paket empfohlen werden. Dabei handelt es sich aber nicht um technische Abhängigkeiten des Pakets, womit die Installation dieser empfohlenen Pakete unterbunden werden kann:

Code:
RUN apt-get update && apt-get install -y --no-install-recommends \
    git=2.26.*
Multi-Stage-Builds nutzen

Docker-Images setzen sich aus mehreren Layern zusammen. Und jeder Layer vergrößert den Speicherbedarf eines Images. Wie schafft man es, ein Image mit nur einem einzigen Layer zu erstellen?

Docker 17.05 beantwortete diese Frage mit Multi-Stage-Builds, die das nunmehr historische Builder-Pattern ablösten. Dabei wird ein Dockerfile in verschiedene Stages, also Stufen eines Builds, aufgeteilt. Jede Stage repräsentiert einen Abschnitt des Workflows, der für das Kompilieren und Ausführen der Anwendung notwendig ist. Ziel ist es meist, am Ende ein minimales Image auszuliefern, welches die fertig gebaute Anwendung ausführt.

Code:
# Das FROM-Schlüsselwort leitet eine neue Stage
# ein. Mit AS weise ich dem Stage einen Namen zu,
# den ich später wiederverwenden werde.
FROM golang:1.13-alpine AS build

WORKDIR /app

# Hier kopiere ich meinen Quellcode des Projekts
# und kompiliere es zu einer Binärdatei.
COPY go.mod go.sum .
RUN go mod download
COPY . .
RUN go build -o my-app .

# Nun beginne ich mit einer neuen Stage, die
# das finale Image darstellen soll.
FROM scratch AS final

# Neuer Layer, neues Glück: Ich kopiere nun die
# fertige Binärdatei und erstelle damit den ersten
# und einzigen Layer für dieses Image. Dem COPY-
# Befehl übergebe ich den Namen der Build-Stage.
COPY --from=build /app/my-app /bin/my-app

ENTRYPOINT ["/bin/my-app"]
Führt man nun einen Build mit docker image build durch, ist das Ergebnis dieses Builds die letzte Stage, also final. Das finale Image besteht dementsprechend nur aus einem Layer und den Layern des Basis-Images.

Über fortgeschrittene Pattern bei Multi-Stage-Builds werde ich eine gesonderte Zusammenfassung schreiben.

.dockerignore verwenden

Auch die Größe der vorherigen Stages kann optimiert werden, indem man das Kopieren von nicht benötigten Dateien im Build Context unterbindet. Dies kann mit einer oder mehreren .dockerignore-Dateien erreicht werden: Beim Senden des Build Contexts an den Docker-Daemon werden alle dort aufgelisteten Verzeichnisse und Dateien übersprungen.

3. Build-Cache

Eine effiziente oder weniger effiziente Nutzung des Build-Caches kann vor allem bei großen Images einen kritischen Einfluss auf die Dauer und Performance eines Builds haben. Wer seinen Entwicklern ein Dockerfile zum "Selberbauen" eines Images bereitstellt oder automatisierte Builds in einer CI-Pipeline durchführt, muss den Build-Cache verstehen.

Funktionsweise des Build-Caches

Bei einem Build parst Docker sequenziell alle Befehle des Dockerfiles. Für jeden Befehl wird dann überprüft, ob er tatsächlich ausgeführt werden muss, oder ob dafür bereits ein temporäres Image oder ein Layer im Cache liegt. Dabei geht Docker wie folgt vor:

Für die meisten Dockerfile-Befehle reicht es aus, den eigentlichen Befehl mit einem temporären Image im Cache zu vergleichen und es bei Gleichheit wiederzuverwenden. Für die Befehle ADD und COPY genügt das allerdings nicht. Hier überprüft Docker die Prüfsummen der zu kopierenden Dateien mit den Prüfsummen der Dateien im Cache. Nur wenn die Prüfsummen übereinstimmen, also wenn die Dateiinhalte gleich sind, können die Layer im Cache wiederverwendet werden.

In allen anderen Fällen ist Docker gezwungen, den Cache als invalide zu markieren. Trifft dies ein, müssen der aktuelle Befehl und alle nachfolgenden Befehle neu ausgeführt werden. Beim Entwickeln eines Dockerfiles gilt es, diesen Zustand so lange wie möglich hinauszuzögern, sodass möglichst viele Befehle ausgeführt werden, bevor eine solche Cache Invalidation eintritt.

Inkrementelle Builds durchführen

Das oben genannte Verhalten von ADD und COPY ist prinzipiell simpel, hat aber weitreichende Konsequenzen. Gehen wir davon aus, dass wir ein Node.js-Projekt containerisieren möchten, dessen Abhängigkeiten wie üblich in der Datei package.json aufgelistet sind. Folgendes Dockerfile wäre für diesen Zweck höchst ineffizient:

Code:
#
# Achtung, Negativbeispiel!
#
FROM node:lts

# Das gesamte Projekt wird nach /code kopiert.
WORKDIR /code
COPY . /code

# Hier wird ein 'clean install' durchgeführt,
# d. h. alle Abhängkeiten in der mitkopierten
# package.json werden heruntergeladen.
RUN npm ci

# Das Programm wird ausgeführt.
CMD ["npm", "start"]
Die Datei package.json wird im selben COPY-Befehl wie der Rest des Quellcodes kopiert. Danach erfolgt der Download der entsprechenden npm-Pakete. Wenn nun eine beliebige Datei des Quellcodes geändert wird, ändert sich die Prüfsumme der zu kopierenden Dateien und es erfolgt eine Cache-Invalidierung. Und das wiederum hat zur Folge, dass RUN npm ci und damit der gesamte Download aller Pakete ausgeführt wird - weil sich eine einzige Stelle im Quellcode geändert hat.

Es gilt also, cachebare Einheiten zu erkennen und entsprechend aufzuteilen. Abhängigkeiten sollten nur dann neu heruntergeladen werden, wenn sie sich geändert haben. Genau genommen dann, wenn sich der Inhalt von package.json geändert hat.

Code:
FROM node:lts

# Zunächst werden lediglich die package.json-
# und package-lock.json nach /code kopiert.
WORKDIR /code
COPY package.json package-lock.json /code/

# Nun werden die Abhängigkeiten heruntergeladen.
RUN npm ci

# Sollten sich die Abhängigkeiten nicht geändert
# haben, wird mindestens bis zu dieser Stelle bei
# jedem Build auf den Cache zurückgegriffen.
COPY src /code/src

CMD ["npm", "start"]
Sollte sich nun der Inhalt einer Quellcode-Datei ändern und der Rest des Projekts unverändert bleiben, werden die npm-Pakete nicht mehr bei jedem neuen Build heruntergeladen. Stattdessen erkennt Docker, dass package.json unverändert blieb, weshalb der Layer aus dem Cache verwendet wird. Dementsprechend fand keine Cache-Invalidierung statt, das Ergebnis von RUN npm ci blieb gleich und wird auch hier aus dem Cache geladen. Lediglich der Quellcode wird aufgrund der Änderung neu kopiert.

Befehle nach Häufigkeit der Änderung sortieren

Was ebenfalls berücksicht werden sollte, ist eine sinnvolle Sortierung der Befehle. Je früher eine Cache-Invalidierung stattfindet, desto mehr Befehle müssen danach völlig ohne Cache neu ausgeführt werden. Um den Zeitpunkt einer Invalidierung möglichst lange hinauszuzögern, sollten daher öfter geänderten Befehle und Inhalte an späterer Stelle im Dockerfile stehen als seltener geänderte Befehle.

Beispielsweise ist es effizient, erst Pakete zu installieren und dann den Code zu kopieren, weil sich die benötigten Pakete in der Regel seltener ändern als der Quellcode.

Korrekte Verwendung von apt-get

Das Verhalten des Build-Caches spielt auch bei der Installation von Paketen eine Rolle. Es ist üblich, vor einem apt-get install ein apt-get update durchzuführen. Dennoch dürfen diese beiden Anweisungen nicht in zwei RUN-Befehle aufgeteilt werden:

Code:
#
# Achtung, Negativbeispiel!
#
RUN apt-get update
RUN apt-get install -y git
Sollte die zweite Anweisung geändert werden, z. B. zu RUN apt-get install -y git tar, wird zwar der Cache als invalide markiert und der Befehl neu ausgeführt - allerdings wurde zuvor apt-get update nicht ausgeführt, weil sich der Befehl nicht geändert hat. Als Folge erhält man unter Umständen völlig veraltete Paketversionen, weil der Update-Befehl möglicherweise seit langer Zeit nicht mehr ausgeführt wurde.

Als Lösung müssen die beiden apt-get-Befehle zusammengefasst werden, sodass beide ausgeführt werden, wenn sich die zu installierenden Pakete ändern.

Code:
RUN apt-get update && apt-get install -y \
    git \
    tar
4. Fazit

Diese Best Practices machen sich umso mehr bemerkbar, je mehr Entwickler an einem Projekt beteiligt sind und je komplexer das Setup einer Software ist. Auch tragen diese Tipps dazu bei, sämtliche Kosten für CI-Pipelines in der Cloud zu reduzieren und Entwicklern mit der Pipeline schnelleres Feedback geben zu können.

Auch, wenn ein Einsteiger zunächst froh ist, wenn seine Anwendung überhaupt containerisiert läuft: Zumindest diejenigen, die intensiv mit Containern arbeiten, tun sich selbst auf lange Sicht einen großen Gefallen, wenn sie diese Best Practices verstehen und umsetzen.
  • Like
Reaktionen: JR Cologne und Mat
Autor
dominik
Aufrufe
58
Erstellt am
Letzte Bearbeitung
Bewertung
0,00 Stern(e) 0 Bewertung(en)

Weitere Ressourcen von dominik

Oben Unten