Przejdź do treści głównej
OpenAI

30 czerwca 2026

Inżynieria

Epidemiologia zrzutów pamięci: naprawa 18-letniego błędu

Analiza przeprowadzona na poziomie populacji mająca na celu debugowanie trudnych do zdiagnozowania awarii w naszej infrastrukturze danych.

Ładowanie…

Modele i agenty OpenAI coraz częściej korzystają ze skalowalnej infrastruktury danych, aby wyszukiwać istotne dane w czasie inferencji, czyli wtedy, gdy modele analizują twoje pytanie. Część tych usług napisano w języku C++, którego niskopoziomowa kontrola nad systemem pozwala nam maksymalizować wydajność i minimalizować zużycie pamięci. Te korzyści są ważne przy skalowaniu, ale brak bezpieczeństwa pamięci w C++ oznacza, że błędy mogą powodować awarie przez zapis w niepoprawnych lub nieistniejących adresach pamięci.

Kilka miesięcy temu zaobserwowaliśmy awarie wewnątrz usługi Rockset, wyspecjalizowanej części infrastruktury danych ChatGPT, kluczowej dla wielu wtyczek danych i przeszukiwania rozmów. W każdej z tych awarii zwykła funkcja C++ pozornie kończyła działanie, po czym wracała pod fałszywy adres, przez co jądro zatrzymywało program, bo wskaźnik instrukcji nie wskazywał już na kod. Czasem pole adresu powrotu w ramce stosu miało wartość NULL. Czasem sam rejestr CPU wskaźnika stosu wyglądał, jakby był przesunięty o 8 bajtów, jak gdyby %rsp został jakoś zmniejszony w trakcie normalnego wykonania. W obu przypadkach awaria następowała przy powrocie.

To nie są normalne awarie kodu aplikacji. Zabłąkany zapis trafiający wyłącznie w zapisany adres powrotu jest możliwy, ale skrajnie mało prawdopodobny. Błąd, który przesuwa %rsp o 8 bez użycia asemblera inline, setcontext ani longjmp (żadnego z nich nie używamy), jest jeszcze dziwniejszy, bo skompilowany kod zmienia ten rejestr bezpośrednio tylko w prologu i epilogu funkcji. Każda hipoteza, jaką my lub ChatGPT potrafiliśmy wymyślić, miała przeciw sobie mocne dowody, więc błąd wydawał się niemożliwy.

To, co uznaliśmy za jeden problem, okazało się w końcu dwoma niezwiązanymi błędami, przypadkiem odkrytymi w tym samym czasie. Po pierwsze: ciche uszkodzenie sprzętowe na jednym hoście Azure, gdzie CPU po prostu źle wykonywał obliczenia. Po drugie: 18-letnia sytuacja wyścigu w GNU libunwind, niezauważony błąd w powszechnie używanej bibliotece open source.

W tym wpisie opowiadamy, jak rozpoznaliśmy i naprawiliśmy pozornie niewytłumaczalne awarie, myśląc jak epidemiolodzy i budując wysokiej jakości zbiór danych o całej populacji awarii.

Pierwsza próba debugowania: uważne badanie kilku zrzutów pamięci

Najpierw przyjrzyjmy się bliżej Rockset. To natywny dla chmury system danych do wyszukiwania i analityki w czasie rzeczywistym, którego używamy w OpenAI w wielu wewnętrznych przypadkach, takich jak konektory synchronizacji (Rockset został przejęty przez OpenAI w 2024 r.). Aktualizacje strumieniowe utrzymują bieżący indeks bazy wiedzy przestrzeni roboczej, aby ChatGPT mógł szukać istotnych informacji przy odpowiadaniu na pytania lub wykonywaniu działań.

