Epidemiologija izpisov jedra: odprava 18 let stare napake
Uporaba analize na ravni populacije za razhroščevanje zahtevnih zrušitev v naši podatkovni infrastrukturi.
Modeli in agenti OpenAI se vse bolj zanašajo na razširljivo podatkovno infrastrukturo, da ob sklepanju poiščejo ustrezne podatke: ko modeli razmišljajo o vašem vprašanju. Nekatere od teh storitev so napisane v C++, kjer nam nizkonivojski nadzor nad sistemom omogoča čim večjo zmogljivost in čim manjšo porabo pomnilnika. Te prednosti učinkovitosti so pri širjenju pomembne, vendar pomanjkanje pomnilniške varnosti v C++ pomeni, da lahko napake povzročijo zrušitve z zapisovanjem na napačne ali neobstoječe pomnilniške naslove.
Pred nekaj meseci smo opazili zrušitve znotraj storitve Rockset, posebnega dela naše podatkovne infrastrukture ChatGPT, ki je ključna za številne podatkovne vtičnike in iskanje po pogovorih. Pri vsaki od teh zrušitev se je zdelo, da se običajna funkcija C++ konča in se nato vrne na lažen naslov, zato je jedro ustavilo program, ker kazalec ukaza ni več kazal na kodo. Včasih je bila reža za povratni naslov v okvirju sklada NULL. Včasih se je zdelo, da je register CPU za kazalec sklada zamaknjen za 8 bajtov, kot da bi bil %rsp nekako zmanjšan sredi običajnega izvajanja. V obeh primerih se je zrušitev zgodila ob vrnitvi.
To niso običajni načini odpovedi aplikacijske kode. Zgrešen zapis, ki zadene samo shranjen povratni naslov, je mogoč, a izjemno malo verjeten. Napaka, ki %rsp zamakne za 8 brez vgrajenega zbirnika, setcontext ali longjmp (ničesar od tega ne uporabljamo), je še bolj nenavadna, ker prevedena koda ta register neposredno prilagaja le v prologu in epilogu funkcije. Proti vsaki hipotezi, ki smo se je lahko domislili mi (ali ChatGPT), so govorili močni dokazi, zato se je napaka zdela nemogoča.
Kar smo imeli za eno težavo, sta se na koncu izkazali za dve nepovezani napaki, po naključju odkriti hkrati. Prva je bila tiha okvara strojne opreme na enem gostitelju Azure, kjer CPU preprosto ni pravilno računal. Druga je bila 18 let stara tekmovalna napaka v GNU libunwind, neopažena napaka v široko uporabljani odprtokodni knjižnici.
Ta objava opisuje, kako smo z razmišljanjem epidemiologa in kakovostnim naborom podatkov o celotni populaciji zrušitev prepoznali in odpravili na videz nerazložljive zrušitve.
Najprej poglejmo Rockset podrobneje. To je oblačno zasnovan podatkovni sistem za iskanje in analitiko v realnem času, ki ga pri OpenAI uporabljamo za številne interne primere, na primer sinhronizacijske povezovalnike (OpenAI je Rockset prevzel leta 2024). Pretočne posodobitve vzdržujejo ažuren indeks baze znanja delovnega prostora, da lahko ChatGPT pri odgovorih ali izvajanju dejanj poišče ustrezne informacije.
Izvajalna plast Rockseta je napisana v C++. Jezik C++ omogoča nizkonivojski dostop do CPU, kar je dobro za zmogljivost in učinkovitost, vendar pomeni, da lahko napake v aplikaciji povzročijo neveljavne dostope do pomnilnika in segfault. Pri iskanju teh napak uporabljamo obravnavalnik usodnih signalov iz folly, ki ob zrušitvi zabeleži sled sklada, ustrezne izpise jedra (posnetek stanja programa ob zrušitvi) pa naložimo v Azure blob storage za poznejšo analizo. Vsi listi za obdelavo poizvedb v Rocksetu so replicirani, kar zmanjša vpliv zrušitve na odjemalca. Vendar vsak segfault pomeni napako, ki jo moramo odpraviti, da dosežemo cilje zanesljivosti in kakovosti.
Naš začetni pristop je bil te core datoteke obravnavati kot klasično razhroščevanje: natančno pregledati nekaj izpisov jedra, oblikovati hipoteze in jih izločati eno za drugo.
Večina zrušitev se je zgodila v metodi DocumentTree::updateDocument. Videti je bilo, da je updateDocument poklicala neznano funkcijo X, da se je sklad med izvajanjem X pokvaril, nato pa se je X vrnila na naslov, ki ni bil izvedljiva koda. V nekaterih primerih je bil pravkar odstranjen okvir X videti veljaven, le shranjeni povratni naslov je bil NULL. V drugih primerih je bil videti napačen sam kazalec sklada, vendar se je naslednji veljavni okvir še vedno zdel updateDocument.
Nismo vedeli, kdaj se sklad pokvari, zato je bil prostor iskanja ogromen. updateDocument je velika metoda z veliko vgrajevanja, zato je bilo kandidatov za X neobvladljivo veliko.
Je bila to napaka v naši kodi C++? Težava prevajalnika ali povezovanja? Težava v eni od naših izvajalnih knjižnic? Napaka jedra Linuxa pri dostavi signalov ali preklapljanju konteksta? Kaj še redkejšega? Če je šlo za zgrešen zapis, zakaj ga ni ujelo naše pripravljalno okolje ASAN?
Z aplikacijskimi dnevniki smo poskušali najti vse pojavitve težave, a napake pokvarjenega sklada je težko razvrstiti samo iz dnevnikov, ker so zabeležene sledi sklada same pokvarjene ali manjkajo. Ni nam uspelo sestaviti poizvedbe po dnevnikih brez lažno pozitivnih in lažno negativnih zadetkov. Ročno smo pregledali več core datotek in našli dodatne primere, vendar je bil postopek preveč zamuden za zaupanja vreden nabor podatkov.
Na tej stopnji preiskave smo (napačno) izključili strojno napako, ker smo zrušitve videli v več regijah in na več tipih strojne opreme, zato smo še iskali izključno programske vzroke. Nekaj dni smo se zelo poglobili v eno zrušitev z napačno poravnanim %rsp in iz vsebine sklada ter registrov rekonstruirali zgodovino pred zrušitvijo. To je dalo nekaj možnih sledi, a ker nismo opustili začetnega sklepa, da imajo vse napake isti vzrok, nas ni premaknilo z mrtve točke.
Pred prelomnico preiskave je pomembno pojasniti, kakšne informacije smo izluščili iz core datotek.
Rockset je preveden z -fno-omit-frame-pointer, zato je aktivni okvir sklada vedno dosegljiv prek %rbp, klicatelji pa tvorijo povezani seznam kazalcev okvirjev.
Na Linuxu x86_64 ABI AMD64 System V rezervira tudi 128 bajtov pod %rsp kot rdeče območje. To območje je na voljo uporabniški kodi in, kar je pomembno, jedro v pogodbi ABI obljublja, da ga pri dostavi signala ne bo prepisalo.
Rdeče območje je bilo ključno pri razhroščevanju zrušitve po vrnitvi, ker ohrani nekaj informacij iz časa pred vrnitvijo. Ko se sproži SIGSEGV, obravnavalnik usodnih signalov iz folly steče na skladu niti, ki se je zrušila. Okvirje sklada, ki niso več aktivni (ker se je njihova funkcija vrnila), obravnavalnik signala prepiše, razen zadnjih 128 bajtov. Zato lahko rečemo stvari, kot je: »Pravkar odstranjeni okvir sklada X je bil videti veljaven, razen povratnega naslova NULL.« Rdeče območje ohrani del neaktivnih okvirjev ali včasih le rep enega.
Našli smo eno zrušitev z napačno poravnanim skladom, pri kateri so bile vse vpletene funkcije zelo majhne. Tako smo videli, da se je %rsp napačno poravnal med izvajanjem razmeroma preproste funkcije in da so pozneje uspeli še drugi klici. Program se je zrušil šele, ko se je aktivna funkcija končno poskusila vrniti. Nobena od teh poti kode ni uporabljala izjem, vgrajenega zbirnika, setcontext ali longjmp, zato ob resnični taki spremembi kazalca sklada nobena verjetna napaka v uporabniški kodi ni pojasnila težave.
To nas je usmerilo k jedru.
Rockset uporablja signale bolj agresivno kot večina programov. Izvajanje poizvedb je razdeljeno na številna lahka opravila, ki si izmenjujejo podatke. To je pomembno za učinkovito obdelavo obremenitev z visokim QPS, vendar oteži obračun CPU po poizvedbi, ker je delo za številne poizvedbe multipleksirano na isti sklad niti.
Naša rešitev je coarse_thread_cputime_clock, ki dovolj poceni približa clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...), da lahko vzorčimo ob vsaki meji opravila. API timer_create omogoča načrtovanje periodične dostave signala glede na več pojmov poteka časa, tudi nabrani čas CPU. Načrtujemo dostavo signala (SIGUSR2) na vsakih nekaj milisekund časa CPU; ob tem obravnavalnik signala posodobi vrednost, lokalno niti. Čeprav številna opravila med izvajanjem ne vidijo premika grobe ure, vsota vseh delt daje nepristransko oceno dejanskega časa CPU za poizvedbo.
Ker signale dostavljamo tako pogosto, se je redka napaka jedra pri preklapljanju konteksta ali dostavi signalov zdela verjetna. Čas smo porabili za branje poročil o napakah, izvorne kode jedra in popravkov jedra, specifičnih za Azure. Poskusili smo stresne teste. Nismo našli ničesar, kar bi se zdelo povezano.
Takrat smo se odločili stopiti korak nazaj in poskusiti drugačen pristop.
Za razhroščevanje takšne težave sta dva glavna načina.
Eden je delovati kot nekakšen zdravnik: osredotočiti se na enega bolnika, izvesti veliko testov in poskusiti diagnosticirati posamezen primer iz podrobnih dokazov.
Drugi je bolj epidemiološki: pogledati celotno populacijo in vprašati, ali obstajajo vzorci, ki jih posamezen primer ne more razkriti. Se je napaka začela pri določeni izdaji? Je povezana z eno strojno SKU (določenim modelom CPU in strežnika), eno regijo ali eno različico jedra? Se znotraj nečesa, kar je videti kot en sindrom, skriva več ločenih skupin?
Večinoma smo bili v zdravniškem načinu. Ključni premik je bila odločitev, da moramo zbrati kakovostne podatke o populaciji.
Prejšnji poskusi samodejnega iskanja vseh primerov težave so propadli, ker smo poskušali uporabljati besedilno iskanje po dnevnikih. Izpisi jedra vsebujejo veliko več informacij, vendar ročno pregledovanje ni bilo razširljivo. Odločili smo se vložiti trud v cevovod, ki bi lahko samodejno analiziral izpise jedra.
ChatGPT smo naročili, naj napiše skript, ki prenese predpono vsake core datoteke, izvleče registre, z dnevniki filtrira znane lažne zadetke in zrušitev samodejno označi kot return-to-null, misaligned-stack ali drugo. Nato smo ta skript vzporedno zagnali nad vsakim produkcijskim izpisom jedra Rockset iz prejšnjega leta.
To je bila prelomnica.
Ko smo imeli čist nabor podatkov, so se korelacije pokazale takoj. Kar smo obravnavali kot eno čudno napako, sta bili v resnici dve ločeni populaciji zrušitev.
Core datoteke return-to-null so bile razpršene po številnih gručah in geografskih regijah. Njihova pogostost se je nedavno povečala, vendar ni bilo ostrega začetnega datuma niti jasne infrastrukturne meje.
Zrušitve z napačno poravnanim skladom so bile videti povsem drugače. Vse so prihajale iz ene regije, imele jasen začetni datum in se nikoli niso zgodile na vozliščih, ki so tekla že dolgo. Čeprav so vključevale več VM-jev Azure (navideznih strojev v oblaku), je vzorec kazal na en fizični računalnik z okvarjeno strojno opremo, ki je povzročal težave vsaki VM, ki se je znašla na njem.
Takrat smo spoznali, da smo v mislih zlivali dve napaki. Ker smo mešali protiprimere iz obeh napak, nismo mogli najti ene same skladne razlage.
S čistim seznamom vozlišč Kubernetes in časovnimi žigi smo zrušitve z napačno poravnanim skladom izsledili do enega fizičnega gostitelja, ki ga je bilo preprosto dodati na seznam zavrnjenih.
Pokvarjenja registrov na tem gostitelju nismo mogli reproducirati v nadzorovanem okolju, niti po več tednih stresnega testiranja. Ko pa smo problematični gostitelj umaknili iz uporabe, so zrušitve z napačno poravnanim skladom izginile.
Odstranitev slabega gostitelja ni trajna rešitev, saj ne prepreči nove pojavitve iste težave. Lahko pa spremenimo programsko opremo, da se podobna težava ob ponovitvi zlahka zazna in obravnava. Izboljšali smo obravnavalnik usodnih signalov, da vključuje stanje registrov, zato lahko ponovitev zaznamo samo iz dnevnikov (brez izpisa jedra). Spremenili smo nadzorno ravnino, tako da se VM-ji običajno znova uporabijo namesto reciklirajo, kar na naši ravni infrastrukturnega sklada precej olajša zaznavanje slabih vozlišč. Posodobili smo tudi svoje runbooke (in miselne modele ekipe), da vključujejo to možnost.
Ko smo zrušitve slabega gostitelja ločili, so preostale core datoteke return-to-null postale veliko lažje razumljive. Prej smo izključili odvijanje izjem, ker smo mislili, da imamo protiprimere: zrušitve na poteh kode, kjer se izjeme zagotovo niso uporabljale. Vsi ti protiprimeri pa so bili iz skupine strojne okvare.
Ko smo s tem v mislih znova pregledali preostale core datoteke, smo ugotovili, da je bil sklep ravno obraten: vse zrušitve so se dogajale med odvijanjem izjem.
Ko C++ vrže izjemo, mora izvajalno okolje ugotoviti, kateri blok catch naj jo prejme ter kateri destruktorji ali obravnavalniki čiščenja naj se izvedejo na poti. Prevajalnik izpiše te metapodatke, dejansko ujemanje pa se zgodi dinamično med izvajanjem.
Odvijanja izjem dejansko ne izvede funkcija, ki pokliče throw, temveč pomožne funkcije, ki jih pokliče nastala prevedena koda. Te izvajalne rutine pregledajo sklad, pridobijo metapodatke o funkcijah na skladu, dinamično poiščejo obravnavalnike čiščenja in bloke catch, nato pa prenesejo nadzor na eno od teh lokacij. Prenos nadzora vključuje odvijanje vseh vmesnih okvirjev sklada (tudi tistih pomožnih funkcij).
Operativno je to veliko bližje longjmp ali preklopu fiberja kot običajnemu klicu in vrnitvi. Obnoviti je treba registre, ki jih shranjuje klicana funkcija, ter registra okvirja sklada %rbp in %rsp.
Naš binarnik se povezuje z dvema knjižnicama, ki vsebujeta implementacije funkcij za odvijanje izjem C++: libgcc in GNU libunwind. Dinamični povezovalnik je izbral definicije GNU libunwind. To nas je presenetilo; pričakovali smo, da bo zaradi pravil različic simbolov zmagala implementacija libgcc, vendar je pregled delujočih binarnikov pokazal drugače.
Na tej točki se je naša delovna hipoteza spremenila, ko smo omilili še eno predpostavko iz časa, ko smo mislili, da je napaka samo ena.
Morda nismo videli običajne vrnitve funkcije na NULL. Morda smo videli prenos odvijanja — v bistvu obnovitev registrov v slogu setcontext — kjer je ciljni kazalec ukaza postal NULL pred prenosom nadzora. Z drugimi besedami: napačni podatki iz knjižnice za odvijanje, ne napačna reža povratnega naslova na skladu.
To je težavo močno zožilo. GNU libunwind je bodisi izračunal napačno ciljno stanje bodisi izračunal pravo stanje, a ga je nekaj pokvarilo, preden se je lahko uporabilo.
Prebrali smo izvorno kodo GNU libunwind in ugotovili, da na skladu sintetizira ucontext_t, vanj vpiše želeno stanje registrov za okvir obravnavalnika čiščenja, nato pa kazalec na to strukturo preda notranji zbirniški rutini: _Ux86_64_setcontext.
Na tej točki smo imeli vse koščke.
Sintetizirani ucontext_t med izvajanjem _Ux86_64_setcontext živi v enem od okvirjev sklada, ki jih ta funkcija odvije. Ali je _Ux86_64_setcontext bral iz strukture po tem, ko je spremenil %rsp, ko struktura ni bila več del aktivnega sklada? Zaradi tega bi bila ranljiva za prepis ob dostavi signala, na primer našega pogostega SIGUSR2.
Odgovor je bil da.
Tukaj je zadnjih šest instrukcij _Ux86_64_setcontext v različici GNU libunwind, ki smo jo uporabljali; večinoma so to instrukcije mov, ki nalagajo iz pomnilnika v ciljni register:
(%rdi kaže na ucontext_t, dodeljen na skladu, makri UC_MCONTEXT_* pa se le razširijo v fiksni odmik, kjer je shranjen določen register.)
Prva instrukcija je začetek tekmovalnega okna. Posodobi %rsp, da kaže na novo dno aktivnega sklada. Takoj ko se to zgodi, struktura, na katero kaže %rdi, ni več del aktivnega sklada (ali rdečega območja) in za jedro ni več nedotakljiva.
Običajno to ne povzroča težav, če pa signal prispe v ravno pravem (napačnem?) trenutku, bo jedro zgradilo signalni okvir na %rsp-128. To lahko prepiše pomnilnik, na katerega kaže %rdi.
Če se to zgodi, preden naslednja instrukcija prebere UC_MCONTEXT_GREGS_RIP(%rdi), se lahko obnovljeni kazalec ukaza pokvari. Pri naših zrušitvah je postal NULL.
To je napaka.
Ta zbirniška koda pojasni tudi eno od opažanj, ki nas je zmedlo: zakaj je imela funkcija X v reži za povratni naslov prejšnjega okvirja sklada vrednost NULL.
setcontext je bil napisan tako, da obnovi vse registre, tudi %rdi, zato tega registra v zadnjem trenutku prenosa nadzora ne more uporabiti za branje UC_MCONTEXT_GREGS_RIP(%rdi). Namesto tega vrednost prebere prej, jo shrani na sklad, obnovi še nekaj registrov, nato pa z retq prebere shranjeno vrednost in prenese nadzor.
Kar je bilo v core datotekah videti kot »funkcija se je vrnila na NULL«, je bilo v resnici »unwinder je na skladu sintetiziral ciljni povratni naslov, vendar se je ta cilj pokvaril, preden se je prenos končal«. Predpostavili smo, da se mora pokvarjenje reže za povratni naslov zgoditi na mestu, ker nismo poznali mest, kjer bi se (pokvarljivi) podatki namerno zapisovali v režo za povratni naslov.
Ta napaka se zdi absurdna zaradi izjemno ozkega tekmovalnega okna. Pri takšni tekmovalni napaki se mora zunanji dogodek (signal) zgoditi med dvema korakoma druge niti. Bližje ko sta si ta koraka, manj verjetna je tekmovalna napaka.
V tem primeru je ranljivo okno dobesedno široko eno instrukcijo! Signal mora biti dostavljen po spremembi %rsp, vendar preden naslednja instrukcija naloži %rip. Na sodobnem superskalarnem CPU z izvajanjem izven vrstnega reda se lahko na cikel izvede več takih preprostih instrukcij, zato je tekmovalno okno dolgo približno sto pikosekund.
Ko smo našli to tekmovanje, je bil naš prvi odziv, da je gotovo preredko za opaženo stopnjo zrušitev. Po celotni floti smo videli več kot ducat zrušitev return-to-null na dan. Bi lahko tekmovanje ene instrukcije med čiščenjem izjem res pojasnilo to?
Obrnili smo se k Fermijevi oceni. Če je ranljivo okno reda sekunde in SIGUSR2 prispe vsakih sekunde časa CPU, ima vsak obravnavalnik čiščenja izjem ali blok catch približno verjetnosti, da izgubi tekmo.
Rockset uporablja izjeme kot del notranjega mehanizma protitlaka pri vnosu. En sam preobremenjen gostitelj lahko vrže reda izjem na sekundo. To pomeni, da je povprečni čas med odpovedmi gostitelja, ki uporablja protitlak, sekund oziroma ena zrušitev na nekaj ur. Na ravni flote je to več kot dovolj za pojasnitev opažene pogostosti zrušitev.
Napaka v GNU libunwind je stara — več kot 18 let, prisotna že v prvi različici x86_64, ki je podpirala odvijanje izjem C++.
Zakaj se je torej pojavila zdaj?
Stopnja zrušitev je približno sorazmerna s številom vrženih izjem in dostavljenih signalov. Odvisna je tudi od tega, koliko sklada porabi obravnavalnik signala.
Rockset je neobičajen po vseh treh oseh. Izjeme pogosto mečemo kot del običajnega nadzora preobremenitve; SIGUSR2 zaradi coarse_thread_cputime_clock dostavljamo neobičajno pogosto; letos pa smo obravnavalnik SIGUSR2 prisilili k večji porabi sklada z dodanim klicem timer_getoverrun, da bi upoštevali združene signale.
Zdi se, da je bila zadnja sprememba pomembna. Če obravnavalnik porabi dovolj malo sklada, morda ne doseže in prepiše zastarelega pomnilnika ucontext_t. Pred to spremembo teh zrušitev sploh ne opažamo. Po spremembi je stopnja ostala nizka, dokler nismo povečali obremenitve za nekatere primere rabe, ki so obremenili mehanizem protitlaka.
Z drugimi besedami: napaka libunwind je bila vedno prisotna, a produkt naše stopnje izjem, stopnje signalov in porabe sklada obravnavalnika je šele pred kratkim presegel prag operativne vidnosti.
Ta mehanizem pojasni tudi naključje, da sta se tako strojna napaka kot napaka libunwind večinoma zrušili znotraj DocumentTree::updateDocument. Zrušitve iz libunwind so bile močno nagnjene k tej metodi, ker je vedno aktivna, ko vržemo izjemo za uporabo protitlaka pri vnosu. Močno je bila izbrana tudi za zrušitve z napačno poravnanim %rsp, ker je bilo okvarjeno strojno vozlišče SKU, ki ga uporabljamo za množični vnos in večino časa CPU porabi v tej metodi.
Naša takojšnja omilitev je bila prehod z GNU libunwind na unwinder iz libgcc. To je bila že sama po sebi dobra menjava: implementacija libgcc je imela veliko izboljšav za zmanjšanje sporov pri zaklepanju, kar je pomembno pri skaliranju na velike VM-je.
V GNU libunwind smo prispevali tudi samostojen reproduktor in popravek(odpre se v novem oknu) ter preverili, da drugi unwinderji nimajo podobne težave.
Ta pot razhroščevanja nas je veliko naučila o podrobnostih dinamičnega povezovanja, metapodatkih odvijanja DWARF, dostavi signalov v Linuxu, ABI System V in mehaniki izjem C++. A glavna lekcija je bila preprostejša od vsega tega.
Najpomembnejši korak ni bilo spretno branje zbirniške kode ali poglobljeno poznavanje podrobnosti. Bil je izgradnja kakovostnega nabora podatkov. Brez tega nabora podatkov smo dva ločena pojava mešali v eno zgodbo in se iz zmede poskušali izkopati z razmišljanjem. Ko smo imeli natančne in popolne populacijske podatke, je struktura težave postala očitna: ena populacija zrušitev je pripadala slabemu gostitelju, druga pa tekmi v libunwind. Ko so se podatki izboljšali, je razhroščevanje postalo lažje.
Za infrastrukturne sisteme, kot je Rockset, je to zelo pomembno. Ta preiskava je utrdila našo zavezanost globoki instrumentaciji, avtomatiziranim preiskavam in stalnim izboljšavam operativnih orodij. Zanesljivost ni le odpravljanje napak po tem, ko se zgodijo — pomeni graditi podatke, poteke dela in veščine, ki nemogoče težave spremenijo v diagnosticirljive in rešljive.
Avtorji
By Nathan Bronson in Member of Technical Staff


