In den ersten beiden Artikeln dieser Reihe (Teil 1Interaktive Arbeit mit Docker-Containern, Teil 2 – Containerdaten und Backups) haben Sie im Detail gelernt, auf dem eigenen System mit Docker zu arbeiten, Container zu erzeugen, zu verwalten und zu betreiben. Mit Images, die von dritten erzeugt und über den Docker-Hub verteilt werden, lässt sich viel erreichen. Nun ist es Zeit, eigene Docker-Images zu erstellen und so Ihre eigene Software mit der richtigen Ausführungsumgebung zu paketieren und für andere Anwender systemunabhängig nutzbar zu machen.

Der erste Schritt auf dem Weg zum eigenen Image ist eine Entscheidung: Soll das neue Image auf einem vorhandenen Image basieren, oder wird es von Grund auf neu erstellt? In den allermeisten Fällen sollten Sie ein vorhandenes Image als Grundlage wählen, aber Sie können für bestimmte Einsatzzwecke besonders kompakte Images erstellen, wenn Sie ohne Basis arbeiten.

Zur Erzeugung eines Docker-Images erstellen Sie gewöhnlich eine Konfigurationsdatei namens Dockerfile, und die erste Zeile dieser Datei sollte so aussehen:

parent ist dabei der Name des Basis-Images, in derselben Syntax, die Sie auch zum Start eines Docker-Containers benutzen. Sie könnten also z.B. node:14.8.0-buster dafür verwenden, oder auch einfach alpine.

Achtung: Es ist auch möglich, Images ohne Parent zu erzeugen. Zu diesem Zweck schreiben Sie FROM scratch – ganz so, als sei scratch ein spezieller Name für ein Image.

Abstammung Ihres Images festlegen

Es hängt von den Anforderungen der eigenen Software ab, ob die Angabe einer genauen Versionsnummer für das Parent-Image notwendig oder sinnvoll ist. Sie sollten sich fragen, unter welchen Umständen Sie das Image in Zukunft neu erzeugen werden. Es kann sinnvoll sein, eine generische Basis wie alpine oder debian zu verwenden, damit in Zukunft für ein Update auf eine neuere Version des Parent-Images keine Änderung der Konfigurationsdatei notwendig ist. Andererseits ist es auch möglich, dass die Software, die Sie in dem Image installieren möchten, mit einem bestimmten Parent getestet wird, und somit das Dockerfile manuell mit der neuen Version aktualisiert werden muss, wenn ein Update ansteht.

Bei der Auswahl eines geeigneten Parent-Images gibt es mehrere relevante Kriterien. Es ist hilfreich, wenn das Image bereits die Softwarepakete enthält, die Sie benötigen. Zum Beispiel gibt es vom Image “httpd” – dem offiziellen Image für den Apache-Webserver – Varianten, die auf debian:buster-slim und auf alpine:3.14 (zum Zeitpunkt des Schreibens) basieren. In beiden Fällen sind die Dockerfiles für diese Images über 200 Zeilen lang! Natürlich könnten Sie nun bei Bedarf selbst ein Image auf Basis von debian oder alpine erzeugen und dort Apache installieren, aber es ist sicherlich eine bessere Idee, das den Fachleuten des Projekts zu überlassen. So würden Sie also selbst Ihr neues Image etwa auf Basis von httpd:alpine erzeugen, und bräuchten sich nicht um den richtigen Weg zu kümmern, Apache in Betrieb zu nehmen.

Es soll auch erwähnt sein, dass es im Docker-Hub Images gibt, die von anderen Entwickler:innen einmal erzeugt und verfügbar gemacht worden sind, die aber nicht regelmäßig gepflegt werden. Ich empfehle sehr, sich im entsprechenden Repository des Projekts einen Eindruck über die Supportsituation zu verschaffen, bevor Sie ein eigenes Image auf Basis eines anderen erstellen.

