zurück zum Artikel

Mit Docker automatisiert Anwendungscontainer erstellen

Golo Roden

Docker-Container von Hand zu konfigurieren ist zwar praktisch, birgt allerdings auch Nachteile. Deshalb kann es sinnvoll sein, das Verpackungstool automatisiert zu verwenden.

Mit Docker automatisiert Anwendungscontainer erstellen

Docker-Container von Hand zu konfigurieren ist zwar praktisch, birgt allerdings auch Nachteile. Deshalb kann es sinnvoll sein, das Verpackungstool automatisiert zu verwenden.

Nachdem die Grundlagen von Docker bereits im ersten Teil [1] des Artikels zur Sprache kamen und der Schwerpunkt auf der manuellen Konfiguration der Container lag, soll es nun um das automatisierte, reproduzierbare Erzeugen von Containern und deren Zusammenspiel gehen. Container von Hand einzurichten und das Ergebnis anschließend als Image zu speichern, weist in der Praxis einige Nachteile auf: Zum einen ist der Vorgang nicht wiederholbar, zum anderen ist er unter Umständen sehr mühsam. Abhilfe schafft eine Datei namens Dockerfile, die die Bauanleitung für ein Image enthält.

Es handelt sich dabei um eine einfache Textdatei, die Anweisungen für Docker enthält. Das Ziel eines Dockerfiles entspricht dem von Vagrantfiles: Statt Images beziehungsweise virtuelle Maschinen dauerhaft speichern zu müssen, genügt es, die weitaus kleinere Anleitung zu hinterlegen. Im Falle eines Falles lässt sich ein Image beziehungsweise eine virtuelle Maschine anhand dieser erneut auf die gleiche Art erstellen.

Die Kommandos innerhalb eines Dockerfiles sind stets einzeilig und beginnen mit einem Schlüsselwort. Zwar ist die Groß-/Kleinschreibung für Docker unerheblich, allerdings hat sich eine konsequente Großschreibung für Schlüsselwörter eingebürgert. Dockerfiles dürfen zudem Kommentare enthalten, die das #-Zeichen einleitet.

Ein minimales Dockerfile enthält mindestens die Angabe des FROM-Schlüsselworts, das das gewünschte Basis-Image angibt. Bei Bedarf lässt sich zudem ein Tag eintragen, und auch das Verzeichnen der Autoren samt Kontaktdaten empfiehlt sich:

# goloroden/nodejs
# VERSION 0.0.1

FROM ubuntu
MAINTAINER Golo Roden <webmaster@goloroden.de>

Um ein Image auf Basis dieses Dockerfiles zu erzeugen, dient der build-Befehl von Docker. Er erwartet die Angabe des Verzeichnisses, das das Dockerfile enthält und im weiteren Verlauf als Build-Kontext bezeichnet wird:

$ docker build .

Nun führt das Programm die im Dockerfile beschriebenen Schritte nacheinander aus und erzeugt für jeden Zwischenschritt zunächst einen temporären Container, den es anschließend in ein temporäres Image umwandelt. Zuletzt gibt Docker die ID des erzeugten finalen Images aus:

$ docker build .
Step 0 : FROM ubuntu
---> 9cd978db300e
Step 1 : MAINTAINER Golo Roden <webmaster@goloroden.de>
---> Running in 968111fc369c
---> a5ed8e1b2435
Successfully built a5ed8e1b2435

Ein Blick auf die Liste der im System hinterlegten Images bestätigt, dass das neue Image erfolgreich erzeugt und hinzugefügt wurde:

$ docker images
REPOSITORY TAG IMAGE_ID CREATED VIRTUAL SIZE
<none> <none> a5ed8e1b2435 2 minutes ago 204.4 MB
...

Führt man den build-Befehl erneut aus, fällt die deutlich höhere Verarbeitungsgeschwindigkeit auf: Docker greift nun auf die zuvor erzeugten temporären Images zurück und führt nur noch jene Schritte tatsächlich aus, die ein anderes Ergebnis nach sich ziehen würden. Das Programm betreibt also nur den minimal notwendigen Aufwand zum Erzeugen eines Images.

In der Regel soll ein fertiges Image einen Namen erhalten, um es komfortabel verwenden zu können. Hierzu dient das tag-Kommando:

$ docker tag a5ed8e1b2435 goloroden/nodejs

Gibt man außer dem Namen ein Tag (beispielsweise eine Versionsnummer) an, ist es ratsam, das Kommando zusätzlich mit der Angabe ":latest" auszuführen. Das Vorgehen stellt sicher, dass das Image auch ohne die explizite Angabe eines Tags ansprechbar ist:

$ docker tag a5ed8e1b2435 goloroden/nodejs:0.10.26
$ docker tag a5ed8e1b2435 goloroden/nodejs:latest

Optional lassen sich ein Name und ein Tag bereits mit dem build-Kommando angeben, indem man den Parameter -t übergibt:

$ docker build -t goloroden/nodejs .

Hat man ein Basis-Image erzeugt, kann man es anschließend mit Leben füllen. Dazu dient das Schlüsselwort RUN, das beliebige Kommandos für die Konsole entgegennimmt:

[...]
MAINTAINER Golo Roden <webmaster@goloroden.de>

RUN apt-get update
RUN apt-get install -y wget git
RUN wget http://nodejs.org/dist/v0.10.26/node-v0.10.26-linux-x64.tar.gz
RUN tar xvfz node-v0.10.26-linux-x64.tar.gz
RUN rm node-v0.10.26-linux-x64.tar.gz
RUN mv ./node-v0.10.26-linux-x64 /opt/node

Es gilt, darauf zu achten, dass die Kommandos ohne Interaktion ausführbar sind. Daher muss beispielsweise der Aufruf von "apt-get install" mit dem Parameter -y erfolgen, der sämtliche Nachfragen während der Paketinstallation automatisch beantwortet.

Wie bereits angesprochen, erzeugt Docker für jeden einzelnen Schritt einen neuen temporären Container und ein entsprechendes Image: Daher erhöht sich mit der Anzahl der verwendeten RUN-Schlüsselwörter auch die Anzahl der Layer, auf denen das endgültige Image aufbaut. Docker weist allerdings eine Obergrenze von maximal 127 Layern auf, vor Version 0.7.2 lag sie bei lediglich 42. Es schadet deshalb nicht, mehrere Kommandos in einem Aufruf zusammenzufassen.

Vorsicht ist übrigens geboten, wenn Kommandos nicht global zur Verfügung stehen oder Pfade nicht absolut angegeben werden sollen: Ein Verzeichniswechsel durch den cd-Befehl wirkt sich nämlich nur für den zugehörigen RUN-Schritt aus – alle weiteren starten erneut im Wurzelverzeichnis. Abhilfe schafft das Schlüsselwort WORKDIR, dem sich ein Verzeichnispfad übergeben lässt. Alle nachfolgenden RUN-Schritte führt Docker im Anschluss im angegebenen Verzeichnis aus. Dementsprechend darf WORKDIR innerhalb eines Dockerfiles mehrfach vorkommen.

Zusätzlich ist auch die Angabe des Schlüsselworts USER möglich, dem man entweder einen Benutzernamen oder eine Benutzer-ID übergeben kann. Alle Schritte innerhalb des Dockerfiles werden daraufhin mit den Rechten des angegebenen Benutzers ausgeführt.

Obwohl Node.js nun im erzeugten Image installiert ist, lässt sich noch nicht darauf zugreifen. Die Ursache hierfür besteht in der fehlenden Ergänzung der PATH-Variablen, sodass das System nicht weiß, in welches Verzeichnis Node.js installiert wurde. Zur Ergänzung von Umgebungsvariablen dient in einem Dockerfile das Schlüsselwort ENV:

[...]
RUN mv ./node-v0.10.26-linux-x64 /opt/node

ENV PATH $PATH:/opt/node/bin

Zu guter Letzt fehlt noch die Möglichkeit, einem Image gezielt Verzeichnisse und Dateien aus dem Wirtssystem hinzuzufügen. Das ist beispielsweise dann erforderlich, wenn Quellcode vom Wirt in das Image übertragen werden soll. Diese Integration übernimmt das ADD-Schlüsselwort, das zwei Parameter erwartet:

Um beispielsweise jenes Verzeichnis, das das Dockerfile enthält, in das Image zu integrieren, genügt prinzipiell die Angabe eines ADD-Schritts. Allerdings ist es ratsam, das Zielverzeichnis zuvor anzulegen:

[...]
RUN mv ./node-v0.10.26-linux-x64 /opt/node

RUN mkdir /app
ADD . /app

ENV PATH $PATH:/opt/node/bin

Um die Anwendung nun tatsächlich auszuführen, ist zunächst ein Container zu starten. Die gewünschte Applikation lässt sich danach mit dem bekannten run-Kommando anwerfen:

$ docker run goloroden/nodejs node /app/app.js

