Zug um Zug: Docker-Images effizient aufbauen

the next big thing  –  1 Kommentare

Docker-Images sind schichtweise aufgebaut. Die Dokumentation von Docker empfiehlt, die Anzahl der Schichten zu minimieren, ohne allerdings konkrete Hinweise zu geben, wie man das Ziel erreichen könne. Einige Tricks helfen, Docker-Images effizient zu bauen.

Prinzipiell lässt sich das Vorgehen, um Docker-Images mit möglichst wenigen Schichten zu erzeugen, in einem einzigen Satz erklären: Jede Anweisung in der Dockerfile-Datei entspricht einer Schicht. Reduziert man die Anzahl der Anweisungen, reduziert man zugleich auch die Anzahl der Schichten.

RUN-Anweisungen zusammenfassen

Eine einfache Möglichkeit dafür besteht darin, RUN-Anweisungen zusammenzufassen. Beispielsweise lässt sich curl mit zwei Aufrufen von RUN installieren:

FROM ubuntu
MAINTAINER the native web <hello@thenativeweb.io>

RUN apt-get update
RUN apt-get install -y curl

Das gleiche Ergebnis lässt sich jedoch auch mit einem einzigen Aufruf erzielen, indem die einzelnen Anweisungen bereits auf der Kommandozeile verknüpft werden:

FROM ubuntu
MAINTAINER the native web <hello@thenativeweb.io>

RUN apt-get update && \
apt-get install -y curl
Den Cache verwenden

Docker speichert nicht nur das fertige Image, sondern alle erzeugten Schichten in einem internen Cache. Muss ein Image nochmals gebaut werden, greift Docker aus Effizienzgründen auf diesen Cache zurück, sodass nicht alle Schichten neu zu erzeugen sind.

Das Vorgehen lässt sich leicht verifizieren, indem man das vom Dockerfile beschriebene Image zweimal baut:

$ docker build .
$ docker build .

Der erste Aufruf benötigt etliche Sekunden, da curl heruntergeladen und installiert werden muss. Der zweite Aufruf hingegen benötigt lediglich wenige Millisekunden, da er auf die im Cache gespeicherten Schichten zurückgreifen kann:

Sending build context to Docker daemon 2.048 kB
Sending build context to Docker daemon
Step 0 : FROM ubuntu
---> d2a0ecffe6fa
Step 1 : MAINTAINER the native web <hello@thenativeweb.io>
---> Using cache
---> 306482365a08
Step 2 : RUN apt-get update && apt-get install -y curl
---> Using cache
---> c689b04b3cb0
Successfully built c689b04b3cb0

Docker invalidiert den Cache, wenn das Dockerfile geändert wird. Das stellt sicher, dass Änderungen an der Datei auch tatsächlich zu einem geänderten Image führen.

Dateien hinzufügen

Innerhalb eines Dockerfiles lassen sich die Anweisungen ADD und COPY verwenden, um Dateien vom Host in ein Images zu kopieren. Das wird häufig dazu genutzt, den Code einer Anwendung einem Image hinzuzufügen, das die erforderliche Laufzeitumgebung enthält.

Ein typisches Beispiel dafür ist Node.js. Ein Dockerfile für eine in Node.js geschriebene Anwendung könnte beispielsweise den folgenden Aufbau haben:

FROM iojs:2.3.4
MAINTAINER the native web <hello@thenativeweb.io>

ADD . /code/

RUN cd /code && \
npm install --production --silent

CMD [ "node", "/code/app" ]

Die Datei folgt dem zuvor beschriebenen Vorgehen, RUN-Anweisungen zusammenzufassen (anders würde das Beispiel an der Stelle übrigens auch gar nicht funktionieren, da das npm-Kommando dann im falschen Verzeichnis ausgeführt würde).

Trotzdem arbeitet das Dockerfile nicht so effizient wie es wünschenswert wäre. Problematisch ist in dem Fall der Aufruf von ADD, was ebenfalls den Cache invalidiert: Die Anweisungen COPY und ADD erhalten den Cache lediglich dann, wenn keine Datei verändert wurde.

Intern berechnet Docker für jede Datei einen Hash-Wert aus deren Inhalt und Metadaten. Hat er sich geändert, invalidiert Docker den Cache, sodass alle Schichten nach der Anweisung neu gebaut werden müssen.

Den Cache austricksen

Prinzipiell ist das Vorgehen sinnvoll, schließlich soll das Image stets den aktuellen Quellcode enthalten. Ärgerlich ist allerdings, dass bei jedem Erzeugen des Images das Kommando npm install ausgeführt wird, was zu hohem Netzwerkverkehr und langen Wartezeiten führt.

Es wäre allerdings vollkommen ausreichend, das Kommando nur dann auszuführen, wenn Änderungen an der Datei package.json vorgenommen wurden. Die dafür erforderliche Logik lässt sich allerdings mit COPY und ADD nicht beschreiben.

Hier hilft ein Trick weiter: Fügt man im Dockerfile zunächst nur die Datei package.json hinzu, invalidiert das den Cache nur dann, wenn tatsächlich Änderungen an der Datei vorgenommen wurden. Dadurch lässt sich der Aufruf von npm install cachen. Erst danach wird die eigentliche Anwendung hinzugefügt:

FROM iojs:2.3.4
MAINTAINER the native web <hello@thenativeweb.io>

ADD ./package.json /code/

RUN cd /code && \
npm install --production --silent

ADD . /code/

CMD [ "node", "/code/app" ]

Auf dem Weg entsteht zwar eine nicht zwingend benötigte Schicht, das Bauen des Images lässt sich aber deutlich beschleunigen.

Dateien ausschließen

Ebenfalls hilfreich ist, nicht benötigte Dateien von COPY und ADD auszuschließen. Dazu zählt in Node.js beispielsweise das node_modules-Verzeichnis, das sich innerhalb des Images aus dem Aufruf von npm install rekonstruieren lässt.

Zu diesem Zweck lässt sich dem Verzeichnis die Datei .dockerignore hinzufügen, die ähnlich einer .gitignore-Datei jene Dateien und Verzeichnisse beschreibt, die im Build-Kontext ausgeschlossen werden sollen. Im einfachsten Fall enthält die Datei für eine Node.js-basierte Anwendung daher die folgenden Zeilen:

.git
node_modules

tl;dr: Ob sich Docker-Images effizient bauen lassen, hängt vor allem vom verwendeten Dockerfile ab. Befolgt man einige Regeln, lassen sich Images erzeugen, die nur wenige Schichten umfassen und die den Cache von Docker optimal ausnutzen.