Zum Schluss dieser Betrachtung möchte ich noch anfügen, dass eigene Vorlieben bei der Auswahl einer zugrundeliegenden Linux-Distribution nicht sehr wichtig sind! Ich habe schon mehrfach Alpine erwähnt, das als besonders schlanke Linux-Distribution bei Docker-Images gern eingesetzt wird. Kaum jemand arbeitet im Alltag mit dieser Distribution, aber für Docker eignet sie sich sehr gut. Ein Docker-Image sollte einen einzigen Zweck haben (für komplexere Zusammenhänge verwenden Sie dann Orchestrierung, dazu später mehr!), und wenn dieser Zweck am einfachsten mithilfe eines bestehenden Images abzudecken ist, dann empfehle ich, diesen Weg unabhängig von persönlichen Systemvorlieben einzuschlagen.

Ein einfaches Beispiel-Image

An dieser Stelle soll nun endlich ein Docker-Image vorgestellt werden, das klein, aber vollständig ist, und tatsächlich ausgeführt werden kann. Ausser der Datei Dockerfile ist dafür realistischerweise ein Startskript notwendig – es ist technisch möglich, ein Image ohne Startskript zu erstellen, aber das ist selten sinnvoll. Zum Zwecke der ersten Demo nehmen Sie also bitte an, es gäbe im lokalen Verzeichnis ein Skript namens hallo.sh:

Wenn Sie mit der Ausgabe des Kommandos ls vertraut sind, können Sie erkennen, dass das Skript ausführbar ist (Tip: das Flag “x” ist für die Rechtemaske gesetzt).

Die Datei Dockerfile sieht so aus:

Die erste Zeile ist bereits ausführlich besprochen worden. Als nächstes enthält die Datei eine COPY-Anweisung, die das Startskript in einen Ordner im Image kopiert. Bitte lesen Sie den letzten Satz noch einmal durch! Ganz wichtig: COPY kopiert die angegebene Quelle (oder Quellen – auch Platzhalter wie *.sh sind zulässig) aus dem lokalen Dateisystem in das “virtuelle” Dateisystem des Images, das gerade gebaut wird. Dabei muss ich betonen, dass bisher nicht festgelegt ist, dass die Quelldatei im lokalen Dateisystem zu finden ist! Tatsächlich kennt Docker das Konzept eines Kontextes, und mit der Build-Anweisung, die in einem Moment folgen wird, muss der Kontext so angegeben werden, dass Quelldateien in der Konfigurationsdatei gefunden werden können.

Das Ziel der Kopieraktion ist das Linux-Verzeichnis /usr/local/bin, ein Standardpfad, in dem das System automatisch nach ausführbaren Dateien sucht. In der letzten Zeile des Dockerfile wird mit CMD das Startkommando für das Image festgelegt. Die Syntax des Kommandos sieht aus wie ein Array, etwa in JSON, und das ist Absicht. Es handelt sich um die sogenannte exec-Syntax, bei der mehrere Teile des Kommandos (also der Name selbst und eventuell folgende Argumente) separat angegeben werden. Diese Form ist für die meisten Zwecke zu empfehlen, weil sie im Gegensatz zur alternativen Shell Syntax keinen Overhead hat. Für CMD-Angaben in der alternativen Shell Syntax wird immer eine Shell gestartet, die dann das Kommando ausführt. Dies kann auch zu Schwierigkeiten etwa bei interaktiven Containern führen, da die Handhabung von “Signalen” von der Kapselung in einen Shell-Prozess beeinträchtigt wird. Wenn es sich etwa als unmöglich herausstellt, den laufenden interaktiven Container mit Strg-C abzubrechen, kann das mit der Konfiguration von CMD zusammenhängen. Die Shell Syntax ist verlockend, da das Kommando in dem Fall einfach als String angegeben wird, aber sie ist nicht allgemein empfehlenswert.

Das Image bauen

Nun bleibt vor dem Test des Images nur noch ein Schritt: Es muss erzeugt werden. Dazu führen Sie das Kommando docker build aus, etwa so:

