1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

Teil 5 von 6

Pipeline: Wer führt meinen Code eigentlich aus?

Eine Testreihe fällt nachts um elf durch, obwohl am Code nichts falsch ist. Die Suche nach dem Grund führt zu der Frage, wer meinen Code eigentlich ausführt.

29. Oktober 2026 · 9 Min. Lesezeit

gitlabdockerproxmoxdebianopentofuansible

Vom Push bis zum RunnerEin git push startet die Pipeline: lint, plan, apply von Hand und hinter einer Sperre, dann Ansible. Die Stufen leuchten nacheinander auf, und Runner 1 holt sich jeden Job über das Tag homelab ab. Der nächtliche Testlauf, als Mond gezeichnet, springt zu Runner 2 mit dem Tag paper-nightly.GitLab.com$ git pushlintplanapplyansiblevon Hand, mit SperreRunner 1homelab · dockerRunner 2paper-nightlyhomelabhomelabhomelabhomelabNightly23:00paper-nightlyRunner holen sichihre Jobs selbst ab:kein offener Port

Es ist kurz nach 23 Uhr, und eine Testsuite fällt durch, eine Sammlung automatischer Tests für eines meiner Softwareprojekte. Kein roter Assert, kein Fehler im Code, nur ein Timeout. Der Test hatte schlicht länger gebraucht, als er durfte.

Bei so etwas schaut man zuerst in den Code. Dort lag die Ursache aber nicht. Sie lag eine Ebene tiefer, bei der Frage, wer diesen Code eigentlich ausführt und mit wem er sich dabei den Rechner teilt.

Die Frage passt gut an diese Stelle der Serie. In Teil 4 habe ich gezeigt, wie aus einer kurzen Textdatei eine fertige VM wird, und dabei so getan, als säße ich selbst an der Tastatur. Meistens tippe ich die Befehle aber gar nicht selbst. Das erledigt eine Pipeline, und um die geht es in diesem Teil. Danach kommen wir zu dem Timeout zurück.

Die Serienkarte mit den vier Teilen des Homelabs; im Bereich Betrieb ist die Pipeline mit den GitLab-Runnern hervorgehoben.
Du bist hier: Pipeline

Ein Fließband für Code

Mein Code liegt auf GitLab, einer Plattform für Repos, also für Ordner, die jede Änderung festhalten. Jedes Mal, wenn ich eine Änderung hochlade, einen Push, startet GitLab eine Pipeline. Das ist ein Fließband mit mehreren Stationen, den Jobs. Ein Job prüft vielleicht die Schreibweise, ein anderer führt Tests aus. Fällt einer durch, steht das Band.

GitLab selbst verteilt nur die Arbeit. Ausgeführt wird sie von einem Runner, einem Programm auf irgendeinem Rechner, das bei GitLab nach Arbeit fragt, wie ein Kurierfahrer, der sich in der Zentrale seine nächste Tour abholt. GitLab.com stellt dafür eigene Runner bereit, die Shared Runner, die sich viele Projekte teilen. Für kleine Projekte reichen die völlig. Ich hatte trotzdem zwei Gründe für eigene.

Warum eigene Runner?

Der erste Grund ist mein Heimnetz. Die Jobs des homelab-Repos, in dem der Code aus Teil 4 liegt, sprechen mit Proxmox und per SSH mit den VMs. Ein Shared Runner irgendwo im Internet kommt da nicht hin, und ich will ihn da auch nicht hinlassen.

Der zweite Grund kam am 14. Oktober. Mein Softwareprojekt paper-reader hatte sein Kontingent an Freiminuten auf den Shared Runnern aufgebraucht, und jede Pipeline brach mit ci_quota_exceeded ab. Das Projekt übernimmt eine Änderung erst dann in den Hauptstand, einen Merge, wenn ihre Pipeline grün ist, also alle Jobs bestanden haben. Ohne Pipeline stand die Arbeit deshalb still. Ich habe meinen eigenen Runner für das Projekt freigeschaltet und die Shared Runner abgeschaltet, komplett über GitLabs Programmierschnittstelle, die API.

