Core-Dump-Epidemiologie: einen 18 Jahre alten Fehler beheben
Mit Populationsanalysen schwer zu erklärende Abstürze in unserer Dateninfrastruktur debuggen.
OpenAIs Modelle und Agenten nutzen zunehmend skalierbare Dateninfrastruktur, um zur Inferenzzeit, also während des Reasonings der Modelle zu deiner Frage, nach relevanten Daten zu suchen. Einige dieser Dienste sind in C++ geschrieben. Die systemnahe Kontrolle hilft uns, Leistung zu maximieren und Speichernutzung zu minimieren. Diese Effizienz ist beim Skalieren wichtig. Weil C++ aber keine Speichersicherheit bietet, können Bugs Abstürze auslösen, wenn sie in falsche oder nicht vorhandene Speicheradressen schreiben.
Vor einigen Monaten sahen wir Abstürze im Rockset-Dienst, einem speziell entwickelten Teil unserer ChatGPT‑Dateninfrastruktur, der für viele Daten-Plug-ins und die Suche in Unterhaltungen zentral ist. Bei jedem dieser Abstürze schien eine normale C++-Funktion zu enden und dann zu einer ungültigen Adresse zurückzukehren. Der Kernel stoppte das Programm, weil der Instruction Pointer nicht mehr auf Code zeigte. Manchmal war der Slot für die Rücksprungadresse im Stack Frame NULL. Manchmal schien das CPU-Register des Stack Pointers selbst um 8 Byte verschoben zu sein, als wäre %rsp mitten in der normalen Ausführung irgendwie dekrementiert worden. In beiden Fällen passierte der Absturz beim Return.
Für Anwendungscode sind das keine normalen Fehlermodi. Ein verirrter Schreibzugriff, der nur eine gespeicherte Rücksprungadresse trifft, ist möglich, aber extrem unwahrscheinlich. Ein Bug, der %rsp ohne Inline-Assembly, setcontext oder longjmp (nichts davon nutzen wir) um 8 falsch ausrichtet, ist noch merkwürdiger. Kompilierter Code passt dieses Register nur im Prolog und Epilog einer Funktion direkt an. Gegen jede Hypothese, die uns (oder ChatGPT) einfiel, gab es starke Gegenbelege. Der Bug wirkte deshalb unmöglich.
Was wir zunächst für ein Problem hielten, stellte sich am Ende als zwei voneinander unabhängige Bugs heraus, die zufällig gleichzeitig entdeckt wurden. Erstens: stille Hardware-Korruption auf einem Azure-Host, dessen CPU schlicht falsch rechnete. Zweitens: eine 18 Jahre alte Racebedingung in GNU libunwind, ein unbemerkter Bug in einer weit verbreiteten Open-Source-Bibliothek.
Dieser Beitrag erzählt, wie wir scheinbar unerklärliche Abstürze identifiziert und behoben haben: indem wir wie Epidemiologen dachten und einen hochwertigen Datensatz über die gesamte Population der Abstürze aufbauten.
Zuerst ein genauerer Blick auf Rockset. Rockset ist ein cloudnatives Datensystem für Suche und Echtzeitanalysen, das wir bei OpenAI für viele interne Anwendungsfälle nutzen, etwa Sync-Connectors (Rockset wurde 2024 von OpenAI übernommen). Streaming-Updates halten den Index der Wissensbasis eines Workspace aktuell, damit ChatGPT beim Beantworten von Fragen oder Ausführen von Aktionen relevante Informationen suchen kann.
Die Ausführungsschicht von Rockset ist in C++ geschrieben. C++ bietet hardwarenahen Zugriff auf die CPU. Das ist gut für Leistung und Effizienz, bedeutet aber auch, dass Anwendungsbugs zu ungültigen Speicherzugriffen und Segfaults führen können. Um solche Fehler aufzuspüren, nutzen wir den Fatal Signal Handler von folly, der bei einem Absturz einen Stack Trace protokolliert. Die zugehörigen Core Dumps, also Snapshots des Programmzustands beim Absturz, laden wir zur späteren Analyse in Azure Blob Storage hoch. Alle Leaves der Query-Verarbeitung von Rockset sind repliziert. Das minimiert die Auswirkungen eines Absturzes auf Clients. Jeder Segfault steht jedoch für einen Bug, den wir beheben müssen, um unsere Ziele für Zuverlässigkeit und Qualität zu erreichen.
Unser erster Ansatz behandelte diese Cores wie ein klassisches Debugging-Problem: einige Core Dumps sehr genau ansehen, Hypothesen bilden und sie nacheinander ausschließen.
Die meisten Abstürze traten in einer Methode namens DocumentTree::updateDocument auf. Dabei sah es so aus, als hätte updateDocument eine unbekannte Funktion X aufgerufen, der Stack sei während der Ausführung von X beschädigt worden und X sei dann zu einer Adresse zurückgekehrt, die kein ausführbarer Code war. In einigen Fällen sah der gerade entfernte Frame von X gültig aus, bis auf die gespeicherte Rücksprungadresse: Sie war NULL. In anderen Fällen sah der Stack Pointer selbst falsch aus, doch der nächste gültige Frame schien weiterhin updateDocument zu sein.
Wir wussten nicht, wann der Stack beschädigt wurde. Der Suchraum war riesig. updateDocument ist eine große Methode mit viel Inlining. Die Zahl möglicher Kandidaten für X war entsprechend überwältigend.
War es ein Bug in unserem C++-Code? Ein Compiler- oder Linkage-Problem? Ein Problem in einer unserer Runtime-Bibliotheken? Ein Linux-Kernel-Bug bei der Signalauslieferung oder beim Context Switching? Etwas noch Selteneres? Wenn es ein verirrter Schreibzugriff war, warum hatte unsere ASAN-Staging-Umgebung ihn nicht erkannt?
Wir versuchten, mit unseren Anwendungslogs alle Vorkommen des Problems zu finden. Stack-Korruptionsbugs lassen sich aus Logs allein aber schwer klassifizieren, weil die protokollierten Stack Traces selbst beschädigt sind oder fehlen. Wir konnten keine Log-Abfrage bauen, die nicht zugleich False Positives und False Negatives hatte. Wir prüften manuell weitere Cores und fanden zusätzliche Beispiele, doch der Prozess war zu aufwendig für einen verlässlichen Datensatz.
In dieser Phase der Untersuchung schlossen wir einen Hardware-Bug fälschlicherweise aus: Wir sahen Abstürze über mehrere Regionen und Hardwaretypen hinweg und suchten weiter nur nach Softwareursachen. Einige Tage lang untersuchten wir einen einzelnen Absturz mit falsch ausgerichtetem %rsp sehr tiefgehend und rekonstruierten anhand von Stack- und Registerinhalten die Vorgeschichte vor dem Crash. Das lieferte mögliche Hinweise. Weil wir aber an der anfänglichen Annahme festhielten, alle Bugs hätten dieselbe Ursache, kamen wir damit nicht weiter.
Bevor wir zum Wendepunkt der Untersuchung kommen, lohnt sich ein Blick darauf, welche Informationen wir aus den Core-Dateien extrahierten.
Rockset wird mit -fno-omit-frame-pointer kompiliert. Dadurch ist der aktive Stack Frame immer über %rbp erreichbar, und die Aufrufer bilden eine verkettete Liste von Frame Pointern.
Unter Linux x86_64 reserviert das AMD64 System V ABI außerdem 128 Byte unterhalb von %rsp als Red Zone. Dieser Bereich steht Userspace-Code zur Verfügung. Wichtig ist: Laut ABI-Vereinbarung darf der Kernel ihn bei der Auslieferung eines Signals nicht überschreiben.
Die Red Zone stand für unser Debugging eines Absturzes nach einem Return im Mittelpunkt, weil sie Informationen aus der Zeit vor dem Return bewahrt. Wenn ein SIGSEGV ausgelöst wird, läuft follys Fatal Signal Handler auf dem Stack des abstürzenden Threads. Nicht mehr aktive Stack Frames, deren Funktion also bereits zurückgekehrt ist, werden vom Signal Handler überschrieben, mit Ausnahme der letzten 128 Byte. Deshalb können wir sagen: „Der gerade entfernte Stack Frame von X sah gültig aus, bis auf eine NULL-Rücksprungadresse.“ Die Red Zone bewahrt Teile inaktiver Frames, manchmal nur das Ende eines einzelnen inaktiven Frames.
Wir fanden einen Absturz mit falsch ausgerichtetem Stack, bei dem alle beteiligten Funktionen sehr klein waren. So konnten wir sehen, dass %rsp während der Ausführung einer relativ einfachen Funktion falsch ausgerichtet wurde und danach weitere Aufrufe erfolgreich waren. Das Programm stürzte erst ab, als die aktive Funktion schließlich zurückkehren wollte. Keiner dieser Codepfade nutzte Ausnahmen, Inline-Assembly, setcontext oder longjmp. Wenn sich der Stack Pointer also wirklich so verändert hatte, wie der Core nahelegte, erklärte kein plausibler Bug im Userspace-Code das Problem.
Das brachte uns in Richtung Kernel.
Rockset nutzt Signale aggressiver als die meisten Programme. Die Query-Ausführung ist in viele leichtgewichtige Tasks aufgeteilt, die Daten austauschen. Das ist wichtig, um Workloads mit hoher QPS effizient zu verarbeiten. Es erschwert aber die CPU-Zuweisung pro Query, weil Arbeit für viele Queries auf denselben Thread Pool gemuxt wird.
Unsere Lösung nennen wir coarse_thread_cputime_clock. Sie nähert clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) so günstig an, dass wir an jeder Task-Grenze samplen können. Mit der API timer_create lässt sich die periodische Auslieferung eines Signals nach verschiedenen Zeitbegriffen planen, unter anderem nach angesammelter CPU-Zeit. Wir lassen alle paar Millisekunden CPU-Zeit ein Signal (SIGUSR2) ausliefern. Der Signal Handler aktualisiert dann einen threadlokalen Wert. Auch wenn viele Tasks während ihrer Ausführung kein Fortschreiten der groben Uhrzeit sehen, ergibt die Summe aller Deltas eine unverzerrte Schätzung der tatsächlichen CPU-Zeit einer Query.
Weil wir so häufig Signale ausliefern, erschien ein seltener Kernel-Bug beim Context Switching oder bei der Signalauslieferung plausibel. Wir lasen Bug Reports, Kernel-Quellcode und die Azure-spezifischen Kernel-Patches. Wir probierten Stresstests aus. Wir fanden nichts, das relevant zu sein schien.
An diesem Punkt traten wir einen Schritt zurück und wählten einen anderen Ansatz.
Ein Problem wie dieses lässt sich grob auf zwei Arten debuggen.
Man kann wie ein Arzt vorgehen: sich auf einen Patienten konzentrieren, viele Tests durchführen und versuchen, einen Einzelfall anhand detaillierter Belege zu diagnostizieren.
Oder man geht eher wie ein Epidemiologe vor: Man betrachtet die gesamte Population und fragt, ob es Muster gibt, die ein Einzelfall nicht zeigen kann. Begann der Bug mit einem bestimmten Release? Korreliert er mit einer Hardware-SKU, also einem bestimmten CPU- und Servermodell, einer Region oder einer Kernel-Version? Verbergen sich mehrere klar getrennte Cluster in dem, was wie ein einziges Syndrom aussieht?
Wir waren meist im Arztmodus gewesen. Der entscheidende Wechsel war die Entscheidung, hochwertige Populationsdaten zu sammeln.
Unsere früheren Versuche, alle Instanzen des Problems automatisch zu finden, scheiterten, weil wir Textsuchen in den Logs verwendeten. Die Core Dumps selbst enthalten deutlich mehr Informationen, aber ihre manuelle Prüfung lässt sich nicht skalieren. Wir entschieden uns, den Aufwand in eine Pipeline zu stecken, die Core Dumps automatisch analysieren konnte.
Wir ließen ChatGPT ein Skript schreiben, das ein Präfix jeder Core-Datei herunterlud, die Register extrahierte, bekannte False Positives mithilfe der Logs herausfilterte und den Absturz automatisch als return-to-null, misaligned-stack oder other labelte. Dann ließen wir dieses Skript parallel über jeden Rockset-Core-Dump aus der Produktion des vergangenen Jahres laufen.
Das war der Wendepunkt.
Sobald wir einen sauberen Datensatz hatten, wurden Korrelationen sofort sichtbar. Was wir als einen seltsamen Bug behandelt hatten, waren in Wirklichkeit zwei getrennte Crash-Populationen.
Die Return-to-Null-Cores verteilten sich über viele Cluster und geografische Regionen. Ihre Häufigkeit war zuletzt gestiegen, doch es gab kein klares Startdatum und keine saubere Infrastrukturgrenze.
Die Misaligned-Stack-Abstürze sahen völlig anders aus. Sie kamen alle aus einer Region, hatten ein klares Startdatum und traten nie auf Nodes auf, die schon lange liefen. Obwohl mehrere Azure-VMs beteiligt waren, also virtuelle Maschinen in der Cloud, sah das Muster nach einer physischen Maschine mit defekter Hardware aus, die jeweils die VM traf, die zufällig darauf landete.
In diesem Moment erkannten wir, dass wir gedanklich zwei Bugs vermischt hatten. Weil wir Gegenbeispiele aus beiden Bugs vermischten, konnten wir keine einzelne stimmige Erklärung finden.
Mit einer sauberen Liste von Kubernetes-Nodes und Zeitstempeln konnten wir die Abstürze mit falsch ausgerichtetem Stack auf einen einzelnen physischen Host zurückführen, den wir leicht sperren konnten.
Wir konnten die Registerkorruption auf diesem Host in einer kontrollierten Umgebung nicht reproduzieren, selbst nach mehreren Wochen Stresstests. Nachdem der problematische Host außer Betrieb genommen worden war, verschwanden die Abstürze mit falsch ausgerichtetem Stack jedoch.
Den defekten Host zu entfernen, ist keine dauerhafte Lösung, weil es ein erneutes Auftreten desselben Problems nicht verhindert. Wir können die Software aber so ändern, dass ein ähnliches Problem beim nächsten Mal leicht erkannt und behandelt wird. Wir verbesserten unseren Fatal Signal Handler so, dass er den Registerzustand enthält. Dadurch können wir ein Wiederauftreten allein aus den Protokollen erkennen, ohne Core Dump. Wir änderten die Control Plane so, dass VMs in der Regel wiederverwendet statt recycelt werden. Das macht die Erkennung defekter Nodes auf unserer Ebene des Infrastruktur-Stacks viel einfacher. Außerdem aktualisierten wir unsere Runbooks (und die mentalen Modelle unseres Teams), um diese Möglichkeit einzubeziehen.
Nachdem die Abstürze durch den defekten Host isoliert waren, ließen sich die verbleibenden Return-to-Null-Cores deutlich leichter verstehen. Zuvor hatten wir Exception Unwinding ausgeschlossen, weil wir glaubten, Gegenbeispiele zu haben: Abstürze in Codepfaden, in denen definitiv keine Ausnahmen genutzt wurden. Doch diese Gegenbeispiele stammten alle aus dem Cluster mit Hardware-Korruption.
Als wir die verbleibenden Cores mit diesem Wissen erneut prüften, stellten wir fest, dass unsere Schlussfolgerung genau falsch herum war: Alle Abstürze passierten während des Exception Unwinding.
Wenn C++ eine Ausnahme ausgibt, muss die Runtime ermitteln, welcher Catch-Block sie erhalten soll und welche Destruktoren oder Cleanup-Handler dann ausgeführt werden sollen. Der Compiler erzeugt diese Metadaten, das eigentliche Matching geschieht aber dynamisch zur Laufzeit.
Exception Unwinding wird nicht von der Funktion ausgeführt, die throw aufruft, sondern von Hilfsfunktionen, die der resultierende kompilierte Code aufruft. Diese Runtime-Routinen untersuchen den Stack, rufen Metadaten zu den dort gefundenen Funktionen ab, suchen dynamisch nach Cleanup-Handlern und Catch-Blöcken und übertragen dann die Kontrolle an eine dieser Stellen. Die Kontrollübertragung umfasst das Unwinding aller dazwischenliegenden Stack Frames, einschließlich der Frames der Hilfsfunktionen.
Praktisch ist das einem longjmp oder einem Fiber Switch deutlich näher als einem normalen Aufruf und Return. Callee-Save-Register müssen wiederhergestellt werden, ebenso die Stack-Frame-Register %rbp und %rsp.
Unser Binary arbeitet mit Links zu zwei Bibliotheken, die Implementierungen der Funktionen für C++ Exception Unwinding enthalten: libgcc und GNU libunwind. Die Definitionen von GNU libunwind waren diejenigen, die der dynamische Linker auswählte. Das überraschte uns. Wir hatten erwartet, dass sich die libgcc-Implementierung wegen der Regeln für Symbol Versioning durchsetzt. Die Prüfung laufender Binarys zeigte jedoch, dass das nicht der Fall war.
An diesem Punkt änderte sich unsere Arbeitshypothese, weil wir eine weitere Annahme aufgaben, die wir getroffen hatten, als wir noch von nur einem Bug ausgingen.
Vielleicht sahen wir gar keinen gewöhnlichen Funktions-Return zu NULL. Vielleicht sahen wir einen Unwind-Transfer, faktisch eine Registerwiederherstellung im Stil von setcontext, bei der der Ziel-Instruction-Pointer vor der Kontrollübertragung zu NULL geworden war. Mit anderen Worten: falsche Daten aus der Unwind-Bibliothek statt eines falschen Slots für die Rücksprungadresse auf dem Stack.
Das grenzte das Problem stark ein. Entweder berechnete GNU libunwind den falschen Zielzustand, oder es berechnete den richtigen Zustand und etwas beschädigte ihn, bevor er angewendet werden konnte.
Wir lasen den Quellcode von GNU libunwind und fanden heraus, dass es ein ucontext_t auf dem Stack synthetisiert, den gewünschten Registerzustand für den Frame des Cleanup-Handlers einträgt und dann einen Pointer auf diese Struktur an eine interne Assembly-Routine übergibt: _Ux86_64_setcontext.
Jetzt hatten wir alle Teile.
Das synthetisierte ucontext_t liegt in einem der Stack Frames, die _Ux86_64_setcontext während der Ausführung dieser Funktion abwickelt. Las _Ux86_64_setcontext aus der Struktur, nachdem sie %rsp geändert hatte und die Struktur damit nicht mehr Teil des aktiven Stacks war? Das würde sie anfällig dafür machen, von einer Signalauslieferung überschrieben zu werden, etwa durch unser häufiges SIGUSR2.
Die Antwort lautete: ja.
Hier sind die letzten sechs Instruktionen von _Ux86_64_setcontext in der von uns genutzten GNU-libunwind-Version. Sie bestehen vor allem aus mov-Instruktionen, die aus dem Speicher in ein Zielregister laden:
(%rdi zeigt auf das auf dem Stack angelegte ucontext_t, und die UC_MCONTEXT_*-Makros expandieren nur zu dem festen Offset, an dem ein bestimmtes Register gespeichert ist.)
Die erste Instruktion ist der Beginn des Racefensters. Sie aktualisiert %rsp, sodass es auf das neue untere Ende des aktiven Stacks zeigt. Sobald das passiert, gehört die Struktur, auf die %rdi zeigt, nicht mehr zum aktiven Stack (oder zur Red Zone) und ist für den Kernel nicht mehr tabu.
Normalerweise verursacht das keine Probleme. Trifft ein Signal jedoch im genau richtigen bzw. falschen Moment ein, baut der Kernel den Signal Frame bei %rsp-128 auf. Das kann den Speicher überschreiben, auf den %rdi zeigt.
Wenn das passiert, bevor die nächste Instruktion UC_MCONTEXT_GREGS_RIP(%rdi) liest, kann der wiederhergestellte Instruction Pointer beschädigt werden. Bei unseren Abstürzen wurde er NULL.
Das ist der Bug.
Diese Assembly erklärt auch eine der Beobachtungen, die uns verwirrten: warum Funktion X im Slot für die Rücksprungadresse des vorangehenden Stack Frames NULL hatte.
setcontext sollte alle Register wiederherstellen, auch %rdi. Deshalb kann es dieses Register im letzten Moment des Kontrolltransfers nicht nutzen, um UC_MCONTEXT_GREGS_RIP(%rdi) zu lesen. Stattdessen liest es den Wert früher, speichert ihn auf dem Stack, stellt einige weitere Register wieder her und nutzt dann retq, um den gespeicherten Wert zu lesen und die Kontrolle zu übertragen.
Was in den Cores wie „eine Funktion kehrte zu NULL zurück“ aussah, war tatsächlich: „Der Unwinder erzeugte auf dem Stack eine Ziel-Rücksprungadresse, doch dieses Ziel wurde beschädigt, bevor der Transfer abgeschlossen war“. Wir hatten angenommen, dass eine beschädigte Rücksprungadresse direkt an ihrem Slot entstehen müsse, weil wir keine Stelle kannten, an der absichtlich (beschädigbare) Daten in den Slot der Rücksprungadresse geschrieben werden.
Absurd wirkt dieser Bug vor allem, weil dieses Racefenster so schmal ist. Bei dieser Art Racebedingung muss das externe Ereignis, also das Signal, zwischen zwei Schritten eines anderen Threads eintreten. Je näher diese Schritte beieinanderliegen, desto unwahrscheinlicher wird die Racebedingung.
In diesem Fall ist das anfällige Fenster buchstäblich nur eine Instruktion breit. Ein Signal muss ausgeliefert werden, nachdem %rsp geändert wurde, aber bevor die nächste Instruktion %rip lädt. Auf einer modernen superskalaren Out-of-Order-CPU können mehrere einfache Instruktionen dieser Art pro Zyklus laufen. Das Racefenster liegt also ungefähr bei hundert Pikosekunden.
Als wir diese Racebedingung fanden, war unsere erste Reaktion: Sie muss zu selten sein, um die beobachtete Crash-Rate zu erklären. Wir sahen flottenweit mehr als ein Dutzend Return-to-Null-Abstürze pro Tag. Konnte eine Racebedingung von nur einer Instruktion während der Bereinigung von Ausnahmen das wirklich erklären?
Wir griffen zu einer Fermat-Schätzung. Wenn das anfällige Fenster in der Größenordnung von Sekunden liegt und SIGUSR2 alle Sekunden CPU-Zeit eintrifft, hat jeder Exception-Cleanup-Handler oder Catch-Block eine Wahrscheinlichkeit von etwa , zu langsam zu sein.
Rockset nutzt Ausnahmen als Teil seines internen Backpressure-Mechanismus beim Ingest. Ein einzelner überlasteter Host kann Ausnahmen in der Größenordnung von Exceptions pro Sekunde ausgeben. Daraus ergibt sich für einen Host mit Backpressure eine mittlere Zeit zwischen Ausfällen von Sekunden, also ein Absturz alle paar Stunden. Auf Flottenskala reicht ist das mehr als genug, um die beobachtete Crash-Häufigkeit zu erklären.
Der GNU-libunwind-Bug ist alt: über 18 Jahre alt und schon in der ersten x86_64-Version vorhanden, die C++ Exception Unwinding unterstützte.
Warum wurde er also erst jetzt sichtbar?
Die Crash-Rate ist ungefähr proportional dazu, wie viele Ausnahmen ausgegeben und wie viele Signale ausgeliefert werden. Sie hängt auch davon ab, wie viel Stack der Signal Handler braucht.
Rockset ist auf allen drei Achsen ungewöhnlich. Wir geben bei normalem Überlastschutz Ausnahmen mit hoher Rate aus, liefern wegen coarse_thread_cputime_clock ungewöhnlich oft SIGUSR2 aus und haben Anfang des Jahres durch den SIGUSR2-Handler verursacht, dass ein Aufruf von timer_getoverrun mehr Stack braucht, um zusammengeführte Signale zu berücksichtigen.
Diese letzte Änderung scheint wichtig gewesen zu sein. Wenn der Handler wenig genug Stack nutzt, erreicht und überschreibt er den veralteten ucontext_t-Speicher möglicherweise nicht. Vor dieser Änderung beobachten wir diese Abstürze überhaupt nicht. Nach der Änderung blieb die Rate niedrig, bis wir für einige Anwendungsfälle die Last erhöhten und damit den Backpressure-Mechanismus belasteten.
Anders gesagt: Der libunwind-Bug war immer da. Aber erst vor Kurzem überschritt das Produkt aus unserer Ausnahmerate, Signalrate und Stack-Nutzung des Handlers die Schwelle, ab der er im Betrieb sichtbar wurde.
Dieser Mechanismus erklärt auch den Zufall, dass sowohl die Abstürze des Hardware-Bugs als auch des libunwind-Bugs meist in DocumentTree::updateDocument auftraten. Abstürze durch libunwind waren stärker auf diese Methode konzentriert, weil sie immer aktiv ist, wenn wir eine Ausnahme ausgeben, um Ingest-Backpressure anzuwenden. Auch die Abstürze durch falsch ausgerichtetes %rsp waren dort überrepräsentiert, weil der defekte Hardware-Node zu einer SKU gehörte, die wir für Bulk-Ingest nutzen und die den Großteil ihrer CPU-Zeit in dieser Methode verbringt.
Unsere unmittelbare Gegenmaßnahme war der Wechsel von GNU libunwind zum Unwinder von libgcc. Das war schon für sich genommen ein guter Kompromiss: Die Implementierung von libgcc hat von viel Arbeit zur Reduzierung von Lock Contention profitiert, was beim Skalieren auf große VMs wichtig ist.
Außerdem haben wir einen eigenständigen Reproducer und einen Fix(wird in einem neuen Fenster geöffnet) zu GNU libunwind upstream beigetragen und geprüft, dass die anderen Unwinder kein ähnliches Problem haben.
Diese Debugging-Reise hat uns viel über Details von dynamischem Linking, DWARF-Unwind-Metadaten, Linux-Signalauslieferung, System V ABI und C++-Ausnahme-Mechanik gelehrt. Die wichtigste Erkenntnis war jedoch einfacher als all das.
Der wichtigste Schritt war nicht das clevere Lesen von Assembly oder tiefes Detailwissen. Es war der Aufbau eines hochwertigen Datensatzes. Ohne diesen Datensatz vermischten wir zwei unterschiedliche Phänomene zu einer Geschichte und versuchten, mit Schlussfolgerungen aus der Verwirrung herauszukommen. Sobald wir genaue und vollständige Populationsdaten hatten, wurde die Struktur des Problems offensichtlich: Eine Crash-Population gehörte zu einem defekten Host, die andere zu einer Racebedingung in libunwind. Als die Daten besser wurden, wurde das Debugging leichter.
Für Infrastruktursysteme wie Rockset ist das sehr wichtig. Diese Untersuchung hat uns darin bestärkt, tiefgehende Instrumentierung, automatisierte Untersuchungen und kontinuierliche Verbesserungen unserer Betriebswerkzeuge voranzutreiben. Zuverlässigkeit bedeutet nicht nur, Bugs nach ihrem Auftreten zu beheben. Es geht darum, Daten, Workflows und Kompetenzen aufzubauen, die unmögliche Probleme diagnostizierbar und lösbar machen.
Autoren
By Nathan Bronson und Member of Technical Staff


