Fünf Minuten, in denen meine CI auf tote Maschinen geplant hat
Meine CI-Runner laufen auf Spot-Instanzen. Jedes Mal, wenn AWS eine davon zurückgeholt hat, scheiterten danach Dutzende Jobs innerhalb weniger Sekunden. GitLab hat sie als Systemfehler verbucht und neu gestartet, und die Wiederholungen liefen in dieselbe Falle. Dahinter steckte eine Lücke von fünf Minuten, die sich mit keiner Einstellung am Runner schließen ließ.
TL;DR
TL;DR: Der docker-autoscaler von GitLab erfährt über das Fleeting-Plugin, welche Worker es gibt, und zwar aus der Instanzliste der Auto Scaling Group. Holt AWS eine Spot-Instanz zurück, führt die Gruppe sie noch rund fünf Minuten als InService, bis ihr eigener Health Check den Ausfall bemerkt. In dieser Zeit verteilt der Runner neue Jobs auf eine Maschine, die es nicht mehr gibt, und jeder scheitert mit instance no longer running. Ein kürzeres update_interval hilft nicht, es liest nur dieselbe veraltete Liste öfter. Geholfen hat eine kleine Lambda, die die Instanz schon beim Unterbrechungshinweis aus der Gruppe löst. Seitdem ist kein Job mehr auf diese Weise gescheitert.
Der Aufbau
Meine GitLab-Runner arbeiten mit dem Executor docker-autoscaler. Ein kleiner Manager nimmt die Jobs an und verteilt sie auf Worker. Die Worker sind Spot-Instanzen in mehreren Auto Scaling Groups, eine pro Pool. Dazwischen sitzt das Fleeting-Plugin für AWS: Es skaliert die Gruppen und liest regelmäßig, welche Instanzen gerade darin laufen. Wie oft, bestimmt update_interval.
Spot ist für CI günstig, dafür kann AWS eine Instanz jederzeit zurückholen. Etwa zwei Minuten vorher kommt ein Hinweis, die Spot Instance Interruption Warning. Ein Job, der in diesem Moment läuft, ist verloren, damit hatte ich gerechnet. Nicht gerechnet hatte ich damit, dass danach auch Jobs scheitern, die noch gar nicht angefangen hatten.
Was passiert ist
Im Journal des Managers standen Fehler wie dieser, oft Dutzende hintereinander:
ERROR: Preparation failed: creating docker connection: creating docker tunnel:
preparing environment: getting instance connect info: … instance no longer running
GitLab meldet das als runner_system_failure und startet den Job bis zu zweimal neu. Die Wiederholung kommt aber nur Sekunden später und landet deshalb oft auf derselben toten Instanz. Bis die Pipeline rot war, hatte ein Job dann drei Fehlversuche hinter sich. Gezählt nach Jobs statt nach Fehlerzeilen, sah das an drei aufeinanderfolgenden Tagen so aus:
| Tag | gescheiterte Jobs |
|---|---|
| erster Tag | 163 |
| zweiter Tag | 726 |
| dritter Tag, bis zum Fix | 409 |
Eine dieser Unterbrechungen habe ich Minute für Minute verfolgt. Drei Worker bekamen um 12:59 den Hinweis, um 13:01 waren sie beendet. Erst um 13:06 nahm die Auto Scaling Group sie aus ihrer Liste. Für das Plugin waren sie also sieben Minuten lang ganz normale Worker, fünf davon schon tot.
Warum die naheliegenden Fixes nicht helfen
Der erste Gedanke war update_interval. Der Standard liegt bei einer Minute, einer meiner Pools lief aber schon lange mit fünf Sekunden. Genau dieser Pool hat am schlimmsten Tag trotzdem 196 Jobs verloren. Das Plugin fragt die Gruppe zwar öfter, bekommt aber jedes Mal dieselbe Antwort. Solange die Gruppe die Instanz als InService führt, gilt sie als gesunder Worker.
Danach lag ein Update des Plugins nahe. Version 1.3.0 räumt Instanzen schneller ab, sobald sie aus der Gruppe verschwunden sind. Das hilft aber erst nach dem Health Check und nicht in den Minuten davor. Eine eigene Spot-Prüfung hat das Plugin durchaus: Vor jeder Vergabe fragt sie, ob die Instanz noch läuft und ob ein Unterbrechungshinweis vorliegt. gitlab-runner ruft sie aber nur auf, wenn das Feature-Flag FF_USE_FLEETING_ACQUIRE_HEARTBEATS gesetzt ist, und das ist standardmäßig aus. Gefunden habe ich das erst beim Schreiben dieses Beitrags, im Quelltext.
On-Demand-Instanzen hätten das Problem gelöst, kamen wegen der Kosten aber nicht in Frage. Die Lücke liegt also nicht im Runner, sondern zwischen dem Hinweis von EC2 und dem Health Check der Auto Scaling Group.
Der Fix: die Instanz beim Hinweis aus der Gruppe lösen
EventBridge liefert die Spot Instance Interruption Warning als Ereignis, ohne Aufpreis. Eine Regel leitet sie an eine Lambda weiter. Die prüft, ob die Instanz zu einer meiner Runner-Gruppen gehört und dort InService ist, und löst sie dann aus der Gruppe:
row = client.describe_auto_scaling_instances(
InstanceIds=[instance_id])["AutoScalingInstances"][0]
if row["AutoScalingGroupName"] in runner_groups \
and row["LifecycleState"] == "InService":
client.detach_instances(
InstanceIds=[instance_id],
AutoScalingGroupName=row["AutoScalingGroupName"],
ShouldDecrementDesiredCapacity=False,
)
Zwei Details machen die Lösung aus. ShouldDecrementDesiredCapacity=False lässt die Sollgröße der Gruppe unverändert, sodass sie sofort eine Ersatzinstanz startet und nicht erst Minuten später. Und die Prüfung auf InService lässt alle Instanzen in Ruhe, die das Plugin gerade selbst abbaut, denn die stehen schon auf Terminating.
Scheitert das Lösen, etwa weil die Gruppe schneller war, setzt die Lambda die Instanz stattdessen auf Unhealthy. Auch das nimmt sie aus der Liste. Eine zweite Regel reagiert, wenn eine Instanz in den Zustand shutting-down oder terminated wechselt, und fängt so die Fälle ab, in denen kein Hinweis kam.
Die Regeln selbst sind kurz:
{ "source": ["aws.ec2"],
"detail-type": ["EC2 Spot Instance Interruption Warning"] }
{ "source": ["aws.ec2"],
"detail-type": ["EC2 Instance State-change Notification"],
"detail": { "state": ["shutting-down", "terminated"] } }
Die Rolle der Lambda darf DescribeAutoScalingInstances auf allen Ressourcen aufrufen, weil sich diese Aktion nicht einschränken lässt. DetachInstances und SetInstanceHealth darf sie nur auf den Runner-Gruppen. Dazu kommt update_interval = "5s" auf allen Pools, damit das Plugin die gelöste Instanz schon wenige Sekunden später nicht mehr sieht.
Stolpersteine bei der Umstellung
gitlab-runner hat das neue update_interval beim Neuladen der config.toml zwar gelesen, den Autoscaler aber nicht neu aufgesetzt. Gewirkt hat erst ein Neustart des Dienstes. Dafür habe ich SIGQUIT geschickt statt eines normalen Restarts, so beendet der Runner laufende Jobs, statt sie abzubrechen. Meine systemd-Unit wartet danach zwei Minuten bis zum Neustart, und so lange nahm die CI keine neuen Jobs an.
Die Regel für den Zustandswechsel ist laut. Sie feuert für jede Instanz im Account, auch für jeden Worker, den das Plugin ganz regulär abbaut. Bei mir sind das einige tausend Aufrufe am Tag, fast alle mit dem Ergebnis skipped:Terminating. Das liegt weit im kostenlosen Kontingent, im Log sollte man es aber erwarten.
Außerdem trifft eine Unterbrechung nicht nur den Job, der gerade läuft. Bei mir ist so auch ein Release-Job auf einer toten Instanz gescheitert. Der nächste Schritt, der die abhängigen Projekte über die neue Version informiert, wurde deshalb übersprungen. Die Pipeline war rot, aber dass ein Release nur halb fertig war, fiel niemandem auf.
Das Ergebnis
In den ersten 17 Stunden nach der Umstellung hat die Lambda 267 Worker beim Unterbrechungshinweis aus ihren Gruppen gelöst. Im selben Zeitraum ist kein einziger Job mehr mit instance no longer running gescheitert. Am Tag davor waren es im gleichen Zeitfenster 415.
Die Kosten sind vernachlässigbar: AWS-Ereignisse auf dem Standard-Bus von EventBridge kosten nichts, die Aufrufe der Lambda liegen im kostenlosen Kontingent, und das Log behalte ich 14 Tage. Jobs, die gerade laufen, wenn AWS die Instanz zurückholt, gehen weiterhin verloren. Gerettet ist alles, was danach noch auf die Instanz verteilt worden wäre.
Empfehlungen
- Zähl nach Jobs, nicht nach Fehlerzeilen. Ein Job erzeugt bis zu drei Versuche und jeder Versuch mehrere Fehlerzeilen. Erst die Zahl der Jobs zeigt, wie viel Arbeit wirklich verloren geht.
- Misstrau automatischen Wiederholungen bei Spot. Wiederholungen helfen gegen unabhängige Fehler. Eine veraltete Instanzliste macht die Fehler abhängig, und alle Versuche laufen in dieselbe Lücke.
- Schau auf die Quelle der Instanzliste. Wenn ein Scheduler aus einer Auto Scaling Group liest, ist deren Health Check der langsamste Teil der Kette. Ein schnelleres Polling ändert daran nichts.
- Nutz den Hinweis, den AWS ohnehin schickt. Zwei Minuten Vorlauf reichen, um eine Instanz aus der Gruppe zu nehmen, bevor sie verschwindet.
- Prüf Folgejobs nach Systemfehlern. Ein übersprungener Schritt nach einem gescheiterten Release fällt sonst erst auf, wenn jemand die neue Version vermisst.
Fazit
Spot-Unterbrechungen sind bei CI eingepreist. Teuer war bei mir nicht die Unterbrechung selbst, sondern die fünf Minuten danach, in denen alles Neue auf eine Maschine ging, die es nicht mehr gab. Die Lösung besteht aus etwa hundert Zeilen Python und zwei Regeln. Wenn deine Runner auf Spot laufen und du ab und zu Jobs siehst, die nach wenigen Sekunden mit einem Systemfehler enden, lohnt sich ein Blick auf genau diese Lücke.
Ich schaue mir an, wo deine CI Zeit und Geld verliert.
Runner-Flotten auf Spot, Autoscaling, Systemfehler, die als Wiederholungen versanden, und Pipelines, die halb fertig stehen bleiben.
Über den Autor

Kai Ole Hartwig
Programmiert seit 2002 – autodidaktisch gelernt, 2012 mit KO-Web selbständig gemacht. Über 100 Projekte, Fokus auf Security, Performance, Automatisierung und Qualität. Heute freiberuflich: DevSecOps-Beratung, Schulungen und Softwareentwicklung.