Warstwa wykonawcza Rockset jest napisana w C++. Język C++ daje niskopoziomowy dostęp do CPU, co sprzyja wydajności i efektywności, ale oznacza też, że błędy aplikacji mogą prowadzić do niepoprawnych odwołań do pamięci i segfaultów. Aby je tropić, używamy obsługi sygnałów krytycznych z folly do zapisywania śladu stosu przy awarii, a odpowiadające im zrzuty pamięci (migawkę stanu programu w chwili awarii) przesyłamy do Azure Blob Storage do późniejszej analizy. Wszystkie liście przetwarzania zapytań Rockset są replikowane, co minimalizuje wpływ awarii na klienta. Każdy segfault odpowiada jednak błędowi, który trzeba naprawić, aby spełnić nasze cele niezawodności i jakości.

Początkowo traktowaliśmy te zrzuty jak klasyczny problem debugowania: dokładnie obejrzeć kilka zrzutów, postawić hipotezy i kolejno je wykluczać.

Większość awarii występowała w metodzie o nazwie DocumentTree::updateDocument. Wszystko wskazywało na to, że updateDocument wywołuje nieznaną funkcję X, stos zostaje uszkodzony podczas działania X, a potem X wraca pod adres niebędący kodem wykonywalnym. W niektórych przypadkach właśnie usunięta ramka X wyglądała poprawnie poza tym, że zapisany adres powrotu miał wartość NULL. W innych przypadkach sam wskaźnik stosu wyglądał błędnie, ale następną poprawną ramką wciąż zdawała się być updateDocument.

Nie wiedzieliśmy, kiedy dochodzi do uszkodzenia stosu, co zmuszało nas do szukania igły w stogu siana. updateDocument to rozbudowana metoda z intensywnym inliningiem, więc liczba potencjalnych funkcji X była przytłaczająca.

Czy to był błąd w naszym kodzie C++? Problem z kompilatorem albo linkowaniem? Problem w jednej z naszych bibliotek wykonawczych? Błąd jądra Linuksa przy dostarczaniu sygnałów lub przełączaniu kontekstu? Coś jeszcze rzadszego? Jeśli był to zabłąkany zapis, dlaczego nie wykryło go nasze środowisko stagingowe ASAN?

Próbowaliśmy użyć dzienników aplikacji, by znaleźć wszystkie wystąpienia problemu, ale błędy uszkodzenia stosu trudno klasyfikować na podstawie samych dzienników, ponieważ zapisane ślady stosu same są uszkodzone albo ich w ogóle nie ma. Nie potrafiliśmy opracować zapytania przeszukującego dzienniki bez fałszywych trafień i fałszywych pominięć. Ręcznie sprawdziliśmy więcej zrzutów i znaleźliśmy dodatkowe przykłady, ale było to zbyt pracochłonne, by dać wiarygodny zbiór danych.

Na tym etapie śledztwa błędnie wykluczyliśmy usterkę sprzętową, bo widzieliśmy awarie w wielu regionach i na wielu typach sprzętu, więc nadal szukaliśmy przyczyn wyłącznie programowych. Przez kilka dni bardzo głęboko analizowaliśmy jedną awarię z niewyrównanym %rsp, odtwarzając historię sprzed awarii z zawartości stosu i rejestrów. Dało to kilka możliwych wskazówek, ale ponieważ nie porzuciliśmy początkowego wniosku, że wszystkie błędy mają tę samą przyczynę, nie pomogło nam ruszyć z miejsca.

Wskazówki ze stosu

Zanim przejdziemy do punktu zwrotnego śledztwa, warto wyjaśnić, jakie informacje wydobywaliśmy z plików zrzutów.

Rockset kompilujemy z -fno-omit-frame-pointer, więc aktywna ramka stosu jest zawsze dostępna przez %rbp, a instrukcje wywołujące tworzą połączoną listę wskaźników ramek.

W systemie Linux x86_64 ABI AMD64 System V rezerwuje też 128 bajtów poniżej %rsp jako strefę red zone. Ten obszar jest dostępny dla kodu przestrzeni użytkownika, a co ważne, jądro w ramach kontraktu ABI obiecuje go nie nadpisywać podczas dostarczania sygnału.

