Making of: die Technik hinter We Call It 42
Wie versprochen möchte ich als ersten richtigen Artikel ein bisschen was über die Technik hinter We Call It 42 erzählen. Ich werde mal versuchen, das so zu formulieren, dass es einerseits auch für interessierte Laien verständlich, andererseits aber auch für jemanden, der das nachbauen will, ausführlich genug ist. Entsprechend werde ich auch ein paar Sachen verlinken, die für jemanden, der in der Materie drin ist, längst bekannt sein sollten.
Ein kurzer Rückblick
Die letzte Version von We Call It 42, die von ca. 2010 bis 2013 in Betrieb war (und von der es leider kein vollständiges Abbild in der Way Back Machine gibt), war für damalige Verhältnisse eine relativ normale Bloganwendung mit einer Datenbank und einem Web Interface zum Schreiben der Posts. Weil ich damals PHP gehasst habe und ziemlich auf Ruby stand (und das übrigens auch immer noch tue), habe ich nicht wie alle anderen Wordpress genommen, sondern mir mit Ruby on Rails selbst was geschrieben. Mehr Features als Wordpress hatte ich damit zwar nicht, aber dafür hab ich ordentlich was gelernt.
Der Nachteil an Rails war, dass es für das bisschen, was ich gebraucht habe, recht viel Overhead, sowohl was den Entwicklungsaufwand angeht, als auch die Einrichtung auf dem Server, als auch CPU und RAM. Vor allem war jedes Update, das aus mehr bestand, als einen Beitrag zu schreiben, ein aufwändiger mehrschrittiger Prozess.
Tschüss Rails, hallo Jekyll
Für ein paar andere Seiten, die auf meinem Server laufen - zum Beispiel entARTete PiXel und Falk Gasch Autoservice habe ich in den letzten Jahren immer wieder mit Jekyll experimentiert. Jekyll ist ein Static Site Generator, der aus HTML-Templates und Markdown-Dateien statische HTML-Seiten generiert. Entsprechend muss auf dem Server außer einem einfachen Webserver wie Apache oder NGINX keine besondere Software laufen und die Seitenzugriffe sind super schnell. Das bedeutet zwar auch, dass die Seiten keine dynamischen Inhalte haben können, aber wie letztes Mal schon beschrieben, will ich ja eh keine Kommentarfunktion mehr, damit ich mich nicht Spammern rumschlagen muss.
Im einfachsten Fall besteht das ausrollen einer neuen Seite dann nur daraus, lokal jekyll build
aufzurufen und die entstandenen Dateien über scp
oder rsync
auf den Server zu kopieren. Da ich den Quelltext ohnehin in Git verwalte, um von überall aus darauf zugreifen zu können, wollte ich aber am liebsten einen Prozess haben, der die Änderungen automatisch veröffentlicht, sobald ich sie in meine GitLab-Instanz pushe. Das erspart mir einen Arbeitsschritt und verhindert, dass ich beim Hochladen einen Fehler mache.
Ab in den Container mit dir
Auch wenn es für den konkreten Anwendungsfall nicht zwingend notwendig ist, wollte ich eine Lösung finden, die die automatische Veröffentlichung über Docker-Container löst. Das bedeutet zwar wieder etwas Overhead, aber einerseits ist das hier auch ein Experiment, um später auch andere Docker-Container (zum Beispiel die BMT-Anmeldeseite) automatisch zu veröffentlichen und andererseits habe ich bereits eine sehr angenehme Infrastruktur, um Docker-Container über HTTP(S) erreichbar zu machen.
Mein früheres Setup sah so aus, dass jede eigenständige Webanwendung und jeder Docker-Container an einen Port beginnend mit 8000 gebunden wurde und mein Apache dann über mod_rewrite und mod_proxy alle Anfragen an die entsprechenden Ports weitergeleitet wurden.
<VirtualHost *:80>
ServerName wecallit42.de
ServerAlias www.wecallit42.de
ProxyPreserveHost On
<Location />
ProxyPassReverse http://127.0.0.1:8003
ProxyPassReverse http://wecallit42.de/
ProxyPassReverse http://www.wecallit42.de/
RewriteEngine on
RewriteRule .* http://127.0.0.1:8003%{REQUEST_URI} [P,QSA]
</Location>
</VirtualHost>
Das bedeutet aber leider, dass ich mir merken muss, welche Ports vergeben werden und dass ich für jeden VirtualHost eine recht umfangreiche Konfiguration anlegen muss. Wenn ich HTTPS will, kommt noch mehr Konfiguration dazu und ich muss mich von Hand darum kümmern, die passenden Zertifikate zu organisieren.
Für genau diesen Einsatzzweck habe ich Anfang des Jahres Traefik entdeckt. Das ist ein minimaler HTTP-Server, der nichts weiter macht, als eingehende Anfragen gemäß ihren Domains auf die entsprechenden Backends verteilt. Für HTTPS werden automatisch Zertifikate von Let’s Encrypt abgeholt und sobald sie ablaufen auch erneuert.
Traefik kann man ganz klassisch über eine recht schlanke Textdatei konfigurieren. Seine wahre Stärke spielt es aber aus, wenn die Backends Docker-Container sind. Diese werden automatisch erkannt und über eine Hand voll Container-Labels konfiguriert:
# Der Port der Anwendung innerhalb des Containers. Eine Portfreigabe nach außen
# wird nicht gebraucht.
traefik.port = 80
# Die Domains, unter denen die Seite erreichbar sein soll. Man kann auch
# komplexere Regeln mit Unterordnern angeben.
traefik.frontend.rule = Host:www.wecallit42.de,wecallit42.de
# Falls der Container an mehrere virtuelle Netzwerke angebunden ist, muss
# angegeben werden, über welches davon, Traefik ihn erreichen kann
traefik.docker.network = traefiknet
Alles andere passiert automatisch (vorausgesetzt natürlich, die DNS-Einträge für die Domains stimmen).
Mach das mal automatisch
Damit haben wir eigentlich schon die wichtigsten Teilschritte zusammen:
jekyll build
ausführen- Das Buildergebnis in ein Docker-Image mit einem Webserver verpacken
- Den Docker-Container starten
Um das Ganze automatisch auszführen, verwende ich GitLab CI. Damit kann ich automatisch Skripte ausführen, sobald neue Commits in GitLab gepusht werden. Praktischerweise kann jeder Schritt in einem getrennten Docker-Container ausgeführt werden, so dass ich immer genau die Software vorinstalliert habe, die ich gerade brauche.
build:
image: "jekyll/jekyll:3.8.3"
stage: build
script:
- jekyll build
artifacts:
paths:
- _site/
expire_in: 1 week
Im ersten Schritt wird einfach nur jekyll build
ausgeführt und das Ergebnis zur Verwendung in späteren Schritten archiviert. Damit mir die Festplatte des Servers nicht zu schnell voll läuft, werden die archivierten Dateien nur für eine Woche aufbewahrt.
upload:
image: docker:stable
stage: upload
script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $REGISTRY_HOST
- docker pull $CONTAINER_IMAGE:latest || true
- docker build --cache-from $CONTAINER_IMAGE:latest --tag $CONTAINER_IMAGE:$CI_BUILD_REF --tag $CONTAINER_IMAGE:latest .
- docker push $CONTAINER_IMAGE:$CI_BUILD_REF
- docker push $CONTAINER_IMAGE:latest
Der zweite Schritt baut das Docker-Image und lädt das Ergebnis in die in GitLab integrierte Docker Container Registry (quasi ein Verzeichnis für fertige Docker-Images, die sich andere Rechner dann runterladen können um sie für ihre Container zu verwenden). Technisch gesehen wäre das Hochladen wahrscheinlich nicht nötig, aber so habe ich alle Images auf einen Blick. Das Dockerfile
besteht aus ganzen zwei Zeilen:
FROM httpd:2.4-alpine
COPY _site /usr/local/apache2/htdocs
Statt Apache könnte ich sicher auch noch einen schlankeren Webserver verwenden, aber ich binde für We Call It 42 tatsächlich noch ein paar Unterordner vom Hostsystem ein, die sich auf .htaccess
-Dateien als Konfiguration verlassen.
deploy_production:
image: docker:stable
stage: deploy
script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $REGISTRY_HOST
- docker service update --image $CONTAINER_IMAGE:$CI_BUILD_REF wecallit42
environment:
name: production
url: https://www.wecallit42.de
only:
- master
Zum Ausliefern verwende ich Docker Swarm Mode. So kann ich einen Dienst im Voraus einmal konfigurieren und dann mit docker service update
einzelne Parameter aktualisieren. Mit “normalen” Docker-Containern, die nicht in Services organisiert sind, müsste ich jedes Mal den alten Container entfernen, einen neuen anlegen und dabei sämtliche Parameter neu angeben. Die Konfiguration für den Service sieht ungefähr so aus:
docker service create --name wecallit42 \
--network traefik-net \
--label traefik.docker.network=traefik-net \
--label traefik.port=80 \
--label traefik.frontend.rule="Host:www.wecallit42.de,wecallit42.de" \
dfyx/wecallit42:latest
Als wichtiges Detail muss der GitLab CI-Runner noch so konfiguriert werden, dass die Container, in denen die Jobs laufen, Zugriff auf den Docker-Dienst des Hosts haben:
concurrent = 1
check_interval = 0
[[runners]]
name = "Helios42 Docker"
url = "<meinegitlaburl>/ci"
token = "<meintoken>"
executor = "docker"
environment = [ "DOCKER_DRIVER=aufs" ]
[runners.docker]
tls_verify = false
image = "ubuntu"
privileged = true
disable_cache = false
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
[runners.cache]
Insecure = false
Wenn man das alles zusammensetzt, erscheinen alle Änderungen, die gepusht werden, nach wenigen Minuten ohne weitere manuelle Schritte automatisch auf wecallit42.de. Wenn ich bestimmte Sachen wie die Abhängigkeiten von Jekyll zwischenspeichern würde, könnte ich den Prozess wahrscheinlich nochmal deutlich beschleunigen.
Bonusfeature: Review Apps
Wer aufgepasst hat, hat vielleicht gesehen, dass der deploy_production
-Job nur für den master
-Branch ausgeführt wird. Dadurch kann ich auf anderen Branches schonmal halbfertige Änderungen pushen, ohne dass sie öffentlich sichtbar werden. Damit ich den Stand dieser Branches auch anderen Leuten zum Korrekturlesen (oder einfach so) zeigen kann, habe ich noch einen weiteren Job, der GitLabs Review Apps verwendet, um pro Branch wahlweise einen neuen Service anzulegen oder einen bestehenden zu aktualisieren.
deploy_review:
image: docker:stable
stage: deploy
script:
- chmod +x deploy_review.sh
- ./deploy_review.sh
environment:
name: review/$CI_COMMIT_REF_NAME
url: https://$CI_ENVIRONMENT_SLUG.wecallit42.swarm.helios42.de
on_stop: stop_review
only:
- branches
except:
- master
stop_review:
image: docker:stable
stage: deploy
variables:
GIT_STRATEGY: none
script:
- docker service remove wecallit42-review-$CI_ENVIRONMENT_SLUG
when: manual
environment:
name: review/$CI_COMMIT_REF_NAME
action: stop
only:
- branches
except:
- master
Jedes Mal, wenn ein neuer Commit auf einem Branch gepusht wird, wird deploy_review
ausgeführt, wobei die Variable $CI_ENVIRONMENT_SLUG
automatisch von GitLab aus dem Branchnamen generiert wird. Das Skript deploy_review.sh
tut nichts weiter, als zu prüfen, ob es schon einen entsprechenden Service gibt und dann entweder einen neuen anzulegen oder den bestehenden zu aktualisieren:
#!/bin/sh
SERVICE_NAME=wecallit42-review-$CI_ENVIRONMENT_SLUG
docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $REGISTRY_HOST
if (docker service ps $SERVICE_NAME) > /dev/null 2>&1; then
echo "Updating existing service $SERVICE_NAME"
docker service update --image $CONTAINER_IMAGE:$CI_BUILD_REF $SERVICE_NAME
else
echo "Creating new service"
docker service create --name $SERVICE_NAME \
--network traefik-net \
--label traefik.docker.network=traefik-net \
--label traefik.port=80 \
--label traefik.frontend.rule="Host:$CI_ENVIRONMENT_SLUG.wecallit42.swarm.helios42.de" \
$CONTAINER_IMAGE:$CI_BUILD_REF
fi
Wenn der Branch gelöscht wird, wird stop_review
ausgeführt und der Service restlos gelöscht. Auf die Art verbleiben keine unnötigen Überreste von alten Experimenten mehr.