Wie alles begann

Wer den ersten Post zu dem Thema bereits gelesen hat, der weiß von den zwei grundlegenden Problemen, die ich noch habe. Zum einen das Docker-Image zu bauen, zum anderen das frische Image auf meinen Server zu ziehen, ohne Sicherheitslücken - oder besser Sicherheitscanyons - zu bauen. Ich hab mir da paar Gedanken gemacht und tatsächlich was gebastelt bekommen.

Problem 1: Docker-Image bauen

Um ein Docker-Image zu bauen muss Docker laufen. In dem standard GitLab-CI geht das aber nicht. Da gab es nun zwei Lösungsansätze. Erstens gibt es wohl die Möglichkeit, Docker-Images mit bazel zu bauen. Dafür bräuchte man dann kein laufenden Docker-Daemon, muss sich aber damit beschäftigen, wie Bazel funktioniert. Darauf hatte ich keine Lust und bin eher den langen Weg gegangen.

Man kann den Docker Hub mit GitHub verbinden und automatische Builds einrichten. Wenn also in das ausgewählte Repository auf GitHub Änderungen reinkommen, dann wird dadurch bei Docker das Docker-Image gebaut. Also baue ich das Projekt erstmal auf GitLab, die daraus generierten Dateien werden an GitHub geschickt, was dann den Docker-Build anstößt. Minimal umständlich, aber läuft!

Problem 2: Docker-Container neu starten

Das große Problem ist nun auf meinem Server das neue Image zu laden und die Container, die das alte Image benutzt haben, neu zu starten. Jetzt muss aber erstmal irgendwie mein Server mitbekommen, dass das Docker-Image neu gebaut ist, weil das läuft ja außerhalb des GitLab Prozesses.

Docker an Server: Übernehmen Sie!

Glücklicherweise kann man für Docker Builds auch WebHooks registrieren. Dann wird an den Hook nen POST geschickt mit ganz vielen Informationen zum Build. Damit kann man theoretisch schonmal was anfangen. Mir selber groß was schreiben, was auf diesen WebHook reagiert, wollte ich nicht, weil das wäre wieder sehr viel gefummel. Ich wollte, dass der WebHook bei GitLab einen Job triggert, der dann auf dem Server das nötige tut, um das neue Image zu laden. Und bei GitLab kann man auch WebHooks dafür anlegen, eine Pipeline zu starten (Also eine Reihe an einzelnen Jobs). Weil ich nicht wollte, dass mein normaler Build wieder durchläuft musste ich bei jedem Job in der Pipeline ein except: triggers anhängen, damit sie eben nicht durch den WebHook ausgelöst werden und einen Job, der eben das Ziel des WebHooks war, hat dann entsprechend only: triggers angefügt bekommen.

Aber so einfach war das noch nicht. Docker hat Daten an den WebHook geschickt, mit denen GitLab nichts anfangen könnte. Also musste ich erstmal dafür sorgen, dass sich die beiden verstehen. Dafür hab ich ein ganz kleines PHP Script gebastelt, das ich als Ziel für den Docker-WebHook eingetragen habe, welches dann einfach den GitLab WebHook aufruft. Man könnte noch paar Informationen zum Docker-Build mitgeben, aber ich wollte erstmal eine einfache, laufende Version:

$curl = curl_init("https://gitlab.com/api/v4/projects/PROJECT_ID/ref/master/trigger/pipeline?token=WEBHOOK_TOKEN");
curl_setopt($curl, CURLOPT_POST, true);
$response = curl_exec($curl);
curl_close($curl);
echo $response;

Nun lief es bereits soweit, dass ich meine Änderungen an GitLab gepusht habe, dort meine Anwendung gebaut wurde, diese Anwendung + Dockerfile in ein GitHub Repository gepusht wurde, was dann den Docker-Build ausgelöst hat, der meine Anwendung zu einem Docker-Image gebündelt hat, was sich anschließend bei meinem Server gemeldet hat, damit dieser sich bei GitLab meldet, damit GitLab einen Job startet, der sich wieder bei meinem Server meldet - aber sicherer - damit mein Server das neue Docker-Image lädt. Man könnte es eine “Verkettung von Ereignissen” nennen.

Einmal neues Image zum Mitnehmen, bitte!

Wie bereits im letzten Post zum Thema geschrieben: Docker ist doof! Um ein Docker-Image zu aktualisieren und die betroffenen Container neu zu starten, muss man komplett Zugriff auf Docker haben. Man kann Rechte nicht nur für einen Container geben, oder irgendwie einschränken, was ein User machen darf. Man kann keinen Trigger ausführen, der vorgefertigte, sichere Scripts ausführt. Das alles muss man sich irgendwie selber basteln.

Ich bin einfach mal wer anderes

Meine erste Idee, die mir da kam: SetGID. In der Theorie kann man damit ein Programm als eine bestimmte Gruppe ausführen, in der man selber nicht ist, also eben nen Script geschrieben, was genau meine Funktionalität hat, dem die Docker-Group gegeben und zum ausführen als Group gesetzt.

$ ll updateImage.sh
-rwxr-sr-x  1 root   docker   526 Apr 20 20:34 updateImage.sh*

Doch irgendwie wollte das nicht, ich hab von den docker Befehlen dennoch ein “Permission Denied” bekommen. Also auch noch das SetUID Flag gesetzt, womit das script als root ausgeführt wird.

$ ll updateImage.sh
-rwsr-sr-x  1 root   docker   526 Apr 20 20:34 updateImage.sh*

Unangenehm aber wenns geht verkraftbar, muss man halt noch mehr auf Sicherheit im Script achten. Doch das Resultat änderte sich nicht. Was zur Hölle?

