Dieser Artikel stammt aus unserem Blog, der nicht mehr betreut wird. Für Neuigkeiten zu oose und interessante Inhalte zu unseren Themen, folgt uns gerne auf LinkedIn.
Jenkins in Docker und mit Docker und für Docker
Blog offline
Um Software in Form von Docker Images bereitzustellen braucht es ein Buildsystem und eine Infrastruktur, die die Images im Rahmen einer Continuous Integration erzeugt und ggf. in einer Docker Registry ablegt. Für Open-Source-Projekte gibt es Angebote in der Cloud, von Travis-CI bis zum Build auf Dockerhub selbst. Für Inhouse Entwicklung heißt die Infrastruktur für den Build oft und aus gutem Grund Jenkins. Um einen Jenkins - egal ob für das ganze Unternehmen, das Team oder lokal zum Testen - zu installieren, gibt es nun viele Wege.
Eine Installation von Betriebsystemspaketen oder über ZIP Archive ist eine Möglichkeit, lässt aber noch viele Dinge offen (z.B. die Java Version) und ist u.U. betriebsystemabhängig. Naheliegend - und außerdem total im Trend - ist das Starten von Jenkins selbst als Docker Container. Jenkins in einem Container zu starten ist einfach. Docker in einem Container auszuführen ist zwar möglich aber egal auf welche Art und Weise leider mit etwas Konfiguration verbunden.
[caption id="attachment_10689" align="alignleft" width="188"] Jenkins im Container greift über Volumes auf das Docker Executable und den Docker Socket des Hosts zu.[/caption]
Inspiriert durch z.B. diese Artikel [1], [2], [3], [4] habe ich ein spezielles Docker Image (source) für einen Jenkins gebaut, der als Docker Container läuft und seinerseits auch Docker ausführen kann, indem auf die Docker Installation des Hosts zugegriffen wird. Eine Installation von Docker in das Jenkins Image und die Ausführung als "privileged" Container wäre eine andere Möglichkeit, die in [3] und [4] diskutiert wird. Die Ausführung von Docker als eigenen Prozess in privilegierten Containern ist aber insbesondere im Zusammenhang mit Linux Security Modules und "ineinander geschachtelten" Dateisystemen nicht unproblematisch und deshalb auch nicht per se einfacher oder besser.
Wer das Image einfach nur benutzen möchte, kann sich mit der Kommandozeile
docker run -p 8080:8080 -p 50000:50000 -v /var/run/docker.sock:/var/run/docker.sock -v /usr/bin/docker:/usr/bin/docker -v /var/lib/jenkins_home:/var/jenkins_home -e "DOCKER_GID_ON_HOST=$(cat /etc/group | grep docker: | cut -d: -f3)" oose/dockerjenkins
schnell mal eben einen Jenkins starten. Als Voraussetzung braucht es eine Docker Installation auf dem Host bzw. eine konfigurierte Verbindung zu einem entfernten Docker Host. Wird das Kommando nicht direkt auf dem (Linux) Docker Host ausgeführt sondern z.B. unter Windows mit einer remote Verbindung zu Docker, dann muss der Aufruf $(cat /etc/group | grep docker: | cut -d: -f3)
durch die Group ID der Gruppe docker
auf dem Docker Host ersetzt werden. Nach der Installation der Docker Toolbox unter Windows ist das z.B. die Group ID 100
.
Ein Skript, um die Group ID dynamisch über ein ssh Kommando vom Host zu ermitteln, überlasse ich der Vorstellung des geneigten Lesers.
Als weitere Voraussetzung muss auf dem Host das Verzeichnis /var/lib/jenkins_home
erstellt werden bzw. existieren. Hier legt der Jenkins seine Konfiguration ab bzw. liest sie wieder ein. Für Tests oder beim Starten auf entfernten Docker Hosts kann dieses Volume auch einfach weggelassen bzw. durch einen Volume Container ersetzt werden.
Auf localhost:8080 ist dann eine Jenkins Instanz erreichbar, die Docker Images bauen und pushen kann.
Wie funktioniert es?
Wer wissen möchte, wie es funktioniert und warum die Kommandozeile so kompliziert ist, liest hier weiter.
Zunächst werfen wir einen Blick ins Dockerfile.
Als Basis wird das Standard Jenkins Image verwendet.
FROM jenkins:1.625.1
Diese Basis wird an vier Stellen ergänzt bzw. modifiziert.
1. Zunächst wird als Benutzer root ein Oracle Java 8 und fakeroot installiert. fakeroot wird während des Builds meiner javafx Projekte benötigt.
USER root
# Java 8 and fakeroot for javafx builds
RUN echo "deb http://ppa.launchpad.net/webupd8team/java/ubuntu trusty main" | tee /etc/apt/sources.list.d/webupd8team-java.list && \
echo "deb-src http://ppa.launchpad.net/webupd8team/java/ubuntu trusty main" | tee -a /etc/apt/sources.list.d/webupd8team-java.list && \
apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys EEA14886 && \
apt-get update && \
echo oracle-java8-installer shared/accepted-oracle-license-v1-1 select true | /usr/bin/debconf-set-selections && \
apt-get --no-install-recommends -y install oracle-java8-installer && \
apt-get --no-install-recommends -y install fakeroot && \
rm -rf /var/lib/apt/lists/*
2. Anschließend wird sudo installiert und dem jenkins
Benutzer erlaubt, ohne Passwort als beliebiger Benutzer alles auszuführen.
# Let Jenkins be sudoer
RUN apt-get update && \
apt-get --no-install-recommends -y install sudo && \
echo "jenkins ALL = (ALL) NOPASSWD: ALL" >> /etc/sudoers && \
rm -rf /var/lib/apt/lists/*
Das ist notwendig, weil der Jenkins Prozess im Container auf das Docker Binary und den Docker Socket des Hosts, auf dem der Container gestartet wird, zugreifen muss, und deshalb mit der "richtigen" Benutzer- bzw. Gruppenkennung ausgeführt werden muss!
3. Wieder als jenkins
Benutzer wird die Anzahl der Buildprozessoren über die executors.groovy Datei konfguriert und die Installation von Plugins über die plugins.txt, in der einfach die Plugins mit ihrer Version aufgelistet werden, veranlasst.
USER jenkins
COPY executors.groovy /usr/share/jenkins/ref/init.groovy.d/executors.groovy
COPY plugins.txt /usr/share/jenkins/plugins.txt
RUN /usr/local/bin/plugins.sh /usr/share/jenkins/plugins.txt
4. Die spezielleste Anpassung kommt ganz unscheinbar und ganz zum Schluss
ENV DOCKER_GID_ON_HOST ""
COPY jenkins.sh /usr/local/bin/jenkins.sh
Die Umgebungsvariable DOCKER_GID_ON_HOST wird erstmal auf einen leeren String gesetzt. Beim Starten des Containers muss diese Umgebungsvariable auf die Group ID gesetzt werden, die das Docker Binary ausführen darf und auf den Docker Socket zugreifen darf. Im Allgemeinen also die ID der Gruppe docker
auf dem Host (der Rechner, auf dem der Docker daemon läuft).
Es gibt zwischen Docker Host und Docker Container kein Mapping von Benutzern und Gruppen! Ein Zugriff aus einem Container auf ein Volume findet mit der User ID und Group ID statt, die der Prozess im Container hat, unabhängig davon, welchem Benutzernamen und welchem Gruppennamen das auf Container und Host jeweils entspricht. So kann es z.B. sein, dass ein Prozess im Container als Benutzer "jenkins" mit der Gruppe "jenkins" läuft, was im Container der User ID 1400 und der Group ID 1000 entspricht, und die Dateien in einem vom Host gemounteten Volume dann auch mit diesen IDs(!) angelegt werden, was dann aber z.B. dem Benutzer "klaus" und der Gruppe "modem" auf dem Host entspricht.
Da aus dem Container auf Docker Binary und Socket zugegriffen werden soll, muss der Container die ID der Gruppe docker
auf dem Host kennen und unter dieser Group ID laufen. Da der Name der Gruppe zum Zeitpunkt des Image Builds bekannt ist, nicht aber die ID, wird die ID erst beim Starten des Container Entrypoints ausgewertet.
Dazu wird der Entrypoint des Images, das Skript jenkins.sh
, gegen eine angepasste Version ausgetauscht. Dieses angepasste Skript wertet die Umgebungsvariable DOCKER_GID_ON_HOST aus. Wenn die Variable auf einen Wert gesetzt wird, dann stellt das Skript sicher, dass der Jenkins Prozess mit der Group ID in der Umgebungsvariable ausgeführt wird und so auf das Docker Binary und den Docker Socket zugreifen kann.
Dieses Snippet in der jenkins.sh:
# When a DOCKER_GID_ON_HOST is supplied, run jenkins with this
# group id - to access the docker socket shared via volume
dockerGroupName=""
if [ -n "$DOCKER_GID_ON_HOST" ]; then
echo "Create group for gid $DOCKER_GID_ON_HOST"
sudo groupadd -g $DOCKER_GID_ON_HOST docker && sudo grpconv
sudo usermod -a -G $DOCKER_GID_ON_HOST jenkins
dockerGroupName=$(cat /etc/group | grep :$DOCKER_GID_ON_HOST: | cut -d: -f1)
fi;
Legt im Container eine Gruppe für die übergebene ID an und macht diese in der selben Session mit "grpconv" verfügbar, fügt den Benutzer jenkins
zu der Gruppe hinzu und ermittelt schließlich den Namen der Gruppe mit der übergebenen ID. Das kann die Gruppe docker
sein, wenn noch keine Gruppe mit der ID existierte, oder auch irgendeine anderer Gruppenname, wenn diese bereits mit der übergebenen ID exsitierte. Schließlich führt
exec sg $dockerGroupName "$cmdLine"
Jenkins in Kontext der "richtigen" Gruppe aus. Wichtig ist dabei das "exec" damit der aktuelle Prozess (das Entrypoint Skript) durch den Jenkins Prozess ersetzt wird, damit Signale vom Terminal (z.B ein Str+C) an den Jenkiddens Prozess weitergeleitet werden.
Und jetzt...
Die "total einfache" Kommandozeile
docker run -p 8080:8080 -p 50000:50000 -v /var/run/docker.sock:/var/run/docker.sock -v /usr/bin/docker:/usr/bin/docker -v /var/lib/jenkins_home:/var/jenkins_home -e "DOCKER_GID_ON_HOST=$(cat /etc/group | grep docker: | cut -d: -f3)" oose/dockerjenkins
bindet die beiden Netzwerkport 8080 und 50000 des Hosts an die selben Ports des Containers, so dass auch von außen auf den Jenkins zugegriffen werden kann, so als ob der Jenkins wirklich "nativ" installiert wäre. Der Docker Socket /var/run/docker.sock
und die ausführbare Datei /usr/bin/docker
werden über Volumes dem Container zur Verfügung gestellt. Wenn in dem Jenkins Container ein Build Job docker
aufruft wird also sowohl das Binary als auch der Docker Daemon auf dem Host verwendet und keine "eigene" Instanz davon.
Das dritte Volume dient Jenkins als "home", in dem die Konfiguration und die Jobs etc. abgelegt werden. Der letzte, kryptische Ausdruck in der Zeile vor dem Namen des Images setzt für den Container die Umgebungsvariable DOCKER_GID_ON_HOST auf den mit cat, grep und cut ermittelten Wert für die ID der Gruppe docker
auf dem Host.
Da mir das zu kompliziert zu merken ist und da wir auch noch keine Konfiguration im Jenkins haben, brauche ich jetzt ein Puppet Modul, das mir das automatisiert. Dieses Modul beschreibe ich dann im nächsten Blogpost.