Anwendungen mit Docker transportabel machen

Werkzeuge  –  7 Kommentare

Jede Anwendung hat Abhängigkeiten. Die Palette reicht von der Konfiguration des lokalen Betriebssystems bis zur Integration von Netzwerk- und Webdiensten. Das erschwert das Verteilen von Anwendungen auf unterschiedliche Systeme. Das Open-Source-Werkzeug Docker verspricht Abhilfe.

Die grundlegende Problematik ist rasch erklärt: Jede Anwendung hängt von einigen Elementen ihrer Umgebung ab, die sich auf unterschiedlichen Systemen nur schwerlich garantieren lassen. Bereits die verwendete Programmiersprache kann ein Problem darstellen, wenn unterschiedliche Versionen gemischt zum Einsatz kommen: Brüche in der Abwärtskompatibilität sind in der Praxis keine Seltenheit. Ein solcher hat beispielsweise mit der Einführung der Version 0.10 von Node.js stattgefunden, deren Streams-Modul Inkompatibilitäten zu dem der Vorgängerversion aufwies.

Dieses Beispiel zeigt nur einen winzigen Ausschnitt aus der Palette der potenziellen Schwachstellen: Weitere Faktoren sind unter anderem das installierte Betriebssystem, dessen Version und Einstellungen, sämtliche hinzugefügten Pakete und Module oder auch die Konfiguration des Netzwerks. Die Liste der möglichen Störfaktoren, die die fehlerfreie Ausführung einer Anwendung gefährden können, ist quasi endlos. Daher lässt sich die Aufgabe, das eigene System für eine einzige Anwendung passend einzurichten, als enorme Herausforderung begreifen.

Zahlreiche Entwickler arbeiten in Teams und veröffentlichen ihre Anwendung in unterschiedlichen Umgebungen, beispielsweise auf einem Test- und einem Produktivsystem, was die Komplexität weiter steigert (siehe Tabelle).

Entwickler 1 Entwickler 2 Testsystem Produktivsystem
OS Ubuntu 13.10 Windows 7 CentOS 6.4 Ubuntu12.04 LTS
Node.js 0.10.23 0.10.21 0.10.17 0.8.16
Express 3.3.0 3.4.6 3.4.7 3.1.3
... ... ... ... ...


Die Komplexität der Abhängigkeiten einer Anwendung steigt mit der Anzahl der Systeme.

Die gezeigte Tabelle lässt sich gut mit dem Transport von Waren vergleichen: Im Transportwesen müssen Güter auf unterschiedlichen Wegen transportieren werden, wobei je nach Kombination von Gut und Transportmedium spezielle Bedingungen einzuhalten sind. Die Lösung hierfür besteht in genormten Containern, die sich gleichermaßen für den Transport per Bahn, Schiff oder Flugzeug eignen und die gewünschten Waren von dem gewählten Transportmedium abstrahieren.

Genau die Idee greift Docker auf: Statt die Anwendung direkt auf den Systemen einzurichten, packt man sie einschließlich all ihrer Abhängigkeiten in einen Container, der sich anschließend auf einheitlichem Weg verteilen und ausführen lässt. Bislang war solch ein Vorgehen virtuellen Maschinen vorbehalten, doch weisen Letztere einige Nachteile auf. Dazu zählen insbesondere der hohe Bedarf an Festplatten- und Arbeitsspeicher und der langwierige Ladevorgang beim Start: Die Anzahl der zeitgleich ausführbaren virtuellen Maschinen ist daher ausgesprochen gering. Werkzeuge wie Vagrant erleichtern zwar das Einrichten und Verwalten von virtuellen Maschinen, können an diesen Einschränkungen allerdings nichts ändern.

Docker basiert daher nicht auf virtuellen Maschinen, sondern auf Linux-Containern (LXC). Ein solcher ist eine vom Betriebssystem bereitgestellte virtuelle Umgebung zur isolierten Ausführung von Prozessen. Alle Container teilen sich allerdings einen gemeinsamen Kernel. Daher ist es mit den Containern beispielsweise nicht möglich, eine Windows-Anwendung auf Linux auszuführen: Hierfür benötigt man nach wie vor eine virtuelle Maschine.

Der größte Vorteil von Linux-Containern gegenüber virtuellen Maschinen liegt in ihrem äußerst sparsamen Umgang mit Ressourcen und einer kurzen Startzeit, die sich im Rahmen weniger Sekunden bewegt. Ihr größter Nachteil ist die für den unbedarften Anwender komplexe und aufwändige Konfiguration. Diese Aufgabe übernimmt Docker und stellt daher einen für jeden verständlichen und leicht benutzbaren Zugang zu Containern dar. Das Werkzeug steht kostenfrei zum Herunterladen bereit und ist unter der Apache License 2.0 lizenziert.