Das Kommando docker build hat zwei wichtige Parameter. Erstens wird mit -t hallowelt ein Tag für das Image angegeben. Wie Sie am Ende der Ausgabe erkennen können, hängt Docker automatisch :latest an, wenn nichts anderes angegeben wird. Natürlich können Sie je nach Bedarf eigene Versionen oder andere Tags anhängen, genau wie Sie es von Images auf Docker-Hub kennen.

Der zweite Parameter für docker build ist noch wichtiger, und er ist leicht zu übersehen, denn es handelt sich um einen einzelnen Punkt. Dieser Punkt gibt den vorher erwähnten Kontext für den Vorgang an: das aktuelle Verzeichnis. Allein aufgrund der syntaktischen Kürze ist dies oft der Weg der Wahl, aber merken Sie sich bitte, dass Sie als Kontext auch andere Verzeichnisse angeben können. Und das ist noch nicht alles: Docker unterstützt auch die Verwendung eines Archivs (.tar.gz) als Kontext, oder einer URL für ein Git-Repository. Die Beschreibung aller Möglichkeiten würde hier zu weit führen, aber ich empfehle sehr, sich dazu die Dokumentation anzusehen.

Ein letzter wichtiger Punkt zum Kontext: Es ist möglich, in einer Datei .dockerignore Dateien und Verzeichnisse einzutragen, die nicht in den Kontext überführt werden sollen. Dies kann aus Performancegründen sehr wichtig sein. Zum Beispiel sollten Sie etwa als Node-Entwickler:in mindestens den Eintrag **/node_modules in .dockerignore aufnehmen, damit das Kopieren der oft enorm großen Node-Modulhierarchien beim Build vermieden wird. Ein weiterer wichtiger Punkt ist, dass eventuell im lokalen Verzeichnis Dateien liegen könnten, die sicherheitsrelevante Informationen enthalten – und unter ungünstigen Umständen würden diese eventuell in ein Image kopiert, das später weitergegeben wird. Es ist also eine wichtige Empfehlung, von Anfang an den Inhalt von .dockerignore sorgfältig zu pflegen.

An dieser Stelle können Sie nun das neue Image starten. Die Ausführung zeigt keine Überraschungen:

Software im Image aktualisieren und installieren

Nachdem Sie nun den Vorgang einmal vollständig gesehen haben, kehre ich einen Moment zurück zur allgemeinen Zielstellung bei der Erzeugung eines Docker-Images. Es gilt, eine Laufzeitumgebung zu schaffen, in der ein Prozess wie vorgesehen arbeiten kann, und die auf eine vorgesehene Weise mit anderen Komponenten eines Gesamtsystems zusammenarbeiten kann. Ein notwendiger Schritt, der bisher nicht erwähnt worden ist, besteht darin, zusätzliche Software im eigenen Image zu installieren, bzw. weitere Anpassungen vorzunehmen.

Es empfiehlt sich oft, in einem eigenen Image zunächst verfügbare Updates zu installieren. Dies ist leider notwendig, um mit Sicherheit (und aus Sicherheitsgründen!) auf dem letzten Stand zu sein. Zum Zeitpunkt des Schreibens habe ich etwa folgenden Test ausgeführt:

Zunächst habe ich die gerade aktuellste Version des Images node:latest heruntergeladen. Dann habe ich einen Container gestartet und darin die Version Node 16.11.0 gefunden – genau wie erwartet. Allerdings fand das Kommando apt bereits 20 neue Pakete in den Repositories von Debian. Die Node-Version 16-alpine zeigte zum selben Zeitpunkt keine verfügbaren Updates an. Aus der Diskrepanz zu diesem zufälligen Zeitpunkt möchte ich keine Schlüsse ziehen, aber offensichtlich ist der beste Rat der, bei der Erstellung eines Images vorhandene Updates zu installieren. Ich empfehle, die notwendigen Schritte zunächst interaktiv zu testen, ähnlich wie im Beispiel zu sehen. So könnte ich das Dockerfile aus dem ersten Beispiel mit einer RUN-Anweisung erweitern:

Analog dazu könnte ein Dockerfile auf Basis von Debian eine Anweisung wie diese enthalten:

Achtung: Es findet sich oft der Ratschlag im Internet, bei der Erstellung eines Docker-Images keine Instruktion zum Update von Paketen einzubauen. Der Grund für diesen Rat ist zweiteilig. Erstens sorgen sich manche Anwender:innen um einen Cache, den Docker beim Erstellen eines Images verwendet, und der bei Dockerfile-Anweisungen, die bei jedem Durchlauf potentiell ein neues Ergebnis erzeugen, nicht wie vorgesehen einen Vorteil verschaffen kann. Dieses Argument ist verständlich, allerdings oft nicht besonders wichtig, da der Erzeugungsvorgang nicht unbedingt sehr oft ausgeführt wird und auch nicht besonders lange dauert. Der zweite Grund ist, dass zu Debugging-Zwecken eventuell die Erstellung eines exakt identischen Images zu einem späteren Zeitpunkt sinnvoll sein könnte – aber dies wird unmöglich, wenn jeder Build-Durchlauf eventuell mit etwas verschiedenen Paketversionen arbeitet. Ich empfehle, diese Aspekte anhand eigener Prioritäten zu bewerten, ähnlich den Überlegungen zum Parent-Image, die ich zuvor bereits beschrieben habe.

Mit ähnlichen Kommandos wie denen zum Update von Paketen können Sie natürlich auch andere Software installieren. Das geschieht für Alpine mit apk add package oder für Debian mit apt-get install -y package. Ich empfehle, möglichst alle notwendigen Pakete in einem Kommando zu installieren und mehrere zusammenhängende Kommandos mit && zu verketten, falls dies notwendig ist. Auf diese Weise geben Sie Docker eine Chance, den Build-Vorgang zu optimieren.

Grundsätzlich sind der Komplexität des Vorgangs keine Grenzen gesetzt. Sie sollten allerdings bei der Planung auf Konsistenz aus sein und langfristig denken. Wenn Sie zum Beispiel vorhaben, eine Konfigurationsdatei für einen Dienst in einem Container gegenüber dem Original zu modifizieren, könnten Sie theoretisch mit Diffs arbeiten oder gar sed einsetzen, um die Datei zu patchen. Allerdings kann dies fehleranfällig sein, und wenn nach einigen Monaten plötzlich eine neue Version Ihres Images nicht mehr macht, was sie soll, ist es eventuell schwierig, sich genau an die Umstände zu erinnern, die bei der ersten Erstellung etwa zu einem bestimmten sed-Skript geführt haben. Mein Rat in diesem Zusammenhang ist, eine Kopie der Konfigurationsdatei extern vorzuhalten und zu pflegen. Diese kann dann per COPY in ein Image übertragen werden, oder gar einfach von aussen (mit der Option -v für docker run) verfügbar gemacht werden.

Effizienz durch mehrere Build-Schritte

Bei komplexen Builds gibt es ein weiteres Detail zu bedenken: Alle Daten, die während des Build-Vorgangs erzeugt werden, bleiben im Image gespeichert. Es lohnt sich, das Volumen eines Images klein zu halten, indem Sie temporäre Dateien löschen. In manchen Fällen können Sie zu diesem Zweck einfach bestimmte Dateien oder Verzeichnisse entfernen:

Diese RUN-Anweisung installiert Pakete für den C/C++-Compiler und entfernt danach die meisten der temporären Dateien wieder, so dass sie nicht im Image abgelegt werden. Um kompliziertere Vorgänge als Teil eines Docker-Builds ablaufen zu lassen, ohne die Größe des resultierenden Images zu weit anwachsen zu lassen, empfehle ich den Einsatz eines Multi-Stage-Builds. Betrachten Sie als Beispiel das folgende Dockerfile:

Mit der syntaktischen Erweiterung as builder weise ich in diesem Beispiel dem Parent debian einen Namen zu. Es folgt eine Installationsanweisung, wie oben bereits erklärt – damit steht der C-Compiler zur Verfügung. Ich erzeuge ein Verzeichnis und kopiere eine Quelltextdatei in das Image. Ich rufe den Compiler auf und erzeuge so ein Binary für das C-Programm.

Nun folgt der verblüffende Teil: Mit einer neuen FROM-Anweisung beginnt ein zweiter Abschnitt. Zu Demonstrationszwecken verwende ich nun ein Alpine-Image (für die Entwickler: Darum übergebe ich auch -static für den Compiler, damit das Binary in der neuen Umgebung läuft) – natürlich könnte ich auch ein zweites Mal dasselbe Image als Parent benutzen. Die COPY-Anweisung kopiert nun das fertig kompilierte Binary aus dem ersten Schritt des Builds in das Zielverzeichnis im zweiten Image. Sie sehen, wie ich mit der option –from=builder den Namen des Quellschrittes angebe.

Dieser Ansatz lässt sich für sehr komplexe Vorgänge nutzen und eröffnet viele neue Anwendungsfälle für Docker. In meinen bisherigen Artikeln habe ich hauptsächlich davon geschrieben, wie sich Serverprozesse auf einfache Art als Docker-Container betreiben lassen, auch für den “schnellen, einfachen” Fall, wo solche Prozesse temporär benötigt werden. Mithilfe von Multi-Stage-Builds kann ich nun als Entwickler Umgebungen in Docker-Images erzeugen, mit denen sorgfältig konfigurierte Kompilationsumgebungen für eigene Software nicht nur erzeugt, sondern auch verlässlich beschrieben und dokumentiert werden. Das Dockerfile kann so zum Bestandteil der Arbeit eines Entwicklerteams werden. Das hilft jedem Teammitglied, und der Erstellungsprozess kann auf einfachste Art etwa auf eine Continuous Integration-Plattform ausgelagert werden. Schließlich ist es für Admins natürlich auch möglich, Multi-Stage-Builds für andere Zwecke zu verwenden, etwa für die initiale Erzeugung von Zertifikaten oder die Generierung von Ausgangsdaten in Deployments.

Auf den richtigen Start kommt’s an

Ich möchte noch einmal auf das Startskript für Ihr Image zurückkommen. Das Beispiel war sehr kurz, aber in “echten” Docker-Images kann dieses Skript beliebig komplex sein! Gewöhnlich wird ein Skript verwendet, um die notwendigen Prozesse im Container zu starten, aber unter Umständen kommen Sie auch ohne Skript aus, wenn ein ausführbares Programm stattdessen direkt gestartet werden kann.

Zum Skript habe ich drei Tipps, die ich hier ansprechen möchte.

  • Erstens: Es ist empfehlenswert, zum Start eines anderen Prozesses aus dem Skript heraus das Kommando exec der Bash-Shell zu verwenden, oder ein ähnliches Kommando einer anderen Shell. Dies hat zur Folge, dass kein weiterer neuer Prozess zusätzlich zur Shell selbst gestartet wird. Für Docker ist das relevant, aus Gründen der bereits erwähnten “Signale” – wenn Prozesse bei der Ausführung geschachtelt werden, ist die Weitergabe dieser Signale an den Hauptprozess eventuell nicht gewährleistet bzw. schwieriger zu erreichen.
  • Zweitens: Wie auf einem echten System ist es eine gute Idee, Prozesse in einem Container mit den minimal notwendigen Berechtigungen laufen zu lassen. Statt alles als “root” zu starten, können Sie ein anderes Benutzerkonto verwenden, so dass ein potentieller Eindringling in die virtuelle Umgebung weniger Schaden anrichten kann. Zu diesem Zweck wird oft das Tool Gosu verwendet, das sich aus technischen Gründen etwas besser für den Einsatz in Docker-Containern eignet als etwa su oder sudo.
  • Drittens: Wenn ihr Image konfigurierbar ist, sollten Sie im Allgemeinen das Setzen von Optionen durch Umgebungsvariablen ermöglichen. Sie wissen aus der Sicht des Benutzers bereits, wie solche Variablen beim Start eines Containers mit Kommandozeilenoptionen gesetzt werden können, oder auch mithilfe einer Definitionsdatei. Auch im Dockerfile können Sie Umgebungsvariablen mit der Anweisung ENV setzen, und diese sind innerhalb des Containers sehr einfach im Startskript oder auch in jeder Programmiersprache verwendbar.