Solch ein Vorgehen funktioniert zwar, ist aber unpraktisch. Schließlich sollen von diesem Image erzeugte Container stets Node.js ausführen. Um das zu implementieren, lässt sich im Dockerfile das CMD-Schlüsselwort angeben:

[...]
ENV PATH $PATH:/opt/node/bin

CMD [ "node", "/app/app.js" ]

Nun genügt es, den Container mit run zu starten. Durch das CMD-Schlüsselwort weiß Docker, welche Anwendung auszuführen ist:

$ docker run goloroden/nodejs

Für Verwirrung sorgt gelegentlich das Schlüsselwort ENTRYPOINT, das dem CMD-Schlüsselwort auf den ersten Blick zu gleichen scheint. Allerdings besteht ein feiner Unterschied zwischen beiden: Während das mit CMD definierte Kommando überschreibbar ist, ist das über ENTRYPOINT festgelegte fix. Das tatsächlich ausgeführte Kommando ergibt sich dabei stets aus der Kombination der beiden Schlüsselworte.

Der serienmäßig vorgegebene ENTRYPOINT ist "/bin/sh -c", das tatsächlich ausgeführte Kommando lautet daher:

/bin/sh -c node /app/app.js

Sobald man den Unterschied begriffen hat, lassen sich flexible Konfigurationen erstellen. Beispielsweise führt die Angabe von

ENTRYPOINT [ "node" ]
CMD [ "/app/app.js" ]

dazu, dass bei einem Aufruf des Containers ohne zusätzliche Angaben die Anwendung wie gehabt ausgeführt wird. Da man den Wert von CMD allerdings durch die Angabe eines Kommandozeilenparameters überschreiben kann, lassen sich auch andere Node.js-Anwendungen starten. Das Ausführen von Node.js selbst ist jedoch fest vorgegeben.

Schließlich lässt sich durch Angabe von EXPOSE noch definieren, welche Ports des Containers zugänglich sein sollen. In Verbindung mit Node.js läuft das häufig auf Port 3000 hinaus. Das Binden des containereigenen Ports auf den des Wirts lässt sich allerdings nicht innerhalb des Dockerfiles abbilden, sondern obliegt stets dem Aufrufer. Das gesamte Dockerfile enthält nun folgenden Inhalt:

# goloroden/nodejs
# VERSION 0.0.1

FROM ubuntu
MAINTAINER Golo Roden <webmaster@goloroden.de>

RUN apt-get update
RUN apt-get install -y wget git
RUN wget http://nodejs.org/dist/v0.10.26/node-v0.10.26-linux-x64.tar.gz
RUN tar xvfz node-v0.10.26-linux-x64.tar.gz
RUN rm node-v0.10.26-linux-x64.tar.gz
RUN mv ./node-v0.10.26-linux-x64 /opt/node

RUN mkdir /app
ADD . /app

ENV PATH $PATH:/opt/node/bin

EXPOSE 3000

CMD [ "node", "/app/app.js" ]

Besonders interessant an Dockerfiles ist die Möglichkeit, sogenannte "Trusted Builds" für die zentrale Registry [2] zu erzeugen: Bei einem derartigen Build verbindet man ein GitHub-Repository, das ein Dockerfile enthält, mit der Docker-Registry. Wenn eine neue Version des Dockerfiles an GitHub gesendet wird, benachrichtigt ein Webhook automatisch die Docker-Registry, die die geänderte Datei daraufhin abruft und das Image neu erzeugt. Auf diesem Weg können Anwender der Docker-Registry sicher sein, dass ein dort veröffentlichtes Image tatsächlich aus dem angegebenen Dockerfile hervorgegangen ist.

Da sich Images in Docker ausgesprochen leicht erzeugen und starten lassen, liegt es nahe, für jeden Dienst einer Anwendung einen gesonderten Container zu verwenden: Das überträgt das Single-Responsibility-Prinzip auf die Infrastruktur und vereinfacht die Entwicklung und Wartung der einzelnen Container. Selbst verhältnismäßig kleine Anwendungen lassen sich auf unterschiedliche Container verteilen, indem man beispielsweise die Datenbank auslagert.

Das Starten eines gesonderten Containers für die Datenbank ist dabei der einfachste Schritt, für den sich beispielsweise auf ein vorgefertigtes Image wie dockerfile/mongodb [3] zurückgreifen lässt. Nachdem man den Container mit

$ docker run -p 27017:27017 dockerfile/mongodb

