30.07.2020
Docker ist in den letzten Jahren immer populärer geworden und hat sich mittlerweile praktisch zum Industriestandard für Containerisierung gemausert, sei es nun mittels docker-compose oder auch mit Kubernetes. Oftmals wird Docker auch gleich als Synonym für Container verwendet. Dabei gibt es einige Aspekte, auf die man beim Erstellen von Dockerfiles achten sollte. In diesem Blogpost zeigen wir euch ein paar Strategien, um möglichst schlanke Docker Images zu erzeugen.
Docker Images haben sich auch bei Blueshoe als präferierte Technologie herausgestellt, um einerseits Anwendungen lokal zu entwickeln, und, um andererseits die Anwendungen in einem Testing-/Staging- oder Produktiv-System zu betreiben (“Deployment”). Neben der ungewohnten Situation als Python-Entwickler plötzlich wieder eine gewisse Build-Wartezeit zu haben, sind uns mit als erstes die zum Teil großen resultierenden Docker Images aufgefallen.
Gerade das Minimieren der Imagegröße spielt für uns eine zentrale Rolle für die Erstellung unserer Dockerfiles. Das Docker Image sollte natürlich über alles Benötigte verfügen, um die Anwendung ausführen zu können - aber eben auch nicht mehr. Überflüssiges sind z. B. Software-Pakete und Libraries, die nur zum Kompilieren oder für das Ausführen automatisierter Tests benötigt werden.
Man kann gleich mehrere Gründe finden, wieso in einem Docker Image nur das absolut Nötigste vorhanden sein sollte:
Trotz aller Minimierung der Imagegröße müssen natürlich auch einige Anforderungen bedacht werden. Wenn ich meine Docker Images bis aufs letzte Megabyte optimiere, danach aber nicht mehr vernünftig damit entwickeln kann, oder die Anwendung nicht robust läuft, ist nicht viel gewonnen. Im Endeffekt kann man die Überlegungen auf die drei Ebenen Entwicklung, Testing/Staging und Production herunterbrechen.
Als Entwickler möchte ich möglichst wenig in meinem Arbeitsfluss gestört werden, mein Komfort steht hier definitiv im Vordergrund. Das heißt, ich benötige ein Docker Image, das im Idealfall eine möglichst geringe Build-Zeit hat und das z. B. live code-reloading unterstützt. Außerdem möchte ich vielleicht noch weitere Tools wie den Python Remote Debugger oder auch Telepresence einsetzen. Evtl. benötige ich dafür weitere Dependencies, die z. B. in einem Produktiv-System nicht relevant sind.
Für Testing-, bzw. Staging- und für Produktiv-Systeme sehen die Anforderungen sehr ähnlich aus. Hier steht ganz klar die Sicherheit an erster Stelle. Ich möchte möglichst wenig Libraries und Packages auf dem System haben, die für die Ausführung der Anwendung gar nicht gebraucht werden. Eigentlich sollte ich als Entwickler gar keinen Bedarf mehr haben, mit dem laufenden Container zu interagieren, wodurch hier sämtliche Bedenken bzgl. Komfort vernachlässigt werden können.
Aber gerade auf einem Testing-/Staging-System kann es dennoch sehr praktisch sein, wenn ich dort zu Debugging-Zwecken gewisse Packages verfügbar habe. Das ist allerdings nicht unbedingt ein Argument dafür, diese Packages bereits im Docker Image für den Fall der Fälle bereitzuhalten. Mittels Telepresence lässt sich ja z. B. in einer Kubernetes-Umgebung ein Deployment austauschen. Das bedeutet, ich kann mir lokal ein Docker Image bauen, das all meine benötigten Dependencies bereitstellt, und dieses in meinem Testing-/Staging-Cluster ausführen. Wie man das bewerkstelligen kann, haben wir in einem unserer letzten Blogposts - Cloud Native Kubernetes Development - dargestellt.
Gerade zu Beginn der Entwicklungsphase eines Projekts kann der oben beschriebene Use-Case recht häufig auftreten. Für ein Produktiv-System sollte das aber eigentlich keine Rolle mehr spielen. Dort möchte ich vielleicht noch Logs betrachten, was einerseits mittels kubectl bewerkstelligt werden kann oder evtl. auch mit einer Log-Collector-Lösung. Im Endeffekt möchte ich, dass das Testing-/Staging-System mit dem identischen Docker Image ausgeführt wird, wie das Produktiv-System. Ansonsten besteht z. B. die Gefahr, dass im Produktiv-System ein Fehlverhalten auftritt, welches im Testing-/Staging-System aufgrund der unterschiedlichen Umgebung nicht aufgetreten ist.
Auch von Operations-Seite können Anforderungen kommen, z. B. in Form eines Vulnerability-Checks, um zu gewährleisten, dass bekannte Vulnerabilities, wo es möglich ist, gar nicht erst im Image vorhanden sind, bzw. damit behebbare Vulnerabilities auch behoben sind. Weiterhin kann auch eine Company-Policy Einfluss auf das Dockerfile haben, entweder die der eigenen Firma oder die des Auftraggebers. Ein Szenario wäre z. B. der Ausschluss bestimmter Basis Images oder das Gewährleisten der Verfügbarkeit gewisser Packages oder Libraries.
In den folgenden Abschnitten wollen wir nun betrachten, welche Schritte man konkret vornehmen kann, um das eigene Dockerfile zu optimieren. Zunächst schauen wir uns Voraussetzungen an, die das resultierende Docker Image beeinflussen können. Außerdem werfen wir einen Blick auf die Strategien und Patterns für Dockerfiles, bevor wir uns in mehreren Iterationen die Auswirkungen der Optimierungen anschauen.
An erster Stelle steht die Auswahl des Basis Images. Dieses muss auch im Dockerfile an erster Stelle definiert werden. Für eine Django-Anwendung genügt uns ein Python Base Image. Wir könnten zwar natürlich auch einfach ein Ubuntu Image wählen, aber wir wollen die Image-Größe ja möglichst klein halten und redundante Packages gar nicht erst im Image haben. Der Docker Hub stellt viele vorgefertigte Images bereit. Auch für Python gibt es verschiedene Images, die anhand der Python Versionsnummer oder auch mit den Begriffen slim und alpine unterschieden werden.
Das “Standard” Python Base Image basiert auf Debian Buster und stellt somit die größte der drei Varianten dar. Die slim Variante basiert ebenfalls auf Debian Buster, allerdings mit herunter getrimmten Packages. Das resultierende Image ist somit natürlich kleiner. Die dritte Variante ist alpine und basiert, wie der Name vermuten lässt, auf Alpine Linux. Das entsprechende Python Base Image hat die geringste Größe, allerdings kann es gut sein, dass man benötigte Packages im Dockerfile nachinstallieren muss.
Einen u. U. großen Einfluss auf das Dockerfile haben auch die System Runtime und Build Dependencies. Gerade beim Base Image gilt zu beachten, dass ein auf Alpine basierendes Image mit musl libc ausgeliefert wird und nicht mit glibc, wie auf Debian-basierten Base Images. Selbiges gilt für gcc, der GNU Compiler Collection, die nicht standardmäßig auf Alpine verfügbar ist.
Als Django-Entwicklung haben Anwendungen natürlich einige pip-Requirements. Je nach gewähltem Basis-Image muss das Dockerfile also sicherstellen, dass alle für die pip-Requirements benötigten System-Packages und Libraries installiert sind. Aber auch die pip-Requirements selbst können Auswirkungen auf das Dockerfile haben, wenn ich zusätzliche Packages für die Entwicklung habe, die jedoch im Produktiv-Betrieb nicht benötigt werden und dort auch nicht vorhanden sein sollen. Ein Beispiel dafür ist das pydevd-pycharm Package, welches wir lediglich für den Python Remote Debugger in PyCharm benötigen.
Mit der Zeit haben sich verschiedene Strategien und Patterns entwickelt, um Dockerfiles zu gestalten und zu optimieren. Die Herausforderung, eine kleine Image-Größe zu erhalten, knüpft sich stark daran, wie aus dem Dockerfile ein Image wird. Jede Instruktion fügt einen neuen Layer hinzu, wobei jeder Layer auf dem vorherigen aufbaut. Mit diesem Wissen ist es natürlich naheliegend zu versuchen, die Anzahl der Layer und die Größe der verschiedenen Layer möglichst gering zu halten. Man kann z. B. nicht mehr benötigte Artefakte innerhalb eines Layers löschen, oder mit verschiedenen Shell Tricks und anderen Logiken verschiedene Instruktionen in einem Layer zusammenfassen.
Ein mittlerweile veraltetes Pattern ist das sogenannte Builder Pattern. Hierfür erstellt man ein Dockerfile für die Entwicklung, das Builder-Dockerfile. Dieses enthält alles benötigte, um die Applikation zu bauen. Für die Testing-/Staging- und Produktiv-Umgebungen erstellt man ein zweites, herunter getrimmtes Dockerfile. Dieses enthält die Anwendung an sich und ansonsten nur das Nötigste, um die Software auszuführen. Dieser Ansatz ist zwar machbar, hat allerdings zwei große Nachteile: Zum einen ist es definitiv nicht ideal zwei verschiedene Dockerfiles warten zu müssen, zum anderen entsteht dadurch ein komplizierter Workflow:
Dieser Ablauf lässt sich natürlich durch Scripts automatisieren, ist aber dennoch nicht ideal.
Als bessere Lösung finden mittlerweile sogenannte Multi-Stage Dockerfiles immer mehr Verbreitung. Diese werden von Docker selbst empfohlen. Ein Multi-Stage Dockerfile folgt einer eigentlich recht simplen Struktur:
Die verschiedenen Stages werden durch FROM-Statements voneinander getrennt. Dabei kann eine “Stage” auch immer mit einem Namen versehen werden, was es einfacher macht, diese Stage zu referenzieren. Da jede Stage mit einem FROM-Statement beginnt, wird auch in jeder Stage ein neues Base-Image verwendet. Der Vorteil mehrere Stages ist nun, dass sich einzelne Artefakte aus einer Stage selektiv in eine nächste kopieren lassen. Es ist auch möglich an einer gewissen Stage zu stoppen, um z. B. Debugging-Funktionalitäten bereitzustellen, also um verschiedene Stages für Development/Debug und Staging/Production zu unterstützen.
Wird während des Build-Prozesses keine Stage angegeben, an der gestoppt werden soll, wird das komplette Dockerfile durchlaufen, was im Docker Image für das Produktiv-System resultieren sollte. Im Vergleich zum Builder Pattern wird dabei nur ein Dockerfile benötigt und es ist kein Build-Script nötig, um den Workflow abzubilden.
Mit all diesem Wissen machen wir uns nun an die Evaluation verschiedener Dockerfiles. Wir haben sechs verschiedene Dockerfiles geschrieben, die wir anhand der Größe evaluiert haben. Zunächst schauen wir uns nun der Reihe nach die Dockerfiles mit den gewählten Optimierungen an. Allen Dockerfiles ist gemeinsam, dass postgresql-client bzw. postgresql-dev installiert wird, das Kopieren und Installieren von pip-Requirements sowie das Kopieren der Anwendung.
Das erste Dockerfile basiert auf dem python:3.8 Basis Image. Wir bekommen also alle Debian “Nuts and Bolts” mitgeliefert, um innerhalb des Containers praktisch ohne Einschränkungen arbeiten zu können.
Dieses Dockerfile ist identisch zum ersten, mit der Ausnahme, dass nach der Installation der benötigten zusätzlichen Packages der Inhalt des Verzeichnisses /var/lib/apt/lists/ gelöscht wird. In diesem Verzeichnis werden nach einem apt update Paketlisten gespeichert, die für unser Docker Image nicht mehr relevant sind.
Unser drittes Dockerfile basiert ganz simpel auf Alpine Linux. Da in Alpine einige Packages für den Betrieb der Anwendung fehlen, müssen diese zunächst noch installiert werden.
Das nächste auf Alpine Linux basierende Dockerfile fügt im Grunde auch nur ein Löschen von Build Dependencies hinzu, also von zusätzlich installierten Packages, die nur für das Builden des Images benötigt werden, nicht aber für das Ausführen der Anwendung. Damit das in nur einer Anweisung funktioniert, muss die COPY-Anweisung für die pip-Requirements nach oben geschoben werden, um das Installieren der pip-Requirements zwischen dem Installieren und Löschen der Build Dependencies durchführen zu können.
Das letzte Dockerfile nutzt das Multi-Stage Pattern. Beide Stages nutzen das Alpine Linux Python Base Image. In der ersten Stage, builder, werden die benötigten Build Dependencies installiert, das Verzeichnis /install als WORKDIR genutzt sowie anschließend die pip-Requirements kopiert und installiert. Die zweite Stage kopiert nun den Inhalt aus dem /install-Verzeichnis der ersten Stage, kopiert den Code der Anwendung und installiert mit libpq noch ein Package, das für die Ausführung der Anwendung benötigt wird.
Folgende Tabelle zeigt die resultierende Größe der Docker Images für unsere fünf Dockerfiles:
Den quantitativ deutlichsten Sprung kann man bei der Nutzung des Alpine-basierten Base Images beobachten. Sind die beiden Debian-basierten Docker Images noch jeweils über ein Gigabyte groß, spart die Nutzung von Alpine Linux über die Hälfte ein. Das daraus resultierende Image ist nur noch knapp unter 400 Megabyte groß. Durch geschickt geschriebene Anweisungen und somit optimiertere Dockerfiles können wir die Größe sogar mehr als halbieren und sind letztendlich bei 176 Megabyte.
Das Multi-Stage Dockerfile liegt bei knapp 155 Megabyte. Im Vergleich zum zuvor optimierten Dockerfile haben wir hier nicht allzu viel eingespart. Das Dockerfile ist durch die verschiedenen Stages zwar etwas umfangreicher, aber auch wesentlich aufgeräumter und wie weiter oben erläutert, mit den verschiedenen Stages deutlich flexibler. Mit diesem Image sind wir bei einer Größe von gerade mal 15% des ersten naiven Debian-basierten Images angelangt. Auch im Vergleich zum naiven Alpine-basierten Image haben wir mehr als 60% eingespart.
Unsere ganz klare Empfehlung ist die Verwendung von Multi-Stage Dockerfiles. Wie wir bei der Evaluation beeindruckend sehen können, lässt sich die resultierende Image-Größe dadurch deutlich reduzieren. Sofern es die Gegebenheiten und die Anwendung zulassen, sollte hinsichtlich der Image-Größe auch ein Alpine-basiertes Base Image verwendet werden.
Aber nicht nur aufgrund der resultierenden Image-Größe können wir Multi-Stage Builds empfehlen. Vor allem die Flexibilität durch die verschiedenen Stages ist aus unserer Sicht ein großer Pluspunkt. Damit können wir unseren Entwicklungsprozess bis hin zum Production-Deployment mit nur einem Dockerfile unterstützen und müssen nicht mehrere Dockerfiles maintainen.