Road to Production: Tipps für schlanke Docker-Images

Nach vielen vielen gebauten und gestarteten Containern möchte ich ein paar praktische Tipps zum Image-Bau geben, die einem das Leben erleichtern. Entstehen soll ein ganzheitlicher Blick von der lokalen Entwicklungsumgebung, über das Bauen und Testen in der Continuous Delivery Pipeline, bis zum Betreiben in Produktion.

Es folgen ein paar Basics, damit jeder im Bilde ist. Es beginnt mit dem Dockerfile, welches in textlicher Form beschreibt, wie ein Container aussehen soll. Damit entspricht diese Datei einem Bauplan. Aus dem Dockerfile formen wir aber nicht etwa direkt unseren Container, sondern zunächst ein Abbild, das sogenannte Image. Dieser Prozess wird mit docker image build angestoßen. Mit dem gebauten Image sind wir in der Lage, unsere Applikation zu bewegen. In Analogie zu Git wird mittels docker image push ein Image in eine entfernte Registry übertragen, welches anschließend mit docker image pull an anderer Stelle wieder heruntergeladen werden kann. Ob zuvor bewegt oder nicht, dient uns das Image als Grundlage für unsere Laufzeitumgebung, den Container. Mit docker container run starten wir einen solchen Container. Der isolierte Prozess, um den es eigentlich geht, wird somit zum Leben erweckt.

Mit zusätzlichen Tools, wie Docker Compose, Dockers Swarm Mode oder Kubernetes, entstehen zusätzliche Abstraktionen über dem gerade Beschriebenen. Sie erleichtern das Leben in DevOps-Strukturen, aber dennoch ist es gerade beim Image Handling wichtig zu wissen, wie gerade die Basics ineinander greifen. Das soll uns an Vorwissen reichen und nun kommen wir zu den Tipps.

Beware of the Layers

Eine der ersten Regeln, die man sich beim Bau von Images vor Augen führen sollte, ist das Kombinieren von Befehlen, um keine unnötigen Layer zu erzeugen. Dateien, die in einem Layer erzeugt wurden, können in einem anderen Layer nicht mehr entfernt werden, um Speicherplatz zu reduzieren.