Strefa red zone była kluczowa w debugowaniu awarii po powrocie, bo zachowuje część informacji sprzed powrotu. Gdy zostanie wywołany SIGSEGV, obsługa sygnału krytycznego folly działa na stosie wątku, który uległ awarii. Ramki stosu, które nie są już aktywne, bo ich funkcja wróciła, zostaną nadpisane przez handler sygnału, poza ostatnimi 128 bajtami. Dlatego możemy mówić na przykład że „właśnie zdjęta ramka stosu X wyglądała poprawnie poza adresem powrotu NULL”. Strefa red zone zachowuje część nieaktywnych ramek, a czasem tylko końcówkę jednej z nich.

Diagram stosu pokazujący uszkodzone ramki stosu, które mogą nadpisywać adresy powrotu i powodować awarie.

Znaleźliśmy jedną awarię misaligned-stack, w której wszystkie zaangażowane funkcje były bardzo małe. Dzięki temu zobaczyliśmy, że %rsp rozjechał się podczas wykonywania względnie prostej funkcji, jednak kolejne wywołania zakończyły się pomyślnie. Program zawiesił się dopiero wtedy, gdy aktywna funkcja w końcu próbowała wrócić. Żadna z tych ścieżek kodu nie używała wyjątków, asemblera inline, setcontext ani longjmp, więc jeśli wskaźnik stosu naprawdę zmienił się tak, jak sugerował zrzut, nie wyjaśniał tego żaden wiarygodny błąd w kodzie przestrzeni użytkownika.

To skierowało nas ku jądru.

Rockset używa sygnałów agresywniej niż większość programów. Wykonywanie zapytań jest dzielone na wiele lekkich zadań wymieniających dane. To ważne dla wydajnej obsługi obciążeń o wysokim QPS, ale utrudnia rozliczanie CPU wg zapytań, ponieważ praca wielu zapytań jest multipleksowana na tej samej puli wątków.

Naszym rozwiązaniem stał się coarse_thread_cputime_clock; wykonuje on aproksymację clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) na tyle tanio, że pozwala próbkować na granicy każdego zadania. API timer_create pozwala zaplanować okresowe dostarczanie sygnału według kilku miar upływu czasu, w tym narastania czasu CPU. Planujemy dostarczenie sygnału (SIGUSR2) co kilka milisekund czasu CPU; wtedy handler sygnału aktualizuje wartość lokalną dla wątku. Choć wiele zadań nie widzi postępu zgrubnego zegara podczas wykonania, suma wszystkich delt daje nieobciążone oszacowanie rzeczywistego czasu CPU zapytania.

Sygnały dostarczamy bardzo często, więc rzadki błąd jądra przy przełączaniu kontekstu lub dostarczaniu sygnałów wydawał się prawdopodobny. Czytaliśmy raporty z błędów, kod źródłowy jądra i poprawki jądra specyficzne dla Azure. Próbowaliśmy testów obciążeniowych. Nie znaleźliśmy nic podejrzanego.

Wtedy postanowiliśmy się cofnąć i spróbować innego podejścia.

Lekarz czy epidemiolog?

Ogólnie istnieją dwa sposoby debugowania takich problemów.

Pierwszy – zachowywać się trochę jak lekarz: skupić się na jednym pacjencie, wykonać wiele badań i spróbować zdiagnozować pojedynczy przypadek na podstawie szczegółowych dowodów.

Drugi – działać bardziej jak epidemiolog: spojrzeć na całą populację i pytać, czy istnieją wzorce, których pojedynczy przypadek nie ujawni. Czy błąd zaczął się od konkretnego wydania? Czy koreluje z jednym SKU sprzętu (konkretnym CPU i modelem serwera), jednym regionem albo jedną wersją jądra? Czy w tym, co wygląda na jeden syndrom, nie kryje się kilka odrębnych klastrów?

Przez większość czasu działaliśmy w trybie lekarza. Kluczowa zmiana polegała na decyzji, że musimy zebrać wysokiej jakości dane o populacji.

Czyszczenie danych

Nasze wcześniejsze próby automatycznego znalezienia wszystkich wystąpień problemu nie powiodły się, bo próbowaliśmy przeszukiwać tekst dzienników. Same zrzuty pamięci mają znacznie więcej informacji, ale ręczne ich przeglądanie się nie skalowało. Postanowiliśmy zainwestować wysiłek w potok, który automatycznie analizowałby zrzuty pamięci.