gestartet hat, besteht die einzige Herausforderung darin, eine Verbindung zwischen ihm und dem Container herzustellen, der Node.js ausführt. Natürlich lässt sich zu dem Zweck der freigegebene Port verwenden, aber dann ist die Konfiguration eines Containers von dem konkreten Aufruf eines anderen abhängig. Einen solchen Bezug gilt es in der Regel zu vermeiden. Zudem funktioniert diese Herangehensweise ausschließlich bei fest verdrahteten Ports: Überlässt man Docker die Wahl eines freien Ports auf dem Wirtssystem, weiß man bis zum Aufruf des Datenbankcontainers nicht, welcher Port überhaupt verwendet wird.

Docker bietet daher die Möglichkeit an, Container zu verbinden. Dazu muss man zunächst dem Datenbankcontainer bei dessen Start einen Namen zuweisen, zudem kann man auf die explizite Port-Weiterleitung verzichten:

$ docker run -name mongodb dockerfile/mongodb

Anschließend lässt sich der Name beim Start des Node.js-Containers angeben, um eine Verbindung zwischen beiden herzustellen. Dazu dient der Parameter -link. Das folgende Beispiel koppelt den Node.js- an den MongoDB-Container, wobei letzterer unter dem Namen "db" bereitgestellt wird:

$ docker run -link mongodb:db goloroden/nodejs

Docker stellt die Verbindungsdaten zum MongoDB-Container durch unterschiedliche Umgebungsvariablen zur Verfügung, die sich mit process.env aus Node.js auslesen lassen. Allen Umgebungsvariablen ist gemein, dass sie das Präfix "DB" verwenden, weil dem MongoDB-Container über den Parameter -link ebendieser Name zugewiesen wurde. Mit ihrer Hilfe, insbesondere durch DB_PORT, kann Node.js nun eine Verbindung zu MongoDB herstellen, ohne dass man dem Container explizit von Hand mitteilen müsste, unter welcher Adresse die Datenbank auffindbar ist.

Die Verbindung zwischen Containern stellt derzeit die einzige Möglichkeit in Docker dar, Dienste für andere Container zur Verfügung zu stellen. Denkbar sind zukünftig allerdings weitergehende Szenarien. Einen ersten Ausblick auf zukünftige Varianten stellt die Verwendung eines weiteren Containers als Vermittler zwischen Node.js und MongoDB dar: Der Einsatz eines solchen "Ambassador"-Containers wird in der Dokumentation [4] von Docker ausführlich beschrieben.

Außer dem direkten Zugriff auf einen Dienst, der von einem anderen Container bereitgestellt wird, unterstützt Docker auch die Integration mit der Umgebung über gemeinsam genutzte Laufwerke. Das gilt gleichermaßen für die Integration mehrerer Container und für die mit dem Wirtssystem.

Im einfachsten Fall wird ein Verzeichnis des Wirts für einen Container freigegeben, sodass er direkt lesend und schreibend darauf zugreifen kann. Das birgt insbesondere den Vorteil, dass ein solches Verzeichnis dauerhaft und unabhängig vom Container existiert und daher auch dessen Neustart oder Löschen anstandslos übersteht. Um ein Wirtsverzeichnis aus einem Container heraus verwenden zu können, dient der Parameter -v beim Aufruf des run-Kommandos:

$ docker run -v /home/golo/.ssh:/root/.ssh goloroden/nodejs

Das Verzeichnis /home/golo/.ssh des Wirts dient in diesem Fall als /root/.ssh innerhalb des Containers: Da sowohl der Wirt als auch der Container nun das gleiche Verzeichnis verwenden, wirken sich Änderungen in beide Richtungen sofort aus.

Verzichtet man auf die Angabe eines Wirtsverzeichnisses, erzeugt Docker ein Verzeichnis und bindet es in den Container ein. Auf dem Weg lassen sich externe Verzeichnisse unabhängig vom Wirt definieren. Das kann beispielsweise bei der Verwendung mit einem Datenbank-Container ausgesprochen hilfreich sein:

$ docker run -v /data/db -name mongodb dockerfile/mongodb

Diese Variante wird auch im Rahmen von Dockerfiles mit dem VOLUME-Schlüsselworts unterstützt. Die Angabe von

VOLUME [ "/data/db" ]

erfüllt dabei den gleichen Zweck wie die Angabe des Parameters -v im vorigen Beispiel. Eine Zuordnung auf ein dediziertes Wirtsverzeichnis ist über das Dockerfile nicht möglich, da – ähnlich wie bei den Ports – sonst eine Abhängigkeit des Containers zu seinem Wirt bestünde.