Nebeneinander gestellt sieht der Unterschied so aus:

Shared Runner von GitLabEigener Runner
Wo läuft der Job?in GitLabs Cloudin einer VM auf meinem Server
Kommt er ins Heimnetz?neinja
GrenzeKontingent an Freiminutendie Hardware, die ohnehin läuft
Wer pflegt ihn?GitLabich

Und noch etwas passt zum Bild aus Teil 2. Weil der Runner selbst per HTTPS bei GitLab nach Arbeit fragt, muss GitLab sich nie zu mir verbinden. Auch für die Pipeline bleibt am Router also jeder Port zu.

Zwei Runner auf einem Server

Bei mir sind es zwei Runner, beide Debian-VMs auf meinem Server pve1. Jeder Job startet dort in einem frischen Docker-Container, der verschlossenen Kiste aus Teil 4, und hinterlässt nichts für den nächsten. Den ersten Runner gab es schon vor dem Umzug ins neue Homelab. Den zweiten habe ich am 16. September dazugebaut, mit demselben OpenTofu-Modul wie jede andere VM, weil sich die Pipelines hinter einem einzigen Runner stauten.

Neben dem homelab-Repo nutzen drei Softwareprojekte von mir die Runner: paper-reader, e-learning und telefix. Wer welchen Job bekommt, regeln zwei Stellschrauben. Ein Tag ist ein Etikett, wie die Adresse auf einem Paket. Ein Job, der das Tag homelab verlangt, landet nur auf einem Runner, der dieses Etikett trägt. Jobs ganz ohne Tag nimmt jeder Runner, der ungetaggte Jobs erlaubt, und das tun bei mir beide. Die zweite Stellschraube heißt concurrent und legt fest, wie viele Jobs ein Runner gleichzeitig ausführt.

Das nächste Bild zeigt links die Projekte, rechts die beiden Runner, und an jedem Pfeil das Tag, über das ein Job seinen Runner findet.

Projekte auf GitLab.com links, zwei Runner-VMs auf pve1 rechts. homelab und paper-reader gehen über die Tags homelab und docker zu Runner 1, e-learning und telefix ohne Tag zu beiden Runnern, die Nightly von paper-reader über das Tag paper-nightly zu Runner 2.
Welcher Job auf welchem Runner landet. Die Beschriftung der Pfeile ist das Tag, das der Job verlangt.

So sind die beiden heute eingestellt. vCPU steht für virtuelle Prozessorkerne, also den Anteil am echten Prozessor, den die VM bekommt.

Runner 1 (geteilt)Runner 2
CPU / RAM8 vCPU / 16 GB4 vCPU / 6 GB
concurrent21
Tagshomelab, docker, elearningelearning-dedicated, paper-nightly
Projektehomelab, e-learning, telefix, paper-readere-learning, telefix, Nightly von paper-reader

Hinter fast jeder Zeile steckt eine Korrektur. Runner 1 hatte nach dem Umzug nur 8 GB Arbeitsspeicher, bis ein Testjob, der die Testabdeckung misst, 57 Minuten hing. Seitdem hat er 16 GB, und ein kleines Skript schickt mir eine Nachricht aufs Handy, sobald bei einem Runner weniger als 15 % Speicher frei sind. Im Oktober liefen dann auf einmal drei Jobs gleichzeitig, weil in der Konfiguration concurrent = 3 stand, ohne dass ein Skript das je geschrieben hätte.

Seitdem lese ich concurrent = 2 nicht mehr als doppelten Runner, sondern als halbierten. Jeder Job teilt sich Prozessor, Speicher und Platte mit einem Nachbarn, den er nicht kennt. Merk dir diesen Nachbarn, er kommt gleich wieder.

Vom Push zur VM

Ihre wichtigste Aufgabe haben die Runner allerdings im eigenen Haus. Alle Jobs des homelab-Repos tragen das Tag homelab und landen damit auf Runner 1. Ihre Pipeline führt genau die Schritte aus Teil 4 aus, Plan und Apply mit OpenTofu, danach Ansible. Das Bild zeigt sie von oben nach unten.

