Ich verarbeite meine Buildchain via Gitlab CI. Neben dem eigentlichen Build der Applikation, der Frontend-Assets werden zahlreiche Tests ausgeführt, die Code-Qualität geprüft und am Ende die fertige Website deployed. Da kommen einige Jobs zusammen. Wie schön wäre es, wenn immer genügend Gitlab-Runner zur Verfügung stehen würden? Wir brauchen autoscale Gitlab-Runner. Eine Anleitung.

Gitlab-Runner: Autoscaling in der HETZNER-Cloud.

Wofür brauche ich einen Gitlab-Runner?

Jeder Job innerhalb der Gitlab CI wird von einem sogenannten Runner ausgeführt. Dieser Runner ist an und für sich nichts besonderes; er erhält vom Gitlab-Server eine Aufgabe (z.B. "Baue die Applikation", "Teste Syntax auf Gültigkeit" oder "Deploye das Ergebnis auf den Produktiv-Server") und die passenden Daten (in der Regel das Gitlab-Repository) und arbeitet sie ab - das Ergebnis wird (auf Wunsch) zursückgeschickt und steht dann nachfolgenden Jobs zur Verfügung.

Ein Gitlab-Runner hat in der Regel kein Gedächtnis, kann sich aber Dinge merken (insbesondere wichtig für das Caching). 

Nun kann man Gitlab Runner problemlos auf einem Server registrieren, um erste Gehversuche mit Gitlab CI zu machen. Sobald man dieses Tool aber ernsthaft benutzt, stellt man fest, dass Jobs zu lange warten müssen, weil kein Runner frei ist. Wir wollen die Buildchain aber so schnell wie möglich abarbeiten und warten ist tote Zeit. 

Mehr Runner!

Die erste Reaktion auf einen Mangel ist: Mehr davon! Wir brauchen Runner! Spätestens wenn diese Runner aber über mehrere Server verteilt sind - und an diesen Punkt kommt man schneller, als man denkt: Runner können sehr viel Last auf einem Server erzeugen! - gibt es ein neues Problem: Runner auf unterschiedlichen Servern können sich nicht so ohne weiteres einen Cache teilen. Das macht die Buildchain aber wieder langsamer. Ein Dilemma.

Hinzu kommt ein wirtschaftlicher Aspekt: Die wenigsten von uns werden rund um die Uhr arbeiten. Wie überall gibt es Phasen, in denen die Luft brennt und man Runner ohne Ende braucht; dann ist es aber (typischerweise nachts und am Wochenende) auch komplett ruhig und es gibt nichts zu tun. Und es gibt normale Arbeitstage, an denen deutlich weniger durch die Build-Chains läuft als sonst.

Alle Runner, die man zu Spitzenzeiten benötigt, ständig vorzuhalten, ist ökonomischer und ökologischer Unsinn. 

Chart auf einem Computer-Bildschirm

Autoscaling!

Das Ziel ist also, immer dann viele Runner zur Verfügung zu haben, wenn wir sie brauchen. Und wenn wir sie nicht brauchen, sollen diese sich abschalten. Wir brauchen Autoscaling!

Gitlab Runner sind ein ausgezeichnetes Beispiel, wo aus meiner Sicht Cloud-Computing sinnvoll ist. Die Runner selbst haben untereinander keine Abhängigkeiten; es ist einfach eine kleine Software-Armee, die auf Arbeit wartet und diese erledigt. Ob es einen, drei oder fünfhundert Runner gibt, ist für die Buildchain egal.

Die Auswahl des richtigen Cloud-Anbieters

Hier gibt es im eigentlichen Sinn kein richtig oder falsch. Grundsätzlich sind alle Anbieter von Cloud-Lösungen dafür geeignet, sofern sie eine entsprechende API zur Verfügung stellen, um Server automatisiert zu starten, konfigurieren und wieder zu löschen. 

Das ist natürlich bei den Großen der Fall: Amazon AWS, Google Cloud-Front, Microsoft Azure, Akamai, Digital Ocean usw. können das alle. Ich habe mich für das Cloud-Angebot von HETZNER entschieden, weil ich zum einen schon lange Server bei diesem Provider stehen habe und immer gute Erfahrungen mit dem Support gesammelt habe. Zum anderen ist der Serverstandort wählbar, und man kann z.B. Server in Deutschland wählen, wenn man das möchte (was ich tue).

