Der Agent misst, wo der Mensch klickt — Fehlersuche mit einem Coding-Agenten in Datenbank, Docker und Shell

Ein Fehler wird so gemeldet, wie er sich anfühlt: Die Meldung erscheint nicht, die Seite reagiert kaum, das Register lädt nach einem Neustart nicht mehr. Ein Anwender kann nur beschreiben, was die Oberfläche zeigt, und die Oberfläche ist das Ende einer langen Kette. Ein Coding-Agent mit Zugriff auf die Datenbank, die Container-Runtime und eine Shell sieht die Oberfläche nicht und fängt mit der Fehlersuche an einer anderen Stelle an. Er sieht stattdessen, was gespeichert, gestartet und ausgelagert ist.

Dieser Artikel beschreibt vier Fehlersuchen aus dem privaten DI²-Projekt, in denen genau dieser Unterschied den Ausschlag gab. In drei dieser Fälle hat eine Messung früh geklärt, was eine Vermutung offen gelassen hätte: welcher Teil der Kette betroffen ist, ob der neue Code überhaupt schuld sein kann und ob ein Schutz tatsächlich greift. In einem Fall hat dieselbe Methode eine Stunde lang nur Erklärungen ausgeschlossen, ohne die Ursache zu treffen. Derselbe Agent hat dann aus den verbliebenen Anzeichen eine Erklärung gebaut, die in sich stimmig war und trotzdem nicht zutraf, und sie sicherer als Befund vorgetragen, als die Belege es erlaubt hätten. Beide Seiten gehören in denselben Artikel, weil sie dieselbe Arbeitsweise zeigen, einmal an ihrer Stärke und einmal an ihrer Grenze.

Alle vier Fälle sind während der Entwicklung entstanden und nicht im Betrieb einer Anwendung mit Nutzern. Zur Entwicklung gehört hier allerdings, dass der Maintainer die vier Umgebungen des Projekts selbst aufsetzt und am Laufen hält, und dafür bringt ein Datenbank-Entwickler von Haus aus keinen Server-Hintergrund mit. Die Befehle dafür hat der Agent vorgeschlagen, ausgeführt wurden sie erst, nachdem der Maintainer sie freigegeben hatte.

Das Wichtigste vorab:

  • Die erste Frage lautet nicht „warum“, sondern „stimmt der Verdacht überhaupt“: Einen Verdacht zu widerlegen ist leichter, als eine Ursache zu bestätigen. Die Bestätigung verlangt, die ganze Kette nachzuvollziehen. Die Widerlegung ist oft nur eine einzige Abfrage, und ein Agent mit Zugriff auf Datenbank und Server sollte genau damit beginnen. In einem der Fälle hat ein Vergleich über vier Umgebungen den Verdacht, der gerade ausgerollte neue Stand sei schuld, in wenigen Minuten erledigt, ohne dass jemand eine Zeile Code gelesen hat.
  • Die Datenbank sagt, ob etwas nie geschrieben oder nur nicht angezeigt wurde: Viele Anwendungen legen Meldungen zuerst in der Datenbank ab und zeigen sie erst von dort aus an. Fehlt eine Meldung in der Oberfläche, kann sie also beim Schreiben in die Datenbank verloren gegangen sein oder erst auf dem Weg zur Anzeige. Ein Blick auf den Datensatz und seinen Zeitstempel entscheidet das sofort. Stand die Meldung in der Datenbank, scheidet alles vor der Speicherung aus, und die Suche beginnt dahinter.
  • Werkzeuge haben blinde Flecken, die man kennen muss: Ein Dienst, dessen Speicher fast vollständig auf die Platte ausgelagert ist, erscheint in der Container-Statistik mit einem kleinen Wert, weil sie nur den Anteil im Arbeitsspeicher ausweist. Wer den Normalbedarf des Dienstes kennt, kann daran stutzen, aber ein kleiner Wert liest sich leicht wie ein gesunder. Wie viel tatsächlich ausgelagert ist, zeigt erst die Speicher-Übersicht der Maschine.
  • Ein gelaufener Vorgang ist keine eingetretene Wirkung: Eine Bremse gegen automatisiertes Durchprobieren von Passwörtern stand seit Monaten korrekt in der Konfiguration. Sie zählte allerdings die Anfragen auf dem Anmelde-Pfad von früher, den es nach dem Umstieg auf einen externen Anmelde-Dienst gar nicht mehr gab, und der tatsächliche Anmelde-Weg lief ungebremst. Ein anderer Fix hat ein Symptom beseitigt, ohne die Ursache zu treffen, sodass am nächsten Tag alles wieder da war. In beiden Fällen half nur, die Wirkung direkt zu messen, statt der Konfigurationsdatei oder der Erfolgsmeldung zu glauben.
  • Die Kehrseite gehört dazu: Ein Sprachmodell erzählt mühelos eine Erklärung, die in sich stimmig klingt, auch wenn die Belege sie nicht tragen. Genau das ist in einem der vier Fälle passiert. Die nützlichste Rückfrage an einen Agenten ist deshalb nicht „warum“, sondern welche konkrete Messung diese Aussage trägt.