Poprosiliśmy ChatGPT o napisanie skryptu, który pobierał prefiks każdego pliku zrzutu, wyodrębniał rejestry, filtrował znane fałszywe trafienia z użyciem dzienników i automatycznie oznaczał awarię jako return-to-null, misaligned-stack albo inną. Następnie uruchomiliśmy ten skrypt równolegle na każdym produkcyjnym zrzucie pamięci Rockset z poprzedniego roku.

To był punkt zwrotny.

Gdy mieliśmy czysty zbiór danych, korelacje pojawiły się od razu. To, co traktowaliśmy jako jeden dziwny błąd, było w rzeczywistości dwiema oddzielnymi populacjami awarii.

Zrzuty return-to-null były rozproszone po wielu klastrach i regionach geograficznych. Ich częstość ostatnio wzrosła, ale nie było wyraźnej daty początku ani czystej granicy infrastrukturalnej.

Awarie misaligned-stack wyglądały zupełnie inaczej. Wszystkie pochodziły z jednego regionu, miały jasną datę początku i nigdy nie występowały na węzłach działających od dawna. Choć obejmowały wiele maszyn wirtualnych Azure (VM hostowanych w chmurze), wzorzec wyglądał tak, jakby jedna fizyczna maszyna z wadliwym sprzętem powodowała problemy na dowolnej VM, która akurat na nią trafiła.

Wykres punktowy częstości awarii według klastrów w czasie, pokazujący, że większość awarii skupia się w klastrach 2, 3 i 6, ze skokiem w klastrze 1 pod koniec okresu.

Wtedy zrozumieliśmy, że mentalnie zlewaliśmy dwa błędy w jeden. Mieszaliśmy kontrprzykłady z obu błędów, więc nie potrafiliśmy znaleźć jednego spójnego wyjaśnienia.

Błąd nr 1: wadliwy host

Mając przejrzystą listę węzłów Kubernetes i znaczników czasu, mogliśmy namierzyć awarie misaligned-stack z dokładnością do jednego fizycznego hosta, którego łatwo było dodać do listy blokowanych.

Nie udało nam się odtworzyć uszkodzenia rejestrów na tym hoście w kontrolowanym środowisku, nawet po kilku tygodniach testów obciążeniowych. Gdy jednak problematyczny host wycofano z użycia, awarie misaligned-stack zniknęły.

Usunięcie wadliwego hosta nie jest trwałym rozwiązaniem w tym sensie, że nie zapobiega ponownemu wystąpieniu tego samego problemu. Możemy jednak zmienić oprogramowanie tak, aby podobny problem był łatwy do wykrycia i zabezpieczenia, jeśli wróci. Ulepszyliśmy handler sygnałów krytycznych, dodając stan rejestrów, aby wykrywać nawroty wyłącznie z dzienników, bez potrzeby zrzutu pamięci. Zmieniliśmy płaszczyznę sterowania tak, aby VM zwykle były ponownie używane zamiast odtwarzane, co znacznie ułatwia wykrywanie błędnych węzłów na naszym poziomie stosu infrastruktury. Zaktualizowaliśmy też nasze zestawy procedur i modele mentalne zespołu, aby uwzględniały taką możliwość.

Po oddzieleniu awarii wadliwego hosta pozostałe zrzuty return-to-null stały się dużo łatwiejsze do zrozumienia. Wcześniej wykluczyliśmy rozwijanie wyjątków, bo sądziliśmy, że mamy kontrprzykłady: awarie w ścieżkach kodu, w których wyjątków na pewno nie używano. Ale wszystkie te kontrprzykłady pochodziły z klastra uszkodzeń sprzętowych.

Gdy z tą wiedzą wróciliśmy do pozostałych zrzutów, odkryliśmy, że wniosek był dokładnie odwrotny: wszystkie awarie zachodziły podczas rozwijania wyjątków.

Obsługa wyjątków to dynamiczne przekazanie sterowania