Die Pipeline von oben nach unten: lint bei jedem Push, dann plan und apply in einer gemeinsamen Resource Group. Plan liest den Tofu-State in S3, apply schreibt ihn, legt VMs über die Proxmox-API an und reicht das Inventar an ansible-base weiter, das die Debian-VMs per SSH einrichtet.
Die Pipeline des homelab-Repos. Apply und Ansible starte ich von Hand; plan und apply teilen sich eine Resource Group.

Ganz oben steht lint, eine Art Rechtschreibprüfung für Code, die bei jedem Push läuft. Danach schreibt plan seinen Plan, den Kostenvoranschlag aus Teil 4, und speichert ihn als Datei. Apply startet nie von selbst. Zwischen den beiden sitze ich und lese den Plan, und steht dort bei einer VM „must be replaced“, drücke ich den Knopf nicht. Drücke ich ihn, wendet apply genau die gespeicherte Datei an, die ich gelesen habe, ohne neu zu planen. Was zwischen Lesen und Klicken passiert, ändert am Ergebnis nichts mehr.

Bleibt die Frage, was passiert, wenn zwei Pipelines gleichzeitig am State arbeiten wollen, dem Inventarbuch von OpenTofu. Dafür teilen sich plan und apply eine Resource Group. Das ist eine Sperre wie der Schlüssel zum einzigen Bad im Haus: Wer ihn hat, ist drin, alle anderen warten. GitLab lässt so nie zwei dieser Jobs gleichzeitig laufen.

Passwörter liegen als geschützte Variablen in GitLab, und gitleaks sucht bei jedem Push nach versehentlich gespeicherten. Am 8. September ist auf diesem Weg die erste Dienst-VM komplett entstanden. Die VM stand nach 30 Sekunden, Ansible war nach etwa zwei Minuten durch.

Am Ende von Teil 4 habe ich den Nachmittag dieses Tages offengelassen, und der hat viel mit genau dieser Pipeline zu tun.

Aus der Praxis: Am Nachmittag des 8. September sollte ein Apply nur eine Einstellung des Proxmox-Providers ändern, des Bausteins, über den OpenTofu mit Proxmox spricht. Der Provider startet VMs bei Änderungen an ihrer Konfiguration standardmäßig neu, und so startete er drei VMs neu, darunter den Runner, auf dem der Job gerade lief. Der Job starb mit seinem Runner, zwei VMs blieben aus. Seitdem steht in jeder VM-Definition reboot_after_update = false, also: nach einer Änderung nicht neu starten. Eine Pipeline, die Infrastruktur verwaltet, gehört eben selbst dazu, und was sie mit „allen VMs“ tut, tut sie auch mit sich.

Der zweite Fehler aus dieser Zeit war leiser.

Aus der Praxis: Ein früher Ansible-Job lief grün, und nichts war passiert. Die Kopie des Repos im Runner ist für jeden beschreibbar, und in so einem Verzeichnis ignoriert Ansible aus Sicherheitsgründen seine Einstellungsdatei ansible.cfg. Damit wusste es auch nicht mehr, wo das Inventar liegt. Heraus kam ein Playbook gegen null VMs, ohne jede Fehlermeldung. Heute sagt der Job Ansible ausdrücklich, wo die Einstellungen liegen, und bricht ab, wenn das Inventar leer ist.

Zurück in die Nacht

So weit Runner und Pipeline. Mit Tags und concurrent im Kopf ist der Timeout vom Anfang schnell erzählt.

paper-reader hat eine Nightly, eine Pipeline, die jeden Abend um 23 Uhr von selbst startet. Sie fährt einen einzigen Job mit zwei eigenschaftsbasierten Tests. Solche Tests denken sich selbst zufällige Eingaben aus und prüfen, ob eine Regel immer gilt, hier je 50.000 Mal. Am 28. Oktober lief auf Runner 1 gleichzeitig die Pipeline nach einem Merge, und die Nightly bekam den zweiten Platz. Da war er, der Nachbar. Beide teilten sich die 8 vCPU, der Test brauchte bis zu 62 % länger als sonst und riss seine feste Grenze von 120 Sekunden.