Die Preise von HETZNER sind sehr fair: der kleinste Server ist für knapp 3,- €/Monat zu haben, wenn er ununterbrochen läuft. Da wir u.U. sehr viele Schaltzeiten haben, habe ich Wert darauf gelegt, einen Tarif zu wählen, der nur die reine Laufzeit und keine Setup-Kosten o.ä. berechnet.

Für die Gitlab-Runner habe ich die CX21-Server gewählt. Diese bieten genügend Power für die Abarbeitung der Jobs, sind mit 1 ct/Stunde aber immer noch sehr günstig. Die CX11-Server reichen zum Ausprobieren, bei größeren Jobs kam ich damit aber an die Grenze des Hauptspeichers. 

Wir brauchen einen Dirigenten

Orchester

Damit unser Orchester von Gitlab-Runnern funktioniert, benötigen wir einen Dirigenten. Dieser nimmt von Gitlab CI die Jobs entgegen und weist diese den Runnern zu. Gleichzeitig stellt er sicher, dass immer ausreichend Runner bereit stehen und schaltet Runner ab, die nicht mehr benötigt werden. 

Ich nenne diese Rolle Broker und habe den Server entsprechend benannt. Mein Broker ist ebenfalls ein Server in der HETZNER Cloud; und hier reicht bislang auch problemlos ein kleiner CX11-Server. Dieser Server läuft die ganze Zeit. 

Der Broker übernimmt bei mir noch eine weitere wichtige Rolle: er stellt Speicherplatz zur Verfügung, um Caches zwischenzuspeichern. Dies ist auch der Grund, warum ich einen Cloud-Server für den Broker benutze und diesen Dienst nicht von einem bereits bestehenden Server mit-erledigen lasse. Es ist sinnvoll, dass Broker und Gitlab-Runner netzwerktopologisch nah beiander sind, um Latenzen und Übertragungszeiten gering zu halten. Bei einer Buildchain zählt jede Sekunde und der Preis für den Broker ist sehr gut investiertes Geld.

Damit der Broker nicht aus Versehen abgeschaltet oder gar gelöscht wird, kann man diesen über die Cloud-Verwaltung schützen. 

Die Konfiguration des Brokers

Für die Konfiguration des Brokers habe ich ein Gitlab-Runner-Image von mawalu benutzt. Damit kann der Gitlab-Runner einfach in einem Docker-Container gekapselt laufen und macht wenig Probleme bei der Konfiguration. Das Projekt findet ihr hier: https://github.com/mawalu/hetzner-gitlab-runner

Die docker-compose.yml ist simpel: 

 

# docker-compose.yml
version: '2'
services:
  hetzner-runner:
    image: mawalu/hetzner-gitlab-runner:latest
    mem_limit: 128mb
    memswap_limit: 256mb
    volumes:
      - "./hetzner_config:/etc/gitlab-runner"
    restart: always

 

Mit docker-compose up -d wird der Broker gestartet. Anschliessend wird mit docker-compose run hetzner_runner register der Runner beim Gitlab-Server angemeldet und dadurch im Verzeichnis hetzner_config eine config.toml hinterlegt und die Verbindung zu Gitlab CI hergestellt. Diese config.toml werden wir gleich noch modifizieren.

Der Broker wird mit docker-compose run gestartet und ist dann aktiv. Änderungen an der config.toml wirken sich sofort aus; der Broker braucht nicht neu gestartet zu werden!

Meine config.toml sieht so aus (vertrauliche Informationen sind entfernt): 

 

[[runners]]
  name = "[HETZNER] Cloud-runner with autoscale"
  limit = 15
  url = "*************"
  token = "*************"
  executor = "docker+machine"
  environment = ["COMPOSER_CACHE_DIR=/composer-cache"]
  [runners.custom_build_dir]
  [runners.docker]
    tls_verify = false
    image = "marcwillmann/codeception"
    memory = "2048m"
    privileged = true
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/var/cache:/cache:rw", "/var/run/docker.sock:/var/run/docker.sock"]
    pull_policy = "if-not-present"
    shm_size = 536870912
  [runners.machine]
    IdleCount = 2
    IdleTime = 600
    MachineDriver = "hetzner"
    MachineName = "runner-%s"
    MachineOptions = ["hetzner-api-token=*************", "hetzner-image=ubuntu-18.04", "hetzner-server-type=cx21"]
    OffPeakPeriods = ["* * 0-8,19-23 * * mon-fri *", "* * * * * sat,sun *"]
    OffPeakTimezone = "Europe/Berlin"
    OffPeakIdleCount = 1
    OffPeakIdleTime = 600
  [runners.custom]
    run_exec = ""

 