Voraussetzung: Datenbank-Grundkenntnisse und eine ungefähre Vorstellung davon, was ein Container ist, genügen. Die Fälle sind so erzählt, dass der Diagnose-Weg nachvollziehbar bleibt, nicht die Anwendung dahinter. Befehle stehen als Muster, nicht als Anleitung zum Nachmachen auf einem fremden System.

Inhalt

Was der Agent sieht und der Browser nicht

Eine Fehlermeldung aus dem Browser ist eine Beobachtung am Ende der Kette. Zwischen der Aktion des Benutzers und dem, was er sieht, liegen ein Formular, eine Server-Aktion, eine Datenbank-Transaktion, ein Benachrichtigungs-Pfad und eine Darstellung. Jede dieser Stationen kann die Ursache sein, und die Meldung sagt nicht, welche es war. Wer nur die Oberfläche hat, muss raten, in welcher Station er zu suchen anfängt.

Ein Agent, der in derselben Umgebung arbeitet, hat andere Instrumente. Er kann die Datenbank direkt fragen, ob ein Datensatz existiert und wann er geschrieben wurde. Er kann prüfen, welche Container laufen und welche ihrer Ports tatsächlich von außen erreichbar sind. Er kann eine Shell auf der Maschine öffnen und nachsehen, wie viel Speicher wirklich zur Verfügung steht. Diese Zugänge machen ihn nicht klüger als den Menschen. Sie machen bestimmte Fragen billig, die vorher teuer waren, und billige Fragen stellt man früher.

Der Unterschied zeigt sich vor allem beim Widerlegen. Eine Ursache zu bestätigen ist aufwendig, weil man die ganze Kette nachvollziehen muss. Eine Hypothese zu falsifizieren ist oft eine einzige Abfrage. Genau darin liegt der Hebel der vier folgenden Fälle. In keinem davon hat der Agent die Ursache erraten. In dreien hat eine Messung früh entschieden, wo weiterzusuchen ist, und in einem hat dieselbe Methode eine Stunde lang nur ausgeschlossen, ohne die Ursache zu treffen.

Fall 1: Fehlt die Meldung, oder wird sie nur nicht angezeigt?

Der Maintainer meldete, eine erwartete Fehlermeldung erscheine nicht in der Oberfläche. Die Anwendung legt solche Meldungen zuerst als Datensatz in der Datenbank ab, und die Oberfläche zeigt sie von dort aus an. Die Beobachtung aus dem Browser lässt deshalb zwei völlig verschiedene Ursachen offen. Entweder wurde die Meldung nie in die Datenbank geschrieben, dann liegt der Fehler im Schreib-Pfad. Oder der Datensatz ist da und kommt nur nicht in der Oberfläche an, dann liegt der Fehler auf dem Weg von der Datenbank zur Anzeige, im Benachrichtigungs-Pfad. Beide Vermutungen sind gleich plausibel, und beide führen zu ganz unterschiedlichen Stellen im Quelltext.

Statt der Fehlerbeschreibung zu folgen, hat der Agent zuerst in der Datenbank nachgesehen. Die Meldung stand dort. Sie war 22 Sekunden nach der auslösenden Änderung geschrieben worden, und zwar in zwei Schreibvorgängen im Abstand von 348 Millisekunden. Damit war die Frage entschieden, bevor jemand eine Zeile Anwendungscode gelesen hatte. Geschrieben wurde korrekt, angezeigt wurde nicht, und die Suche verlagerte sich auf den Benachrichtigungs-Pfad. Dort lag die Ursache.

Das Muster dahinter ist einfacher, als es klingt. Wird ein Fehler als „etwas fehlt in der Anzeige“ gemeldet und speichert die Anwendung die betroffenen Daten, bevor sie sie anzeigt, gibt es eine Messung, die sofort entscheidet, ob vor oder hinter der Speicherung zu suchen ist. Die Abfrage muss dafür dieselbe Datenbank erreichen, aus der auch die Anwendung liest. Dass selbst das schiefgehen kann, zeigt Fall 2. Als Merksatz sieht die Messung so aus:

Frag die Datenbank, ob der erwartete Datensatz existiert,
und lies seinen Zeitstempel.

Existiert er nicht  -> der Fehler liegt im Schreib-Pfad.
Existiert er        -> der Fehler liegt zwischen Speicher und Anzeige.

Die Zeitstempel sind dabei mehr als ein Nebenprodukt, auch wenn sie allein noch nichts erklären. Gemessen hatte der Agent nur, dass der Datensatz zweimal geschrieben wurde, im Abstand von 348 Millisekunden, und dass das erst 22 Sekunden nach der auslösenden Änderung geschah. Warum das so war, sagen die Zeitstempel nicht. Hinter zwei Schreibvorgängen kann ein zweiter Schreib-Pfad stecken oder ein Wiederholungsversuch, und hinter einer Verzögerung ein Hintergrund-Job, der nur in Abständen läuft. Solche Abstände sind aber oft der billigste Weg, eine Reihenfolge zu rekonstruieren, an die sich niemand erinnert.

