Core dump epidemiology: 18 साल पुराने बग को ठीक करना
हमारे डेटा इंफ्रास्ट्रक्चर में होने वाले जटिल क्रैश की डीबगिंग के लिए जनसंख्या-स्तरीय विश्लेषण का इस्तेमाल.
OpenAI के मॉडल और एजेंट, इनफ़रेंस के समय प्रासंगिक डेटा खोजने के लिए तेज़ी से स्केलेबल डेटा इंफ्रास्ट्रक्चर पर निर्भर हो रहे हैं, यानी जब मॉडल आपके सवाल के बारे में सोच रहे होते हैं. इनमें से कुछ सेवाएँ C++ में लिखी गई हैं. सिस्टम पर इसका लो-लेवल नियंत्रण बेहतर परफ़ॉर्मेंस और कम मेमोरी उपयोग में मदद करता है. जैसे-जैसे हम बड़े स्तर पर काम करते हैं, यह दक्षता अहम होती जाती है. लेकिन C++ में मेमोरी सुरक्षा न होने की वजह से, गलत या मौजूद न रहने वाले मेमोरी एड्रेस पर लिखने से बग क्रैश का कारण बन सकते हैं.
कुछ महीने पहले हमने Rockset सेवा के भीतर कुछ क्रैश देखे. यह हमारे ChatGPT डेटा इंफ्रास्ट्रक्चर का एक विशेष रूप से बनाया गया हिस्सा है, जो कई डेटा प्लग-इन और बातचीत में खोज के लिए अहम है. इनमें से हर क्रैश में, C++ का एक सामान्य फ़ंक्शन पूरा होता दिखा और फिर एक गलत एड्रेस पर लौट गया. इस वजह से कर्नेल ने प्रोग्राम रोक दिया, क्योंकि इंस्ट्रक्शन पॉइंटर अब कोड की ओर संकेत नहीं कर रहा था. कभी-कभी स्टैक फ़्रेम में रिटर्न एड्रेस वाला स्लॉट NULL था. कभी-कभी स्टैक पॉइंटर वाला CPU रजिस्टर खुद आठ बाइट से बदला हुआ लग रहा था, मानो सामान्य निष्पादन के बीच %rsp किसी तरह घट गया हो. दोनों मामलों में क्रैश रिटर्न के समय हुआ.
एप्लिकेशन कोड में इस तरह की विफलताएँ सामान्य नहीं होतीं. ऐसा हो सकता है कि कोई अनचाहा लेखन सिर्फ़ सुरक्षित रखे गए रिटर्न एड्रेस पर ही हो, लेकिन इसकी संभावना बेहद कम है. इससे भी ज़्यादा अजीब वह बग होगा, जो inline assembly, setcontext या longjmp (जिनमें से किसी का भी हम इस्तेमाल नहीं करते) के बिना %rsp को आठ बाइट से गलत संरेखित कर दे. ऐसा इसलिए, क्योंकि कंपाइल किया गया कोड उस रजिस्टर को सीधे केवल फ़ंक्शन के प्रोलॉग और एपिलॉग में ही बदलता है. हम (या ChatGPT) जितनी भी परिकल्पनाएँ सोच सके, उन सभी के ख़िलाफ़ मज़बूत सबूत थे. इसलिए यह बग लगभग असंभव लग रहा था.
शुरुआत में हमें लगा कि यह एक ही समस्या है. लेकिन बाद में पता चला कि ये दो अलग-अलग और असंबंधित बग थे, जो संयोग से एक ही समय पर सामने आए. पहला, एक Azure होस्ट पर बिना किसी स्पष्ट संकेत के हुई हार्डवेयर खराबी, जहाँ CPU सही गणना नहीं कर रहा था. दूसरा, GNU libunwind में 18 साल पुरानी रेस कंडीशन, जो व्यापक रूप से इस्तेमाल होने वाली एक ओपन सोर्स लाइब्रेरी का अब तक अनदेखा बग था.
यह पोस्ट उस कहानी के बारे में है कि कैसे हमने एक महामारी विज्ञानी की तरह सोचकर और सभी क्रैश का उच्च-गुणवत्ता वाला डेटासेट तैयार करके, देखने में रहस्यमय लगने वाले क्रैश की पहचान की और उन्हें ठीक किया.
सबसे पहले, आइए Rockset को थोड़ा गहराई से समझते हैं. यह खोज और रीयल-टाइम एनालिटिक्स के लिए बनाया गया एक क्लाउड-नेटिव डेटा सिस्टम है, जिसका इस्तेमाल OpenAI में कई आंतरिक उपयोगों के लिए किया जाता है, जैसे sync connectors (Rockset का अधिग्रहण OpenAI ने 2024 में किया था). स्ट्रीमिंग अपडेट्स की मदद से किसी वर्कस्पेस के नॉलेज बेस का इंडेक्स हमेशा अद्यतन रखा जाता है, ताकि ChatGPT सवालों के जवाब देते समय या कोई कार्रवाई करते समय प्रासंगिक जानकारी खोज सके.
Rockset की एक्ज़ीक्यूशन लेयर C++ में लिखी गई है. C++ भाषा CPU तक लो-लेवल पहुँच देती है, जिससे बेहतर परफ़ॉर्मेंस और दक्षता मिलती है. लेकिन इसका यह भी मतलब है कि एप्लिकेशन में मौजूद बग अमान्य मेमोरी एक्सेस और सेगफ़ॉल्ट का कारण बन सकते हैं. इनकी पहचान में मदद के लिए, जब भी कोई क्रैश होता है, हम folly के फ़ेटल सिग्नल हैंडलर का इस्तेमाल करके स्टैक ट्रेस लॉग करते हैं. साथ ही, बाद में विश्लेषण के लिए संबंधित core dumps (यानी क्रैश के समय प्रोग्राम की स्थिति का स्नैपशॉट) को Azure blob storage पर अपलोड करते हैं. Rockset की सभी क्वेरी प्रोसेसिंग लीफ़्स की प्रतिकृतियाँ मौजूद रहती हैं, जिससे किसी क्रैश का क्लाइंट पर असर कम से कम होता है. फिर भी, हर सेगफ़ॉल्ट किसी ऐसे बग की ओर इशारा करता है जिसे हमारे विश्वसनीयता और गुणवत्ता लक्ष्यों को पूरा करने के लिए ठीक करना ज़रूरी है.
शुरुआत में हमने इन core dumps को एक सामान्य डीबगिंग समस्या की तरह लिया. हमने कुछ core dumps की बारीकी से जाँच की, संभावित कारणों के अनुमान लगाए, और उन्हें एक-एक करके खारिज किया.
ज़्यादातर क्रैश DocumentTree::updateDocument नाम की एक मेथड में हो रहे थे. इन क्रैश में ऐसा लग रहा था कि updateDocument ने किसी अज्ञात फ़ंक्शन X को कॉल किया, X के चलने के दौरान स्टैक खराब हो गया, और फिर X ऐसे एड्रेस पर लौट गया जो निष्पादन योग्य कोड नहीं था. कुछ मामलों में X का अभी-अभी हटाया गया फ़्रेम सही दिख रहा था, लेकिन उसका सुरक्षित रखा गया रिटर्न एड्रेस NULL था. दूसरे मामलों में स्टैक पॉइंटर ही गलत दिखाई दे रहा था, जबकि अगला सही फ़्रेम फिर भी updateDocument का ही लग रहा था.
हमें यह नहीं पता था कि स्टैक कब खराब हो रहा था, इसलिए संभावित कारणों की तलाश का दायरा बहुत बड़ा था. updateDocument एक बड़ी मेथड है, जिसमें बड़े पैमाने पर इनलाइनिंग होती है. इसलिए X के लिए संभावित फ़ंक्शंस की संख्या बहुत ज़्यादा थी.
क्या यह हमारे C++ कोड में बग था? कंपाइलर या लिंकेज से जुड़ी समस्या थी? हमारी किसी रनटाइम लाइब्रेरी में समस्या थी? सिग्नल डिलीवरी या कॉन्टेक्स्ट स्विचिंग से जुड़ा Linux कर्नेल बग था? या कुछ और भी दुर्लभ? अगर यह अनचाहा राइट था, तो हमारे ASAN स्टेजिंग एनवायरनमेंट ने इसे पकड़ा क्यों नहीं?
हमने समस्या के सभी मामलों की पहचान करने के लिए एप्लिकेशन-स्तर के लॉग का इस्तेमाल करने की कोशिश की, लेकिन सिर्फ़ लॉग के आधार पर स्टैक करप्शन वाले बग की पहचान करना मुश्किल था, क्योंकि लॉग किए गए स्टैक ट्रेस अक्सर खुद ही खराब होते थे या मौजूद नहीं होते थे. हम ऐसी लॉग क्वेरी नहीं बना सके जिसमें फ़ॉल्स पॉज़िटिव्स और फ़ॉल्स नेगेटिव्स, दोनों न हों. हमने और भी कई core dumps की मैन्युअल जाँच की और कुछ अतिरिक्त मामले भी मिले, लेकिन यह प्रक्रिया इतनी मेहनत वाली थी कि इससे भरोसेमंद डेटासेट तैयार नहीं हो सका.
जाँच के इस चरण में हमने (गलती से) हार्डवेयर बग की संभावना को खारिज कर दिया, क्योंकि हमें कई क्षेत्रों और अलग-अलग तरह के हार्डवेयर पर क्रैश दिखाई दे रहे थे. इसलिए हम अब भी सिर्फ़ सॉफ़्टवेयर से जुड़े कारणों की तलाश कर रहे थे. कुछ दिनों तक हमने %rsp के गलत संरेखण वाले एक ही क्रैश की गहराई से जाँच की. इसके लिए स्टैक और रजिस्टर की सामग्री के आधार पर क्रैश से पहले की घटनाओं का क्रम दोबारा तैयार किया. इससे कुछ संभावित सुराग मिले, लेकिन हम अपनी शुरुआती धारणा नहीं छोड़ पाए कि सभी बग का कारण एक ही है. इसलिए इससे भी हम समस्या की असली वजह तक नहीं पहुँच सके.
हमारी जाँच के निर्णायक मोड़ पर पहुँचने से पहले, यह बताना ज़रूरी है कि हम core files से किस तरह की जानकारी निकाल रहे थे.
Rockset को -fno-omit-frame-pointer के साथ कंपाइल किया जाता है. इसलिए सक्रिय स्टैक फ़्रेम तक हमेशा %rbp के ज़रिए पहुँचा जा सकता है, और उसे कॉल करने वाले फ़ंक्शन फ़्रेम पॉइंटर्स की एक लिंक्ड सूची बनाते हैं.
Linux x86_64 पर AMD64 System V ABI, %rsp के नीचे 128 बाइट की जगह को red zone के रूप में आरक्षित रखता है. यह हिस्सा userspace कोड के इस्तेमाल के लिए उपलब्ध रहता है. साथ ही, ABI के नियमों के तहत कर्नेल यह सुनिश्चित करता है कि सिग्नल भेजते समय वह इस हिस्से को ओवरराइट नहीं करेगा.
post-return क्रैश की डीबगिंग में red zone की अहम भूमिका थी, क्योंकि यह रिटर्न होने से पहले की कुछ जानकारी सुरक्षित रखता है. जब SIGSEGV ट्रिगर होता है, तो folly का फ़ेटल सिग्नल हैंडलर क्रैश हुई थ्रेड के स्टैक पर चलता है. जो स्टैक फ़्रेम अब सक्रिय नहीं रहते (क्योंकि उनका फ़ंक्शन लौट चुका होता है), वे सिग्नल हैंडलर द्वारा ओवरराइट हो जाते हैं. सिर्फ़ आख़िरी 128 बाइट सुरक्षित रहते हैं. यही वजह है कि हम कह सकते हैं, "X का अभी-अभी हटाया गया स्टैक फ़्रेम सही दिख रहा था, बस उसका रिटर्न एड्रेस NULL था." red zone निष्क्रिय स्टैक फ़्रेम्स का कुछ हिस्सा, या कभी-कभी किसी एक निष्क्रिय फ़्रेम का सिर्फ़ आख़िरी हिस्सा सुरक्षित रखता है.
हमें स्टैक के गलत संरेखण वाला एक ऐसा क्रैश मिला, जिसमें शामिल सभी फ़ंक्शन बहुत छोटे थे. इससे हमें पता चला कि अपेक्षाकृत सरल फ़ंक्शन के निष्पादन के दौरान %rsp का संरेखण बिगड़ गया था, और इसके बाद भी कई फ़ंक्शन कॉल सफलतापूर्वक हुए. प्रोग्राम तभी क्रैश हुआ, जब सक्रिय फ़ंक्शन ने अंत में रिटर्न करने की कोशिश की. उन कोड पाथ्स में कहीं भी एक्सेप्शंस, inline assembly, setcontext या longjmp का इस्तेमाल नहीं हुआ था. इसलिए अगर core dump के अनुसार स्टैक पॉइंटर वास्तव में बदला था, तो userspace कोड में ऐसा कोई संभावित बग नहीं था जो इस समस्या की व्याख्या कर सके.
इससे हमारा ध्यान ऑपरेटिंग सिस्टम के kernel की ओर गया.
Rockset, ज़्यादातर प्रोग्रामों की तुलना में सिग्नल्स का कहीं ज़्यादा आक्रामक तरीके से इस्तेमाल करता है. क्वेरी निष्पादन को कई हल्के-फुल्के टास्क्स में बाँटा जाता है, जो आपस में डेटा का आदान-प्रदान करते हैं. यह हाई-QPS वर्कलोड को कुशलता से संभालने के लिए ज़रूरी है, लेकिन इससे हर क्वेरी के लिए CPU उपयोग का हिसाब रखना मुश्किल हो जाता है, क्योंकि कई क्वेरीज़ का काम एक ही थ्रेड पूल पर मल्टीप्लेक्स किया जाता है.
हमारा समाधान coarse_thread_cputime_clock है, जो clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) का कम लागत वाला अनुमान देता है, ताकि हर टास्क सीमा पर सैंपलिंग की जा सके. timer_create API का इस्तेमाल समय बीतने के अलग-अलग आधारों, जिनमें CPU समय का संचय भी शामिल है, के अनुसार नियमित अंतराल पर सिग्नल भेजने का शेड्यूल बनाने के लिए किया जा सकता है. हम हर कुछ मिलीसेकंड के CPU समय के बाद एक सिग्नल (SIGUSR2) भेजने का शेड्यूल बनाते हैं. उस समय सिग्नल हैंडलर थ्रेड-लोकल वैल्यू को अपडेट करता है. हालाँकि कई टास्क्स को अपने निष्पादन के दौरान coarse clock आगे बढ़ता हुआ दिखाई नहीं देता, लेकिन सभी डेल्टाज़ का योग किसी क्वेरी के वास्तविक CPU समय का निष्पक्ष अनुमान देता है.
चूँकि हम सिग्नल बहुत बार भेजते हैं, इसलिए कॉन्टेक्स्ट स्विचिंग या सिग्नल डिलीवरी से जुड़ा कोई दुर्लभ कर्नेल बग एक संभावित कारण लग रहा था. हमने बग रिपोर्ट्स, कर्नेल का सोर्स कोड और Azure के लिए बनाए गए कर्नेल पैच पढ़ने में काफ़ी समय लगाया. हमने स्ट्रेस टेस्ट भी किए, लेकिन हमें ऐसा कुछ नहीं मिला जो इस समस्या से जुड़ा हुआ लगे.
उस समय हमने एक कदम पीछे हटकर अलग तरीका अपनाने का फैसला किया.
इस तरह की समस्या की डीबगिंग करने के दो मुख्य तरीके हैं.
पहला तरीका डॉक्टर की तरह काम करना है: एक मरीज़ पर ध्यान देना, कई जाँच करना और विस्तृत सबूतों के आधार पर उस एक मामले का कारण पता लगाने की कोशिश करना.
दूसरा तरीका महामारी विज्ञानी की तरह काम करना है: पूरी आबादी के आँकड़ों को देखकर यह समझना कि क्या उनमें ऐसे पैटर्न हैं, जो किसी एक मामले को देखकर नज़र नहीं आते. क्या यह बग किसी खास रिलीज़ के बाद शुरू हुआ? क्या इसका संबंध किसी एक हार्डवेयर SKU (यानी किसी खास CPU और सर्वर मॉडल), किसी एक क्षेत्र या किसी एक कर्नेल संस्करण से है? क्या जो एक ही समस्या दिखाई दे रही है, उसके भीतर वास्तव में कई अलग-अलग क्लस्टर छिपे हुए हैं?
तब तक हम ज़्यादातर डॉक्टर वाले तरीके से ही समस्या की जाँच कर रहे थे.
समस्या के सभी मामलों को अपने-आप ढूँढने की हमारी पिछली कोशिशें असफल रहीं, क्योंकि हम लॉग में टेक्स्ट खोज के ज़रिए उन्हें पहचानने की कोशिश कर रहे थे. जबकि core dumps में इससे कहीं ज़्यादा जानकारी मौजूद थी, लेकिन उनकी मैन्युअल जाँच बड़े पैमाने पर करना संभव नहीं था. इसलिए हमने ऐसा पाइपलाइन बनाने का फैसला किया, जो core dumps का अपने-आप विश्लेषण कर सके.
हमने ChatGPT से एक स्क्रिप्ट लिखवाई, जिसने हर core file का शुरुआती हिस्सा डाउनलोड किया, registers निकाले, लॉग की मदद से पहले से ज्ञात false positives को हटाया, और हर क्रैश को अपने-आप return-to-null, स्टैक-अलाइनमेंट से जुड़ा, या अन्य श्रेणी में वर्गीकृत कर दिया.
यही हमारी जाँच का निर्णायक मोड़ साबित हुआ.
जैसे ही हमारे पास साफ़-सुथरा डेटासेट आया, अलग-अलग घटनाओं के बीच सहसंबंध तुरंत दिखाई देने लगे. जिसे हम अब तक एक अजीब बग मान रहे थे, वह वास्तव में दो अलग-अलग तरह के क्रैश समूह थे.
return-to-null वाले core dumps कई क्लस्टरों और अलग-अलग भौगोलिक क्षेत्रों में फैले हुए थे. हाल के समय में उनकी संख्या बढ़ी थी, लेकिन इसके शुरू होने की कोई स्पष्ट तारीख या इंफ्रास्ट्रक्चर की कोई साफ़ सीमा दिखाई नहीं दे रही थी.
स्टैक के गलत संरेखण वाले क्रैश पूरी तरह अलग दिखाई दे रहे थे. वे सभी एक ही क्षेत्र से आए थे, उनके शुरू होने की तारीख़ भी स्पष्ट थी, और वे कभी भी उन नोड्स पर नहीं हुए जो लंबे समय से चल रहे थे. हालाँकि इनमें कई Azure VMs (क्लाउड पर होस्ट की गई वर्चुअल मशीनें) शामिल थीं, लेकिन पैटर्न से ऐसा लग रहा था कि एक ही फ़िज़िकल मशीन में हार्डवेयर की खराबी थी, जो उस पर चलने वाली किसी भी VM के लिए समस्या पैदा कर रही थी.
उसी पल हमें एहसास हुआ कि हम दिमाग़ में दो अलग-अलग बग को एक ही मानकर चल रहे थे. क्योंकि हम दोनों बग से जुड़े उलटे उदाहरणों को मिला रहे थे, इसलिए हमें कोई एक साफ़ और सुसंगत व्याख्या नहीं मिल पा रही थी.
Kubernetes नोड्स और टाइमस्टैम्प्स की साफ़-सुथरी सूची मिलने के बाद, हम स्टैक-अलाइनमेंट से जुड़े क्रैश का पता लगाते हुए एक ही फ़िज़िकल होस्ट तक पहुँच गए, जिसे डिनायलिस्ट में डालना आसान था.
कई हफ़्तों तक स्ट्रेस टेस्ट करने के बाद भी हम नियंत्रित वातावरण में उस होस्ट पर रजिस्टर करप्शन दोबारा नहीं पैदा कर सके. लेकिन जैसे ही उस समस्या वाले होस्ट को सेवा से हटा दिया गया, स्टैक के गलत संरेखण वाले क्रैश पूरी तरह बंद हो गए.
खराब होस्ट को हटाना स्थायी समाधान नहीं है, क्योंकि इससे वही समस्या दोबारा होने से नहीं रुकती. लेकिन हम सॉफ़्टवेयर में ऐसे बदलाव कर सकते हैं कि अगर ऐसी समस्या फिर से हो, तो उसे आसानी से पहचाना और संभाला जा सके. हमने अपने फ़ेटल सिग्नल हैंडलर में रजिस्टर की स्थिति भी शामिल कर दी, ताकि सिर्फ़ लॉग के आधार पर ही ऐसी समस्या दोबारा होने का पता चल सके (core dump की ज़रूरत न पड़े). हमने कंट्रोल प्लेन में भी बदलाव किया, ताकि VMs को बार-बार रीसायकल करने के बजाय ज़्यादातर दोबारा इस्तेमाल किया जाए. इससे हमारे इंफ्रास्ट्रक्चर स्टैक के स्तर पर खराब नोड्स की पहचान करना कहीं आसान हो गया. हमने अपने रनबुक्स (और टीम की समझ) को भी अपडेट किया, ताकि इस संभावना को उसमें शामिल किया जा सके.
खराब होस्ट से जुड़े क्रैश अलग करने के बाद, बचे हुए return-to-null core dumps को समझना काफ़ी आसान हो गया. पहले हमने exception unwinding की संभावना को खारिज कर दिया था, क्योंकि हमें लगा था कि हमारे पास उसके ख़िलाफ़ उदाहरण मौजूद हैं, यानी ऐसे कोड पाथ्स में हुए क्रैश जहाँ एक्सेप्शंस का इस्तेमाल बिल्कुल नहीं हुआ था. लेकिन बाद में पता चला कि वे सभी उदाहरण हार्डवेयर करप्शन वाले क्लस्टर से जुड़े थे.
जब हमने इसी समझ के साथ बचे हुए core dumps की दोबारा जाँच की, तो पता चला कि हमारा निष्कर्ष बिल्कुल उलटा था. सभी क्रैश वास्तव में exception unwinding के दौरान ही हो रहे थे.
जब C++ कोई एक्सेप्शन फेंकता है, तो रनटाइम को यह पता लगाना होता है कि उसे कौन-सा कैच ब्लॉक संभालेगा और रास्ते में किन डिस्ट्रक्टर्स या क्लीनअप हैंडलर्स को चलाना होगा. कंपाइलर इसके लिए ज़रूरी मेटाडेटा तैयार करता है, लेकिन वास्तविक मिलान रनटाइम के दौरान अपने-आप होता है.
एक्सेप्शन अनवाइंडिंग वास्तव में उस फ़ंक्शन द्वारा नहीं किया जाता जो throw को कॉल करता है, बल्कि कंपाइल किए गए कोड द्वारा बुलाई गई सहायक फ़ंक्शंस इसे पूरा करती हैं. ये रनटाइम रूटीन स्टैक की जाँच करती हैं, उस पर मौजूद फ़ंक्शंस का मेटाडेटा प्राप्त करती हैं, फिर ज़रूरत के अनुसार क्लीनअप हैंडलर्स और कैच ब्लॉक्स खोजती हैं, और उसके बाद कंट्रोल को उनमें से किसी एक स्थान पर पहुँचा देती हैं. इस कंट्रोल ट्रांसफ़र के दौरान बीच में आने वाले सभी स्टैक फ़्रेम्स (सहायक फ़ंक्शंस के फ़्रेम्स सहित) को अनवाइंड किया जाता है.
ऑपरेशनल तौर पर, यह सामान्य कॉल और रिटर्न की तुलना में longjmp या फ़ाइबर स्विच के कहीं ज़्यादा क़रीब है. इसमें कैली-सेव रजिस्टर्स के साथ-साथ स्टैक फ़्रेम रजिस्टर्स %rbp और %rsp को भी वापस बहाल करना पड़ता है.
हमारी बाइनरी, C++ exception unwinding करने वाले फ़ंक्शंस के कार्यान्वयन वाली दो लाइब्रेरीज़ से लिंक होती है: libgcc और GNU libunwind. डायनैमिक लिंकर ने GNU libunwind की परिभाषाओं को चुना. यह हमारे लिए हैरान करने वाला था, क्योंकि सिंबल वर्ज़निंग के नियमों के अनुसार हमें उम्मीद थी कि libgcc का कार्यान्वयन चुना जाएगा. लेकिन चल रही बाइनरीज़ की जाँच से पता चला कि ऐसा नहीं था.
इस चरण पर हमारी कार्यकारी परिकल्पना बदल गई, क्योंकि हमने उस एक और धारणा को छोड़ दिया जो हमने तब बनाई थी, जब हमें लगता था कि केवल एक ही बग है.
हो सकता है कि हम किसी सामान्य फ़ंक्शन का NULL पर लौटना नहीं देख रहे थे. हो सकता है कि हम एक unwind transfer देख रहे हों—यानी setcontext शैली में रजिस्टर बहाल होने की प्रक्रिया—जिसमें कंट्रोल ट्रांसफ़र होने से पहले ही गंतव्य इंस्ट्रक्शन पॉइंटर NULL हो गया था. दूसरे शब्दों में, समस्या स्टैक पर मौजूद गलत रिटर्न एड्रेस स्लॉट की नहीं, बल्कि unwind लाइब्रेरी से मिले गलत डेटा की थी.
इससे संभावित समस्या का दायरा काफ़ी छोटा हो गया. या तो GNU libunwind गंतव्य की स्थिति की गलत गणना कर रहा था, या फिर वह सही स्थिति की गणना कर रहा था लेकिन उसे लागू किए जाने से पहले ही कोई चीज़ उसे खराब कर रही थी.
हमने GNU libunwind का सोर्स कोड पढ़ा और पाया कि यह स्टैक पर एक ucontext_t तैयार करता है, क्लीनअप हैंडलर के फ़्रेम के लिए ज़रूरी रजिस्टर स्थिति उसमें भरता है, और फिर उस स्ट्रक्ट का पॉइंटर एक आंतरिक असेंबली रूटीन _Ux86_64_setcontext को सौंप देता है.
इस चरण तक हमारे पास पूरी तस्वीर समझने के लिए ज़रूरी सभी कड़ियाँ मौजूद थीं.
तैयार किया गया ucontext_t उस स्टैक फ़्रेम में मौजूद होता है, जिसे _Ux86_64_setcontext अपने निष्पादन के दौरान अनवाइंड करता है. क्या _Ux86_64_setcontext, %rsp बदलने के बाद उस स्ट्रक्ट से डेटा पढ़ रहा था, जबकि उस समय तक वह स्ट्रक्ट सक्रिय स्टैक का हिस्सा नहीं रह जाता था? अगर ऐसा था, तो बार-बार आने वाले SIGUSR2 जैसे सिग्नल के दौरान उसके ओवरराइट होने की संभावना बन जाती थी.
जवाब था, हाँ.
नीचे GNU libunwind के उस संस्करण में मौजूद _Ux86_64_setcontext के आख़िरी छह निर्देश दिए गए हैं, जिसका हम इस्तेमाल कर रहे थे. इनमें ज़्यादातर mov निर्देश हैं, जो मेमोरी से डेटा को गंतव्य रजिस्टर में लोड करते हैं.
(%rdi, स्टैक पर आवंटित ucontext_t की ओर संकेत करता है, और UC_MCONTEXT_* मैक्रोज़ केवल उस निश्चित ऑफ़सेट तक विस्तृत होते हैं जहाँ कोई विशेष रजिस्टर संग्रहीत होता है.)
पहला निर्देश ही रेस विंडो की शुरुआत करता है. यह %rsp को अपडेट करके सक्रिय स्टैक के नए निचले हिस्से की ओर इंगित करता है. ऐसा होते ही %rdi जिस स्ट्रक्ट की ओर संकेत कर रहा होता है, वह सक्रिय स्टैक (या red zone) का हिस्सा नहीं रहता और कर्नेल के लिए भी अब सुरक्षित क्षेत्र से बाहर हो जाता है.
आम तौर पर इससे कोई समस्या नहीं होती. लेकिन अगर कोई सिग्नल ठीक उसी सही (या गलत?) समय पर आ जाए, तो कर्नेल %rsp-128 पर सिग्नल फ़्रेम बना देगा. इससे %rdi जिस मेमोरी की ओर संकेत कर रहा है, वह ओवरराइट हो सकती है.%rsp-128 पर सिग्नल फ़्रेम बना देगा.
अगर ऐसा अगला निर्देश UC_MCONTEXT_GREGS_RIP(%rdi) पढ़ने से पहले हो जाए, तो बहाल किया गया इंस्ट्रक्शन पॉइंटर खराब हो सकता है. हमारे क्रैश में यही इंस्ट्रक्शन पॉइंटर NULL हो गया था.
यही बग है.
यह असेंबली उस बात की भी व्याख्या करती है जिसने हमें उलझन में डाला था: पिछले स्टैक फ़्रेम के return address वाले स्थान पर function X के लिए NULL क्यों था.
setcontext को सभी रजिस्टर्स, जिनमें %rdi भी शामिल है, को वापस बहाल करने के लिए लिखा गया है. इसलिए कंट्रोल ट्रांसफ़र के अंतिम क्षण में वह UC_MCONTEXT_GREGS_RIP(%rdi) पढ़ने के लिए उसी रजिस्टर का इस्तेमाल नहीं कर सकता. इसके बजाय, वह इस वैल्यू को पहले ही पढ़ लेता है, उसे स्टैक पर सुरक्षित रखता है, फिर कुछ और रजिस्टर्स को बहाल करता है, और अंत में retq का इस्तेमाल करके सुरक्षित रखी गई वैल्यू पढ़ता है और कंट्रोल ट्रांसफ़र करता है.
core dumps में जो "किसी फ़ंक्शन ने NULL पर रिटर्न किया" जैसा दिखाई दे रहा था, वह वास्तव में यह था कि "अनवाइंडर ने स्टैक पर एक लक्ष्य रिटर्न एड्रेस तैयार किया था, लेकिन कंट्रोल ट्रांसफ़र पूरा होने से पहले ही वह एड्रेस खराब हो गया था." हमने मान लिया था कि रिटर्न एड्रेस वाले स्लॉट में खराबी उसी जगह हुई होगी, क्योंकि हमें ऐसी किसी जगह की जानकारी नहीं थी जहाँ जानबूझकर (खराब हो सकने वाला) डेटा रिटर्न एड्रेस वाले स्लॉट में लिखा जाता हो.
इस बग को हैरान करने वाली बात यह है कि इसकी रेस विंडो कितनी छोटी है. इस तरह की रेस कंडीशन में, बाहरी घटना (यानी सिग्नल) का दूसरी थ्रेड द्वारा किए गए दो चरणों के बीच होना ज़रूरी है. ये दोनों चरण जितने ज़्यादा एक-दूसरे के करीब होंगे, रेस कंडीशन होने की संभावना उतनी ही कम होगी.
इस मामले में संवेदनशील विंडो सचमुच सिर्फ़ एक निर्देश जितनी चौड़ी है! %rsp बदलने के बाद, लेकिन अगला निर्देश %rip लोड करने से पहले, सिग्नल पहुँचना ज़रूरी है. आधुनिक सुपर-स्केलर, आउट-ऑफ़-ऑर्डर CPU पर इस तरह के कई सरल निर्देश एक ही साइकल में चल सकते हैं. इसलिए यह रेस विंडो लगभग सौ पिकोसेकंड की होती है.
जब हमें इस रेस का पता चला, तो हमारी पहली प्रतिक्रिया यही थी कि यह इतनी दुर्लभ है कि इससे दिखाई दे रही क्रैश दर की व्याख्या नहीं की जा सकती. पूरे फ़्लीट में हमें हर दिन return-to-null के एक दर्जन से ज़्यादा क्रैश दिखाई दे रहे थे. क्या एक्सेप्शन क्लीनअप के दौरान सिर्फ़ एक निर्देश की रेस वास्तव में इसकी वजह हो सकती थी?
इसके बाद हमने Fermat estimation का सहारा लिया. अगर संवेदनशील विंडो लगभग सेकंड की है और SIGUSR2 हर 10−2 सेकंड के CPU समय पर पहुँचता है, तो हर एक्सेप्शन क्लीनअप हैंडलर या catch ब्लॉक के लिए इस रेस में हारने की संभावना लगभग होती है.
Rockset, अपने आंतरिक ingest backpressure तंत्र के हिस्से के रूप में एक्सेप्शंस का इस्तेमाल करता है. ज़रूरत से ज़्यादा लोड वाला एक ही होस्ट प्रति सेकंड लगभग 10^{4}\) एक्सेप्शंस फेंक सकता है. इसका मतलब है कि backpressure इस्तेमाल करने वाले किसी होस्ट में दो विफलताओं के बीच का औसत समय लगभग सेकंड, यानी हर कुछ घंटों में एक क्रैश होता है. पूरे फ़्लीट के स्तर पर यह संख्या दिखाई दे रही क्रैश आवृत्ति की व्याख्या करने के लिए काफ़ी है.
GNU libunwind बग नया नहीं है. यह 18 साल से भी ज़्यादा पुराना है और x86_64 के उस पहले संस्करण से मौजूद है, जिसमें C++ एक्सेप्शन अनवाइंडिंग का समर्थन जोड़ा गया था.
फिर यह अब क्यों सामने आया?
क्रैश की दर लगभग इस बात के अनुपात में होती है कि कितने एक्सेप्शंस फेंके जाते हैं और कितने सिग्नल भेजे जाते हैं. यह इस बात पर भी निर्भर करती है कि सिग्नल हैंडलर कितना स्टैक इस्तेमाल करता है.
इन तीनों पहलुओं में Rockset सामान्य नहीं है. सामान्य ओवरलोड नियंत्रण के हिस्से के रूप में हम बहुत अधिक दर पर एक्सेप्शंस फेंकते हैं. coarse_thread_cputime_clock की वजह से SIGUSR2 भी असामान्य रूप से बहुत बार भेजा जाता है. इसके अलावा, इसी साल हमने SIGUSR2 हैंडलर में timer_getoverrun का कॉल जोड़कर उसका स्टैक उपयोग बढ़ा दिया, ताकि एक साथ मिले सिग्नल्स का भी सही हिसाब रखा जा सके.
लगता है कि आख़िरी बदलाव काफ़ी अहम था. अगर हैंडलर बहुत कम स्टैक इस्तेमाल करे, तो हो सकता है कि वह पुराने ucontext_t मेमोरी तक पहुँचे ही नहीं और उसे ओवरराइट न करे. इस बदलाव से पहले हमें ऐसे क्रैश बिल्कुल नहीं दिखाई देते थे. बदलाव के बाद भी इनकी दर कम ही रही, जब तक कि हमने कुछ ऐसे उपयोगों के लिए लोड नहीं बढ़ाया, जिन्होंने backpressure तंत्र पर ज़्यादा दबाव डाला.
दूसरे शब्दों में, libunwind का यह बग हमेशा से मौजूद था. लेकिन हमारे एक्सेप्शन की दर, सिग्नल की दर और हैंडलर के स्टैक उपयोग का संयुक्त प्रभाव हाल ही में उस सीमा तक पहुँचा, जहाँ यह वास्तविक संचालन के दौरान दिखाई देने लगा.
यही तंत्र इस संयोग की भी व्याख्या करता है कि हार्डवेयर बग और libunwind बग, दोनों के ज़्यादातर क्रैश DocumentTree::updateDocument के भीतर ही हुए. libunwind से जुड़े क्रैश इस मेथड में ज़्यादा दिखाई दिए, क्योंकि ingest backpressure लागू करने के लिए जब भी हम एक्सेप्शन फेंकते हैं, उस समय यही मेथड सक्रिय होती है. %rsp के गलत संरेखण वाले क्रैश भी इसी मेथड में ज़्यादा मिले, क्योंकि खराब हार्डवेयर नोड उस SKU का था जिसका इस्तेमाल हम bulk ingest के लिए करते हैं, और उसका अधिकांश CPU समय इसी मेथड में बीतता है.
तत्काल समाधान के तौर पर हमने GNU libunwind की जगह libgcc के unwinder का इस्तेमाल शुरू किया. यह अपने आप में भी बेहतर बदलाव था, क्योंकि lock contention कम करने के लिए libgcc के इम्प्लीमेंटेशन पर काफ़ी काम किया गया है, जो बड़े VMs तक स्केल करते समय अहम साबित होता है.
हमने GNU libunwind में एक स्व-निहित रिप्रोड्यूसर और एक सुधार(एक नई विंडो में खुलेगा) भी योगदान के रूप में जोड़ा, और यह भी सत्यापित किया कि दूसरे अनवाइंडर्स में ऐसी समस्या नहीं है.
डीबगिंग की इस यात्रा ने हमें dynamic linking, DWARF unwind metadata, Linux signal delivery, System V ABI और C++ exception तंत्र की कई बारीकियाँ सिखाईं. लेकिन सबसे बड़ा सबक इन सबसे कहीं ज़्यादा सरल था.
सबसे अहम कदम चतुराई से assembly पढ़ना या बारीक तकनीकी जानकारी होना नहीं था. सबसे अहम था एक उच्च-गुणवत्ता वाला डेटासेट तैयार करना. इसके बिना हम दो अलग-अलग घटनाओं को एक ही कहानी मानकर भ्रम दूर करने की कोशिश कर रहे थे. जैसे ही हमारे पास पूरी और सटीक आबादी का डेटा आया, समस्या की असली संरचना साफ़ हो गई. एक तरह के क्रैश खराब होस्ट की वजह से थे, जबकि दूसरे libunwind में मौजूद रेस की वजह से. डेटा बेहतर हुआ, तो डीबगिंग भी आसान हो गई.
Rockset जैसे इंफ्रास्ट्रक्चर सिस्टम्स के लिए यह बेहद अहम है. इस जाँच ने गहन इंस्ट्रूमेंटेशन, स्वचालित जाँच और हमारे ऑपरेशनल टूल्स में लगातार सुधार के प्रति हमारी प्रतिबद्धता को और मज़बूत किया. विश्वसनीयता का मतलब सिर्फ़ बग होने के बाद उन्हें ठीक करना नहीं है. इसका मतलब ऐसा डेटा, वर्कफ़्लोज़ और कौशल विकसित करना भी है, जो असंभव लगने वाली समस्याओं को पहचानने और हल करने योग्य बना दें.
लेखक
By Nathan Bronson और Member of Technical Staff