Ein bemerkenswerter Effekt ist, dass derart erzeugte Datenverzeichnisse auch dann erhalten bleiben, wenn der Container selbst nicht mehr ausgeführt wird: Es genügt, wenn er existiert. Auf diese Weise lassen sich sogenannte "data-only container" erzeugen, die keine andere Aufgabe haben als ein Datenverzeichnis zu definieren. Sie lassen sich aus anderen Containern über die Angabe des Parameters -volumes-from einbinden:

$ docker run -v /data -name data my-data-only-container
$ docker run -volumes-from data goloroden/nodejs

Nun hat der Node.js-Container Zugriff auf das /data-Verzeichnis, sodass er externe Daten mit anderen Container dauerhaft teilen kann, ohne von einem dedizierten Verzeichnis auf dem Wirt abhängig zu sein.

Obwohl es sich bei Docker um ein noch äußerst junges Projekt handelt, ist bereits ein bemerkenswertes Ökosystem entstanden. Besonders aktiv ist jener Bereich, der sich mit dem Deployment und dem Hosting von Docker-Containern beschäftigt.

Vor allem das Deployment und die Verwaltung von unterschiedlichen Docker-Servern ist ein verbreitetes Thema, wie die Projekte MaestroNG [5] und ShipYard [6] zeigen. Ergänzt werden diese Werkzeuge durch professionelles Hosting, wie es Orchard [7] und Tutum [8] anbieten. Was derzeit noch fehlt, ist eine echte Infrastructure-as-a-Service- beziehungsweise Platform-as-a-Service-Alternative auf Basis von Docker, wobei auch hier erste Projekte [9] aktiv sind.

Zu guter Letzt besteht mit CoreOS [10] bereits eine Linux-Distribution, die auf Docker als alleinige Paketverwaltung setzt und mit Diensten wie systemd [11] und etcd [12] die notwendigen Komponenten für eine verteilte Ausführung von Docker-basierten Anwendungen enthält. Wer Software entwickelt, die in einem derartigen Kontext auszuführen ist, ist gut beraten, sich mit den sogenannten "12-Factor-Apps [13]" vertraut zu machen.

Docker ist, wie im ersten Teil [14] erwähnt, eine ausgesprochen bemerkenswerte Software, die einen wesentlichen Anteil zum zukünftigen Hosting von Anwendungen leistet. Mit den in diesem Artikel beschriebenen Möglichkeiten, Container mit Dockerfiles automatisiert zu erzeugen, sie zu verbinden und mit anderen Containern und dem Wirt zu integrieren, lassen sich auch komplexe Szenarien abbilden.

Allerdings mangelt es noch an geeigneten Verwaltungswerkzeugen für umfangreiche Deployments. Erste Schritte in diese Richtung wurden bereits unternommen, auch erste Hoster sind am Markt; man merkt an vielen Stellen aber noch deutlich, dass es sich um eine noch junge Technik handelt: Viele Features fehlen den Werkzeugen und Hostern derzeit noch. Doch das könnte sich in kurzer Zeit ändern.

In Zukunft dürften Docker-basierte Container eine ernsthafte Alternative zur klassischen Virtualisierung darstellen. Daher ist jeder Entwickler schon heute gut beraten, sich mit Docker zu beschäftigen.

Golo Roden
ist Gründer der "the native web UG [15]", eines auf native Webtechniken spezialisierten Unternehmens. Für die Entwicklung moderner Webanwendungen bevorzugt er JavaScript und Node.js und hat mit "Node.js & Co." das erste deutschsprachige Buch zum Thema geschrieben
(jul [16])


URL dieses Artikels:
https://www.heise.de/-2145030

Links in diesem Artikel:
[1] https://www.heise.de/hintergrund/Anwendungen-mit-Docker-transportabel-machen-2127220.html
[2] https://index.docker.io/builds/github/select/
[3] https://index.docker.io/u/dockerfile/mongodb/
[4] http://docs.docker.io/en/latest/use/ambassador_pattern_linking/
[5] https://github.com/signalfuse/maestro-ng
[6] http://shipyard-project.com/
[7] https://orchardup.com/
[8] http://www.tutum.co/
[9] http://ctl-c.io/
[10] https://coreos.com/
[11] http://www.freedesktop.org/wiki/Software/systemd/
[12] https://github.com/coreos/etcd
[13] http://12factor.net/
[14] https://www.heise.de/hintergrund/Anwendungen-mit-Docker-transportabel-machen-2127220.html
[15] http://www.thenativeweb.io
[16] mailto:jul@heise.de