Fall 2: Zwei Stunden im falschen Kaninchenbau

Nach einem Neustart des Entwicklungs-Rechners lud ein Register der Anwendung nicht mehr. Die Fehlermeldungen wechselten zwischen „Relation existiert nicht“ und „Spalte existiert nicht“, und zusätzlich fehlte ein Testprojekt, das am Vortag noch da gewesen war. Alles daran sah nach einem Schaden an der Datenbank oder nach einem Fehler in der Anwendung aus.

Der Agent hat der Reihe nach Hypothesen aufgestellt und jede einzelne mit einer Messung geprüft. Unter anderem verglich er über beide Zugriffswege die Instanz-Kennung (system_identifier), eine Nummer, die PostgreSQL beim Einrichten einer Datenbank-Instanz erzeugt und die diese Instanz danach behält. Sie war auf beiden Wegen identisch. Im Datenbank-Log stand seit dem Start kein einziger Fehler, in der Aktivitäts-Tabelle war keine Verbindung der Anwendung zu sehen, und die Verbindungs-Adresse las er direkt aus der Umgebung des laufenden Prozesses statt aus einer Konfigurationsdatei. Jede dieser Prüfungen war schnell, und bis auf den Vergleich der Instanz-Kennung hat jede eine naheliegende Erklärung ausgeschlossen. Trotzdem war nach einer Stunde keine Ursache gefunden.

Gefunden hat sie erst der Mensch, und zwar beiläufig. Der Maintainer warf zwei Beobachtungen ein, die in keinem Werkzeug stehen: Das Testprojekt sei am Vortag noch da gewesen, und die Datenbank laufe doch normalerweise auf ihrem Standard-Port. Daraufhin fiel der Blick auf die Port-Weiterleitung des Containers. Ein Container ist nach außen abgeschottet, und damit die Anwendung seine Datenbank erreicht, leitet der Rechner einen seiner eigenen Ports an den Container weiter. Diese Weiterleitung war zwar konfiguriert, aber nicht aktiv. Den Port hielten zwei Weiterleitungs-Prozesse, die wie Überbleibsel eines früheren Laufs aussahen. Sie wurden beendet, die Anwendung funktionierte wieder, und der Fall galt als gelöst.

Der Fix wirkte und traf die Ursache trotzdem nicht

Am nächsten Tag war alles wieder da. Dieselben Symptome, dieselbe Stunde Fehlersuche, und diesmal ging der Blick tiefer. Auf dem Rechner liefen zwei vollständige Container-Welten nebeneinander, also zwei Docker-Installationen mit je einer eigenen Engine, dem Dienst, der die Container tatsächlich betreibt. Die eine war eine System-Engine, das Relikt einer ersten Installation, deren Rückbau unvollständig geblieben war, und sie war so eingerichtet, dass sie bei jedem Systemstart mitstartete. Die andere war Docker Desktop, die eigentlich gewollte Installation. Beide brachten einen Stack mit demselben Namen hoch, inklusive einer Datenbank mit demselben Container-Namen.

Daraus folgt ein Rennen um den Port. Jeden Port des Rechners kann nur einer belegen, und wer zuerst startet, bekommt ihn. Der Container der anderen Welt startete in diesem Aufbau trotzdem, nur ohne Weiterleitung, und seine Datenbank war dann von außen nicht erreichbar. Die Anwendung verbindet sich über den Port des Rechners und landet bei der Welt, die ihn hält. Nach einem Systemstart war das die System-Engine, weil sie zuerst hochkam.

Die Gegenproben, mit denen der Agent den Datenbestand prüfte, liefen auf einem anderen Weg. Das waren Abfragen mit docker exec, die direkt im Datenbank-Container ausgeführt werden und am Port vorbeigehen. An welche Engine ein docker-Befehl geht, legt im Normalfall der aktive Docker-Kontext fest, eine Einstellung des Kommandozeilen-Werkzeugs, und aktiv war der Kontext von Docker Desktop. Dort gab es einen Container mit genau demselben Namen, und die Gegenproben liefen in dessen Datenbank, ohne dass irgendetwas darauf hinwies. Die System-Engine war dabei nicht versteckt. Sie wurde nur von keinem docker-Befehl angesprochen, und deshalb tauchten ihre Container auch in keiner Liste auf. So liefen beide Datenbestände langsam auseinander, weil Klicks und Skripte je nach Port-Besitzer mal die eine und mal die andere Datenbank trafen.

Damit wird der vermeintliche Fix vom Vortag zu etwas anderem, als er schien. Die verwaisten Prozesse zu beenden hat gewirkt, denn danach lief die Anwendung wieder. Getroffen hat es die Ursache nicht, denn die zweite Installation startete beim nächsten Systemstart erneut mit. Der Fix hat das eigentliche Problem sogar verdeckt, weil er das Symptom zuverlässig genug beseitigte, um die Suche zu beenden. Erst der Rückfall hat gezeigt, dass die vermeintlich verwaisten Prozesse zur System-Engine gehörten und damit ein Symptom waren und kein Täter. Behoben war der Fall erst, als die System-Engine abgeschaltet und deinstalliert war.