Die Installation erfolgt, abhängig vom verwendeten Betriebssystem, auf mehr oder minder aufwändigem Weg: Unter Umständen ist es nämlich erforderlich, zuvor den Kernel des Betriebssystems zu aktualisieren. Die Dokumentation leistet allerdings eine vergleichsweise gute Hilfestellung. Generell kann die Installation ausschließlich auf einem 64-Bit-basierten Linux erfolgen, wobei Ubuntu lange Zeit die erste Wahl der Entwickler von Docker war. Seit der am 5. Februar 2014 veröffentlichten Version 0.8 unterstützt Docker zudem OS X nativ. Das bezieht sich allerdings nur auf das Kommandozeilenwerkzeug, nicht auf den eigentlichen Docker-Server: Für dessen Einsatz ist unter dem Mac-Betriebssystem nach wie vor eine virtuelle Maschine erforderlich.

Äußerst hilfreich ist, dass Vagrant seit der im Dezember 2013 veröffentlichten Version 1.4 Docker
zum Provisionieren von virtuellen Maschinen unterstützt und in der Lage ist, es zu installieren. Daher lässt sich in wenigen Zeilen ein einfaches Vagrantfile schreiben, das eine mit Ubuntu und Docker arbeitende virtuelle Maschine einrichtet:

Vagrant.configure(2) do |config|
config.vm.box = "precise64"

# When running VMware use http://files.vagrantup.com/precise64_vmware.box
config.vm.box_url = "http://files.vagrantup.com/precise64.box"

# Insert network configuration here

config.vm.provision "docker" do |d|
d.pull_images "ubuntu"
end
end

Alternativ kann man dockervm nutzen, das im aktuellen Verzeichnis ein Vagrantfile anlegt und darauf aufbauend anschließend eine virtuelle Maschine erzeugt, die Docker enthält:

$ dockervm

Docker umfasst, wie im Zusammenhang mit OS X bereits erwähnt, eine Client- und eine Serverkomponente. Im einfachsten Fall sind beide auf dem gleichen System installiert, alternativ lässt sich der Server jedoch von einem Client über das Netzwerk steuern. Führt man das Kommando

$ docker version

aus, erhält man die Versionsnummer der beiden Komponenten und gegebenenfalls einen Hinweis auf eine verfügbare Aktualisierung.

Damit Docker eine Anwendung ausführen kann, benötigt es zunächst ein Abbild des Systems, das als Basis dienen soll. Ein solches bezeichnet Docker als "Image". Interessant ist, dass das Betriebssystem des Abbilds nicht jenem des Wirts entsprechen muss: Lediglich der verwendete Kernel muss zueinander passen. Daher ist es mit Docker beispielsweise problemlos möglich, Ubuntu auf CoreOS auszuführen. Ein Image lässt sich auf zwei Wegen beschaffen: Entweder entwickelt man ein eigenes von Grund auf, oder man lädt ein vorgefertigtes aus dem Web herunter. Letzteres entspricht der von Docker empfohlenen Vorgehensweise, weshalb einige offizielle Images zur Verfügung stehen.

Selbstverständlich ist das von Docker bereitgestellte Image, das Ubuntu enthält, nicht gleichermaßen für jeden geeignet. Daher lassen sich die Abbilder den individuellen Anforderungen und Wünschen anpassen. Allerdings werden die Modifikationen niemals innerhalb eines Images vorgenommen: Stattdessen entsteht ein neues, das auf seinen Vorgänger verweist und zusätzlich lediglich die Unterschiede zu ihm speichert. Auf die Weise bildet sich nach und nach das gewünschte System als Kette von Images. Eine Kette darf seit Version 0.7.2 von Docker maximal 127 Images umfassen, zuvor lag die Obergrenze bei 42. Startet man eine Anwendung, führt Docker die benötigte Kette von Images in ein einziges Abbild zusammen und instanziiert es: Die ausgeführte Instanz bezeichnet Docker als "Container". An ihm lassen sich Änderungen vornehmen, die beim Beenden verfallen.

Images und Container zusammen bilden die sogenanten "Layer": Jeder Layer in Docker verfügt über ein eigenes Dateisystem, wobei lediglich jenes des Containers schreibbar ist. Es ist folglich nicht möglich, ein zu Grunde liegendes Image zu ändern. Bei Lesevorgängen hangelt sich Docker durch die Kette von Images, bis es die gewünschte Datei gefunden hat. Dieses Vorgehen dürfte Entwicklern, die mit einer Prototyp-basierten Sprache vertraut sind, bekannt vorkommen: Letztlich verhält sich das Dateisystem auf die gleiche Art wie die Prototypkette von Objekten in Sprachen wie JavaScript oder Io.

