Jenkins und Co. einfach mit Docker virtualisieren – oder doch nicht?

Ob im Softwareprojekt an der Hochschule oder in kommerziellen Projekten, überall treffe ich auf eine dockerisierte Buildumgebung. Aber ist es wirklich so einfach Continuous Integration (CI) mit Docker zu virtualisieren, wie es in den meisten Blogposts dargestellt wird?

Als wir für eines unserer 4+1-Projekte einen automatisierten Build und ein kontinuierliches Deployment im Jenkins-Container einrichten wollten, stießen wir auf einige Hindernisse. Besonders aufwendig war es, Docker innerhalb eines Docker-Containers zu nutzen. Wie du diese Fallstricke umgehen und eine Menge Zeit bei der Fehlersuche einsparen kannst, möchte ich dir im Folgenden erläutern.

Ich gehe davon aus, dass du für dein Projekt bereits eine virtualisierte Infrastruktur mit Versionsverwaltung, privatem Repository und Jenkins aufgesetzt hast. Natürlich ist es kein Problem für dich, einen Jenkins-Job zu konfigurieren, der einen Maven-Build mit Testsuite anstößt. Ferner kann ich mir vorstellen, dass du bei dir lokal schon einige Experimente mit Docker durchgeführt und deine Anwendung in einem Container gestartet hast.

Der Plan: Die Virtualisierung einer Continuous Integration mit Docker

Unsere To-do-Liste für ein dockerisiertes CI sieht wie folgt aus:

  1. Einen Jenkins-Job erstellen, der ein Docker-Image baut und in eine private Docker-Registry pusht.
  2. Einen zweiten Jenkins-Job anlegen, der jenes Docker-Image aus der privaten Registry pullt und mittels Docker-Compose auf dem Server deployt.

Nach meinen ersten Experimenten mit Docker, dachte ich mir, dass es ziemlich schnell gehen dürfte, mit Jenkins Docker-Images zu bauen und Container zu starten. Ich müsste einfach Docker ebenfalls innerhalb des Jenkins-Containers installieren und schon wäre alles DONE.

Somit bestand das erste Problem darin, herauszufinden, wie wir das Docker Command Line Interface (CLI) und einen Docker-Daemon dem Jenkins-Image hinzufügen. Damit das Jenkins-Image um die Docker Community Edition (CE) erweitert wird, musst du seinem Dockerfile lediglich folgenden RUN-Befehl hinzufügen.

RUN curl -fsSL https://get.docker.com/ | sh

RUN groupadd -g 985 docker-host

RUN usermod -a -G docker-host jenkins

Docker führt out-of-the-box alle Prozesse in seinen Container mit Root-Rechten aus – und das stellt natürlich ein riesiges Sicherheitsrisiko dar! Deshalb schränken wir die Rechte des Jenkins-Containers ein, indem wir ihm eine neue Gruppe docker-host hinzufügen. Dabei ist  die Group-ID nicht zufällig gewählt, diese muss mit der Docker-Gruppe des Hosts übereinstimmen. Da die User- und Group-IDs der Container und des Hosts durch einen gemeinsamen Linux-Kernel verwaltet werden, fügst du den Jenkins-Nutzer so ebenfalls der Docker-Gruppe des Hosts hinzu.

Für den zweiten Schritt unseres Plans, ein automatisiertes Deployment, benötigt der Jenkins-Container neben dem Docker-CLI ebenfalls Docker-Compose. Docker-Compose ist ein Werkzeug, welches dem Entwickler erlaubt, mehrere Docker-Container in lediglich einem Docker-Compose-File zu definieren und diese mit nur einem Befehl zeitgleich zu starten.  Wenn du mit Docker-Compose noch nicht vertraut bist, empfehle ich dir zum Einstieg seine Dokumentation. Wir wollen uns in diesem Blogpost mehr auf die Probleme mit dem Jenkins-Container konzentrieren.

Docker-Compose ist ein quelloffenes Projekt und wurde mit Python entwickelt. Deshalb installieren wir es am besten mit Pythons Paketverwaltung pip in unserem Jenkins-Image, wie das folgende Listing zeigt.

RUN apt-get -y install python-pip

RUN pip install docker-compose

Der Theorie nach hätten wir nun das benötigte Tooling installiert und bräuchten nur noch die Jenkins-Jobs für Schritt 1 und 2 anlegen, welche die entsprechenden Docker-Kommandos aufrufen. Aber kann das wirklich schon alles gewesen sein?

Docker-in-Docker vs. Docker-out-of-Docker

Bei meiner Recherche stieß ich auf eine Vielzahl von Blogposts, die von einer Verwendung Dockers innerhalb eines Docker-Containers abrieten. Der Ansatz von Docker-in-Docker (DinD) beschreibt eine naive Lösung, die zu Datenkorruption führen kann. Stellen wir uns vor, dass wir mehrere Docker-Container mit umfangreichen Docker-Images haben. So würden wir, um Speicherplatz einzusparen und nicht jedes Image mehrmals aus dem Docker-Hub zu pullen, das Verzeichnis /var/lib/docker von dem Docker-Host in jeden Container mounten. Diese Technik würde all unseren Containern erlauben, die Docker-Images des Hosts zu nutzen. Genau dies wird uns aber zum Verhängnis, sobald mehrere Container zeitgleich versuchen auf die Images zu schreiben (siehe Using Docker-in-Docker for your CI or testing environment? Think twice).

