Einfach durchoptimierte Docker-Images mit Multi-Stage Builds

Ich und andere Autoren des IT-Kitchens sind regelmäßige Nutzer von Docker. Es erlaubt uns auf einfache Art und Weise viele Serverprogramme und sonstige Tools ohne komplizierte Installationsprozesse und isoliert in ihrer eigenen Laufzeitumgebung aufzusetzen.

Voraussetzung für das einfache Aufsetzen von Programmen ist jedoch, dass für diese Programme ein sogenanntes Image existiert. In diesem Image ist zusammen mit dem Programm auch alles vorinstalliert was das Programm braucht um ihren Job zu erfüllen. Wenn man Glück hat, gibt es so ein Image bereits vorgebaut in einem Repository wie dem Docker Hub. Ansonsten muss man selber so ein Image bauen, und das ist nun mal ein Prozess für sich.

In diesem Beitrag geht es um den Teil des Prozesses, der sich damit befasst, ein eigens geschriebenes Image zu optimieren, so dass wirklich nur das was man braucht und nicht mehr vorinstalliert ist. Ein typisches Szenario wäre zum Beispiel das Bauen einer JavaScript-basierten Web-App vom Quelltext.

Unsere App die wir bauen wollen benötigt eine Installation von Node.js und dessen Paketverwaltung NPM. Mit NPM werden alle in der App notierten Abhängigkeiten nachinstalliert, die für das Erzeugen von HTML-, JavaScript- und CSS-Dateien für den Browser benötigt werden. Diese Tools sind allerdings nur zur Bauzeit, und nicht zur Laufzeit notwendig!

Alte Methode: Step by step

Es gibt mehrere Wege dieses Problem nun anzugehen. Entweder baut man einmal die App und schifft dann das komplette Verzeichnis mit, ohne sich weiter Gedanken darum zu machen. Das hat den offensichtlichen Nachteil, dass der gesamte Quelltext samt nun unnötiger Entwicklungsumgebung und sonstigen eventuell übergroßen Ressourcen in dem Image mitgeliefert werden. Wir haben auch das Problem, dass wir uns zwischen dem nginx und dem node Image als Basis entscheiden müssen - das Nginx-Image bringt mit sich, dass wir Node und NPM extra nachinstallieren müssen mit potenziellem Verlust von Versionsfixierung, und das Node-Image würde wieder erzwingen, dass wir einen Teil der Entwicklungsumgebung im Image mitliefern und der Webserver muss auch noch extra mitinstalliert werden.

FROM nginx:stable

# Installiere Node.js (mit NPM) - benötigt curl und gnupg
RUN apt-get update
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y curl gnupg
RUN curl -sL https://deb.nodesource.com/setup_8.x | DEBIAN_FRONTEND=noninteractive bash -
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs

WORKDIR /source/

# Kopiere Quelldateien aus aktuellem Ordner nach /source/ wo wir jetzt drin sind
COPY . .

# Installiere Abhängigkeiten
RUN npm install

# Baue die App mittels eines NPM-Skripts mit Namen "build:production"
RUN npm run build:production

# Erzeugte Dateien an richtige Stelle verschieben
RUN rm -r /usr/share/nginx/html/
RUN mv dist /usr/share/nginx/html

Alte Methode: Alles in einem Layer

Man kann aber auch alle Befehle um das Image zu bauen in einen einzigen Schritt zusammenfassen, aus dem nachher nur ein zusätzliches Layer auf dem Basis-Image generiert wird. Zusätzlich zu den nun zusammengefassten Bauschritten werden aber nun auch Aufräumbefehle wie apt-get clean oder rm -r /path/to/source angehängt, um unnötige Dateien loszuwerden. Damit verlieren wir aber die Möglichkeit unsere Bauschritte zu cachen um zukünftige Builds schneller und inkrementell laufen zu lassen, und die Probleme von oben schleppen wir immer noch mit uns herum. Außerdem können wir nun unsere Schritte nicht mehr zeilengenau in Form von Kommentaren dokumentieren und müssen uns mit Backslash-basierten Abtrennungen zufrieden geben.

FROM nginx:stable

# 1. Installiere Node (mit NPM) - benötigt curl und gnupg
# 2. Installiere Abhängigkeiten & baue App
# 3. Verschiebe dist-Dateien zu Nginx-HTML-Dokumentenpfad
# 4. Aufräumen des Images

COPY . src/
RUN export DEBIAN_FRONTEND=noninteractive \
    && apt-get update \
    && apt-get install -y curl gnupg \
    && (curl -sL https://deb.nodesource.com/setup_8.x | bash -) \
    && apt-get install -y nodejs \
\
    && (cd ./src \
        && npm install \
        && npm run build:production \
    ) \
\
    && rm -r /usr/share/nginx/html/ \
    && mv ./src/dist /usr/share/nginx/html \
\
    && apt-get autoremove -y --purge nodejs \
    && apt-get clean \
    && rm -rf \
        ./src \
        /var/tmp/* \
        /tmp/* \
        /var/lib/apt/lists/* \
        /var/cache/apt/archives/*.deb \
        /var/cache/apt/archives/partial/*.deb \
        /var/cache/apt/*.bin
Neue Methode: Bauen in zwei Teilen

Das war bis zu einem bestimmten Zeitpunkt die Standardroutine… bis Docker 17.05 endlich Multi-Stage Builds einführte!

Mit Multi-Stage Builds können wir unser Image jetzt in zwei Sektionen aufspalten, die wir beide wie Images behandeln können. Die erste Sektion ist nur zuständig für die Installation der Entwicklungsumgebung und das Bauen der App. Die zweite Sektion ist dann unser eigentliches Image indem wir nur noch die korrekten COPY-Befehle brauchen um alles an die richtige Stelle zu kopieren. Implizit können wir damit alle Dateien einfach aus dem finalen Image ausschließen, die wir nicht brauchen, und können trotzdem vollständig von dem Caching für jeden Bauschritt Nutzen machen.

In unserem Fall würde das Dockerfile dann so aussehen:

# # # Build-Sektion # # #

FROM node:8 AS builder

# Kopiere Quelldateien und arbeite in neuem source-Ordner
WORKDIR /source/
COPY . .

# Installiere Abhängigkeiten der App und baue die App
RUN npm install
RUN npm run build:production

# # # Finales Image # # #

FROM nginx:stable

# Kopiere Dateien an die richtige Stelle
COPY --from=builder /source/dist/ /usr/share/nginx/html/

Wir sagen Docker damit, dass unsere App in der ersten Sektion mittels dem node-Image auf Versions-Tag 8 gebaut werden soll, und geben dieser Sektion den Namen builder für später. Nachdem wir mit Bauen fertig sind, starten wir eine neue Sektion auf Basis des nginx-Images mit dem Versions-Tag stable indem wir einfach nochmal den FROM-Befehl nutzen. Der einzige Schritt der hier noch ausgeführt wird, ist ein COPY von unserer builder-Sektion den wir mit dem --from-Argument referenzieren. Damit werden unsere fertig gebauten Dateien aus dem dist-Ordner in den Standardpfad für die Nginx-HTML-Dokumente eingefügt und wir haben unser fertiges Image welches nur noch aus dem Basis-Image und unserem Layer mit den kopierten Dateien besteht.

Presto! Ein einfaches, durchoptimiertes Image mit einem sauber geschriebenen Dockerfile.