Zwei Docker-Engines früher erkennen: die Regeln aus dem zweiten Vorfall

Zwei Regeln sind daraus in die Projekt-Dokumentation gewandert. Die erste betrifft die Reihenfolge: Die Frage, welche Engine der aktive Docker-Kontext anspricht, gehört an den Anfang jeder lokalen Diagnose, vor jedes Auflisten von Containern. docker context ls zeigt alle eingerichteten Kontexte mit ihrem Endpunkt und markiert den aktiven mit einem Stern. Ist die Umgebungsvariable DOCKER_HOST gesetzt, übersteuert sie den Kontext, und die Liste weist dann ausdrücklich darauf hin. Mehrere Kontexte sind dabei nur ein Hinweis, denn ein Kontext kann auch auf eine entfernte Maschine zeigen oder auf denselben Endpunkt wie ein anderer. Steht der Stern aber am falschen Kontext, ist alles entwertet, was danach gemessen wird.

Die zweite Regel betrifft den Identitäts-Beweis: Wenn Oberfläche und Gegenprobe widersprüchliche Daten zeigen, lässt sich über die Instanz-Kennung prüfen, ob beide Wege überhaupt dieselbe Datenbank-Instanz erreichen. Eine Instanz trägt genau eine Kennung. Fällt sie über die beiden Wege unterschiedlich aus, erreichen die Wege nachweislich zwei verschiedene Instanzen, und jede weitere Gegenprobe über den falschen Weg prüft die falsche Datenbank. Umgekehrt gilt das nicht. Eine physische Kopie des Datenverzeichnisses, etwa aus einer Sicherung mit pg_basebackup, trägt dieselbe Kennung wie ihr Original, und eine identische Kennung schließt zwei Instanzen deshalb nicht aus. Wird dagegen ein logischer Dump wiederhergestellt, behält die Ziel-Instanz ihre eigene Kennung.

Instanz-Kennung ueber beide Wege abfragen und vergleichen:

  Weg 1: docker exec direkt im Container (Engine des aktiven Kontexts)
  Weg 2: ueber den Port des Rechners, so wie die Anwendung verbindet

  SELECT system_identifier FROM pg_control_system();

Unterschiedliche Werte  -> zwei verschiedene Instanzen, bewiesen.
Gleiche Werte           -> kein Beweis, eine physische Kopie traegt dieselbe Kennung.

Ein Detail hat dabei viel Zeit gekostet und lohnt die Erwähnung. Solange die andere Engine den Port hielt, hat auch ein Neustart des Containers die Weiterleitung nicht zurückgebracht. Wer es damit versucht, hält den Container für defekt, obwohl das Problem bei der Engine liegt, die den Port belegt.

Fall 3: „Die Seite reagiert kaum“

Unmittelbar nach drei Deployments hintereinander meldete der Maintainer ein vages Symptom. Der Prompt bestand aus einer Zeile:

die Seite reagiert kaum. können wir irgendwas messen

Ein Verdacht lag nahe: Gerade war neuer Code auf drei Umgebungen gegangen, also hatte vermutlich der neue Stand etwas kaputt gemacht, was vorher funktioniert hatte. Diesem Verdacht ist der Agent nicht im Code nachgegangen, sondern hat die Umgebungen verglichen. Er rief zuerst eine statische Versions-Route über alle vier Umgebungen ab und danach die Startseite und die Anmeldeseite je Umgebung. Das Ergebnis hat den Verdacht sofort entkräftet, denn betroffen waren alle Umgebungen, auch die Produktions-Umgebung, auf der der neue Stand gar nicht lag. Damit schied der neue Stand als Ursache aus, und die Suche verlagerte sich auf das, was sich alle vier Umgebungen teilen.

Der entscheidende Schritt lag außerhalb der Anwendung, in einer Shell auf der Maschine selbst. Die Systemlast, die uptime als Kennzahl dafür anzeigt, wie viel die Maschine gerade zu tun hat, war niedrig. Damit sprach wenig dafür, dass die Rechenleistung der Engpass war. Gleichzeitig lagen 2,9 Gigabyte im Auslagerungsspeicher (Swap) auf der Platte, obwohl 5,6 Gigabyte Arbeitsspeicher frei waren. Besonders betroffen war der Identity Provider, der Dienst, über den die Anmeldung läuft. Von seinen 802 Megabyte hatte das System 754 auf die Platte ausgelagert, nur 48 lagen noch im Arbeitsspeicher.

