કોર ડમ્પ એપિડેમિયોલોજી: 18 વર્ષ જૂના બગનો ઉકેલ
અમારા ડેટા ઇન્ફ્રાસ્ટ્રક્ચરમાં મુશ્કેલ ક્રેશ ડિબગ કરવા વસ્તી-સ્તરના વિશ્લેષણનો ઉપયોગ.
OpenAIનાં મોડલ અને એજન્ટ ઇન્ફરન્સ સમયે, એટલે કે મોડલ તમારા પ્રશ્ન વિશે વિચારી રહ્યા હોય ત્યારે, સંબંધિત ડેટા શોધવા માટે વધુને વધુ સ્કેલ કરી શકાય તેવા ડેટા ઇન્ફ્રાસ્ટ્રક્ચર પર આધાર રાખે છે. આમાંથી કેટલીક સેવાઓ C++માં લખાયેલી છે, જેનું સિસ્ટમ પરનું નીચા સ્તરનું નિયંત્રણ અમને પ્રદર્શન મહત્તમ કરવા અને મેમરી વપરાશ ઘટાડવા દે છે. અમે સ્કેલ કરીએ ત્યારે કાર્યક્ષમતાના આ લાભો મહત્વપૂર્ણ છે, પરંતુ C++માં મેમરી સેફ્ટી ન હોવાને કારણે બગ ખોટા અથવા અસ્તિત્વમાં ન હોય તેવા મેમરી એડ્રેસ પર લખીને ક્રેશ કરાવી શકે છે.
થોડા મહિના પહેલાં અમે Rockset સેવાની અંદરથી કેટલાક ક્રેશ જોયા; તે અમારા ChatGPT ડેટા ઇન્ફ્રાસ્ટ્રક્ચરનો ખાસ બનાવેલો ભાગ છે, જે ઘણા ડેટા પ્લગઇન અને વાતચીતોમાં શોધ માટે મહત્વપૂર્ણ છે. આ દરેક ક્રેશમાં, સામાન્ય C++ ફંક્શન પૂરું થયું હોય અને પછી ખોટા એડ્રેસ પર રિટર્ન થયું હોય એમ લાગતું હતું, જેના કારણે ઇન્સ્ટ્રક્શન પોઇન્ટર હવે કોડ તરફ ન બતાવતું હોવાથી કર્નલે પ્રોગ્રામ રોકી દીધો. ક્યારેક સ્ટેક ફ્રેમમાં રિટર્ન એડ્રેસ સ્લોટ NULL હતો. ક્યારેક સ્ટેક પોઇન્ટર CPU રજિસ્ટર પોતે જ 8 બાઇટથી ખસેલું લાગતું હતું, જાણે સામાન્ય એક્ઝિક્યુશન દરમિયાન %rsp કોઈ રીતે ઘટાડાયું હોય. બંને કિસ્સામાં ક્રેશ રિટર્ન વખતે થયો.
એપ્લિકેશન કોડ માટે આ સામાન્ય નિષ્ફળતા પદ્ધતિઓ નથી. માત્ર સચવાયેલા રિટર્ન એડ્રેસ પર જ ઉતરતું કોઈ ભટકેલું રાઇટ શક્ય છે, પરંતુ અત્યંત અસંભવ છે. inline assembly, setcontext અથવા longjmp (જેમાંનું કશું અમે વાપરતા નથી) વગર %rspને 8થી મિસઅલાઇન કરતો બગ તો વધુ અજોડ છે, કારણ કે કમ્પાઇલ થયેલો કોડ ફંક્શનના પ્રોલોગ અને એપિલોગમાં જ તે રજિસ્ટરને સીધો એડજસ્ટ કરે છે. અમે (અથવા ChatGPTએ) વિચારેલી દરેક કલ્પનાના વિરોધમાં મજબૂત પુરાવા હતા, તેથી બગ અશક્ય લાગતો હતો.
અમે જેને એક સમસ્યા માની હતી તે અંતે, યોગાનુયોગે એક જ સમયે મળેલા, બે અસંબંધિત બગ નીકળ્યા. પહેલો, એક Azure હોસ્ટ પર મૌન હાર્ડવેર કરપ્શન, જ્યાં CPU ગણિત જ બરાબર કરતું નહોતું. બીજો, GNU libunwindમાં 18 વર્ષ જૂની રેસ કન્ડિશન, વ્યાપક રીતે વપરાતી ઓપન સોર્સ લાઇબ્રેરીમાં અવગણાયેલો બગ.
આ પોસ્ટ એ વાતની કહાની છે કે કેવી રીતે અમે એપિડેમિયોલોજિસ્ટની જેમ વિચારીને અને ક્રેશની આખી વસ્તી વિશે ઉચ્ચ ગુણવત્તાનો ડેટાસેટ બનાવીને દેખીતી રીતે સમજાઈ ન શકે એવા ક્રેશ ઓળખ્યા અને સુધાર્યા.
પહેલા, Rockset વિશે વધુ ઊંડે જઈએ. તે શોધ અને રિયલ-ટાઇમ એનાલિટિક્સ માટેની ક્લાઉડ-નેટિવ ડેટા સિસ્ટમ છે, જેનો ઉપયોગ અમે OpenAIમાં ઘણા આંતરિક ઉપયોગો માટે કરીએ છીએ, જેમ કે sync connectors (Rocksetને 2024માં OpenAIએ અધિગ્રહિત કર્યું હતું). સ્ટ્રીમિંગ અપડેટ્સનો ઉપયોગ વર્કસ્પેસના નોલેજ બેઝનો અદ્યતન ઇન્ડેક્સ જાળવવા માટે થાય છે, જેથી ChatGPT પ્રશ્નોના જવાબ આપતી વખતે અથવા ક્રિયાઓ કરતી વખતે સંબંધિત માહિતી શોધી શકે.
Rocksetનું એક્ઝિક્યુશન લેયર C++માં લખાયેલું છે. C++ ભાષા CPU સુધી નીચા સ્તરની ઍક્સેસ આપે છે, જે પ્રદર્શન અને કાર્યક્ષમતા માટે સારું છે, પરંતુ તેનો અર્થ એ પણ છે કે એપ્લિકેશન બગ અમાન્ય મેમરી ઍક્સેસ અને segfaults તરફ દોરી શકે છે. આને શોધવામાં મદદ માટે, ક્રેશ થાય ત્યારે સ્ટેક ટ્રેસ લોગ કરવા અમે follyના fatal signal handlerનો ઉપયોગ કરીએ છીએ, અને પછીના વિશ્લેષણ માટે સંબંધિત core dumps (ક્રેશ સમયે પ્રોગ્રામની સ્થિતિનો સ્નૅપશોટ) Azure blob storageમાં અપલોડ કરીએ છીએ. Rocksetના તમામ ક્વેરી પ્રોસેસિંગ leaves રિપ્લિકેટેડ છે, જે ક્રેશનો ક્લાયન્ટ પરનો પ્રભાવ ઘટાડે છે. તેમ છતાં, દરેક segfault એક એવા બગને દર્શાવે છે જેને અમારી વિશ્વસનીયતા અને ગુણવત્તાના લક્ષ્યો માટે સુધારવો જરૂરી છે.
અમારો પ્રારંભિક અભિગમ આ કોરને પરંપરાગત ડિબગિંગ સમસ્યા તરીકે લેવાનો હતો: થોડા core dumpsને ખૂબ નજીકથી જોવું, કલ્પનાઓ બનાવવી અને એક પછી એક ખારિજ કરવી.
મોટાભાગના ક્રેશ DocumentTree::updateDocument નામની મેથડમાં થયા. આ ક્રેશમાં એવું લાગતું હતું કે updateDocumentએ કોઈ અજ્ઞાત ફંક્શન X કૉલ કર્યું હતું, X સક્રિય હતું ત્યારે સ્ટેક કરપ્ટ થયો, અને પછી X એવા એડ્રેસ પર રિટર્ન થયું જે એક્ઝિક્યુટેબલ કોડ નહોતું. કેટલાક કિસ્સામાં Xની હમણાં જ પૉપ થયેલી ફ્રેમ માન્ય લાગતી હતી, માત્ર તેનો સચવાયેલો રિટર્ન એડ્રેસ NULL હતો. બીજા કિસ્સામાં સ્ટેક પોઇન્ટર પોતે ખોટું લાગતું હતું, પણ આગલી માન્ય ફ્રેમ હજી પણ updateDocument જ લાગતી હતી.
સ્ટેક ક્યારે કરપ્ટ થઈ રહ્યો હતો તે અમને ખબર નહોતી, તેથી શોધવાની જગ્યા વિશાળ રહી. updateDocument મોટી મેથડ છે અને તેમાં ઘણું inlining થાય છે, એટલે X માટેના ઉમેદવારોની સંખ્યા હદથી વધારે હતી.
શું આ અમારા C++ કોડમાં બગ હતો? કમ્પાઇલર અથવા લિંકેજ સમસ્યા? અમારી કોઈ runtime libraryમાં સમસ્યા? સિગ્નલ ડિલિવરી અથવા કોન્ટેક્સ્ટ સ્વિચિંગ આસપાસ Linux કર્નલ બગ? કે કંઈક વધુ જ દુર્લભ? જો આ કોઈ ભટકેલું રાઇટ હતું, તો અમારી ASAN staging environmentએ તેને પકડ્યું કેમ નહીં?
સમસ્યાના બધા કિસ્સા ઓળખવા માટે અમે એપ્લિકેશન-સ્તરના લોગ વાપરવાનો પ્રયાસ કર્યો, પરંતુ સ્ટેક-કરપ્શન બગને માત્ર લોગ પરથી વર્ગીકૃત કરવું મુશ્કેલ છે, કારણ કે લોગ થયેલા સ્ટેક ટ્રેસ પોતે જ કરપ્ટ અથવા ગાયબ હોય છે. અમે એવી log query બનાવી શક્યા નહીં જેમાં false positives અને false negatives બંને ન હોય. અમે હાથેથી વધુ કોર તપાસ્યા અને કેટલાક વધારાના ઉદાહરણો મળ્યાં, પણ વિશ્વસનીય ડેટાસેટ આપવા માટે તે પ્રક્રિયા બહુ મહેનતભરી હતી.
તપાસના આ તબક્કે, અમે (ખોટી રીતે) હાર્ડવેર બગને નકારી કાઢ્યો, કારણ કે અનેક પ્રદેશો અને અનેક હાર્ડવેર પ્રકારોમાં ક્રેશ દેખાયા હતા; તેથી અમે હજી પણ માત્ર સોફ્ટવેર કારણો શોધી રહ્યા હતા. કેટલાક દિવસો સુધી અમે એક જ misaligned-%rsp ક્રેશમાં અત્યંત ઊંડા ઊતર્યા, સ્ટેક અને રજિસ્ટર સામગ્રીનો ઉપયોગ કરીને ક્રેશ પહેલાંનો ઇતિહાસ ફરી બનાવ્યો. તેમાંથી કેટલાક સંભવિત સંકેતો મળ્યા, પરંતુ બધા બગનું કારણ એક જ છે એવા અમારા પ્રારંભિક નિષ્કર્ષો છોડ્યા નહીં, એટલે અમે અટવાયેલા જ રહ્યા.
અમારી તપાસના વળાંકબિંદુ સુધી પહોંચતા પહેલાં, અમે core filesમાંથી કઈ પ્રકારની માહિતી કાઢી રહ્યા હતા તે સમજાવવું જરૂરી છે.
Rockset -fno-omit-frame-pointer સાથે કમ્પાઇલ થાય છે, તેથી સક્રિય stack frame હંમેશા %rbp મારફતે પહોંચી શકાય છે, અને callers frame pointersની linked list બનાવે છે.
Linux x86_64 પર, AMD64 System V ABI %rspની નીચેના 128 બાઇટને red zone તરીકે પણ આરક્ષિત રાખે છે. તે વિસ્તાર userspace code માટે ઉપલબ્ધ છે અને, મહત્વનું એ કે, ABI કરારના ભાગરૂપે કર્નલ વચન આપે છે કે સિગ્નલ આપતી વખતે તે તેને clobber નહીં કરે.
રિટર્ન પછીના ક્રેશને ડિબગ કરવામાં red zone કેન્દ્રસ્થાને હતો, કારણ કે તે રિટર્ન પહેલાંની કેટલીક માહિતી સાચવી રાખે છે. જ્યારે SIGSEGV ટ્રિગર થાય છે, ત્યારે follyનો fatal signal handler ક્રેશ થ્રેડના સ્ટેક પર ચાલે છે. જે stack frames હવે સક્રિય નથી (કારણ કે તેમના ફંક્શન રિટર્ન થઈ ગયા છે) તે signal handlerથી clobber થશે, છેલ્લા 128 બાઇટ સિવાય. એટલે જ અમે કહી શકીએ છીએ કે “Xની હમણાં જ પૉપ થયેલી stack frame માન્ય લાગતી હતી, NULL return address સિવાય.” red zone કેટલીક નિષ્ક્રિય frames, અથવા ક્યારેક માત્ર એક નિષ્ક્રિય frameનો છેલ્લો ભાગ, સાચવી રાખે છે.
અમને એક misaligned-stack crash મળ્યો જેમાં સામેલ બધા ફંક્શન ખૂબ નાના હતા. તેના કારણે અમને દેખાયું કે %rsp એક પ્રમાણમાં સરળ ફંક્શનના execution દરમિયાન misaligned થયું હતું, અને ત્યારબાદ વધુ calls સફળ રહ્યા હતા. પ્રોગ્રામ માત્ર ત્યારે ક્રેશ થયો જ્યારે સક્રિય ફંક્શન અંતે રિટર્ન કરવાનો પ્રયાસ કર્યો. તે code pathsમાં ક્યાંય exceptions, inline assembly, setcontext અથવા longjmp વપરાયા નહોતા, તેથી જો core સૂચવે તેમ stack pointer સાચે બદલાયો હોય, તો userspace codeમાં કોઈ વિશ્વસનીય બગ આ સમસ્યા સમજાવી શકતો નહોતો.
તે અમને કર્નલ તરફ ધકેલતું હતું.
Rockset મોટાભાગના પ્રોગ્રામ્સ કરતાં signalsનો વધુ આક્રમક રીતે ઉપયોગ કરે છે. ક્વેરી executionને ડેટા આપલે કરતી ઘણી હળવી tasksમાં વહેંચવામાં આવે છે. ઉચ્ચ-QPS workloadsને કાર્યક્ષમ રીતે સંભાળવા માટે આ મહત્વપૂર્ણ છે, પરંતુ અનેક queriesનું કામ એક જ thread pool પર multiplex થતું હોવાથી per-query CPU accounting અણઘડ બને છે.
અમારો ઉપાય coarse_thread_cputime_clock કહેવાય છે, જે clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...)નો એટલો સસ્તો અંદાજ આપે છે કે દરેક task boundary પર sample લઈ શકાય. timer_create APIનો ઉપયોગ CPU સમયના સંગ્રહ સહિત સમય પસાર થવાની અનેક કલ્પનાઓના આધારે periodic signal delivery શેડ્યૂલ કરવા માટે થઈ શકે છે. અમે CPU સમયના દર થોડા millisecondsે signal (SIGUSR2) આપવાનું શેડ્યૂલ કરીએ છીએ, અને તે સમયે signal handler thread-local value અપડેટ કરે છે. ઘણી tasks execution દરમિયાન coarse clock આગળ વધતો નથી જોતી, છતાં બધા deltasનો સરવાળો query માટે વાસ્તવિક CPU સમયનો નિષ્પક્ષ અંદાજ આપે છે.
અમે signals એટલા વારંવાર આપીએ છીએ તેથી context switching અથવા signal delivery આસપાસનો કોઈ દુર્લભ કર્નલ બગ સંભવિત લાગ્યો. અમે bug reports, kernel source code અને Azure-વિશિષ્ટ kernel patches વાંચવામાં સમય વિતાવ્યો. અમે stress tests અજમાવ્યાં. સંબંધિત લાગે એવું કંઈ મળ્યું નહીં.
એ સમયે અમે થોડું પાછળ હટીને અલગ અભિગમ અજમાવવાનો નિર્ણય કર્યો.
આવી સમસ્યાને ડિબગ કરવાની બે વ્યાપક રીતો છે.
એક રીત છે જાણે ડૉક્ટર હોય તેમ વર્તવું: એક દર્દી પર ધ્યાન આપવું, ઘણી તપાસો ચલાવવી અને વિગતવાર પુરાવાથી એક કેસનું નિદાન કરવાનો પ્રયાસ કરવો.
બીજી રીત એપિડેમિયોલોજિસ્ટ જેવી છે: આખી વસ્તીને જોવી અને પૂછવું કે શું એવા patterns છે જે એક કેસ બતાવી ન શકે. શું બગ કોઈ ચોક્કસ releaseથી શરૂ થયો? શું તેનો સંબંધ એક hardware SKU (ચોક્કસ CPU અને server model), એક region, અથવા એક kernel version સાથે છે? જે એક syndrome જેવું લાગે છે તેની અંદર ઘણા અલગ clusters છુપાયેલા છે?
અમે મોટાભાગે doctor modeમાં જ રહ્યા હતા. મુખ્ય ફેરફાર એ નિર્ણય હતો કે અમારે ઉચ્ચ ગુણવત્તાનો population data એકત્ર કરવો જરૂરી છે.
સમસ્યાના બધા instances આપોઆપ શોધવાના અમારા અગાઉના પ્રયાસો નિષ્ફળ ગયા કારણ કે અમે logs પર text searches વાપરવાનો પ્રયાસ કરતા હતા. core dumpsમાં પોતે ઘણી વધુ માહિતી હોય છે, પરંતુ તેમને હાથેથી જોવું scale થતું નહોતું. અમે core dumpsનું આપોઆપ વિશ્લેષણ કરી શકે એવી pipeline બનાવવામાં પ્રયત્ન કરવાનો નિર્ણય કર્યો.
અમે ChatGPT પાસે script લખાવી, જે દરેક core fileનો prefix ડાઉનલોડ કરતી, registers કાઢતી, logsથી known false positives ફિલ્ટર કરતી અને crashને return-to-null, misaligned-stack અથવા other તરીકે આપોઆપ label કરતી. પછી અમે ગયા વર્ષની દરેક production Rockset core dump પર તે script parallelમાં ચલાવી.
આ જ વળાંકબિંદુ હતું.
એકવાર અમારી પાસે સ્વચ્છ data set આવ્યું, correlations તરત જ દેખાવા લાગ્યા. અમે જેને એક અજોડ બગ ગણતા હતા તે ખરેખર બે અલગ crash populations હતા.
return-to-null cores ઘણા clusters અને geographic regionsમાં ફેલાયેલા હતા. તેમની frequency તાજેતરમાં વધી હતી, પરંતુ કોઈ સ્પષ્ટ start date કે સ્વચ્છ infrastructure boundary નહોતી.
misaligned-stack crashes સંપૂર્ણ અલગ દેખાતા હતા. તે બધા એક regionમાંથી આવ્યા હતા, તેમની clear start date હતી, અને લાંબા સમયથી ચાલતા નોડ પર ક્યારેય થયા નહોતા. તેઓ અનેક Azure VMs (cloudમાં hosted virtual machines)માં દેખાતા હોવા છતાં pattern એવો લાગતો હતો કે ખરાબ hardwareવાળી એક physical machine જેના પર જે VM આવી પડે તેને સમસ્યા કરાવતી હતી.
એ ક્ષણે અમને સમજાયું કે અમે મનમાં બે બગને ભેગા કરી રહ્યા હતા. કારણ કે અમે બંને બગમાંથી counterexamples ભેળવી રહ્યા હતા, અમને એકમાત્ર સુસંગત સમજૂતી મળી શકતી નહોતી.
Kubernetes નોડ અને timestampsની સ્વચ્છ યાદી મળતાં, અમે misaligned-stack crashesને એક જ physical host સુધી trace કરી શક્યા, જેને denylist કરવું સરળ હતું.
કેટલાક અઠવાડિયા સુધી stress testing કર્યા પછી પણ, અમે controlled environmentમાં તે host પર register corruption ફરી પેદા કરી શક્યા નહીં. પરંતુ સમસ્યાજનક hostને serviceમાંથી કાઢી નાખ્યા પછી misaligned-stack crashes ગાયબ થઈ ગયા.
ખરાબ host દૂર કરવું કાયમી ઉકેલ નથી, એ અર્થમાં કે તે આ જ સમસ્યા ફરી થવાથી રોકતું નથી. પરંતુ અમે software બદલી શકીએ છીએ જેથી સમાન issue ફરી થાય તો તેને સરળતાથી શોધી અને સંભાળી શકાય. અમે અમારા fatal signal handlerને register state સામેલ કરવા સુધાર્યો, જેથી માત્ર logs પરથી recurrence શોધી શકાય (core dump જરૂરી નથી). અમે control plane બદલી જેથી VMs સામાન્ય રીતે recycle કરતાં reuse થાય, જે infrastructure stackના અમારા સ્તરે bad-node detection ઘણું સરળ બનાવે છે. અમે અમારા runbooks (અને અમારી teamનાં mental models) પણ આ શક્યતા સામેલ કરવા update કર્યા.
bad-host crashes અલગ કર્યા પછી, બાકીના return-to-null cores વિશે વિચારવું ઘણું સરળ બન્યું. અગાઉ અમે exception unwindingને નકારી કાઢ્યું હતું, કારણ કે અમને લાગતું હતું કે અમારી પાસે counterexamples છે: એવા code pathsમાં crashes જ્યાં exceptions ચોક્કસપણે વપરાયા નહોતા. પણ તે counterexamples બધા hardware-corruption clusterમાંથી હતા.
એકવાર અમે એ ધ્યાનમાં રાખીને બાકી cores ફરી જોયા, અમને ખબર પડી કે આ નિષ્કર્ષ તો બરાબર ઊલટો હતો: બધા crashes exception unwinding દરમિયાન જ થઈ રહ્યા હતા.
જ્યારે C++ exception throw કરે છે, ત્યારે runtimeએ શોધવું પડે છે કે કયો catch block તેને મેળવે અને રસ્તામાં કયા destructors અથવા cleanup handlers ચાલે. Compiler આ metadata ઉત્પન્ન કરે છે, પરંતુ actual matching runtime પર dynamically થાય છે.
Exception unwinding વાસ્તવમાં throw invoke કરતું function કરતું નથી, પરંતુ બનેલા compiled code દ્વારા બોલાવવામાં આવતી helper functions કરે છે. આ runtime routines stack તપાસે છે, stack પર મળેલી functions વિશે metadata લાવે છે, cleanup handlers અને catch blocksને dynamically શોધે છે, અને પછી તે locationsમાંથી એક પર control transfer કરે છે. Control transferમાં વચ્ચેની બધી stack frames unwind કરવી સામેલ છે (helper functionsની frames સહિત).
Operationally, આ normal call અને return કરતાં longjmp અથવા fiber switchને ઘણું નજીક છે. Callee save registers restore કરવા પડે છે, તેમજ stack frame registers %rbp અને %rsp પણ.
અમારી binary બે libraries સામે link થાય છે જેમાં C++ exception unwinding કરતી functionsનાં implementations છે: libgcc અને GNU libunwind. Dynamic linkerએ GNU libunwindની definitions પસંદ કરી. આથી અમને આશ્ચર્ય થયું; symbol versioning rulesને કારણે libgcc implementation જીતશે એવી અમારી અપેક્ષા હતી; પરંતુ running binaries તપાસતાં એવું નહોતું.
આ સમયે અમારી working hypothesis બદલાઈ, કારણ કે એક જ બગ છે એવું વિચારતી વખતે કરેલી બીજી એક assumption અમે ઢીલી કરી.
કદાચ અમે સામાન્ય functionનું NULL પર return જોઈ રહ્યા નહોતા. કદાચ અમે unwind transfer જોઈ રહ્યા હતા—અસરકારક રીતે setcontext-style register restore—જ્યાં control transfer થાય તે પહેલાં destination instruction pointer NULL બની ગયો હતો. બીજા શબ્દોમાં, stack પરના ખોટા return address slotને બદલે unwind libraryમાંથી આવેલો ખોટો data.
તેને કારણે સમસ્યા ઘણી સંકુચિત થઈ. અથવા GNU libunwind ખોટી destination state compute કરી રહ્યું હતું, અથવા તે સાચી state compute કરી રહ્યું હતું અને લાગુ થાય તે પહેલાં કંઈક તેને corrupt કરી રહ્યું હતું.
અમે GNU libunwind source વાંચ્યું અને જોયું કે તે stack પર ucontext_t synthesize કરે છે, cleanup handlerની frame માટે ઇચ્છિત register state ભરે છે, અને પછી તે structનો pointer internal assembly routineને આપે છે: _Ux86_64_setcontext.
આ સમયે અમારી પાસે બધા ટુકડા હતા.
synthesized ucontext_t એવી stack framesમાંથી એકમાં રહે છે જેને _Ux86_64_setcontext તે functionના execution દરમિયાન unwind કરે છે. શું _Ux86_64_setcontext %rsp બદલ્યા પછી structમાંથી વાંચતું હતું, જ્યાંથી struct હવે active stackનો ભાગ રહેતો નહોતો? એવું હોય તો તે અમારા વારંવારના SIGUSR2 જેવી signal deliveryથી clobber થવા માટે vulnerable બને.
જવાબ હા હતો.
અમે વાપરતા GNU libunwind versionમાં _Ux86_64_setcontextની છેલ્લી છ instructions અહીં છે, જેમાં મોટેભાગે memoryમાંથી destination registerમાં load કરતી mov instructions છે:
(%rdi stack-allocated ucontext_t તરફ point કરે છે, અને UC_MCONTEXT_* macros માત્ર તે fixed offsetમાં expand થાય છે જ્યાં કોઈ ખાસ register stored હોય છે.)
પહેલી instruction race windowની શરૂઆત છે. તે %rspને active stackના નવા bottom તરફ point કરવા update કરે છે. આ થતાની સાથે, %rdi જે struct તરફ point કરે છે તે હવે active stack (અથવા red zone)નો ભાગ રહેતું નથી, અને તે kernel માટે off-limits રહેતું નથી.
સામાન્ય રીતે આ સમસ્યા કરતું નથી, પરંતુ જો signal એકદમ સાચી (કે ખોટી?) ક્ષણે આવે, તો kernel %rsp-128 પર signal frame બનાવશે. તે %rdi દર્શાવતી memoryને overwrite કરી શકે છે.
જો આવું આગળની instruction UC_MCONTEXT_GREGS_RIP(%rdi) વાંચે તે પહેલાં થાય, તો restored instruction pointer corrupt થઈ શકે છે. અમારા crashesમાં, તે NULL બની ગયો.
બગ એ જ છે.
આ assembly અમને ગૂંચવેલા એક અવલોકનને પણ સમજાવે છે: function Xના અગાઉના stack frameના return address slotમાં NULL કેમ હતું.
setcontext બધા registers, જેમાં %rdi પણ આવે છે, restore કરવા લખાયું હતું, તેથી control transferની અંતિમ ક્ષણે UC_MCONTEXT_GREGS_RIP(%rdi) વાંચવા તે register વાપરી શકતું નથી. તેના બદલે, તે value પહેલાં વાંચે છે, તેને stackમાં સાચવે છે, થોડા વધુ registers restore કરે છે, પછી saved value વાંચવા અને control transfer કરવા retq વાપરે છે.
કોરમાં જે “function NULL પર returned” જેવું લાગતું હતું તે વાસ્તવમાં “unwinderએ stack પર target return address બનાવ્યો, પરંતુ transfer પૂરું થાય તે પહેલાં તે target corrupt થઈ ગયો.” અમે માની લીધું હતું કે return address slotનું corruption in-place જ થવું જોઈએ, કારણ કે અમને ખબર નહોતી કે ક્યાંક (corruptible) dataને return address slotમાં ઇરાદાપૂર્વક લખવામાં આવે છે.
આ બગ અતિઅસંગત લાગે છે તેનું કારણ આ race windowની અતિસંકુચિતતા છે. આ પ્રકારની race conditionમાં, external event (signal) બીજી thread દ્વારા લેવાયેલા બે stepsની વચ્ચે થવો જરૂરી છે. આ steps જેટલા નજીક હોય, race condition થવાની શક્યતા એટલી ઓછી.
આ કિસ્સામાં vulnerable window ખરેખર માત્ર એક instruction જેટલું પહોળું છે! %rsp બદલાયા પછી, પણ આગળની instruction %rip લોડ કરે તે પહેલાં signal deliver થવો જોઈએ. આવી અનેક simple instructions આધુનિક super-scalar out-of-order CPU પર પ્રતિ cycle ચાલી શકે છે, એટલે race window આશરે સો picosecondsનું છે.
જ્યારે અમને આ race મળી, ત્યારે અમારી પહેલી પ્રતિક્રિયા હતી કે જોયેલા crash rateને સમજાવવા તે બહુ દુર્લભ હશે. અમે આખા fleetમાં દરરોજ ડઝનથી વધુ return-to-null crashes જોઈ રહ્યા હતા. exception cleanup દરમિયાનની one-instruction race ખરેખર એટલું સમજાવી શકે?
અમે Fermat estimation તરફ વળ્યા. જો vulnerable window secondsના ક્રમની હોય અને SIGUSR2 CPU timeના દર secondsે આવે, તો દરેક exception cleanup handler અથવા catch block પાસે race હારવાની આશરે probability હોય છે.
Rockset તેના internal ingest backpressure mechanismના ભાગરૂપે exceptions વાપરે છે. એક overloaded host પ્રતિ second જેટલા exceptions throw કરી શકે છે. એથી backpressure વાપરતા hostનો mean time between failures seconds, એટલે દર થોડા કલાકે એક crash, થાય છે. fleet scale પર, જોયેલી crash frequency સમજાવવા માટે આ કરતાં વધારે પૂરતું છે.
GNU libunwind બગ જૂનો છે—18 વર્ષથી પણ જૂનો, C++ exception unwindingને support કરતી પહેલી x86_64 versionમાં હાજર.
તો તે હવે કેમ દેખાયો?
crash rate આશરે કેટલા exceptions throw થાય છે અને કેટલા signals deliver થાય છે તેના પ્રમાણમાં છે. તે signal handler કેટલો stack વાપરે છે તેના પર પણ નિર્ભર છે.
Rockset આ ત્રણેય axes પર અસામાન્ય છે. સામાન્ય overload controlના ભાગરૂપે અમે ઊંચા દરે exceptions throw કરીએ છીએ; coarse_thread_cputime_clockને કારણે અમે SIGUSR2 અસામાન્ય રીતે વારંવાર deliver કરીએ છીએ; અને આ વર્ષની શરૂઆતમાં merged signalsનો હિસાબ રાખી શકીએ માટે timer_getoverrunને call ઉમેરવાથી SIGUSR2 handlerને વધુ stack વાપરતો કર્યો.
આ છેલ્લો ફેરફાર મહત્વપૂર્ણ રહ્યો હોય એવું લાગે છે. જો handler પૂરતો ઓછો stack વાપરે, તો તે stale ucontext_t memory સુધી પહોંચી તેને overwrite ન કરે. તે ફેરફાર પહેલાં, અમે આવા crashes બિલકુલ જોતા નથી. ફેરફાર પછી rate નીચો જ રહ્યો, જ્યાં સુધી અમે backpressure mechanismને stress કરતા કેટલાક use cases માટે load વધાર્યો નહીં.
બીજા શબ્દોમાં, libunwind બગ હંમેશા હતો, પરંતુ અમારી exception rate, signal rate અને handler stack usageનું product તાજેતરમાં જ એવી threshold પાર ગયું કે તે operationally દેખાવા લાગ્યો.
આ mechanism એ યોગાનુયોગ પણ સમજાવે છે કે hardware bug અને libunwind bug બંનેમાં crashes મોટેભાગે DocumentTree::updateDocumentની અંદર થયા. libunwindના crashes આ method તરફ ભારે biased હતા, કારણ કે ingest backpressure લાગુ કરવા exception throw કરીએ ત્યારે તે હંમેશા active હોય છે. %rsp-misalignment crashes માટે પણ તે heavily selected હતું, કારણ કે ખરાબ hardware નોડ એ SKUનો હતો જે bulk ingest માટે અમે વાપરીએ છીએ, અને તે પોતાનો મોટાભાગનો CPU time આ methodમાં વાપરે છે.
અમારો તાત્કાલિક mitigation GNU libunwindમાંથી libgccના unwinder પર switch કરવાનો હતો. આ પોતે જ સારો trade હતો: libgccના implementationને lock contention ઘટાડવાના ઘણા કામનો લાભ મળ્યો છે, જે large VMs સુધી scale કરતી વખતે મહત્વપૂર્ણ છે.
અમે GNU libunwindમાં self-contained reproducer અને fix(નવી વિન્ડોમાં ખૂલે છે) પણ upstream કર્યા, અને ચકાસ્યું કે બીજા unwindersમાં સમાન issue નથી.
આ debugging journeyએ અમને dynamic linking, DWARF unwind metadata, Linux signal delivery, System V ABI અને C++ exception machineryની ચોક્કસ વિગતો વિશે ઘણું શીખવ્યું. પરંતુ મુખ્ય પાઠ આ બધાથી સરળ હતો.
સૌથી મહત્વપૂર્ણ પગલું ચતુર assembly reading અથવા વિગતોનું ઊંડું જ્ઞાન નહોતું. તે હતું ઉચ્ચ ગુણવત્તાનો data set બનાવવો. આ data set વગર, અમે બે અલગ phenomenaને એક storyમાં ભેળવી રહ્યા હતા અને ગૂંચવણમાંથી વિચારથી રસ્તો કાઢવાનો પ્રયાસ કરતા હતા. એકવાર અમારી પાસે ચોક્કસ અને સંપૂર્ણ population data આવ્યું, સમસ્યાની રચના સ્પષ્ટ થઈ ગઈ: એક crash population ખરાબ hostની હતી, અને બીજી libunwindની raceની. ડેટા સારું બનતાં debugging સરળ થયું.
Rockset જેવી infrastructure systems માટે, આ ખૂબ મહત્વનું છે. આ તપાસે deep instrumentation, automated investigations અને અમારા operational toolingમાં સતત સુધારાઓ પ્રત્યેની અમારી પ્રતિબદ્ધતા વધુ મજબૂત કરી. વિશ્વસનીયતા માત્ર બગ થયા પછી તેમને સુધારવાની બાબત નથી—તે એવો data, workflows અને skills બનાવવાની બાબત છે જે અશક્ય સમસ્યાઓને નિદાન કરી શકાય તેવી અને ઉકેલી શકાય તેવી બનાવે છે.
લેખકો
By Nathan Bronson, Member of Technical Staff


