Zum Inhalt springen
Open Source8 Min. LesezeitVon Sebastian Gieseler

Tägliche Releases ohne verlorene Jobs: der Wartungsmodus in der CI

Wer fast täglich ausliefert, deployt fast täglich in laufende Hintergrundjobs hinein. Wie wir das Deploy-Fenster mit dem Wartungsmodus von Croniq, einem Konfigurations-Gate und run_on_register zu einem planbaren Schritt der Pipeline gemacht haben.

Ein Release pro Woche lässt sich noch nachts um drei einspielen, wenn kaum ein Job läuft. Bei einem Release pro Tag geht das nicht mehr. Die Auslieferung passiert tagsüber, und sie trifft auf eine Anwendung, die gerade synchronisiert, abgleicht und rechnet. Jeder Deploy startet Dienste neu, während Hintergrundjobs laufen oder gerade fällig werden.

Wir betreiben eine .NET-Anwendung mit vielen wiederkehrenden Jobs, die nahezu täglich ausgeliefert wird. Die Jobs laufen über Croniq, unseren quelloffenen Job-Scheduler in Rust. Dieser Artikel beschreibt, wie das Deploy-Fenster dort zu einem gewöhnlichen, planbaren Schritt der Pipeline geworden ist.

Das Deploy-Fenster ist ein eigener Betriebszustand

Während eines Deploys passieren drei Dinge, die ein Scheduler auseinanderhalten muss:

  1. Laufende Ausführungen werden vom Neustart getroffen. Wird ein Runner mitten in der Arbeit beendet, bleibt ein halb erledigter Lauf zurück, und ein späterer Retry beginnt von vorn.
  2. Fällige Ausführungen werden an einen Stack verteilt, der gerade hochfährt. Im besten Fall scheitern sie und landen im Retry, im schlechtesten treffen sie auf eine Mischung aus alter und neuer Version.
  3. Geänderte Jobs passen nicht mehr zum Zustand. Ändert ein Release, was ein Abgleich-Job tun muss, läuft bis zu dessen nächstem planmäßigen Termin noch der alte Stand.

Ein Scheduler, der den Deploy einfach ignoriert, behandelt all das als zufällige Fehler. Besser ist, ihm ausdrücklich zu sagen: Jetzt wird ausgeliefert.

Wartungsmodus: Ausgabe einfrieren, nicht den Scheduler stoppen

Croniq hat dafür einen globalen Wartungsmodus (PUT /v1/maintenance, nur für Admins). Ist er aktiv, erzeugt der Scheduler keine neue Arbeit, und Runner bekommen beim Abholen nichts ausgehändigt. Der Server selbst läuft weiter, nimmt Trigger an und beantwortet Anfragen.

In der Pipeline wird daraus ein fester Ablauf mit vier Schritten:

  1. Einschalten. Vor dem Deploy schaltet die CI die Wartung ein, mit einer Notiz, die die ausgelieferte Version und den Link zum Pipeline-Lauf enthält. Wer später ins Dashboard schaut, sieht sofort, warum gerade nichts läuft.
  2. Abwarten. Die CI fragt GET /health ab, bis keine Ausführung mehr läuft (running steht auf null), mit einer festen Obergrenze an Wartezeit. Laufende Jobs dürfen ihre Arbeit sauber beenden.
  3. Ausliefern. Erst jetzt werden die Container neu gestartet, und die Pipeline wartet auf den Health-Check der Anwendung.
  4. Ausschalten und gegenprüfen. Ist alles gesund, schaltet die CI die Wartung wieder aus und liest den Zustand anschließend noch einmal. Erst wenn Croniq bestätigt, dass die Wartung inaktiv ist, gilt der Deploy als abgeschlossen.

Wichtig ist, was mit der Arbeit während der Wartung passiert. Croniq unterscheidet hier bewusst:

  • Laufende Ausführungen laufen normal zu Ende. Ein Retry, den sie auslösen, wird eingereiht und gehalten.
  • Bereits eingereihte Arbeit, manuelle und ereignisgesteuerte Trigger bleiben in der Warteschlange und werden ausgegeben, sobald die Wartung endet.
  • Planmäßige Termine werden übersprungen. Der Zeitplan rückt weiter, es wird nichts nachgeholt.

Das Überspringen ist Absicht. Ein Wartungsfenster von mehreren Stunden darf nicht mit einer Lawine aller verpassten Läufe enden. Die Kehrseite: Ein täglicher Job um 02:45 läuft an diesem Tag nicht, wenn die Wartung über Nacht aktiv bleibt. Deshalb protokolliert Croniq jeden übersprungenen Termin und zählt ihn in einer Prometheus-Metrik pro Job. Nach dem Fenster ist klar, welche Jobs bei Bedarf von Hand angestoßen werden sollten. Bei einem Deploy von wenigen Minuten betrifft das in der Praxis meist nur hochfrequente Jobs, deren nächster Lauf ohnehin gleich kommt.

Wenn der Deploy scheitert