Ausgelagerter Speicher kommt erst zurück, wenn ein Prozess ihn wieder braucht. Von sich aus holt Linux ihn nicht zurück, nur weil wieder Platz frei ist. Deshalb blieb er auf der Platte liegen, obwohl längst wieder Platz war. Das passt zum Symptom: Der erste Anmelde-Aufruf brauchte 9,64 Sekunden, jeder weitere nur 0,19 Sekunden. Beim ersten Zugriff muss das System die ausgelagerten Teile erst von der Platte zurückholen, danach liegen sie wieder im Arbeitsspeicher. Dasselbe Muster erzeugt allerdings auch ein gewöhnlicher Kaltstart. Für die Auslagerung als Ursache sprach hier, dass fast der ganze Speicher dieses Dienstes nachweislich auf der Platte lag.

Eine Werkzeug-Falle gehört zu diesem Fall untrennbar dazu. Die Container-Statistik, die docker stats anzeigt, weist nur den Anteil im Arbeitsspeicher aus und nicht den ausgelagerten Anteil. Der betroffene Dienst erschien dort mit 48 Megabyte. Wer weiß, dass er sonst rund 800 Megabyte belegt, hätte an diesem Wert stutzen können, denn er ist nicht unauffällig, sondern verdächtig niedrig. Bei einem trägen Dienst sucht man allerdings nach einem zu großen Wert, und ein kleiner liest sich dann eher wie ein gesunder. Selbst wer stutzt, hat damit erst einen Verdacht. Die Differenz zum Normalbedarf kann auch andere Ursachen haben, etwa einen gerade neu gestarteten Dienst. Wie viel auf der Maschine tatsächlich ausgelagert ist, zeigt erst ihre Speicher-Übersicht, etwa mit free -h, denn in der Tabelle von docker stats gibt es dafür keine Spalte.

Bei traegen Diensten trotz niedriger Last:

  docker stats   -> zeigt nur den Anteil im Arbeitsspeicher
  free -h        -> zeigt zusaetzlich den Auslagerungsspeicher der Maschine

Erkennungsmerkmal sind alle drei zusammen:
  erster Aufruf sekundenlang, zweiter sofort
  niedrige Systemlast
  belegter Auslagerungsspeicher bei gleichzeitig freiem Arbeitsspeicher

Die Behebung hat der Agent nicht selbst ausgeführt. Den Auslagerungsspeicher zurückzuholen ist ein systemweiter Eingriff, und auf derselben Maschine liegen private Daten des Maintainers. Der Agent hat die Befehlsfolge übergeben und eine Vorprüfung vorangestellt, die ausrechnet, ob der ausgelagerte Speicher überhaupt in den freien Arbeitsspeicher passt. Passt er nicht, greift der Mechanismus des Kernels, der bei Speichermangel Prozesse beendet, und aus einem trägen Dienst wird ein toter. Der übergebene Auftrag sah sinngemäß so aus:

Gib mir die Befehlsfolge, um den Auslagerungsspeicher zurueckzuholen.
Stell eine Rechnung voran, die prueft, ob der belegte Auslagerungs-
speicher in den freien Arbeitsspeicher passt, und die abbricht, wenn
die Reserve zu klein ist.
Sag mir dazu, was den Zustand dauerhaft verhindert.

(Der zweite Prompt ist aus Auftrag und Ergebnis rekonstruiert, nicht wörtlich protokolliert. Der erste steht oben so, wie er getippt wurde.)

Der Maintainer hat die Folge dann selbst ausgeführt, und erst ihr Ergebnis hat die Erklärung bestätigt. Die Anmeldung ging von 9,64 auf 0,19 Sekunden zurück, der Auslagerungsspeicher auf null, und zwar ohne eine einzige Zeile Anwendungscode. Damit sich das bei der nächsten Speicherspitze möglichst nicht wiederholt, wurde zusätzlich ein Kernel-Parameter angepasst. Er gewichtet, ob das System bei Speicherdruck eher Prozess-Speicher auslagert oder eher den Dateicache verwirft, und wurde von seinem Standardwert 60 auf 10 gesetzt. Damit neigt der Kernel stärker dazu, den Dateicache zu opfern, statt einen Dienst auf die Platte zu schieben. Das ist eine Gewichtung und keine Garantie. Reicht der Arbeitsspeicher bei einer Spitze nicht, lagert der Kernel trotzdem aus, und ob derselbe Wert auf einer anderen Maschine hilft, hängt davon ab, was dort den Speicher belegt.

Ein Detail an diesem Fall ist unangenehm und gehört trotzdem erzählt. Der Agent hatte den ersten Hinweis längst selbst erzeugt, ohne ihm nachzugehen. Sein eigener erster Messaufruf hatte 5,16 Sekunden gebraucht, der zweite 0,10. Das ist genau das Muster, das er später auf der Maschine wiedergefunden hat. Zunächst hat er es als Kaltstart abgetan. Möglich war das, geprüft hat er diese Deutung aber nicht.

Fall 4: Die Drossel, die nie griff

In der Konfiguration des Reverse Proxy stand eine Begrenzung der Anfragen pro Minute für den Anmelde-Pfad. Wer sie las, kam zu dem Schluss, dass die Anmeldung gegen automatisiertes Durchprobieren von Passwörtern geschützt ist. Die Zeile stand da, sie war korrekt geschrieben, und sie war seit Monaten aktiv.

