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.

Screenshot der Startseite meiner Traefik-Instanz

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:

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.