Schlägt ein Deploy fehl, bleibt die Wartung absichtlich aktiv. Das ist fail-closed: Solange niemand weiß, in welchem Zustand der Stack ist, sollen keine Jobs gegen ihn laufen. Damit daraus kein stiller Dauerzustand wird, gibt es drei Signale:

  • GET /health meldet die Wartung ohne Anmeldung, neben dem Status. Der Status selbst bleibt ok, damit kein Healthcheck den Server neu startet, nur weil er tut, was man ihm gesagt hat.
  • Eine Alert-Regel maintenance_active schlägt an, wenn die Wartung länger als eine festgelegte Zeit aktiv ist. Bei uns sind das 30 Minuten, gemeldet über dieselben Kanäle wie fehlgeschlagene Jobs.
  • Der Fehlerpfad der Pipeline prüft am Ende selbst, ob die Wartung noch steht, und schreibt das deutlich sichtbar in die Zusammenfassung des Laufs.

Der nächste erfolgreiche Deploy beendet die Wartung wieder, auch ein Rollback auf die vorherige Version. Wer nicht warten will, schaltet sie im Dashboard aus.

Ein Detail, das man leicht übersieht: Während der Wartung setzt Croniq die Liveness-Alerts aus, also die Regeln für ausgebliebene, verspätete und blockierte Läufe. Verspätete Termine sind während eines Deploys der Sinn der Sache und kein Fehler des Schedulers. Ohne diese Regel würde jeder etwas längere Deploy für jeden aktiven Job einen Fehlalarm auslösen.

Fehler vor dem Server: das Konfigurations-Gate

Alle Jobs, Zeitpläne und Alert-Regeln stehen im Croniqfile, einer eigenen Konfigurationssprache. Croniq behandelt unbekannte Direktiven als Fehler und nicht als Warnung. Das ist richtig, hat aber eine Folge: Nutzt das Croniqfile eine Direktive, die der ausgelieferte Server noch nicht kennt, lehnt dieser die ganze Datei ab und startet nicht. Hängt die Anwendung per Healthcheck am Scheduler, startet sie auch nicht.

Deshalb läuft in der CI ein eigenes Gate. Es startet genau das Croniq-Image, das ausgeliefert wird, und ruft darin croniq validate auf das Croniqfile auf. Zusätzlich prüft es, dass alle Stellen, an denen die Croniq-Version festgelegt ist, auf denselben Tag zeigen. Ein Fehler fällt damit im Pull-Request auf und nicht erst beim Deploy auf dem Server.

Das Loch nach dem Deploy: run_on_register

Bleibt der dritte Punkt vom Anfang: Jobs, deren Definition sich mit dem Release ändert. Ein Abgleich-Job, der täglich um 01:30 läuft, holt eine geänderte Logik erst in der nächsten Nacht nach.

Dafür gibt es die Direktive run_on_register. Ein so markierter Job läuft genau einmal, wenn Croniq seine Definition übernimmt: beim ersten Mal und immer dann, wenn sich die kompilierte Definition des Jobs ändert. Ein gewöhnlicher Neustart löst ihn nicht aus. Fällt die Übernahme in die Wartung, wird der Lauf gehalten und startet, sobald die Wartung endet, also genau dann, wenn der neue Stack gesund ist.

IaC first, aber mit Notausgang

Hinter allem steht ein Prinzip: Der Sollzustand lebt im Croniqfile, versioniert in Git und reviewt wie jeder andere Code. Im Dashboard sind Jobs aus dem Croniqfile markiert und nicht direkt editierbar, denn eine Änderung dort würde beim nächsten Neuladen stillschweigend überschrieben.

Im Betrieb braucht man trotzdem Eingriffsmöglichkeiten, die nicht auf den nächsten Deploy warten können. Croniq trennt das sauber:

  • Operative Eingriffe ändern nicht die Definition, sondern das Verhalten im Moment: einen Job manuell anstoßen oder abbrechen, einen Dead Letter erneut ausführen, die Wartung schalten.
  • Alert-Übersteuerungen schalten eine Regel befristet stumm, deaktivieren sie oder ändern ihr Drosselungsfenster. Jede verlangt eine Begründung, die im Audit-Log landet, und befristete Übersteuerungen laufen von selbst aus.
  • Übernahme ist der bewusste Ausstieg aus dem Croniqfile für einen einzelnen Job. Sie muss serverweit erlaubt sein, macht den Job über API und Dashboard editierbar und lässt sich rückgängig machen. Danach gilt wieder die Definition aus dem Croniqfile.

So bleibt das Croniqfile die verlässliche Quelle, ohne dass der Admin im Ernstfall auf einen Pull-Request warten muss.

Was bleibt

Tägliche Releases sind kein Problem des Schedulers, solange der Scheduler weiß, dass gerade ausgeliefert wird. Der Wartungsmodus macht aus dem Deploy-Fenster einen ausdrücklichen Zustand mit klaren Regeln, das Gate verschiebt Konfigurationsfehler in den Pull-Request, und run_on_register schließt die Lücke danach. Alles drei ist aus dem Produktivbetrieb entstanden und steht in Croniq für alle zur Verfügung.

Anrufen