Der naheliegende Reflex wäre, Runner 1 mehr vCPU zu geben. Das hätte wenig gebracht. pve1 hat einen Laptop-Prozessor mit 16 Threads, also 16 Rechenspuren, die sich alle VMs teilen, und alle VMs zusammen haben rund 50 vCPU zugewiesen. Ein größerer Runner hätte vor allem dem Nachbarjob mehr Platz gemacht. Ich habe stattdessen an zwei anderen Stellen gedreht.

Zuerst an der Zeitgrenze. Bei einem Merge Request, einem Änderungsvorschlag, der erst noch in den Hauptstand soll, läuft der Test mit 500 Fällen, nachts mit 50.000, und eine feste Grenze passt nicht zu beidem. Gemessen sind etwa 1,8 ms pro Fall. Die Grenze liegt jetzt bei 60 Sekunden plus 3 ms je Fall, nachts also bei 210 Sekunden und im Merge Request bei gut 60.

Dann am Runner. Runner 2 hat concurrent = 1, ein Job dort ist allein auf seiner VM. Er bekam das Tag paper-nightly, und die Nightly verlangt es. Dabei lauert eine Falle, denn Runner 2 nimmt auch Jobs ohne Tag an. Hätte ich nur die Nightly getaggt, wären ab sofort auch alle anderen paper-reader-Jobs auf Runner 2 gelandet. Also bekam jeder Job ein Tag. In der Pipeline-Datei des Projekts sind das zwei Stellen:

default:
  tags: [docker]          # trägt nur Runner 1

frontend:property-nightly:
  tags: [paper-nightly]   # trägt nur Runner 2

Der erste Lauf auf Runner 2 war grün. Der Test brauchte dort aber 119 Sekunden, eine Sekunde unter der alten Grenze, obwohl auf seiner VM kein anderer Job lief. Auf Runner 1 lief zur selben Zeit die Pipeline nach dem Merge genau dieser Änderung. Zum Vergleich: Auf meinem Arbeitsrechner läuft derselbe Test in 24 Sekunden.

Das letzte Bild fasst die Nacht zusammen. Der große Rahmen ist der Server mit seinen 16 Threads. Oben teilt sich die Nightly vom 28. Oktober Runner 1 mit ihrem Nachbarn, unten läuft sie seit dem Umzug allein auf Runner 2.

pve1 mit 16 Threads für alle VMs. In Runner 1 teilen sich eine Main-Pipeline und die Nightly vom 28. Oktober die 8 vCPU, die Nightly reißt ihre Grenze von 120 Sekunden. Über das Tag paper-nightly zieht sie auf Runner 2, läuft dort allein in 119 Sekunden, mit einer Grenze von jetzt 210 Sekunden.
Die eigene VM nimmt der Nightly den Nachbarjob weg, aber nicht die geteilten Threads darunter.

Diese 119 Sekunden haben mich mehr beschäftigt als der Timeout. Eine eigene VM trennt Programme, Speichergrenzen und Einstellungen. Die Hardware darunter trennt sie nicht. Deshalb braucht es beide Änderungen zusammen. Der eigene Runner nimmt den Nachbarjob weg, und die mitwachsende Grenze schluckt das Rauschen der übrigen VMs.

Eine eigene VM ist keine eigene Hardware

Die Nightly startet weiter jeden Abend um 23 Uhr, jetzt allein auf ihrem Runner und mit 210 Sekunden Luft. Ehrlich gesagt hat mir diese Nacht mehr über mein Homelab beigebracht als über den Test. Eine Zeitgrenze in einem Test sagt genauso viel über die Maschine wie über den Code, und wer viele VMs auf einen Server packt, teilt am Ende doch dieselben Threads.

Die Pipeline baut und ändert meine VMs inzwischen zuverlässig. Aber was, wenn trotzdem eine verloren geht? Oder ein Dienst nachts still ausfällt, ohne dass es jemand merkt? Darum geht es im letzten Teil, Teil 6: wie mein Homelab eine schlechte Nacht übersteht, und wer mich weckt, wenn es das allein nicht schafft.