Will man die an einem Container vorgenommenen Änderungen bewahren, lässt sich der Container wiederum in ein Image umwandeln: Auf die Weise kann man es zu einem späteren Zeitpunkt als Basis für einen neuen Container verwenden. Es ist wichtig, den Unterschied zwischen Images und Container zu verstehen, um die Funktionsweise von Docker nachvollziehen zu können.

Zum Herunterladen eines Images dient das Kommando pull, das als Parameter den Namen des gewünschten Images erwartet, beispielsweise ubuntu:

$ docker pull ubuntu

Der Aufruf überträgt die angegebene Datei aus der öffentlichen Registry von Docker auf das lokale System. Neben den offiziellen Images enthält das Verzeichnis auch von der Community bereitgestellte Varianten. Sie lassen sich leicht daran erkennen, dass ihr Name als Präfix den Namen jenes Benutzers enthält, von dem das Image stammt: Beispielsweise handelt es sich bei "ubuntu" um eine offizielle Veröffentlichung, bei "goloroden/nodejs" hingegen um eine von dem Benutzer "goloroden" bereitgestellte.

Die Webseite der Registry bietet eine Suchfunktion an, mit deren Hilfe man nach vorgefertigten Images stöbern kann (siehe Abbildung 1). Die gleiche Suche lässt sich auch über die Kommandozeile verwenden, indem man den Befehl search angibt:

$ docker search nodejs
Die Registry von Docker enthält vorgefertigte Images, die sich zur lokalen Verwendung herunterladen lassen. (Abb. 1)

Sobald eine passende Datei gefunden und heruntergeladen wurde, lässt sich mit dem Kommando run ein neuer Container starten. Es erwartet außer dem Namen des zu verwendenden Images die auszuführende Anwendung:

$ docker run ubuntu echo Hello world
Hello world

Sobald Letztere endet, stoppt Docker den Container. Daher liefert ein Aufruf des Kommandos ps, das alle derzeit ausgeführten Container auflistet, lediglich eine leere Liste:

$ docker ps

Startet man hingegen eine Anwendung, die dauerhaft läuft, zeigt ps die zugehörigen Daten an. Dazu zählt unter anderem die eindeutige ID des Containers, die für die Umwandlung in ein Image benötigt wird.

Versucht man, run mit einem der Images auszuführen, das lokal nicht vorliegt, schlägt der Vorgang übrigens nicht fehl: Stattdessen versucht Docker zunächst, das gewünschte Image per pull zu beziehen. Daher ist es nicht zwingend erforderlich, ein Image stets von Hand herunterzuladen.

Gelegentlich kann es interessant sein, eine Liste aller lokal vorliegenden Images zu erhalten: Den Zweck erfüllt der images-Befehl. In dessen Ausgabe fällt auf, dass ein Image durchaus auch in verschiedenen Versionen existieren kann. Sie bezeichnet Docker als "Tag":

$ docker images
REPOSITORY TAG IMAGE ID ...
ubuntu 13.10 9f676bd305a4 ...
ubuntu saucy 9f676bd305a4 ...
ubuntu 13.04 eb601b8965b8 ...
... ... ... ...

Optional lässt sich ein solches Tag bei der Ausführung angeben, indem man seinen Namen mit einem Doppelpunkt an den des Images anschließt:

$ docker run ubuntu:13.04 echo Hello world
Hello world

Das bisherige Vorgehen ist geeignet, um eine Anwendung in einem Container auf Grundlage eines Images auszuführen. Allerdings ist es unmöglich, einen solchen fortzusetzen: Da das Erzeugen eines angepassten Containers in der Regel mehr als einen Aufruf auf der Kommandozeile erfordert, benötigt man hierfür einen anderen Weg. Er besteht darin, als Anwendung eine Shell zu übergeben und das run-Kommando zusätzlich um die Parameter -i und -t zu ergänzen. Letztere bewirken, dass der Standardeingabestrom nicht geschlossen wird und ein Pseudo-TTY zur Verfügung steht:

$ docker run -i -t ubuntu bash
root@97132245c370:/#

Ergebnis des Aufrufs ist eine Kommandozeile innerhalb des Containers, auf der man über administrative Rechte verfügt: Nun lässt sich der Container nach Belieben anpassen. Beispielsweise kann der Nutzer ein Personal Package Archive (PPA) hinzufügen und daraus Node.js installieren:

$ docker run -i -t ubuntu bash
root@bf43dab1247c:/# apt-get update
root@bf43dab1247c:/# apt-get install -y software-properties↵
-common python-software-properties
root@bf43dab1247c:/# add-apt-repository -y ppa:chris-lea/node.js
root@bf43dab1247c:/# apt-get update
root@bf43dab1247c:/# apt-get install -y nodejs

Als Bestandteil der Eingabeaufforderung erhält man die ID des Containers. Um sie nach dessen Beenden nochmals abzurufen, lässt sich das ps mit dem Parameter -l nutzen, der die Details des zuletzt ausgeführten Containers ausgibt:

$ docker ps -l
CONTAINER ID IMAGE COMMAND ...
bf43dab1247c ubuntu:12.04 bash ...

Die ID des Containers ist erforderlich, um ihn in ein Image umwandeln zu können. Das erledigt das Kommando commit, das außer der Kennung den gewünschten Namen und optional ein Tag erwartet:

$ docker commit bf43dab1247c goloroden/nodejs:0.10.25
c126f8d31ead2889163f6dd72c887272c4b7205bd0cfccf59165cfbb59176efa

Als Ergebnis zeigt das Kommando die ID des neu erzeugten Images an. Ruft man im Anschluss images auf, findet es sich in der Liste der hinterlegten Abbilder.

$ docker images
REPOSITORY TAG IMAGE ID ...
goloroden/nodejs 0.10.25 c126f8d31ead ...
ubuntu 13.10 9f676bd305a4 ...
ubuntu saucy 9f676bd305a4 ...
ubuntu 13.04 eb601b8965b8 ...
... ... ... ...

Der Versuch, das Image als Grundlage für einen neuen Container zu verwenden, der eine Node.js-Anwendung ausführt, schlägt allerdings fehl:

$ docker run goloroden/nodejs node -e "console.log('Hello Node.js');"
Unable to find image 'goloroden/nodejs' (tag: latest) locally

Grund ist die fehlende Angabe eines Tags beim Aufruf des run-Befehls: Fehlt es, verwendet Docker automatisch den Wert latest, der für das neu erzeugte Image allerdings nicht existiert. Als Ausweg bietet sich an, das entsprechende Tag zusätzlich von Hand zu erzeugen:

$ docker commit bf43dab1247c goloroden/nodejs:latest
c126f8d31ead2889163f6dd72c887272c4b7205bd0cfccf59165cfbb59176efa

Ein erneuter run-Aufruf bringt im Anschluss das gewünschte Ergebnis:

$ docker run goloroden/nodejs node -e "console.log('Hello Node.js');"
Hello Node.js

Während des Experimentierens entstehen rasch zahlreiche Images und Container, die nicht dauerhaft aufzuheben sind. Um Erstere zu entfernen, kennt Docker das rmi-Kommando, das als Parameter die Angabe eines Images erwartet, wobei sich wahlweise dessen Name oder ID angeben lässt:

$ docker rmi <image>

Das Entfernen von Containern erfolgt prinzipiell analog, statt rmi ist lediglich rm zu verwenden:

$ docker rm <container>

Docker prüft vor dem Entfernen eines Images, dass kein Container auf es verweist. Da der Befehl ps lediglich jene Container anzeigt, die derzeit ausgeführt werden, kann es sich schwierig gestalten, die ID von älteren zu ermitteln.

Abhilfe schafft der Parameter -a, der ps anweist, Container unabhängig von ihrem Status anzuzeigen. Kombiniert man das mit der Unix-Anweisung grep, lassen sich leicht alle aufspüren, die auf einem bestimmten Image basieren:

$ docker ps -a | grep <image>

Entscheidet man schließlich, ein Image in der öffentlichen Registry von Docker zu veröffentlichen, dient dazu das Kommando push, das den Namen des Images als Parameter erwartet:

$ docker push goloroden/nodejs

Unter Umständen ist die Verwendung einer Registry zwar gewünscht, nicht jedoch die damit verbundene Öffentlichkeit. Das gilt insbesondere für Images, die für den privaten Gebrauch entwickelt werden, beispielsweise innerhalb von Unternehmen. In dem Fall lässt sich mit verhältnismäßig geringem Aufwand eine private Registry betreiben. Der erforderliche Code und die dazugehörige Anleitung stehen auf GitHub zur Verfügung.