Was fehlt nun noch? Ganz einfach: Sie haben noch nicht gesehen, wie ein Prozess aus einem Image heraus Kontakt mit der Außenwelt aufnehmen kann. Dabei kennen Sie die Mechanismen bereits von der anderen Seite. Wenn Sie nämlich einen Docker-Container starten, geben Sie oft mit der Option -p eine Kombination von Netzwerkports an, die im Hostsystem abgebildet werden sollen. In vielen Fällen verwenden Sie auch -v, um Volumen anzubinden, also Pfade aus dem lokalen Dateisystem im laufenden Container verfügbar zu machen. Eventuell haben Sie auch schon einmal die Option -P benutzt (ein grosses P!), mit der automatisch Ports entsprechend der Vorgaben des Images abgebildet werden können.

Diese letzte Möglichkeit ist es, die bei der Erzeugung eines Images vorbereitet werden kann. Mit der Anweisung EXPOSE geben Sie im Dockerfile bekannt, dass das Image einen bestimmten Port verfügbar machen wird. Es ist wichtig zu verstehen, dass dieser Schritt nicht dazu führt, dass der Port tatsächlich abgebildet wird! Das geschieht nach wie vor erst bei Verwendung von -p oder -P beim Start des Containers – und diese Optionen können auch angewandt werden, wenn EXPOSE nicht benutzt wurde. Die Anweisung dient also gewissermaßen der Dokumentation von Ports, die ein Image gern anbieten möchte. Analog dazu gibt es übrigens auch die Anweisung VOLUME, die denselben Zweck für die Anbindung von Volumen hat – auch hier gilt, dass beim Start mit Volumen beliebige Pfade im Container überladen werden können. Hier ein Beispiel für die beiden beschriebenen Anweisungen:

Wenn Sie ein Image aus dritter Hand anwenden möchten, können Sie mit dem Kommando docker image inspect image die Informationen anzeigen lassen, die der Ersteller des Images mit EXPOSE oder VOLUME vorgegeben hat.

Eigene Docker-Images bauen – Fazit

Damit bleibt zum Abschluss dieses Artikels nur noch, Ihnen das eigene Ausprobieren von Docker noch einmal nahezulegen! Obwohl ich versucht habe, viele Details im Artikel unterzubringen, gibt es noch reichlich zu entdecken. Ich empfehle natürlich die Dokumentation von Docker selbst (hier finden Sie alles zum Dockerfile), aber es kann auch hilfreich sein, vorhandene Images zu studieren. Wenn Sie im Docker-Hub die Seite für ein Image aufrufen und dort auf einen der Tags klicken, führt Sie der Link unmittelbar zum Dockerfile des Images in dessen Repo (meistens bei GitHub). So lernen Sie am schnellsten die “Best Practices”, die verbreitet angewendet werden.

Im nächsten Artikel dieser Reihe wird es dann um Orchestrierung gehen, also wie mithilfe mehrerer Container ein typisches Anwendungssystem aufgebaut werden kann.

Hier finden Sie weitere Artikel zum Thema:

Titelmotiv: Photo by Guillaume Bolduc on Unsplash

Oliver Sturm

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.

Die von Ihnen hier erhobenen Daten werden von der Host Europe GmbH zur Veröffentlichung Ihres Beitrags in diesem Blog verarbeitet. Weitere Informationen entnehmen Sie bitte folgendem Link: www.hosteurope.de/AGB/Datenschutzerklaerung/