Alarme, die einmal auslösen, nicht einmal pro Server
Einer unserer Betriebsalarme, „niedriges Guthaben“ für die veröffentlichte App eines Builders, soll uns höchstens einmal am Tag erreichen. An einem Nachmittag kam er fünfmal an. Die Ursache: Jede Kopie unserer API merkte sich im eigenen Arbeitsspeicher, welche Alarme sie schon verschickt hatte. Zwei Server verschickten ihn deshalb zweimal, und jeder Deploy leerte dieses Gedächtnis, sodass sie ihn erneut verschickten. Dasselbe Muster ließ einen Hintergrundjob seine Arbeit doppelt erledigen. Beide koordinieren sich jetzt über einen gemeinsamen Speicher, und beide scheitern auf der sicheren Seite, falls dieser Speicher nicht erreichbar ist.
Das Symptom
Mythex schickt Team-Alarme, wenn etwas einen Blick braucht: Ein Dienst ist ausgefallen, ein Job steht still, oder das Guthaben eines Builders wird knapp, während seine App veröffentlicht ist. Jeder Alarm hat ein Deduplizierungsfenster, also einen Zeitraum, in dem derselbe Alarm nur einmal verschickt wird. Beim Alarm für niedriges Guthaben beträgt dieses Fenster 24 Stunden.
Fünf Kopien desselben Alarms an einem Nachmittag bedeuteten, dass das Fenster nicht hielt. Schlimmer noch: Sie kamen paarweise, etwa 11 Minuten auseinander.
Warum es passiert ist
Unser Alarm-Paket hielt die Deduplizierung im Arbeitsspeicher: eine Tabelle „Alarmschlüssel → wann zuletzt verschickt“, innerhalb des laufenden Prozesses. Das funktioniert, wenn es genau einen Prozess gibt, der nie neu startet. Produktiv gibt es weder das eine noch das andere:
- Zwei API-Replikate. Produktiv laufen zwei Kopien der API. Jede hatte ihre eigene Tabelle und verschickte den Alarm deshalb einmal. Das sind die Paare.
- Deploys. Jeder Deploy ersetzt beide Replikate durch frische Prozesse mit leeren Tabellen. Nach einem Deploy ging der Alarm erneut hinaus, als wäre er nie verschickt worden.
Der Abstand von ~11 Minuten zwischen den beiden Alarmen eines Paars kam vom Job, der diesen Alarm auslöst. Er läuft auf beiden Replikaten und nimmt eine Sperre, sodass die Replikate sie abwechselnd gewinnen, und jedes löste den Alarm aus, wenn es an der Reihe war.
Im selben Code steckte ein zweiter, stiller Fehler. Damit die Tabelle nicht endlos wächst, wurde sie bereinigt, sobald sie 500 Schlüssel hielt, und dabei wurden alle Einträge gelöscht, die älter als 15 Minuten waren, unabhängig von ihrem Fenster. Unter Last konnte eine 24-Stunden-Deduplizierung also nur eine Viertelstunde lang halten.
Die Lösung
Ein gemeinsamer Eintrag pro Alarm
Bevor die API einen Alarm verschickt, beansprucht sie jetzt den Deduplizierungsschlüssel des Alarms in Redis, einem gemeinsamen In-Memory-Speicher, mit dem beide Replikate sprechen und der nicht neu startet, wenn die API neu startet. Der Anspruch ist eine einzige Operation: „Setze diesen Schlüssel nur, wenn er noch nicht existiert, und lass ihn nach dem Fenster ablaufen“. Welches Replikat zuerst beansprucht, verschickt den Alarm, das andere findet den Schlüssel belegt und schweigt. Ein neuer Prozess nach einem Deploy findet den Schlüssel noch vor.
Schlüssel und Fenster kommen aus derselben Regel, die das Alarm-Paket verwendet, und diese Regel wird jetzt von beiden geteilt, sodass sich die beiden Ebenen nicht darüber uneinig sein können, was als „derselbe Alarm“ zählt.
Im Zweifel offen, nicht geschlossen
Die wichtigste Entscheidung war, was passieren soll, wenn Redis nicht erreichbar ist. Wir haben uns dafür entschieden, den Alarm trotzdem zu verschicken. Ist Redis ausgefallen oder antwortet es nicht innerhalb von 1,5 Sekunden, geht der Alarm hinaus. Ein doppelter Alarm ist Lärm. Ein verlorener Alarm bedeutet, dass jemand nicht informiert wird.
Die ernstesten Alarme, P0, werden durch die Deduplizierung überhaupt nie zurückgehalten.
Jeder Eintrag behält sein eigenes Fenster
In der eigenen In-Memory-Deduplizierung des Alarm-Pakets speichert jeder Eintrag jetzt, wann sein Fenster endet, und die Bereinigung entfernt nur Einträge, deren Fenster abgelaufen ist. Ein 24-Stunden-Schlüssel bleibt 24 Stunden bestehen, egal wie viele andere Alarme eintreffen.
Derselbe Fehler an anderer Stelle
Dasselbe Muster hatte auch einen Hintergrundjob verdoppelt: einen Abgleichsjob, der unsere Aufzeichnung des Status jedes Arbeitsbereichs mit unserem Sandbox-Anbieter synchron hält. Jeder Durchlauf listet jede Sandbox im Konto auf, in der Produktion Tausende, etwa fünf Sekunden lang, dazu jedes laufende oder pausierte Projekt. Beide Replikate führten ihn alle zwei Minuten aus.
Seine Schreibvorgänge sind idempotent, das heißt, zweimal ausgeführt ergeben sie dasselbe Ergebnis wie einmal. Also ging nichts kaputt. Es war nur doppelte Arbeit. Jetzt wird jedes Zwei-Minuten-Fenster in Redis beansprucht, so wie unsere Nutzungszähler ihre Fenster bereits beanspruchten, und nur das Replikat, das den Anspruch gewinnt, führt den Durchlauf aus. Ist Redis nicht erreichbar, läuft der Durchlauf trotzdem: Die Arbeit doppelt zu machen ist harmlos, sie nicht zu machen nicht.
Wie wir es getestet haben
Die Tests bilden das echte Setup nach. Sie laden das Alarm-Modul zweimal, als zwei getrennte „Replikate“, richten beide auf einen gemeinsamen Speicher, lösen von jedem denselben Alarm aus, laden dann eine dritte frische Kopie als Stellvertreter für „nach einem Deploy“ und lösen ihn erneut aus. Genau ein Alarm wird verschickt. Weitere Tests prüfen, dass der Alarm eines anderen Builders weiterhin hinausgeht, dass ein P0 von beiden Replikaten verschickt wird und dass der Alarm auch dann verschickt wird, wenn der Speicher ausgefallen ist oder über die Zeitgrenze hinaus hängt. Die Korrektur der Bereinigung hat einen eigenen Test: Ein 24-Stunden-Schlüssel übersteht 600 andere Alarme, die 20 Minuten später eintreffen.
Für den Abgleichsjob wird geprüft, dass zwei Replikate, die gleichzeitig ticken, plus ein dritter Tick im selben Fenster, genau eine Auflistung erzeugen.
Was wir daraus gelernt haben
- Zustand im Arbeitsspeicher gehört einem Prozess. Alles, was über Server hinweg wahr sein muss, also „einmal verschickt“ oder „einmal ausgeführt“, muss an einem Ort liegen, den sie sich teilen.
- Deploys sind Neustarts. Zustand, der nur bis zum nächsten Neustart überlebt, übersteht keinen normalen Arbeitstag.
- Entscheide, in welche Richtung du scheiterst. Bei Alarmen sollte ein ausgefallener gemeinsamer Speicher zu mehr Alarmen führen, nicht zu weniger.
- Such das Muster, nicht nur die Instanz. Der Alarm war der laute Fall, der doppelte Abgleichsjob derselbe Fehler, der still Arbeit kostete.
Von außen ist davon in Mythex nichts zu sehen. Mehr dazu, wie wir die Plattform stabil halten, findest du in wie wir Deploys auf null 502-Fehler gebracht haben und die fehlende Swap-Datei. Wenn dir die Idee, mehr als eine Kopie eines Dienstes zu betreiben, neu ist, erklärt unser Leitfaden was ein Server ist die Grundlagen.