Gdy C++ powoduje wyjątek, środowisko wykonawcze musi ustalić, który blok catch ma go otrzymać i które destruktory lub handlery sprzątania powinny po drodze zostać uruchomione. Kompilator przekazuje te metadane, ale właściwe dopasowanie odbywa się dynamicznie w środowisku wykonawczym.

Rozwijania wyjątków nie wykonuje w rzeczywistości funkcja wywołująca throw, lecz funkcje pomocnicze wywołane przez powstały skompilowany kod. Te procedury środowiska wykonawczego badają stos, pobierają metadane funkcji znalezionych na stosie, dynamicznie szukają handlerów sprzątania i catch block, a potem przekazują sterowanie do jednej z tych lokalizacji. Przekazanie sterowania obejmuje rozwinięcie wszystkich pośrednich ramek stosu, w tym ramek funkcji pomocniczych.

Pod względem operacyjnym bardziej przypomina to longjmp albo przełączanie fiber niż zwykłe wywołanie i powrót. Trzeba odtworzyć rejestry zapisu obiektu wywoływanego oraz rejestry ramki stosu %rbp i %rsp.

Nasz plik binarny linkuje dwie biblioteki zawierające implementacje funkcji rozwijających wyjątki C++: libgcc i GNU libunwind. Dynamiczny linker wybrał definicje z GNU libunwind. Zaskoczyło nas to; spodziewaliśmy się, że wygra implementacja libgcc ze względu na reguły wersjonowania symboli, ale inspekcja działających zasobów binarnych pokazała, że było inaczej.

Porzucenie ostatniego założenia

W tym momencie nasza hipoteza robocza się zmieniła, bo poluzowaliśmy kolejne założenie przyjęte wtedy, gdy sądziliśmy, że istnieje tylko jeden błąd.

Może nie widzieliśmy zwykłego powrotu funkcji do NULL. Może widzieliśmy transfer rozwijania — w praktyce odtworzenie rejestrów w stylu setcontext — w którym docelowy wskaźnik instrukcji stał się NULL, zanim przekazano sterowanie. Innymi słowy: niepoprawne dane z biblioteki rozwijania, a nie niepoprawne pole adresu powrotu na stosie.

To radykalnie zawęziło problem. Albo GNU libunwind wyliczał zły stan docelowy, albo wyliczał dobry stan, lecz coś go uszkadzało, zanim mógł zostać zastosowany.

Przeczytaliśmy źródła GNU libunwind i odkryliśmy, że syntetyzuje on ucontext_t na stosie, wypełnia pożądany stan rejestrów dla ramki handlera sprzątania, a potem przekazuje wskaźnik do tej struktury wewnętrznej procedurze asemblerowej: _Ux86_64_setcontext.

W tym momencie mieliśmy wszystkie elementy układanki.

Zsyntetyzowany ucontext_t znajduje się w jednej z ramek stosu rozwijanych przez _Ux86_64_setcontext podczas wykonywania tej funkcji. Czy _Ux86_64_setcontext czytał ze struktury po zmianie %rsp, gdy struktura nie była już częścią aktywnego stosu? To czyniłoby go podatnym na nadpisanie przez dostarczenie sygnału, na przykład naszego częstego SIGUSR2.

Błąd nr 2: błąd libunwind

Odpowiedź brzmiała: tak.

Oto sześć ostatnich instrukcji _Ux86_64_setcontext w używanej przez nas wersji GNU libunwind; składają się głównie z instrukcji mov, które wczytują dane z pamięci do rejestru docelowego:

Zwykły tekst

1
74: mov UC_MCONTEXT_GREGS_RSP(%rdi),%rsp
2
75:
3
76: /* push the return address on the stack */
4
77: mov UC_MCONTEXT_GREGS_RIP(%rdi),%rcx
5
78: push %rcx
6
79:
7
80: mov UC_MCONTEXT_GREGS_RCX(%rdi),%rcx
8
81: mov UC_MCONTEXT_GREGS_RDI(%rdi),%rdi
9
82: retq

(%rdi wskazuje na przydzielony na stosie ucontext_t, a makra UC_MCONTEXT_* rozwijają się po prostu do stałego offsetu, pod którym przechowywany jest dany rejestr).