Den HETZNER-API-Token bekommt man in der Cloud-Console (https://console.hetzner.cloud/), wenn man ein neues Projekt anlegt und dort unter Zugänge -> API-Tokens.

Mit diesem Token darf der Broker nun neue Server starten und bestehende löschen.

In der Konfiguration gibt es den IdleCount. Soviele Runner stellt unser Autoscale immer als Reserve zur Verfügung: in der Cloud-Console kann man das sehr gut beobachten. Direkt nach dem Start werden dort 2 Gitlab-Runner gestartet und warten auf Jobs. Läuft eine Gitlab-CI-Pipeline und wird einem (oder normalerweise mehreren) dieser Runner ein Job zugeteilt, erscheinen neue Server. Mit diesem Wert muss man ein wenig experimentieren. Ist er zu klein gewählt, müssen Jobs warten, bis ein neuer Runner gestartet ist (das dauert einen kleinen Moment, bis er einsatzbereit ist). Ist er zu hoch, verschenkt man Geld, weil unbenötigte Rechenpower vorgehalten wird.

Die IdleTime gibt an, wie lange ein Runner behalten wird, wenn er mit einem Job fertig ist. Kommt in der Zwischenzeit ein neuer Job, wird er wiederverwendet. Kommt kein Job, wird der Runner gelöscht.

Ich habe gute Erfahrungen mit einer IdleTime von 600 gemacht; der IdleCount steht bei mir im Produktivsystem momentan auf 5.

Weil wir alle hoffentlich einigermaßen geregelte Arbeitszeiten haben und nicht die ganze Nacht durcharbeiten, gibt es in der Konfiguration noch die OffPeak-Einstellungen. Hier können Zeiten angegeben werden, wenn die Pipeline nicht oder nur wenig gefordert wird (nach Feierabend, am Wochenende). In diesen Zeiten kann man dann einen eigenen OffPeakIdleCount (z.B. 0 oder 1) konfigurieren, um die Kosten zu reduzieren.

Übrigens: auch ein IdleCount=0 heisst nicht, dass nicht gearbeitet werden kann. Es werden nur keine Reserven vorgehalten, d.h. es dauert etwas länger, bis der Job anläuft. Für einen normalen NightlyBuild, bei dem es nicht auf Sekunden ankommt, ist das aber kein Problem.

Zeit für Verbesserungen

Damit läuft das grundsätzliche Setup schon mal. Jobs im Gitlab werden an den Broker verteilt, der dafür sorgt, dass immer genügend Runner bereit stehen und versorgt diese mit den Aufgaben. Die Zahl der Runner wird nachskaliert.

Die Runner selbst haben aber kein Gedächtnis: die Jobs selbst laufen leider momentan eher langsam. Das liegt aber nicht an fehlender Power: in der Job-Ausgabe fällt auf, dass z.B. unser Application-Build-Job kein Paket im Composer-Cache findet. Das ist auch logisch, der Server für den Runner wurde ja gerade erst gestartet und kann keinen Cache haben. Und von dem Cache, den ein anderer Runner gerade erst aufgebaut hat, weiß unser Runner nichts. Das müssen wir ändern!

Schritt 1: Platz bereitstellen

Wir bauen einen zentralen Cache auf, auf den alle Runner lesend und schreibend zugreifen dürfen. Welcher Platz dafür wäre besser geeignet als der Broker: der steht netzwerktopologisch direkt neben den Runnern, ist immer verfügbar und die Runner können ihn auch erreichen. 

Damit auf dem Broker genügend Platz verfügbar ist und dieser auch einen Delete/Rebuild des Broker-Servers überlebt, habe ich ein Volume angehängt. Das ist ein persistenter Dateispeicher, den man jederzeit problemlos vergrößern kann. Momentan nutze ich ein 25GB-Volume als gitlab-runner-cache. 

Das Volume kann über die Hetzner Console einfach angehängt werden und ist vom System aus dann unter /dev/disk/by-id/scsi-0HC_VolumeXXXX sichtbar. Dort verhält es sich wie eine normale Festplatte und kann mit dem Filesystem der Wahl (ext4 in meinem) formatiert und gemountet werden. 

Auch das ist in der Hetzner Console nachzulesen, der Vollständigkeit halber nochmals auf einen Blick:

 

sudo mkfs.ext4 -F /dev/disk/by-id/scsi-0HC_Volume_XXXXXXX
sudo mkdir /export
sudo mount -o discard,defaults /dev/disk/by-id/scsi-0HC_Volume_XXXXXXX /export
sudo echo "/dev/disk/by-id/scsi-0HC_Volume_XXXXXXX /export ext4 discard,nofail,defaults 0 0" >> /etc/fstab

 

Falls man das Volume später in der Größe ändert, muss das im Linux-System natürlich auch bekannt gemacht werden: 

 

sudo resize2fs /export/

 

 

Schritt 2: S3-Storage bereitstellen

Leider gibt es bei Hetzner aktuell noch keine S3-kompatible Storage. Aber das ist kein Problem; wir nutzen das gerade angelegte Volume und lassen den Broker diese zur Verfügung stellen. Mit minio gibt es ein schlankes Docker-Image, das einen S3-kompatiblen Dienst zur Verfügung stellt. Auch der ist schnell gestartet: 

 

docker run -it -d --restart always -p 9005:9000 -v /.minio:/root/.minio -v /export:/export --name minio minio/minio:latest server /export

 

Und mit 

 

cat /export/.minio.sys/config/config.json | grep Key

 

erhalten wir die S3-Zugangsdaten. 

Schritt 3: Multi-Runner Cache einrichten

Damit haben wir alles zusammen, um unseren Runnern einen gemeinsamen Cache zur Verfügung zu stellen. In der config.toml richten wir diesen ein: 

 

[runners.cache]
    Type = "s3"
    Shared = true
    [runners.cache.s3]
      ServerAddress = "###IP_BROKER###:9005"
      AccessKey = "***************"
      SecretKey = "************************************"
      BucketName = "runner"
      Insecure = true
    [runners.cache.gcs]

 

und freuen uns, dass künftig der Cache persistiert wird und unsere Jobs deutlich schneller laufen. Das ist insbesondere bei den Build-Jobs (composer und npm) deutlich spürbar; hier kann man teilweise die Ausführungszeit um eine Größenordnung beschleunigen!

Docker auf Speed

Wir sind aber noch nicht am Ende der Optimierung angekommen. Unsere Gitlab-Runner laufen als docker+machine-Typ, d.h. jeder Job wird an einen Docker-Container übergeben, der auf dem Runner-Server gestartet wird. Welches Image verwendet wird, geben wir in der Job-Konfiguration in der .gitlab-ci.yml an. 

Naturgemäß ist das bei einem Frontend-Build-Job ein anderer als bei einem Quality-Gate-Job und das Deployment wird wieder auf einen anderen Container setzen. 

Und wir stellen fest: das Ziehen des Docker-Images ist nun einer der zeitaufwändigsten Teile eines jeden unseres Jobs. Auch hierfür brauchen wir eine Lösung.

Docker-Images cachen

Hierfür richten wir einen Proxy auf dem Broker ein, der sich für die Runner wie ein Docker-Repository verhält. Hat der Broker das gewünschte Image im Cache, wird es direkt ausgeliefert; andernfalls vom offiziellen Repo angefordert und weitergereicht - und natürlich in den Cache gelegt. 

Hört sich kompliziert an, ist es aber nicht: 

 

sudo docker run -d -p 6005:5000 -e REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io --restart always --name registry registry:2

 

macht den Job. ¯\_(ツ)_/¯ 

In der config.toml des Brokers machen wir dieses neue Repository für die Runner bekannt: 

 

[runners.machine]
    ...
    MachineOptions = ["hetzner-api-token=**************", "hetzner-image=ubuntu-18.04", "hetzner-server-type=cx21", "engine-registry-mirror=http://###IP_BROKER###:6005"]
    ...

 

und freuen uns über deutlich schnellere Pipelines.

Kommentare (0)

Keine Kommentare gefunden!

Neuen Kommentar schreiben