Die Begrenzung lag auf dem Endpunkt eines Anmelde-Verfahrens, das die Anwendung früher selbst durchführte. Seit dem Umstieg auf einen externen Identity Provider gibt es diesen Endpunkt nicht mehr. Der gedrosselte Pfad wurde also nie aufgerufen. Es gab an keiner Stelle eine wirksame Drosselung der Anmeldung, und das Gefährliche daran ist nicht die fehlende Drosselung. Es ist die vorhandene, denn sie beruhigt jede Prüfung, die auf die Konfiguration schaut.

Die Korrektur setzte die Begrenzung auf den Anmelde-Pfad des Identity Providers, und diesmal wurde ihre Wirkung unabhängig von der Konfiguration geprüft. Der Endpunkt wurde tatsächlich beschossen, und schon nach wenigen Versuchen antwortete er mit dem Status für zu viele Anfragen. Eine Gegenprobe auf einen benachbarten Endpunkt, der nicht mitgedrosselt werden darf, lieferte weiterhin reguläre Antworten. Damit war belegt, dass die Drosselung auf dem Anmelde-Pfad greift und den benachbarten Endpunkt nicht mitbremst.

Zusätzlich zur Drosselung im Proxy gibt es eine zweite Verteidigungslinie im Identity Provider selbst. Er sperrt ein Konto vorübergehend, wenn für dieses Konto zu viele Anmeldungen hintereinander fehlschlagen. Die Einstellung dafür steht seit der Korrektur im Einrichtungs-Skript des Projekts. Ob sie aktiv ist, wurde trotzdem nicht aus diesem Skript gefolgert, sondern beim laufenden Identity Provider für alle vier Umgebungen direkt abgefragt. Ein Skript sagt, was eingestellt werden soll, und nicht, was tatsächlich eingestellt ist.

Beide Linien ergänzen sich, weil sie unterschiedliche Angriffe treffen. Die Drosselung im Proxy zählt pro Absender-Adresse und bremst den einfachen Fall, in dem jemand von einer Stelle aus viele Passwörter durchprobiert. Kommen die Versuche von vielen Adressen, reicht sie allein nicht aus. Die Sperre im Identity Provider zählt dagegen pro Konto und greift deshalb auch dann, wenn die Versuche gegen dasselbe Konto von vielen Adressen kommen. Eine der beiden allein hätte nach einer Lösung ausgesehen und einen der beiden Fälle offen gelassen.

Am selben Tag trat dieselbe Klasse von Fehler ein zweites Mal auf, jetzt beim Ausrollen der Korrektur. Der Rollout-Lauf druckte zwei Bildschirmseiten voller Häkchen, und danach schien die Konfiguration auf allen vier Umgebungen aktiv zu sein. Tatsächlich lief der Kopierbefehl in einem falschen Verzeichnis ins Leere, und das Einrichtungs-Skript lief in seiner alten Fassung. Der Effekt war null. Das Skript druckt sein Häkchen nämlich unabhängig davon, welchen Status der Aufruf davor zurückgegeben hat. Das Häkchen bezeugt, dass die Zeile danach erreicht wurde, und sonst nichts.

Bemerkt wurde es nur, weil eine bestimmte erwartete Zeile in der Ausgabe fehlte. Nicht der Gesamteindruck hat den Fehler gezeigt, sondern die gezielte Suche nach einem einzelnen Satz, der hätte dastehen müssen. Diesen dritten Fall hat der Agent selbst verursacht. Er hatte die Rollout-Befehle übergeben, ohne den zugehörigen Commit vorher zu veröffentlichen. Die Befehle konnten auf dem Server gar nicht wirken, weil der Stand dort den Fix noch nicht enthielt.

Ein gelaufener Vorgang ist keine eingetretene Wirkung

Fall 2 und Fall 4 sehen auf den ersten Blick nicht verwandt aus. Der eine spielt auf einem Entwicklungs-Rechner und handelt von Containern, der andere auf einem Server und handelt von Sicherheits-Konfiguration. Sie haben trotzdem dieselbe Struktur, und diese Struktur ist der eigentliche Ertrag der vier Fälle.

In beiden Fällen gab es ein Signal, das den Vorgang bezeugt hat, und niemand hat die Wirkung geprüft. Die verwaisten Prozesse waren beendet, also war das Problem behoben. Die Drosselung stand in der Konfiguration, also war die Anmeldung geschützt. Das Rollout-Skript hat seine Häkchen gedruckt, also war die Korrektur überall aktiv. Jedes dieser Signale war für sich genommen wahr. Der Prozess war beendet, die Zeile stand in der Datei, das Skript war durchgelaufen. Zwischen Signal und Wirkung war die Kette trotzdem gerissen, und keines der Signale hat das gemerkt.