Pierwsza instrukcja jest początkiem okna wyścigu. Aktualizuje %rsp, aby wskazywał nowe dno aktywnego stosu. Gdy tylko to się stanie, struktura wskazywana przez %rdi nie jest już częścią aktywnego stosu ani strefy red zone i przestaje być dla jądra nietykalna.

Zwykle nie powoduje to problemów, ale jeśli sygnał nadejdzie dokładnie we właściwym (czy raczej niewłaściwym?) momencie, jądro zbuduje ramkę sygnału pod %rsp-128. Może to nadpisać pamięć wskazywaną przez %rdi.

Jeśli stanie się to, zanim następna instrukcja odczyta UC_MCONTEXT_GREGS_RIP(%rdi), odtworzony wskaźnik instrukcji może zostać uszkodzony. W naszych awariach stawał się NULL.

To jest ten błąd.

Zrzuty udawały zwykłe błędne powroty

Ten zestaw wyjaśnia też jedną z obserwacji, które nas zmyliły: dlaczego funkcja X miała NULL w polu adresu powrotu poprzedniej ramki stosu.

setcontext napisano tak, aby odtwarzał wszystkie rejestry, w tym %rdi, więc w końcowym momencie przekazania sterowania nie może użyć tego rejestru do odczytu UC_MCONTEXT_GREGS_RIP(%rdi). Zamiast tego wcześniej odczytuje wartość, zapisuje ją na stosie, odtwarza kilka kolejnych rejestrów, a potem używa retq, aby odczytać zapisaną wartość i przekazać sterowanie.

To, co w zrzutach wyglądało jak „funkcja wróciła do NULL”, było w rzeczywistości sytuacją: „unwinder zsyntetyzował docelowy adres powrotu na stosie, ale cel został uszkodzony, zanim transfer się zakończył”. Zakładaliśmy, że uszkodzenie pola adresu powrotu musi nastąpić w danym miejscu, bo nie znaliśmy żadnych obszarów, gdzie podatne na uszkodzenie dane celowo były zapisywane w polu adresu powrotu.

Okno wyścigu o szerokości jednej instrukcji

Ten błąd wydaje się absurdalny przez to, jak wąskie jest okno wyścigu. W takiej sytuacji wyścigu zdarzenie zewnętrzne (sygnał) musi zajść między dwoma krokami wykonywanymi przez inny wątek. Im bliżej siebie są te kroki, tym mniejsze prawdopodobieństwo wystąpienia sytuacji wyścigu.

W tym przypadku podatne okno ma dosłownie szerokość jednej instrukcji! Sygnał musi zostać dostarczony po zmianie %rsp, ale zanim następna instrukcja wczyta %rip. Na nowoczesnym superskalarnym CPU z wykonywaniem poza kolejnością można wykonać kilka takich prostych instrukcji na cykl, więc okno wyścigu ma około stu pikosekund.

Gdy znaleźliśmy ten wyścig, pierwszą reakcją było: to musi być zbyt rzadkie, by wyjaśniać obserwowaną częstość awarii. W całej flocie widzieliśmy ponad tuzin awarii return-to-null dziennie. Czy wyścig obejmujący jedną instrukcję podczas sprzątania po wyjątku naprawdę mógł to tłumaczyć?

Sięgnęliśmy po szacowanie Fermiego. Jeśli podatne okno jest rzędu 101010^{-10} sekundy, a SIGUSR2 przychodzi co 10210^{-2} sekundy czasu CPU, to każdy handler sprzątania po wyjątku lub blok catch ma około 10810^{-8} prawdopodobieństwa przegrania wyścigu.

Rockset używa wyjątków jako części wewnętrznego mechanizmu backpressure przy ingestowaniu. Pojedynczy przeciążony host może rzucać ok. 10410^{4} wyjątków na sekundę. To oznacza, że średni czas między awariami hosta używającego backpressure wynosi 10410^{4} sekund, czyli jedna awaria co kilka godzin. W skali floty to z nawiązką wyjaśnia obserwowaną częstość awarii.

Dlaczego błąd libunwind ujawnił się teraz?

