Ein Benutzer steht vor dem Login-Fenster meines Ticketsystems und bekommt Login failed. Das Konto ist im Verzeichnis vorhanden, die Gruppe stimmt, das Passwort stimmt. Im Ticketsystem existiert der Benutzer nicht — nicht gelöscht, nicht deaktiviert, sondern nie angekommen. Der dokumentierte Stand an diesem Punkt: 17 von 19 LDAP-Benutzern. Zwei fehlten, und der Import hatte dabei keinen einzigen Fehler geworfen.
Das Problem
Mein Ticketsystem (Zammad 7.1.3) zieht seine Benutzer automatisch per LDAP-Import aus dem Verzeichnis. Das ist kein aufregender Vorgang: Ein Scheduler startet regelmäßig einen Import-Job, der neue Konten anlegt und bestehende aktualisiert. Läuft das durch, tauchen neue Mitarbeiter nach spätestens einem Intervall im System auf.
Diesmal nicht. Der betroffene Benutzer konnte sich nicht anmelden, weil sein Konto im Ticketsystem fehlte. Der erste Reflex bei Login failed ist Passwort, Tastaturlayout, gesperrtes Konto. Alles daneben. Die Spur führte nicht zur Anmeldung, sondern zum Import — genauer: zu der Frage, ob der überhaupt noch etwas tut.
Der Import ist ein Hintergrundjob ohne Publikum. Läuft er, interessiert das niemanden. Läuft er nicht, gibt es keinen lauten Fehler, weil der Scheduler den Job gar nicht erst einplant. Es entsteht kein fehlgeschlagener Lauf, den man zählen könnte — nur ein weiterer Takt, in dem nichts passiert. Von außen sieht das aus wie ein System, an dem gerade nichts zu tun ist.
Die Zahl 17 von 19 war der entscheidende Hinweis. Ein einzelner Sonderfall hätte viele Ursachen haben können. Zwei fehlende Konten bei einer erwarteten Gesamtzahl von 19 deuteten auf einen systematischen Aussetzer: Der Import lief nicht, und zwar schon eine Weile.
Die Diagnose
Auf der Verzeichnisseite war alles unauffällig. Das Konto existierte, war nicht gesperrt und gehörte zur richtigen Gruppe. Der Fehler musste auf der Zammad-Seite liegen — oder genauer in der Tabelle, die die Import-Jobs verwaltet.
Der naheliegende Blick gilt den unfertigen Jobs:
SELECT id, dry_run, finished_at, result
FROM import_jobs
WHERE name = 'Import::Ldap' AND finished_at IS NULL;
Das Ergebnis waren mehrere Einträge, die seit ihrem Start nie abgeschlossen wurden. Kein laufender Import, kein Timeout im Log, kein Stacktrace — nur Jobs ohne finished_at. Für sich genommen sieht das nach einem hängenden Prozess aus. Entscheidend war aber nicht, dass sie hingen, sondern was sie dabei verhinderten.
Die Ursache
Zammad verhindert, dass zwei LDAP-Importe gleichzeitig laufen. Dafür gibt es einen Mutex, und der ist erstaunlich einfach gebaut: Vor dem Start prüft der Import, ob bereits ein Job mit demselben Namen existiert, der noch nicht abgeschlossen ist.
SELECT count(*) FROM import_jobs
WHERE name = 'Import::Ldap' AND finished_at IS NULL;
Ist der Zähler größer als null, wird der neue Import nicht eingeplant. Das ist für den Normalfall sinnvoll: kein paralleles Schreiben, keine doppelten Läufe.
Der Haken in Zammad 7.1.3: Die Prüfung unterscheidet nicht zwischen einem echten Import und einem Dry-Run. Ein Testimport, der nur prüfen soll, was passieren würde, hält denselben Mutex. Bleibt so ein Testjob hängen — etwa weil er nie sauber beendet wurde —, blockiert er damit jeden regulären Import. Dauerhaft. Und leise.
Genau das war passiert: Hängengebliebene Dry-Run-Jobs aus der Testphase hielten den Mutex, der stündliche Import wurde nie mehr eingeplant, und die beiden neu aufgenommenen Verzeichnisbenutzer wurden nie angelegt. Kein Alarm, kein Fehlereintrag, keine Mail. Der Scheduler selbst lief weiter und meldete fröhlich seine Ticks — er hatte nur nichts mehr zu tun, weil die Warteschlange den Job ablehnte.
Eine Einschränkung, die ich ausdrücklich stehen lasse: Die Ursachenschilderung nennt zwei alte blockierende Jobs, die Reparatur entfernte am Ende drei Jobs. Die vollständige Zuordnung, welcher Job auf welcher Instanz genau was blockierte, ist nicht aufgeschlüsselt. Ich schleife die Zahlen nicht passend, nur weil zwei und drei nebeneinander unsauber aussehen. Für den Mechanismus ist es unerheblich; für die Beweislage gehört es benannt.
Der Fix
Die Reparatur war unspektakulär: die hängenden Jobs nach Sichtprüfung entfernen und den Import neu anstoßen. Danach standen 19 LDAP-Benutzer im System — die beiden fehlenden waren angekommen, der dokumentierte Stand stimmte wieder. Verifiziert wurde das nicht über den Scheduler, der wieder grün meldete, sondern über die Benutzerzahl und die beiden konkret fehlenden Konten.
Wichtig war die Reihenfolge und die Zurückhaltung. Erst prüfen, welche Jobs wirklich hängen und warum, dann entfernen. Kein pauschales Aufräumen aller Import::Ldap-Einträge, kein Neustart des Stacks auf Verdacht. Die hängenden Jobs waren der einzige Beweis für den Mechanismus; wer sie blind löscht, entfernt die Spur mit dem Problem.
Deshalb repariert der Wächter, den ich danach gebaut habe, bewusst nichts. Kein automatisches Löschen, kein automatischer Import. Ein Auto-Delete würde die Beweisspur vernichten und könnte mit einem gerade startenden Job kollidieren — ein Race, das man sich in einem Ticketsystem nicht freiwillig einbaut.
Der Wächter
Der Wächter ist ein Shell-Script, das alle 15 Minuten per Cron auf srv-rebellion läuft, read-only gegen die Datenbank arbeitet und eine State-Datei für Zabbix schreibt. Die Auswertung übernimmt Zabbix auf srv-yavin.
*/15 * * * * /bin/bash /pfad/zammad_ldap_sync_guard.sh <umgebung>
Er prüft drei Dinge, die zusammen den Mechanismus abdecken:
- Unfertige Jobs:
name='Import::Ldap' AND finished_at IS NULL. Alles über null bedeutet: Der Mutex ist besetzt, der Import ist blockiert. - Letzter erfolgreicher Import: das Alter des jüngsten Jobs mit
dry_run=falseundresult LIKE '%failed: 0%'. Schwelle: 150 Minuten, also mehr als zwei erwartete Intervalle. Lang genug, um einen einzelnen ausgefallenen Lauf nicht sofort zu alarmieren; kurz genug, um einen stillen Ausfall nicht zu übersehen. Ein Dry-Run zählt hier ausdrücklich nicht als Erfolg. - Scheduler-Alter: das Alter des letzten Laufs von
ImportJob.start_registered, Schwelle 90 Minuten.
Die dritte Prüfung allein hätte den Ausfall übrigens nicht gefunden. Der Scheduler lief ja. Genau deshalb steht sie nur als Zusatzinfo neben dem erfolgreichen Arbeitsergebnis — nicht als Ersatz dafür.
Die State-Datei hat ein festes Schema:
run_epoch=<epoch>
guard_ok=1|0
unfinished=<anzahl>
last_ok_min=<minuten|999999>
sched_age_min=<minuten|999999>
ldap_users=<anzahl>
reason=<text>
Drei Details daran sind wichtiger als sie aussehen.
Fail-closed statt gesunder Nullen. Schlägt die Datenbankabfrage fehl, schreibt der Wächter guard_ok=0 und Sentinel-Werte (999999) statt null. Sonst würde ein kaputter Wächter melden: keine hängenden Jobs, alles gut. Ein Monitoring, das bei eigenem Ausfall Entwarnung gibt, ist schlimmer als keins.
Atomares Ersetzen. Die State-Datei wird in eine temporäre Datei geschrieben und dann per mv ersetzt. Zabbix liest damit nie eine halb geschriebene Datei mit fehlenden Feldern.
Separater Heartbeat. Die State-Datei allein überwacht den Wächter nicht. Bleibt der Cron hängen, steht in der Datei weiter der letzte gute Zustand — und der sieht gesund aus. Deshalb schreibt jeder Lauf zusätzlich einen Heartbeat (eine eigene Datei, deren mtime Zabbix prüft). Altert der Heartbeat, ist der Wächter tot, unabhängig davon, was in der State-Datei steht.
Der Test, der zählt, war kein „läuft doch"-Test, sondern ein Negativtest: Der Zähler für unfertige Jobs wurde künstlich auf unfinished=1 gesetzt. Zabbix meldete PROBLEM. Nach der Wiederherstellung sprang der Trigger auf OK. Damit war belegt, dass die Kette vom Script über die Datei bis zum Trigger wirklich funktioniert — und nicht nur die Datei existiert.
Was du mitnimmst
Die Kernlehre ist unangenehm einfach: Überwache nicht, ob der Scheduler läuft. Überwache, ob die Arbeit tatsächlich erledigt wurde — und ob etwas sie blockiert. Ein Scheduler, der brav Ticks zählt, während die Warteschlange jeden Job ablehnt, ist das monitoringtechnische Äquivalent zu einem Droiden, der „Alles in Ordnung" piept, während das Schiff brennt.
Konkret heißt das:
- Den blockierenden Zustand direkt abfragen, nicht indirekt erraten. Hier war es der Mutex, nicht die Job-Dauer.
- Das erfolgreiche Ergebnis prüfen (
failed: 0, kein Dry-Run), nicht nur die Existenz eines Laufs. - Fehlgeschlagene Abfragen als Fehler abbilden, nicht als gesunde Nullen.
- Den Wächter selbst überwachen — mit einem Heartbeat, der unabhängig von der State-Datei altert.
- Mindestens einen Negativtest machen. Ein Alarm, der nie ausgelöst hat, ist eine Behauptung.
Und die Versionsgrenze: Das beschriebene Verhalten ist für Zammad 7.1.3 belegt. Ob andere Versionen denselben fehlenden Dry-Run-Filter haben, habe ich nicht geprüft — und behaupte es deshalb nicht. Beim nächsten Upgrade lohnt der Blick, ob upstream den Mutex angepasst hat; dann darf der Wächter schlanker werden, aber nicht verschwinden. Der nächste hängengebliebene Job kommt bestimmt. Diese Chance besteht immer.