Was in allen Fällen geholfen hat, war dasselbe Rezept. Man sucht sich einen zweiten, unabhängigen Weg zu derselben Frage, und zwar am besten einen, der bei ausbleibender Wirkung anders ausgeht als bei eingetretener. Für eine Konfiguration heißt das, den Endpunkt zu beschießen und die Einstellung aus dem laufenden System zurückzulesen, statt die Datei zu lesen. Für eine Behebung heißt es, die Bedingung wiederherzustellen, unter der der Fehler auftrat, und dazu gehört nach einem Startproblem eben auch ein weiterer Neustart. Für einen Rollout heißt es, nach einer bestimmten erwarteten Zeile zu suchen statt den Gesamteindruck zu bewerten.

Genau diese Art von Gegenprobe ist gut automatisierbar, und deshalb ist sie eine der Stellen, an denen ein Agent mehr leisten kann als eine Sichtprüfung. Er muss es allerdings beauftragt bekommen. Von allein ersetzt er das Erfolgssignal nicht durch eine unabhängige Messung, und ein Agent, der nur gefragt wird, ob die Änderung ausgerollt ist, liest dieselben Häkchen wie der Mensch.

Die Kehrseite: wenn der Agent eine Geschichte erzählt

Fall 2 hat noch eine zweite Lehre, und die fällt weniger schmeichelhaft aus. Mitten in der erfolglosen Suche hat der Agent aus den vorliegenden Anzeichen eine Erklärung gebaut, in der alles zusammenpasste, und sie als Befund vorgetragen. Die Erklärung war in sich stimmig, sie widersprach keiner der Messungen, und sie war falsch. Erst als der Maintainer nachfragte, was damit genau gemeint sei, hat der Agent sie auseinandergenommen und getrennt, welcher Teil davon gemessen war und welcher gedeutet.

Das war kein Ausrutscher, sondern liegt in der Bauart eines Sprachmodells. Es erzeugt flüssigen, plausiblen Text, und eine Erklärung für eine Ursache ist genau das: flüssiger Text. Aus drei Beobachtungen eine vierte Aussage zu formen, die sich wie eine Schlussfolgerung anfühlt, kostet ein Sprachmodell praktisch nichts. Dieselbe Aussage zu belegen kostet es genauso viel wie jeden anderen auch. Der ganze Unterschied liegt zwischen diesen beiden Schritten, und im fertigen Text ist er nicht zu sehen.

Für den Anwender heißt das, jeden Befund in zwei Teile zu zerlegen. Der erste Teil ist das, was gemessen wurde, und der lässt sich benennen: eine Abfrage, ein Rückgabewert, ein Zeitstempel, ein Statuscode. Der zweite Teil ist das, was daraus gefolgert wurde, und der ist eine Behauptung. Die nützlichste Rückfrage an einen Agenten lautet deshalb selten „warum“. Sie lautet, welche konkrete Messung diese Aussage trägt, also welche Abfrage, welcher Rückgabewert, welcher Zeitstempel. Zeigt die Antwort auf eine konkrete Messung, trägt der Befund. Zeigt sie auf eine Zusammenschau mehrerer Indizien, ist der Befund eine Hypothese und sollte auch so behandelt werden.

Eine zweite Grenze betrifft nicht das Deuten, sondern das Handeln. Der Auslagerungsspeicher aus Fall 3 wäre für den Agenten technisch erreichbar gewesen. Ausgeführt hat er den Eingriff trotzdem nicht, weil auf derselben Maschine Daten liegen, die nicht zum Projekt gehören. Ob ein systemweiter Eingriff auf fremdem Bestand läuft, entscheidet ein Agent nicht allein, und das gilt auch dann, wenn der vorgeschlagene Befehl richtig ist. Der Agent hat den Vorschlag samt Sicherheits-Rechnung geliefert, der Mensch hat ihn ausgeführt. Genau so sollte die Arbeit verteilt sein.

Ein verwandter Nebenbefund gehört noch dazu, weil er in derselben Woche auftrat. Wenn sich zwei Sessions einen Arbeitsbaum teilen, ist ein fehlgeschlagener Testlauf zunächst nur eine Hypothese und noch kein Fehler im Produkt, denn er kann genauso gut von der Arbeit der anderen Session stammen. Was daraus für den Parallelbetrieb folgt, steht in Zwei Agenten, ein Arbeitsbaum. Und dass umgekehrt auch ein grüner Testlauf nur ein Signal ist und kein Beleg, ist ein eigenes Thema, das ein späterer Artikel dieses Blogs zur Testfall-Disziplin aufgreift.

Was davon bleibt