Błąd GNU libunwind jest stary, ma ponad 18 lat, i był obecny w pierwszej wersji x86_64 obsługującej rozwijanie wyjątków C++.

Dlaczego więc ujawnił się teraz?

Częstość awarii jest w przybliżeniu proporcjonalna do liczby występujących wyjątków i dostarczanych sygnałów. Zależy też od tego, ile stosu zużywa handler sygnału.

Rockset jest nietypowy we wszystkich trzech wymiarach. W ramach normalnej kontroli przeciążenia zrzucamy wyjątki z dużą częstością; przez coarse_thread_cputime_clock dostarczamy SIGUSR2 nietypowo często; a wcześniej w tym roku zwiększyliśmy użycie stosu przez handler SIGUSR2, dodając wywołanie timer_getoverrun, aby rozliczać scalone sygnały.

Ta ostatnia zmiana wydaje się istotna. Jeśli handler używa dość mało stosu, może nie sięgnąć do nieaktualnej pamięci ucontext_t i jej nie nadpisać. Przed tą zmianą w ogóle nie obserwowaliśmy tych awarii. Po zmianie częstość pozostawała niska, dopóki nie zwiększyliśmy obciążenia w kilku przypadkach użycia, które mocno obciążały mechanizm backpressure.

Innymi słowy, błąd libunwind był tam od zawsze, ale dopiero niedawno iloczyn częstości wyjątków, częstości sygnałów i użycia stosu przez handler przekroczył próg, przy którym stał się widoczny operacyjnie.

Ten mechanizm wyjaśnia też zbieg okoliczności, że zarówno błąd sprzętowy, jak i błąd libunwind powodowały awarie głównie wewnątrz DocumentTree::updateDocument. Awarie z libunwind były mocno przesunięte ku tej metodzie, bo jest ona zawsze aktywna w chwili, gdy zrzucamy wyjątek, aby zastosować backpressure przy ingestowaniu. Silnie wybierały ją też awarie z niewyrównaniem %rsp, bo wadliwy węzeł sprzętowy należał do SKU używanego przez nas do masowego ingestowania, które większość czasu CPU spędza w tej metodzie.

Naszym natychmiastowym obejściem było przejście z GNU libunwind na unwinder z libgcc. Samo w sobie było to dobrą decyzją: implementacja libgcc stała się lepsza dzięki ograniczeniu rywalizacji o blokady, co ma znaczenie przy skalowaniu do dużych VM.

Przekazaliśmy też do GNU libunwind samodzielny reproducer i poprawkę(otwiera nowe okno) oraz sprawdziliśmy, że inne unwindery nie mają podobnego problemu.

Potęga diagnozy na poziomie populacji

Ta droga debugowania nauczyła nas wiele o szczegółach dynamicznego linkowania, metadanych unwind DWARF, dostarczania sygnałów w systemie Linux, ABI System V i mechanizmach wyjątków C++. Ale główna lekcja była prostsza niż wszystkie te szczegóły.

Najważniejszym krokiem nie było sprytne czytanie informacji ani dogłębna znajomość szczegółów. Było nim zbudowanie wysokiej jakości zbioru danych. Bez tego zbioru danych mieszaliśmy dwa odrębne zjawiska w jedną sytuację i próbowaliśmy zrozumieć ten chaos. Gdy mieliśmy już dokładne i pełne dane populacyjne, struktura problemu stała się oczywista: jedna populacja awarii należała do wadliwego hosta, a druga do wyścigu w libunwind. Kiedy jakość danych wzrosła, debugowanie stało się łatwiejsze.

W systemach infrastrukturalnych takich jak Rockset ma to ogromne znaczenie. To śledztwo wzmocniło nasze zaangażowanie w głęboką instrumentację, zautomatyzowane dochodzenia i ciągłe ulepszanie narzędzi operacyjnych. Niezawodność to nie tylko naprawianie błędów po fakcie, to budowanie danych, procesów i umiejętności, które zmieniają problemy niemożliwe do rozwiązania w wyzwania, które można zdiagnozować i rozwiązać.

Autorzy

By Nathan Bronson i Member of Technical Staff