Nun stellt dieser Umstand in unserem Umfeld noch kein großes Problem dar, da wir lediglich einen Container mit DinD nutzen. Allerdings wollen wir im zweiten Schritt eine Anwendung aus dem Jenkins-Container heraus neben diesen deployen. Genau für dieses Szenario bietet sich Docker-out-of-Docker (DooD) an, da wir anders nicht auf den Docker-Daemon des Host zugreifen können.

Für die Verwendung von DooD muss das Docker-CLI zwar ebenfalls dem Jenkins-Image hinzugefügt werden, es nutzt jedoch implizit den Docker-Daemon des Hosts. Die folgende Abbildung veranschaulicht die softwaretechnischen Unterschiede zwischen DinD und DooD.

Unterschiede-Software-Docker-in-Docker-Docker-out-of-docker.png


Das Docker-CLI schreibt seine Befehle auf einen
Unix Domain Socket (Socket), der Docker-Daemon liest und führt diese Befehle aus. Sowohl mit DinD als auch mit DooD startet der Docker-Daemon des Hosts die Container für die Versionsverwaltung, die private Docker-Registry und Jenkins. Ferner verdeutlicht die Abbildung, dass Docker-CLI und -Daemon ebenfalls nativ im Jenkins-Container laufen und über einen eigenen Socket kommunizieren. Bei DinD startet Jenkins Docker-Daemon die App innerhalb seines Containers, was die App von außerhalb des Hosts erst einmal unerreichbar macht.

Für DooD mounten wir den Socket in den Jenkins-Container, somit schreibt Jenkins Docker-CLI seine Befehle ebenfalls auf den Socket des Hosts.  Außerdem führt der Docker-Daemon des Hosts die Befehle des Jenkins-Containers aus. Das erlaubt es uns, aus dem Jenkins-Container heraus die App neben diesen zu deployen.

Nun fragst du dich sicherlich, wie wir diese Technik anwenden. Alles was es dafür braucht, ist beim Starten mit der Option -v den Socket /var/run/docker.sock des Hosts in den Jenkins-Container zu mounten.

$ docker run -d -p 8080:8080 -p 50000:50000 \

     -v /var/run/docker.sock:/var/run/docker.sock jenkins

Allerdings sollte man den Zugriff auf diesen Socket nur vertrauenswürdigen Containern erlauben. Der Docker-Daemon ist ein mächtiger Prozess und kann großen Einfluss auf das Host-Betriebssystem nehmen,z. B. durch das Schreiben von Routing-Tabellen oder Anpassen der Firewall-Konfiguration.

There’s a plugin for that

Jenkins bietet seinen Nutzern gerade durch seine umfangreiche Community viele Plugins an – so auch für die Integration von Docker. Nach meiner ersten Recherche stieß ich auf eine Vielzahl von Plugins und war mir unsicher, welches ich einsetzen sollte, doch kann dir nun das Docker-Plugin von CloudBees empfehlen. Es konzentriert sich ausschließlich auf das Bauen sowie Pushen von Images und seine Bedienung ist recht intuitiv.

Der folgende Screenshot zeigt die Konfiguration des CloudBees-Docker-Plugins. Der Repository Name dient später als Bezeichner für dein Docker-Image. Zusätzlich erlaubt dieses Plugin das Docker-Image mit einem Tag zu markieren, wobei Docker den Tag latest implizit anhand des Zeitstempels setzt. Außerdem möchte dieses Plugin vor dir den Resource Identifier (URI) des Docker-Hosts und den Resource Locator (URL) der privaten Docker-Registry wissen. Da wir DooD verwenden, sind dies der Pfad zum Socket und der Port, auf dem der Host den Nexus anbietet.

Wenn dieses Plugin den Docker-Build nicht in der Wurzel eures Repositories ausführen soll, kannst du im Build Context ein benutzerdefiniertes Verzeichnis und im Dockerfile Path den Ort des Dockerfiles fixieren. An dieser Stellen müssen wir klar zwischen den Begriffen Repository und Registry unterscheiden. Ein Repository beschreibt den Ort, von dem dein Quellcode und vor allen Dingen dein Dockerfile stammen, wohingegen eine Registry lediglich erlaubt, fertige Docker-Images zwischen Entwicklern auszutauschen.

cloudbee_docker_plugin_v2.png


Der letzte Schritt besteht darin, in einem Drop-Down-Menü die Installation des Docker-CLI auszuwählen. Das CloudBees Plugin hängt implizit die URL der privaten Docker-Registry als Präfix an das Image und pusht dieses nach einem erfolgreichen Build automatisch in die Registry.