# Do
RUN apt-get update && \
    apt-get install -y \
    package-bar \
    && \
    rm -rf /var/lib/apt/lists/*

# Don't
RUN apt-get update
RUN apt-get install package-bar
RUN rm -rf /var/lib/apt/lists/*

Zudem spielen Layer eine wichtige Rolle, wenn es um die Build-Geschwindigkeit geht, da hier der Build Cache für Beschleunigung sorgt.

Ein Dockerfile dem Befehl „docker container commit“ vorziehen

Man muss kein Dockerfile als Basis für seine Applikation nutzen. Stattdessen kann man auch einfach einen Basis-Container mit der Linux-Distribution meiner Wahl hochfahren, um quasi „from scratch“ zu starten. Anschließend kann man manuell seine Applikation und Abhängigkeiten über die Shell hinzufügen. Mit docker container commit lässt sich dann der Container wieder als Image verpacken. Für ein erstes Rumprobieren halte ich diese Vorgehensweise für nützlich und zielführend.

Im Gegensatz dazu, steht die Möglichkeit ein Image basierend auf einem Dockerfile zu bauen. In dieser textlichen Form ist es versionierbar, ist dadurch auch für die Kollegen nachvollziehbar und sorgt für eine bessere Automation und Reproduzierbarkeit, die im Umfeld von CI/CD sehr wichtig ist. Wer seit Längerem mit Continuous Delivery Pipelines zu tun hat, weiß aber, dass externe Abhängigkeiten die Reproduzierbarkeit torpedieren können. Wenn der Server, von dem ich eine Abhängigkeit wie ein Tar-Ball oder dergleichen beziehe, offline ist, habe ich trotzdem ein Problem. Das sollte man im Hinterkopf behalten.

It runs on my laptop != It runs in production

Wir alle kennen das: Das gerade frisch entwickelte neue Feature funktioniert tadellos auf der eigenen Maschine, aber nicht in Produktion. Die Docker-Container sollen dies aufgrund ihrer Parität (Parity) jedoch minimieren. Ich schreibe bewusst minimieren, denn wir verfolgen mit den verschiedenen Umgebungen verschiedene Interessen. Lokal wollen wir schnell und komfortabel entwickeln. In der Test-Umgebung soll möglichst automatisiert eine Überprüfung der Qualität statttfinden. Auf dem Produktivsystem wünschen wir uns, dass die Applikation möglichst 24/7 erreichbar ist. Das fasst die Ziele natürlich nur ganz grob zusammen, aber ein grundsätzlicher Unterschied, der dadurch entsteht, ist die Verwendung von Volumes in der Produktivumgebung, die nicht die Applikation, sondern nur die Daten persistieren sollen. Lokal arbeitet man in der Regel so, dass die komplette Applikation aus dem Git-Repository ausgecheckt wird und auf der Host-Maschine liegt. Host-Mounts, die unsere Änderung in die gestarteten Container bringen, werden dadurch notwendig. Bereits das ist ein Unterschied, der nicht zu vernachlässigen ist und es geht weiter mit dem Orchestrator.

In meinen Augen sollte man daher dafür sorgen, dass die Images in allen Umgebungen identisch sind, also ein Dockerfile die Basis für ein Image ist, welches überall genutzt wird, um die Parität so groß wie möglich zu halten. Ein Image sollte also über die notwendige Intelligenz verfügen, lokal in Debugging-Szenarien zur Seite zu stehen, was in Production mittels Environment Variable oder reingerichter Konfigurationsdatei deaktiviert wird. Das Image stellt also den gemeinsamen Nenner dar und kann mittels Host-Mounts, Ports, Env-Variablen, Secrets und Configs an die jeweilige Umgebung angepasst werden. Das Thema Sicherheit steckt hier natürlich die Grenzen ab.

DevOps

Ich bin ein Freund des Ansatzes „You build it, you run it!“, der im Zusammenhang mit dem DevOps-Ansatz immer wieder Erwähnung findet. Als Entwickler bekommt man mit Docker ein Werkzeug an die Hand, wodurch man sich zwangsläufig mehr mit der Laufzeitumgebung Gedanken machen muss, die man zuvor in Ops Händen gesehen hat. Dadurch stellt sich ein breiteres Wissen selbst bei den Technologien ein, die man bereits jahrelang genutzt hat. Als PHP-Entwickler lernt man, wie der FastCGI Process Manager (FPM) arbeitet, wo er seine Log-Dateien und sein Process File ablegt. In meinen Augen bekommt man einen ganzheitlicheren Blick, den man für das Betreiben in der Live-Umgebung auch wirklich benötigt. Menschen, die man klassischer Weise Operations zuordnen würde, fungieren mehr als Makro-Architekten und stecken die Grenzen für Entwickler ab, welche mit Containern sehr viel mehr Freiheiten bekommen.

Offizielle Images

Nutzt die offiziellen Images! Sie stellen ein absolut solides Fundament für eure Applikation dar und werden von Leuten betreut, die sich damit auskennen. Dinge selbst von der Pieke auf zu erstellen, sorgt für einen gehörigen Aufwand, den man nicht unterschätzen sollte. Schnell hat man sich ein Image aus dem Docker Hub geladen und zum Laufen gebracht, aber wer garantiert einem, dass dieses Image sicher ist oder in einem halben Jahr noch gepflegt wird? Mit den offiziellen Images haben die Verantwortlichen bereits ihren langen Atem bei der Pflege der Software-Pakete bewiesen. Wer auf die Idee kommt einen kompletten LAMP-Stack in einen Container zu verpacken, beraubt sich dieser Möglichkeit, da man nur von einem Image ableiten kann und auch der Weg über Multi-Stage-Build zählt nicht.

Alpine Linux

Es lohnt sich auf jeden Fall sich mit dem kleinen Alpine Linux und dessen Paket Manager auseinander zu setzen. Meine ersten Docker-Gehversuche habe ich mit Debian gestartet, weil mir apt vertraut war. Nachdem ich mich mit Alpine auseinander gesetzt habe, kann ich euch nur wärmstens dazu raten, denn die kleinen Images machen beim Bewegen und Bauen extrem viel Spaß. Die CI-/CD-Pipeline wird enorm beschleunigt und auch die lokale Umgebung eines Entwicklers ist in Nullkommanichts aufgesetzt. Spaß beim Entwickeln ist in meinen Augen ein unterschätztes Gut, was es als Verantwortlicher zu jeder Zeit zu wahren gilt. Das einzig Negative, was ich über Alpine Linux berichten kann, ist der verwendete C Compiler, der in seltenen Fällen für Probleme sorgt. Ein Blick wert ist es aber auf jeden Fall.

Dockerfile Best Practices

Docker selbst hat in seiner Dokumentation Best Practices zum Schreiben von Dockerfiles definiert, die jeder mal gelesen haben sollte. Zur Überprüfung des Regelwerks wurden ein paar Linter-Projekte ins Leben gerufen. Eine Empfehlung dafür kann ich bisher jedoch noch nicht aussprechen.

.dockerignore

Die Datei .dockerignore bekommt für mein Empfinden zu wenig Aufmerksamkeit, obwohl sie den Build-Prozess ähnlich stark beeinflussen kann, wie das schlanke Alpine Linux. Dazu muss man sich jedoch vor Augen führen, wie aus dem Dockerfile ein Image gebaut wird. Wichtig bei diesem Vorgang ist der sogenannte Kontext. Das ist ein Ort, aus dem sich Docker beim Bau von Images bedient, um Anweisungen wie COPY auszuführen. Dateien werden also nicht direkt vom lokalen Dateisystem
in das Docker-Image kopiert, sondern gehen einen Umweg über den Kontext. Der Kontext entspricht, wenn nicht anders definiert, dem Ordner in dem ich docker image build . ausführe. Alle Dateien und Ordner innerhalb dieses Verzeichnisses werden also vor dem eigentlichen Build-Prozess zum Docker Daemon übertragen, was bei großen Projekten einige Zeit in Anspruch nimmt, nur um anschließend wenige Dinge aus diesem Kontext ins finale Image zu kopieren.

Hier kommt die Datei .dockerignore ins Spiel. Damit kann man, ähnliche Gits „.gitignore“, Dateien und Ordner ausnehmen, damit diese nicht zum Docker Daemon übertragen werden. Klassische Einträge dieser Datei umfassen den Ordner .git, die Dokumentation des Projektes, Pipeline as Code, Infrastructure as Code, Secrets, die im finalen Container nichts zu suchen haben. Mit dieser Datei auseinander gesetzt, kann man die die CI/CD Pipeline spürbar beschleunigen.

Build and Runtime Dependencies

Mach dir Gedanken darüber, welche Abhängigkeiten es zur Laufzeit und welche es zur Build Time gibt. Ein Beispiel wäre make zum Kompilieren, welches zur Laufzeit nicht benötigt wird und entsprechend in einem Layer installiert, damit kompiliert und anschließend wieder entfernt werden kann. Gleichzeitig sollte man einen Entwickler nicht beschneiden. Wenn er make benötigt, muss das Tool natürlich auch im Container zur Verfügung stehen.

Labels

Das absolute Mindestmaß an Metadaten, das ein Image-Bauer zur Verfügung stellen sollte, sind sein Name und die Kontaktdaten. So sieht man schnell, wer diese Datei verzapft hat und kann sich bei Fragen an ihn wenden. Wer sich hier mehr wünscht, kann sich mit dem Label Schema beschäftigen, welches dafür eine Spezifikation liefert (veraltet). Alternativ kann man bei Project Atomic oder der Open Container Initiative in entsprechende Entwürfe einarbeiten.

FROM golang:1.9.2-alpine3.7
LABEL maintainer "Patrick Baber <patrick.baber@ueber.io>"

Multi-Stage-Builds

Diesem Thema habe ich einen eigenen Artikel gewidmet, weil es sich um eine mächtige Möglichkeit handelt, ein Image basierend auf mehreren Base-Images zu erstellen. Assets bauen, mit Node, die man dann in ein nginx-Image kopiert. Solche Dinge werden damit möglich.

Abschließendes Gebrabbel

Es lohnt sich in schlanke und robuste Images zu investieren. Die Lernkurve ist meiner Meinung nach nicht besonders steil, da man sich den verschiedenen Themen peu à peu annehmen kann, ohne gezwungen zu sein, gleich alles zu erledigen. Im Fokus sollte die Verwendbarkeit des Images in den verschiedenen Umgebungen stehen, was zwar ein wenig zusätzliche Logik erfordert, sich aber zur Freude von Dev und Ops lohnen wird.

 

Patrick Baber

Als erfahrener Programmierer löst Patrick jeden Gordischen Knoten, findet die geeignete Methode und entwickelt damit flexible Software-Systeme.