Alternativ kann man auf eine in der Cloud gehostete private Registry ausweichen, in der sich idealerweise gezielte Zugriffsrechte für Entwickler und Teams vergeben lassen. Derartige Dienste, beispielsweise Quay.io, sind in der Regel jedoch kostenpflichtig.

Kaum einfacher könnte in Docker der tatsächliche Zugriff auf eine alternative Registry gelöst sein. Dem Namen des Images sind dazu lediglich Host und Port der Registry voranzustellen, wie die folgende Anweisung zeigt:

$ docker push 192.168.0.100:5000/goloroden/nodejs

Die Dokumentation von Docker enthält eine ausführliche Beschreibung der Struktur der Registry und der API, über die die Client-Komponente mit der Registry kommuniziert.

Docker schottet jeden Container vom Netzwerk ab. Daher lässt sich über den Aufruf

$ docker run goloroden/nodejs node -e 
"require('http').createServer(function (req, res)
{res.end('Hello Node.js'); }).listen(3000);"

zwar ein mit Node.js arbeitender Webserver starten, allerdings ist der Zugriff darauf von außerhalb des Containers nicht möglich. In dem man zusätzlich den Parameter -p und den internen Port des Containers angibt, lässt sich das leicht ändern:

$ docker run -p 3000 goloroden/nodejs node -e 
"require('http').createServer(function (req, res)
{res.end('Hello Node.js'); }).listen(3000);"

Docker kümmert sich daraufhin um die Einrichtung einer Portweiterleitung zum Wirt. Um den zugewiesenen Port zu ermitteln, genügt ein Blick in die PORTS-Spalte des ps-Kommandos:

$ docker ps
CONTAINER ID ... PORTS ...
854b1876bbb2 ... 0.0.0.0:49153->3000/tcp ...

Ruft man den Wirt nun unter Angabe des Ports 49153 in einem Webbrowser auf, erhält man die entsprechende Ausgabe des Webservers. Will man an Stelle des von Docker zufällig ausgewählten Ports eine Vorgabe machen, ist sie zusätzlich im Aufruf anzugeben:

$ docker run -p 5000:3000 goloroden/nodejs node -e
"require('http').createServer(function (req, res)
{res.end('Hello Node.js'); }).listen(3000);"

Anschließend besteht eine Port-Weiterleitung des internen Ports 3000 auf den Port 5000 des Wirts. Darüber hinaus lässt sich eine IP-Adresse angeben, sodass man den Container gezielt an eine der Netzwerkkarten des Wirts binden kann.

Innerhalb des Containers ist beim Zugriff auf das Netzwerk darauf zu achten, dass die Netzwerkschnittstelle einen kurzen Moment für die Initialisierung benötigt. Daher kann es vorkommen, dass die Anwendung innerhalb des Containers bereits läuft, bevor das Netzwerk verfügbar ist. Es gilt also, gegebenenfalls beim Anwendungsstart auf die Verfügbarkeit von "eth0" zu warten.

Docker ist ein hilfreiches Werkzeug, um Prozesse isoliert voneinander auszuführen. Derzeit benötigt man als Basis hierfür noch ein 64-Bit-basiertes Linux-System, die Entwickler von Docker arbeiten aber an der Kompatibilität zu anderen Betriebssystemen, unter anderem OS X. Bis dahin stellt eine virtuelle Maschine einen guten Ausweg dar.

Bemerkenswert sind der geringe Ressourcenbedarf und die hohe Geschwindigkeit, mit der Docker Container lädt und ausführt. Daher eignet sich das Werkzeug auch für die Entwicklung von Anwendungen, da im Gegensatz zu virtuellen Maschinen keine Wartezeiten von mehreren Minuten einzukalkulieren sind.

Demnächst auf heise Developer:

Der bald erscheinende zweite Teil des Docker-Artikels geht auf die Möglichkeiten ein, Images automatisiert zu erzeugen und Container im Netzwerk zu verbinden. Außerdem werden die Integration mit dem Wirtssystem, die Verwendung von reinen Datencontainern und die Verwaltung von Docker-Containern im Netzwerk beschrieben.

Der Austausch mit anderen Benutzern fällt dank der öffentlichen Registry leicht und das Veröffentlichen von eigenen Images geht vergleichsweise unkompliziert und zügig von der Hand. Prinzipiell ist der Netzwerkzugriff auf Container von außen nicht gestattet, das lässt sich jedoch leicht über eine Portweiterleitung anpassen. Damit ist es mit wenig Aufwand möglich, beispielsweise eine Datenbank oder einen Webserver zu betreiben, ohne das eigene System anpassen zu müssen. (jul)

Golo Roden
ist Gründer der "the native web UG", 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.