Somit können wir den ersten Schritt von unserem Plan abhaken, da wir nun mithilfe eines Jenkins-Jobs Docker-Images bauen und pushen können. Wenden wir uns nun Schritt 2, dem Deployment mit Jenkins-Job, zu.

Ein kurzes Wort zur Docker-Registry

Ich habe bereits den Unterschied zwischen einem Repository und einer Registry skizziert, bei dem Registry ein privates Docker-Hub bedeutet. Bei meiner Recherche bin ich auf unterschiedliche Bezeichnungen gestoßen, von denen ich jedoch fälschlicherweise annahm, dass sie dasselbe meinen. Diese beiden Begriffe sind Docker-Registry und Docker-Index.

Nach der alten Docker-Registry API V1 besteht ein Docker-Hub aus einer Registry und einem Index. Die Registry speichert demnach die Images und ist für das Pullen sowie Pushen verantwortlich. Der Index verwaltet jedoch alle Benutzer und deren Zugriffsrechte. Ferner delegiert die Registry bei einem Request die Authentisierung an den Index.

Das Konzept des Index ist jedoch veraltet und das Distribution-Management von Images wurde durch ein neues Projekt abgelöst, dass die Docker-Registry API V2 implementiert. Sonatype’s Nexus unterstützt primär Version 2 der API kann aber auch Anfragen gegen Version 1 verarbeiten, wenn dies explizit konfiguriert wurde. Der folgende Screenshot zeigt, wie Nexus mit einem Klick Docker-Registry API V1 unterstützt.

nexus_docker_api_v1.png

Kontinuierliches Deployment mittels Jenkins-Job

Leider bietet Jenkins kein Plugin an, das Docker-Compose unterstützt. So bleibt uns nur die Option, dass ein Jenkins-Job das Repository aus dem Git auscheckt und auf seiner Shell docker-compose up aufruft.

Damit Docker-Compose auf Eurem Host wirklich die Images aus der privaten Registry pullt, darfst du natürlich nicht vergessen, den Tag localhost vor den Image-Namen im Docker-Compose-File anzuhängen. Vergisst du diesen Tag, startet Docker möglicherweise lokale Images, die du bei vorherigen Experimenten gebaut hast. Dieser Missstand fällt erst auf, wenn man sich fragt, warum die neuen Features noch nicht im Container laufen.

Jetzt sind wir soweit, dass wir sowohl den ersten als auch den zweiten Schritt unseres Planes umgesetzt haben. Nun wäre es doch schön, wenn unsere Entwickler die Docker-Images ebenfalls aus der privaten Registry pullen und mit ihnen lokal rumspielen könnten. Folgende Abbildung zeigt dir, was ich meine.

docker-local-v2.png


Der Entwickler hat bei sich lokal sein eigenes Docker-Tooling installiert und vielleicht einen Container mit einer lokalen Docker-Registry gestartet. Er möchte nun ebenfalls Docker-Images aus der privaten Registry des Hosts ziehen. Dafür braucht er lediglich den Tag
localhost im Docker-Compose-File durch die Registry-URL ersetzen.  

Doch Docker wirft eine nichtssagende Fehlermeldung auf, die auf eine fehlerhafte URL schließen lässt. Nachdem ich eine Weile verschiedene Varianten der URL ausprobierte (mit und ohne http://), fand ich endlich in einem GitHub-Issue die Lösung. Docker ist einfach nicht in der Lage URLs mit Hyphen zu verarbeiten.

$ docker pull itemis.de/repositories:docker-private/image 

Using default tag: latest

Error response from daemon: error unmarshalling content: invalid character '<' looking for beginning of value

Das Issue wurde bereits 2014 geöffnet und eigentlich in einer neueren Version behoben, aber leider trat bei uns der selbe Fehler auf. Die einzige Möglichkeit besteht darin, anders als es z. B. Sonatype empfiehlt, seine Registry nicht docker-registry zu nennen und auf Hyphens zu verzichten. Ähnliche Probleme treten auf, wenn du ein Docker-Image zu einer Registry pushen willst, die nicht auf der Wurzel des Hosts läuft. Da kann Docker zwischen Registry-URL, Tag und Image-Name nicht unterscheiden. Wenn wir z. B. ein Image private.registry.de/path/image pushen wollen, weiß Docker nicht ob path ein Präfix vom Image oder teil der URL ist. Für diesen Umstand bietet Nexus Repository Connectors. Diese erlauben, einen Port zu konfigurieren unter dem die Registry direkt angesprochen werden kann.

Ich hoffe, dass ich dir einen Einblick in die Arbeit mit Docker und Jenkins geben konnte und dir der Umgang mit diesen Werkzeugen nun ein wenig leichter fällt. Wenn du wissen möchtest, welche Gefahren bei Experimenten mit Docker im Internet auf dich lauern, empfehle ich dir den Blogpost meines Kollegen Roland.

Über den Autor

Erik ist Werkstudent bei der itemis in Leipzig und studiert Informatik-Master an der HTWK.