Nun, nach kurzer Recherche fand ich heraus, dass SGID und SUID bei Scripten ignoriert wird. Bei einem Bash-Script ist iben /bin/bash das ausführende Programm und nicht das Script selber. Daher müsste bei dem Scriptinterpreter das SGID und SUID Flag gesetzt werden: Nein!

Eine andere Möglichkeit wäre ein kleines Programm in z.B. C zu schreiben, dass das Script ausführt und dem man das SGID Flag gibt. Da hab ich aber irgendwie gerade keine Lust drauf, nachdem der erste Versuch mit SGID nicht funktioniert hat. Aber ich hab schon was anderes gefunden: docker-puller.

Dann lausch ich halt doch

Und das macht genau das, was ich oben abgelehnt habe: Einen WebHook, um Scripte auszuführen. Die kleine Besonderheit: Im Gegensatz zu meinen normalen Webserver kann ich diesen kleinen Server auf meiner Backend-VM laufen lassen, wo auch der Docker-Container läuft, für den ich mir überhaupt diese Mühe mache, und ich kann ihn nur intern laufen lassen, dass man nicht von außen triggern kann - wobei das eigentlich auch kein Problem ist, ob man nun von außen den WebHook triggern kann, der bei GitLab den Job triggert, der dann … oder ob man das Dockerupdate direkt triggert. Und natürlich sind die WebHooks zusätzlich nach außen mit einem Token abgesichert, sodass nicht jeder triggern kann.

Ich hab mir dazu dann nen neues Docker-Image gebaut, weil irgendwie gab es dazu noch nichts.

FROM python:3-alpine
ENV REPOSITORY=https://github.com/glowdigitalmedia/docker-puller.git
RUN apk update && apk add openssh git curl docker bash
RUN git clone $REPOSITORY
WORKDIR /docker-puller/dockerpuller/
RUN rm scripts/*
RUN pip install --no-cache-dir -r /docker-puller/requirements.txt
VOLUME /docker-puller/dockerpuller/scripts/
EXPOSE 8000
ENTRYPOINT ["sh", "-c"]
CMD ["exec python app.py"]

Jetzt hat man schonmal nen WebHook, der ausgeführt werden kann, mit dem man nen Haufen an Scripts triggern könnte. Die Scripte und würde ich bei Docker als read only einbinden (-v /docker/docker-puller/scripts:/docker-puller/dockerpuller/scripts:ro -v /docker/docker-puller/config.json:/docker-puller/dockerpuller/config.json:ro), weil es gibt keinen vernünftigen Grund, warum man innerhalb des Containers diese Sachen schreiben können sollte. Wenn man nun noch den Docker-Socket einbindet (-v /var/run/docker.sock:/var/run/docker.sock) kann man innerhalb dieses Containers auf Docker zugreifen. Wie bereits im anderen Post geschrieben, ist das ein komplettes “ich scheiß auf Sicherheit”. Daher finde ich dieses verhältnismäßig kleine Script gut. Da ist kein großer Schnick-Schnack mit Klim-Bimm, um tolle Funktionen zu erlauben, aber es unübersichtlich macht. Es ist simpel und überschaubar - eine gute Grundlage wenn es um Sicherheit geht. Ich habe auch Watchtower gefunden, jedoch ist es eine ganze Ecke komplexer und da will ich dem Ding keine ausschweifende Kontrolle über meinen Server geben.

Nun fehlt nur noch eins: Das Script. Zuerst dachte ich, dass es recht simpel geht, indem man die laufenden Container zu dem Image sucht, das neue Image lädt und die Container neu startet:

# Holt sich die ID der Container
CONTAINER=$(docker ps -a | grep " $IMAGE " | egrep -o "[a-zA-Z_-]+$")
docker pull $IMAGE
docker restart $CONTAINER

Aber nicht mit Docker. Wo kämen wir denn da hin? Es macht natürlich auch gewissermaßen Sinn, weil Docker mit übereinander gestapelten Dateisystemen arbeitet und dabei natürlich nicht sichergestellt ist, dass die neue Version zur alten passt. Leider gibt es keine Möglichkeit das einfach mal zu versuchen oder zu erzwingen, wäre ja auch zu praktisch an dieser Stelle. Ich muss also quasi den Container löschen und automatisch mit der gleichen Konfiguration neu erstellen. Und letztendlich ist das auch gar kein Problem, ich muss einfach nur in meinem Script den Container ganz entfernen und neu anlegen und es luppt!

#!/bin/bash
docker pull nozomibk/anime_api
docker stop anime-api
docker rm anime-api
docker run -p 2080:8080 --name anime-api -v /docker/anime_api/data:/usr/verticles/data -v /docker/anime_api/config.json:/usr/verticles/config.json:ro -d nozomibk/anime_api

Anfangs hatte ich befürchtet, dass die Mounts an dieser Stelle aus dem ausführenden Dockercontainer genommen werden, aber nein, es nimmt glücklicherweise (und gleichzeitig ungülcklicherweise, bezüglich Sicherheit) die Dateien und Ordner des Host Systems. Jetzt hab ich zwar nur ein Script für meinen Container und ich müsste für alle anderen Docker-Images das Script nochmal anlegen, der Vorteil ist aber: Ohne Variablen kann so schnell kein Unfug getrieben werden. Wenn man Variablen nutzen würde, müsste man genau gucken, dass da nur das passieren kann, was wirklich gewollt ist und nicht irgendwer da Schadcode einfügen kann.

Endlich hab ich es geschafft, dass die ganze Kette durchläuft und ich nur meine Änderungen pushen muss, damit paar Minuten später alles auf meinem Server erscheint. Ein wenig umständlich und es gibt sicher noch andere Wege, die ich nicht gefunden habe, Watchtower beispielsweise ist mir auch sehr spät erst über den Weg gelaufen.