Die vier Fälle haben zusammen etwa vier Stunden Fehlersuche gekostet, und der größte Teil davon entfiel auf die beiden Vorfälle mit den zwei Docker-Engines. Vier Dinge verbinden sie.

  • Die erste Messung soll den Suchraum eingrenzen, nicht die Ursache liefern. Existiert der Datensatz? Sind alle Umgebungen betroffen? Landen zwei Zugriffswege in verschiedenen Datenbank-Instanzen? Solche Fragen sagen noch nicht, was kaputt ist, aber sie schließen ganze Bereiche aus. Sie kosten fast nichts und gehören deshalb an den Anfang jeder Suche.
  • Der naheliegendste Verdacht bekommt zuerst eine Gegenprobe und erst danach eine Untersuchung. Ob es überhaupt an der letzten Änderung liegt, lässt sich in Minuten klären. Wer diese Frage überspringt und gleich im Code sucht, riskiert Stunden an der falschen Stelle.
  • Ein Erfolgssignal zeigt, dass etwas gelaufen ist, nicht dass es gewirkt hat. Beendete Prozesse, eine Zeile in der Konfiguration und eine Bildschirmseite voller Häkchen bezeugen alle nur einen Vorgang. Ob die Wirkung eingetreten ist, muss ein zweiter Weg zeigen, der von diesem Vorgang unabhängig ist.
  • Der Befund eines Agenten enthält Messung und Deutung, und beide klingen gleich. Sie stehen im selben Absatz und im selben Tonfall. Die Trennung ist Sache des Anwenders, und sie kostet nur eine Rückfrage: welche konkrete Messung diese Aussage trägt.

Keiner dieser Fälle wurde gelöst, weil das Modell die Anwendung besonders gut verstanden hätte. Gelöst wurden sie, weil Fragen, die vorher zu teuer waren, plötzlich nur noch eine Minute gekostet haben, und in einem Fall, weil ein Mensch zwei Beobachtungen beisteuerte, die in keinem Werkzeug stehen. Das Erste bringt ein Agent mit, der Datenbank und Server direkt befragen kann. Das Zweite bleibt beim Menschen.

FAQ

Womit fängt man bei einem vagen Symptom wie „die Seite ist langsam“ an?

Nicht mit einer Hypothese, sondern mit einem Vergleich. Wenn dieselbe Anwendung in mehreren Umgebungen läuft, ist die schnellste Frage, ob alle betroffen sind. Ist auch eine Umgebung betroffen, die den verdächtigen Stand gar nicht hat, ist der verdächtige Stand als Ursache erledigt, und die Suche verlagert sich auf das, was sich die Umgebungen teilen. Das kann die Maschine sein, das Netzwerk oder ein gemeinsam genutzter Dienst.

Warum zeigt docker stats ein Speicher-Problem nicht an?

Weil docker stats nur den Anteil im Arbeitsspeicher ausweist und nicht den ausgelagerten Anteil. Ein Dienst, dessen Speicher fast vollständig auf die Platte ausgelagert ist, erscheint dort mit einem sehr kleinen Wert. Wer weiß, wie viel der Dienst normalerweise braucht, kann daran stutzen, denn der Wert ist nicht unauffällig, sondern zu niedrig. Wie viel tatsächlich ausgelagert ist, wird aber erst in der Speicher-Übersicht des Betriebssystems sichtbar, etwa mit free -h. Das Erkennungsmerkmal ist die Kombination aus drei Dingen: Seiten, die beim ersten Aufruf sekundenlang brauchen und beim zweiten sofort antworten, eine niedrige Systemlast und belegter Auslagerungsspeicher bei gleichzeitig freiem Arbeitsspeicher. Jedes davon allein hat auch andere Erklärungen.

Wie merkt man, dass zwei Docker-Engines parallel laufen?

Das auffälligste Anzeichen ist ein Port, der antwortet, obwohl der Container laut docker ps gar keine Weiterleitung hat. Dazu kommen Daten, die zwischen Oberfläche und Gegenprobe nicht zusammenpassen, und Änderungen, die nach dem Neuladen der Seite wieder verschwunden sind. Die Prüfung selbst ist kurz: erst mit docker context ls die vorhandenen Docker-Kontexte auflisten und sehen, welcher aktiv ist und wohin er zeigt, dann feststellen, welcher Prozess den Port tatsächlich hält. Beides gehört vor jede weitere Container-Diagnose, weil ein falscher Kontext alle folgenden Messungen entwertet.

Soll ein Agent Systembefehle selbst ausführen dürfen?

Innerhalb des Projekts spricht wenig dagegen, solange die Wirkung umkehrbar ist. Bei systemweiten Eingriffen sieht es anders aus, besonders wenn auf derselben Maschine Daten liegen, die nicht zum Projekt gehören. Die Arbeitsteilung, die sich hier bewährt hat, sieht vor, dass der Agent die Befehlsfolge samt einer Vorprüfung liefert und der Mensch sie ausführt. Das kostet eine Minute und hält die Verantwortung dort, wo sie hingehört.

Wie hält man einen Agenten davon ab, sich eine Ursache auszudenken?

Vollständig gar nicht, denn plausiblen Text zu erzeugen ist seine Bauart. Was hilft, ist die Rückfrage nach der Messung, die zu der Aussage geführt hat. Wer nach der konkreten Abfrage und ihrem Rückgabewert fragt, bekommt entweder einen belastbaren Beleg oder die Auskunft, dass es sich um eine Zusammenschau handelt. Beides ist brauchbar, solange es auseinandergehalten wird. Hilfreich ist außerdem, den Agenten aktiv nach dem Gegenteil suchen zu lassen, also nach der Messung, die seine eigene Erklärung widerlegen würde.

Verwandte Artikel

Übergeordnet:

Geschwister-Artikel: