የOpenAI ሞዴሎችና ወኪሎች በመጠየቂያ ጊዜ፣ ሞዴሎቹ ስለጥያቄዎ ሲያስቡ፣ ተዛማጅ ውሂብ ለመፈለግ እየጨመረ በሚስፋፋ የውሂብ መሠረተ ልማት ላይ ይመካሉ። ከእነዚህ አገልግሎቶች አንዳንዶቹ በC++ ተጽፈዋል፤ ይህም በስርዓቱ ላይ ዝቅተኛ-ደረጃ ቁጥጥር ስለሚሰጥ አፈጻጸምን ማሳደግና የማህደረ ትውስታ አጠቃቀምን መቀነስ ያስችለናል። ስናስፋፋ እነዚህ የብቃት ጥቅሞች አስፈላጊ ናቸው፤ ግን C++ የማህደረ ትውስታ ደህንነት ስለሌለው ቡጎች ወደ ተሳሳተ ወይም የሌለ የማህደረ ትውስታ አድራሻ በመጻፍ ክራሽ ሊያስከትሉ ይችላሉ።
ከጥቂት ወራት በፊት በRockset አገልግሎት ውስጥ ክራሾችን አየን፤ ይህ ለብዙ የውሂብ ፕላጊኖችና በውይይቶች ላይ ለመፈለግ ቁልፍ የሆነ የChatGPT ውሂብ መሠረተ ልማታችን ልዩ ክፍል ነው። በእያንዳንዱ ክራሽ የተለመደ የC++ ፋንክሽን እንደጨረሰና ከዚያ ወደ የማይሆን አድራሻ እንደተመለሰ ይታይ ነበር፤ ይህም የመመሪያ ጠቋሚው ከኮድ ውጭ ስለጠቆመ ከርነሉ ፕሮግራሙን እንዲያቆም አደረገ። አንዳንድ ጊዜ በstack frame ውስጥ ያለው የመመለሻ አድራሻ ቦታ NULL ነበር። አንዳንድ ጊዜ የstack pointer CPU register ራሱ በ8 ባይት የተሳሳተ ይመስል ነበር፤ እንደ %rsp በመደበኛ አፈጻጸም መሃል በአንዳች መንገድ እንደተቀነሰ። በሁለቱም ሁኔታ ክራሹ በመመለስ ጊዜ ተከሰተ።
እነዚህ ለአፕሊኬሽን ኮድ የተለመዱ የውድቀት ሁኔታዎች አይደሉም። በተቀመጠ የመመለሻ አድራሻ ላይ ብቻ የሚያርፍ የባዶ ጽሑፍ ይቻላል፣ ግን እጅግ አይታመንም። inline assembly፣ setcontext ወይም longjmp ሳይካተቱ (አንዱንም አንጠቀምም) %rspን በ8 የሚያሳስት ቡግ ከዚያም የበለጠ እንግዳ ነው፤ ምክንያቱም የተኮምፓይል ኮድ ያንን register በቀጥታ የሚያስተካክለው በፋንክሽን prologue እና epilogue ብቻ ነው። እኛ (ወይም ChatGPT) ያሰብነው ማንኛውም ግምት በተቃራኒው ጠንካራ ማስረጃ ነበረው፤ ስለዚህ ቡጉ የማይቻል ይመስል ነበር።
አንድ ችግር ነው ብለን የገመትነው በመጨረሻ በአንድ ጊዜ በአጋጣሚ የተገኙ ሁለት ያልተዛመዱ ቡጎች መሆኑ ተገለጠ። መጀመሪያ፣ በአንድ Azure host ላይ የተለየ የማይታይ የሃርድዌር ብልሽት፣ በዚያም CPUው ሂሳብን በትክክል አያደርግም ነበር። ሁለተኛ፣ በGNU libunwind ውስጥ ያለ የ18 ዓመት የrace condition፣ በሰፊው በሚጠቀሙበት open source ላይብረሪ ውስጥ ያልተስተዋለ ቡግ።
ይህ ጽሑፍ እንደ ኤፒዲሚዮሎጂስት በማሰብና ስለ ሁሉም ክራሾች ህዝብ ከፍተኛ-ጥራት ያለው ውሂብ ስብስብ በመገንባት መረዳት የማይቻሉ የሚመስሉ ክራሾችን እንዴት እንዳወቅንና እንዳስተካከልን ይተርካል።
መጀመሪያ፣ ስለ Rockset በጥልቀት እንግባ። ለፍለጋና ለቅጽበታዊ ትንተና የሚሆን cloud-native የውሂብ ስርዓት ነው፤ በOpenAI ውስጥ እንደ sync connector ላሉ ብዙ ውስጣዊ አጠቃቀሞች እንጠቀመዋለን (Rockset በ2024 በOpenAI ተገዛ)። ChatGPT ጥያቄዎችን ሲመልስ ወይም እርምጃዎችን ሲፈጽም ተዛማጅ መረጃ እንዲፈልግ፣ streaming updates የአንድ የስራ ቦታ የእውቀት መሠረት ወቅታዊ index ለማቆየት ይጠቅማሉ።
የRockset execution layer በC++ ተጽፏል። C++ ቋንቋ ወደ CPU ዝቅተኛ-ደረጃ መዳረሻ ይሰጣል፤ ይህ ለአፈጻጸምና ብቃት ጥሩ ነው፣ ግን የአፕሊኬሽን ቡጎች ልክ ያልሆነ የማህደረ ትውስታ መዳረሻ እና segfaults ሊያስከትሉ ይችላሉ። እነዚህን ለመከታተል ክራሽ ሲከሰት stack trace ለመመዝገብ የfolly fatal ምልክት handlerን እንጠቀማለን፣ እና ተዛማጅ core dumpዎችን (ፕሮግራሙ በተከሰከሰበት ጊዜ የነበረው ሁኔታ ቅጽበታዊ ምስል) ለኋላ ትንተና ወደ Azure blob storage እንሰቅላለን። የRockset ሁሉም query processing leaves ተባዝተዋል፣ ይህም ክራሽ በclient ላይ የሚያሳድረውን ተጽእኖ ይቀንሳል። ሆኖም እያንዳንዱ segfault የታማኝነትና የጥራት ግቦቻችንን ለማሟላት መስተካከል ያለበትን ቡግ ይወክላል።
መጀመሪያ እነዚህን cores እንደ መደበኛ የዲበግ ችግር አድርገን ወሰድን፦ ጥቂት core dumpዎችን በጣም በቅርብ መመርመር፣ ግምቶችን መፍጠር፣ እና አንድ በአንድ ማስወገድ።
አብዛኞቹ ክራሾች DocumentTree::updateDocument በተባለ መተዳደሪያ ውስጥ ተከስተዋል። በእነዚህ ክራሾች updateDocument የማይታወቅ ፋንክሽን Xን እንደጠራ፣ X ንቁ ሳለ stack እንደተበላሸ፣ ከዚያ X የሚፈጸም ኮድ ወዳልሆነ አድራሻ እንደተመለሰ ይታይ ነበር። በአንዳንድ ጊዜ የX አሁን የተወጣው frame ከተቀመጠው የመመለሻ አድራሻ NULL መሆኑ በስተቀር ትክክል ይመስል ነበር። በሌሎች ጊዜ ግን stack pointer ራሱ የተሳሳተ ይመስል ነበር፣ ግን ቀጣዩ ትክክለኛ frame አሁንም updateDocument ይመስል ነበር።
stack መቼ እንደሚበላሽ አናውቅም ነበር፣ ይህም እጅግ ሰፊ የፍለጋ ቦታ ተወለደ። updateDocument ብዙ inlining የሚያጋጥመው ትልቅ method ነው፣ ስለዚህ ለX የሚታሰቡ እጩዎች በጣም ብዙ ነበሩ።
ይህ በC++ ኮዳችን ውስጥ ያለ ቡግ ነበር? የcompiler ወይም linkage ችግር? በruntime libraries አንዱ ውስጥ ያለ ችግር? በምልክት delivery ወይም context switching ዙሪያ ያለ የLinux kernel ቡግ? ከዚያ የበለጠ እጅግ ያልተለመደ ነገር? ይህ የባዶ ጽሑፍ ከሆነ፣ ለምን በASAN staging environment አልተያዘም?
የችግሩን ሁሉንም ክስተቶች ለመለየት የአፕሊኬሽን-ደረጃ ሎጎቻችንን ለመጠቀም ሞከርን፣ ግን stack-corruption bugs ከሎጎች ብቻ ለመመደብ አስቸጋሪ ናቸው፤ የተመዘገቡት stack traces ራሳቸው የተበላሹ ወይም የጠፉ ስለሆኑ። false positives እና false negatives ሁለቱንም የሌለው የlog query መገንባት አልቻልንም። ተጨማሪ cores በእጅ መርምረን ተጨማሪ ምሳሌዎችን አገኘን፣ ግን ይህ ሂደት የሚታመን ውሂብ ስብስብ ለመስጠት በጣም የሰው ኃይል የሚፈልግ ነበር።
በዚህ የምርመራ ደረጃ የሃርድዌር ቡግን (በስህተት) አስወገድን፤ ምክንያቱም በብዙ ክልሎችና በብዙ የሃርድዌር አይነቶች ላይ ክራሾችን አይተን ነበር፣ ስለዚህ አሁንም software-only ምክንያቶችን እየፈለግን ነበር። ለጥቂት ቀናት በአንድ misaligned-%rsp ክራሽ ላይ በጣም ጠልቀን ገባን፣ ከክራሽ በፊት ያለውን ታሪክ በstack እና register ይዘቶች እንደገና ገነባን። ይህ አንዳንድ ሊሆኑ የሚችሉ ፍንጮችን ሰጠ፣ ግን ሁሉም ቡጎች አንድ ምክንያት አላቸው የሚለውን የመጀመሪያ መደምደሚያችንን ስላልለቀቅን አላስቀጠለንም።
ወደ ምርመራችን የመቀየሪያ ነጥብ ከመድረሳችን በፊት፣ ከcore files ምን ዓይነት መረጃ እንደምናወጣ ማስረዳት አስፈላጊ ነው።
Rockset በ-fno-omit-frame-pointer ተኮምፓይል ይደረጋል፤ ስለዚህ ንቁው stack frame ሁልጊዜ በ%rbp በኩል ይደረሳል፣ እና callers የframe pointers የተያያዘ ዝርዝር ይፈጥራሉ።
በLinux x86_64 ላይ AMD64 System V ABI ከ%rsp በታች 128 ባይትን እንደ red zone ያስቀምጣል። ያ ክልል ለuserspace code ይገኛል፣ እና በABI ውል አካል ከርነሉ ምልክት ሲያደርስ እንዳያበላሸው ቃል ይገባል።
red zone ከመመለስ በኋላ የሚከሰትን ክራሽ ለማዲበግ ማዕከላዊ ነበር፤ ምክንያቱም ከመመለስ በፊት የነበረ አንዳንድ መረጃን ያቆያል። SIGSEGV ሲነሳ፣ የfolly fatal ምልክት handler በሚከሰከሰው thread stack ላይ ይሮጣል። ከእንግዲህ ንቁ ያልሆኑ stack frames (ፋንክሽናቸው ስለተመለሰ) ከመጨረሻዎቹ 128 ባይት በስተቀር በምልክት handler ይበላሻሉ። ስለዚህ “የX አሁን የተወጣው stack frame ትክክል ይመስል ነበር፣ ከNULL የመመለሻ አድራሻ በስተቀር” የምንል ነው። red zone የአንዳንድ ንቁ ያልሆኑ framesን፣ ወይም አንዳንድ ጊዜ የአንድ ንቁ ያልሆነ frame ጅራት ብቻን ያቆያል።
በተካተቱት ፋንክሽኖች ሁሉ በጣም ትንሽ የነበሩበትን አንድ misaligned-stack ክራሽ አገኘን። ይህ በአንጻራዊ ቀላል ፋንክሽን አፈጻጸም ጊዜ %rsp እንደተሳሳተ፣ እና ከዚያ በኋላ ተጨማሪ calls እንደተሳኩ እንድናይ አስቻለን። ፕሮግራሙ የተከሰከሰው ንቁው ፋንክሽን በመጨረሻ ለመመለስ ሲሞክር ብቻ ነበር። ከእነዚያ የኮድ መንገዶች አንዱም exceptions፣ inline assembly፣ setcontext ወይም longjmp አልተጠቀመም፤ ስለዚህ stack pointer በcore እንደታየው በእውነት ተቀይሮ ከሆነ፣ በuserspace code ውስጥ ምንም የሚታመን ቡግ ችግሩን አያብራራም።
ይህ ወደ kernel አቅጣጫ ገፋን።
Rockset ከአብዛኞቹ ፕሮግራሞች ይልቅ ምልክትsን በበለጠ ጠንካራ ሁኔታ ይጠቀማል። Query execution ውሂብ የሚለዋወጡ ብዙ ቀላል tasks ይከፈላል። ይህ ከፍተኛ-QPS workloadsን በብቃት ለመቆጣጠር አስፈላጊ ነው፣ ግን ለብዙ queries ያለ ስራ በአንድ thread pool ላይ ስለሚቀላቀል per-query CPU accountingን ያስቸግራል።
መፍትሔያችን coarse_thread_cputime_clock የምንለው ነው፤ ይህ clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...)ን በእያንዳንዱ task boundary ላይ sample ለማድረግ በቂ ርካሽ በሆነ መንገድ ይገምታል። timer_create API የCPU time መከማቸትን ጨምሮ በጊዜ ማለፍ ልዩ ልዩ መለኪያዎች መሠረት ወቅታዊ ምልክት delivery ለመርሐግብር ሊጠቅም ይችላል። በየጥቂት ሚሊሰከንዶች የCPU time አንድ ምልክት (SIGUSR2) እንዲደርስ እናዘጋጃለን፤ በዚያን ጊዜ ምልክት handler የthread-local እሴትን ያዘምናል። ብዙ tasks በሚፈጸሙበት ጊዜ coarse clock ሲቀጥል ባያዩም፣ ሁሉንም deltas መደመር ለአንድ query የእውነተኛውን CPU time ያልተዛባ ግምት ይሰጣል።
ምልክትsን በጣም ብዙ ስለምናደርስ፣ በcontext switching ወይም ምልክት delivery ዙሪያ እጅግ ያልተለመደ የkernel bug ሊሆን ይችላል መሰለን። bug reports፣ kernel source code እና ለAzure የተለዩ kernel patchesን በማንበብ ጊዜ አሳለፍን። stress tests ሞከርን። የተዛመደ የሚመስል ምንም ነገር ማግኘት አልቻልንም።
በዚያ ጊዜ ወደ ኋላ ብለን ሌላ አቀራረብ ለመሞከር ወሰንን።
እንዲህ ያለ ችግር ለማዲበግ ሁለት ሰፊ መንገዶች አሉ።
አንዱ እንደ ዶክተር መስራት ነው፦ በአንድ ታካሚ ላይ መተኮር፣ ብዙ ምርመራዎችን ማካሄድ፣ እና ከዝርዝር ማስረጃ አንድ ጉዳይ ለመመርመር መሞከር።
ሌላው ደግሞ ይበልጥ እንደ ኤፒዲሚዮሎጂስት መስራት ነው፦ ሙሉውን ህዝብ ማየት እና አንድ ጉዳይ ሊያሳይ የማይችለው ንድፎች አሉን ወይ ብሎ መጠየቅ። ቡጉ በአንድ የተወሰነ release ጀመረ? ከአንድ hardware SKU (የተወሰነ CPU እና server model)፣ ከአንድ region፣ ወይም ከአንድ kernel version ጋር ይዛመዳል? አንድ syndrome የሚመስል ነገር ውስጥ ብዙ የተለዩ clusters ተደብቀዋል?
እኛ በአብዛኛው በdoctor mode ነበርን። ቁልፉ ለውጥ ከፍተኛ-ጥራት ያለው የህዝብ ውሂብ መሰብሰብ እንዳለብን መወሰን ነበር።
የችግሩን ሁሉንም ክስተቶች በራስ-ሰር ለማግኘት ያደረግናቸው ቀደምት ሙከራዎች አልተሳኩም፣ ምክንያቱም በሎጎች ላይ text searches ለመጠቀም እየሞከርን ነበር። core dumpዎቹ ራሳቸው ብዙ ተጨማሪ መረጃ አላቸው፣ ግን በእጅ መመልከት ለመስፋፋት አይመችም ነበር። core dumpዎችን በራስ-ሰር ሊተነትን የሚችል pipeline ለመገንባት ጥረት ለማድረግ ወሰንን።
ChatGPT የእያንዳንዱ core file መጀመሪያ ክፍል የሚያወርድ፣ registers የሚያወጣ፣ ከሎጎች የታወቁ false positivesን የሚያጣራ፣ እና ክራሹን return-to-null፣ misaligned-stack ወይም other ብሎ በራስ-ሰር የሚሰይም script እንዲጽፍ አደረግን። ከዚያ ያንን script ከቀዳሚው ዓመት የሁሉም production Rockset core dump ላይ በparallel አስኬድነው።
ይህ የመቀየሪያ ነጥብ ነበር።
ንጹህ ውሂብ ስብስብ ከነበረን በኋላ፣ ግንኙነቶች ወዲያው ታዩ። አንድ እንግዳ ቡግ ብለን የያዝነው በእውነቱ ሁለት የተለዩ የክራሽ ህዝቦች ነበሩ።
return-to-null cores በብዙ clusters እና ጂኦግራፊያዊ regions ተሰራጭተው ነበር። ድግግሞሻቸው በቅርቡ ጨምሮ ነበር፣ ግን ግልጽ የመጀመሪያ ቀን ወይም ንጹህ infrastructure boundary አልነበረም።
misaligned-stack ክራሾች ግን ሙሉ በሙሉ የተለዩ ይመስሉ ነበር። ሁሉም ከአንድ region መጡ፣ ግልጽ የመጀመሪያ ቀን ነበራቸው፣ እና ለረጅም ጊዜ በሮጡ ማነቃነቆች ላይ ፈጽሞ አልተከሰቱም። ብዙ Azure VMs (በcloud የሚስተናገዱ virtual machines) ቢካተቱም፣ ንድፉ መጥፎ ሃርድዌር ያለው አንድ አካላዊ ማሽን በላዩ ላይ በአጋጣሚ ለወረደችው VM ሁሉ ችግር እንደሚፈጥር ይመስል ነበር።
በዚያ ጊዜ በአእምሮአችን ሁለት ቡጎችን እያደባለቅን እንደነበር ተገነዘብን። ከሁለቱም ቡጎች የመጡ counterexamplesን እየቀላቀልን ስለነበር፣ አንድ ወጥ የሆነ ማብራሪያ ማግኘት አልቻልንም።
በንጹህ የKubernetes ማነቃነቆች እና timestamps ዝርዝር ታጥቀን፣ misaligned-stack ክራሾችን ወደ አንድ አካላዊ host መከታተል ቻልን፤ እሱንም denylist ማድረግ ቀላል ነበር።
በተቆጣጠረ አካባቢ በዚያ host ላይ የregister corruptionን እንደገና ማባዛት አልቻልንም፣ ከብዙ ሳምንታት stress testing በኋላም እንኳ። ግን ችግረኛው host ከአገልግሎት ከተወገደ በኋላ፣ misaligned-stack ክራሾች ጠፉ።
መጥፎውን host ማስወገድ ቋሚ መፍትሔ አይደለም፤ ተመሳሳይ ችግር እንደገና እንዳይከሰት አያግድም። ግን ተመሳሳይ ችግር እንደገና ቢከሰት በቀላሉ እንዲታወቅና እንዲታከም ሶፍትዌሩን መቀየር እንችላለን። fatal ምልክት handlerያችንን register state እንዲያካትት አሻሻልን፤ ስለዚህ ተደጋጋሚ መከሰትን ከሎጎች ብቻ ማወቅ እንችላለን (core dump አያስፈልግም)። VMs ከመጣል ይልቅ ብዙ ጊዜ እንዲደገሙ የcontrol planeን ቀየርን፤ ይህም በinfrastructure stack ደረጃችን bad-node detectionን በጣም ያቀላል። runbooksዎቻችንንም (እና የቡድናችንን mental models) ይህን እድል እንዲያካትቱ አዘምነናል።
የመጥፎ-host ክራሾች ተለይተው ከተወጡ በኋላ፣ የቀሩት return-to-null cores ለማስተዋል በጣም ቀላል ሆኑ። ቀደም ሲል exception unwindingን አስወግደን ነበር፤ ምክንያቱም exceptions በእርግጥ ባልተጠቀሙባቸው code paths ውስጥ ክራሾች ያሉ እንደ counterexamples አሉን ብለን ነበር። ግን እነዚያ counterexamples ሁሉ ከhardware-corruption cluster የመጡ ነበሩ።
ይህን በማሰብ የቀሩትን cores እንደገና ስንመለከት፣ ይህ መደምደሚያ በትክክል ተቃራኒ እንደነበር አገኘን፦ ክራሾቹ ሁሉ በexception unwinding ጊዜ እየተከሰቱ ነበር።
C++ exception ሲወረውር፣ runtime የትኛው catch block እንደሚቀበለው እና በመንገዱ የትኞቹ destructors ወይም cleanup handlers እንደሚሮጡ ማግኘት አለበት። compiler ይህን metadata ያወጣል፣ ግን ትክክለኛው matching በruntime ላይ በዳይናሚክ ይከሰታል።
Exception unwinding በእርግጥ throwን በሚጠራው ፋንክሽን አይፈጸምም፤ በተፈጠረው compiled code የሚጠሩ helper functions ያከናውኑታል። እነዚያ runtime routines stackን ይመረምራሉ፣ በstack ላይ ስለተገኙ ፋንክሽኖች metadata ያመጣሉ፣ cleanup handlers እና catch blocksን በዳይናሚክ ይፈልጋሉ፣ ከዚያ controlን ወደ ከእነዚያ አንዱ ቦታ ያስተላልፋሉ። control ማስተላለፍ በመካከል ያሉ stack frames ሁሉንም (የhelper functionsን ጨምሮ) unwinding ያካትታል።
በኦፕሬሽን ደረጃ፣ ይህ ከመደበኛ call እና return ይልቅ ወደ longjmp ወይም fiber switch የቀረበ ነው። Callee save registers እንዲሁም stack frame registers %rbp እና %rsp መመለስ አለባቸው።
binaryያችን C++ exception unwindingን የሚፈጽሙ ፋንክሽኖች ትግበራዎችን የያዙ ሁለት libraries ጋር ይlink ያደርጋል፦ libgcc እና GNU libunwind። dynamic linker የመረጣቸው የGNU libunwind definitions ነበሩ። ይህ አስገረመን፤ በsymbol versioning rules ምክንያት የlibgcc ትግበራ እንደሚያሸንፍ ጠብቀን ነበር፤ ግን የሚሮጡ binariesን መመርመር እንደዚያ እንዳልሆነ አሳየ።
በዚህ ነጥብ ላይ፣ አንድ ቡግ ብቻ አለ ብለን በአሰብንበት ጊዜ ያደረግነውን ሌላ ግምት ስናላላ፣ የስራ ግምታችን ተቀየረ።
ምናልባት የተለመደ ፋንክሽን ወደ NULL ሲመለስ እያየን አልነበርንም። ምናልባት unwind transfer—በመሠረቱ የsetcontext-style register restore—እያየን ነበር፤ መድረሻው instruction pointer control ከመተላለፉ በፊት NULL ሆኖ ነበር። በሌላ አነጋገር፣ በstack ላይ ያለ የተሳሳተ return address slot ሳይሆን ከunwind library የመጣ የተሳሳተ ውሂብ።
ይህ ችግሩን በጣም አጠበበው። ወይ GNU libunwind የተሳሳተ የመድረሻ state እያሰላ ነበር፣ ወይም ትክክለኛውን state እያሰላ ነበር እና ከመተግበሩ በፊት አንድ ነገር እያበላሸው ነበር።
የGNU libunwind sourceን አነበብን፣ እና በstack ላይ ucontext_tን እንደሚፈጥር፣ ለcleanup handler frame የሚፈለገውን register state እንደሚሞላ፣ ከዚያ የዚያን struct pointer ወደ ውስጣዊ assembly routine፦ _Ux86_64_setcontext እንደሚሰጥ አገኘን።
በዚህ ነጥብ ላይ ክፍሎቹ ሁሉ በእጃችን ነበሩ።
የተፈጠረው ucontext_t በ_Ux86_64_setcontext የሚunwind ከሚደረጉት stack frames አንዱ ውስጥ፣ በዚያ ፋንክሽን አፈጻጸም ጊዜ ይኖራል። _Ux86_64_setcontext %rspን ከቀየረ በኋላ፣ structው ከንቁ stack ውጭ በሆነበት ጊዜ፣ ከstructው እያነበበ ነበር? ያ እንደ ብዙ ጊዜ የሚመጣው SIGUSR2 ያለ ምልክት delivery እንዲያበላሸው ተጋላጭ ያደርገዋል።
መልሱ አዎ ነበር።
እነሆ በምንጠቀመው የGNU libunwind ስሪት ውስጥ የ_Ux86_64_setcontext የመጨረሻ ስድስት instructions፤ እነሱም በአብዛኛው ከmemory ወደ destination register የሚጫኑ mov instructions ናቸው፦
(%rdi በstack ላይ የተመደበውን ucontext_t ይጠቁማል፣ እና UC_MCONTEXT_* macros አንድ የተወሰነ register የሚቀመጥበትን ቋሚ offset ብቻ ይዘረጋሉ።)
የመጀመሪያው instruction የrace window መጀመሪያ ነው። %rspን የአዲሱ ንቁ stack ታችኛው ጫፍ እንዲጠቁም ያዘምናል። ይህ እንደተከሰተ ወዲያው፣ %rdi የሚጠቁመው struct ከንቁ stack (ወይም red zone) አካል አይሆንም፣ እና ከርነሉ እንዳይነካው የተከለከለ አይሆንም።
ብዙ ጊዜ ይህ ችግር አያመጣም፣ ግን ምልክት በትክክለኛው (ወይስ በተሳሳተው?) አፍታ ከደረሰ፣ kernel ምልክት frameን በ%rsp-128 ላይ ይገነባል። ያ በ %rdi የሚጠቆመውን memory ሊጽፍበት ይችላል።
ቀጣዩ instruction UC_MCONTEXT_GREGS_RIP(%rdi)ን ከማንበቡ በፊት ይህ ከተከሰተ፣ የተመለሰው instruction pointer ሊበላሽ ይችላል። በክራሾቻችን ውስጥ NULL ሆነ።
ቡጉ ይህ ነው።
ይህ assembly እኛን ያሳሳተንን አንድ ምልከታም ያብራራል፦ ለምን ፋንክሽን X በቀዳሚው stack frame የመመለሻ አድራሻ ቦታ NULL እንደነበረው።
setcontext %rdiን ጨምሮ ሁሉንም registers ለመመለስ ተጽፏል፤ ስለዚህ በcontrol transfer የመጨረሻ አፍታ UC_MCONTEXT_GREGS_RIP(%rdi)ን ለማንበብ ያንን register መጠቀም አይችልም። በምትኩ እሴቱን ቀደም ብሎ ያነባል፣ ወደ stack ያስቀምጣል፣ ጥቂት ተጨማሪ registers ይመልሳል፣ ከዚያ retqን ተጠቅሞ የተቀመጠውን እሴት ያነባና control ያስተላልፋል።
በcores ውስጥ “ፋንክሽን ወደ NULL ተመለሰ” የሚመስለው በእውነቱ “unwinder በstack ላይ የዒላማ መመለሻ አድራሻ ፈጠረ፣ ግን ያ ዒላማ ማስተላለፉ ከመጠናቀቁ በፊት ተበላሸ” ነበር። ሊበላሽ የሚችል ውሂብ በሆነ ዓላማ ወደ return address slot የሚጻፍበትን ቦታ ስላላወቅን፣ የreturn address slot ብልሽት በቦታው መከሰት አለበት ብለን አስበን ነበር።
ይህን ቡግ አስገራሚ የሚያደርገው የrace windowው በጣም ጠባብ መሆኑ ነው። በዚህ ዓይነት race condition ውስጥ ውጫዊው ክስተት (ምልክት) በሌላ thread በሚወሰዱ ሁለት እርምጃዎች መካከል መከሰት አለበት። እነዚያ እርምጃዎች እርስ በርስ በቀረቡ መጠን race condition የመከሰት እድሉ ይቀንሳል።
በዚህ ጉዳይ ተጋላጭው window ቃል በቃል አንድ instruction ስፋት ብቻ ነው! %rsp ከተቀየረ በኋላ፣ ግን ቀጣዩ instruction %rip ከመጫኑ በፊት ምልክት መድረስ አለበት። እንደዚህ ያሉ ብዙ ቀላል instructions በዘመናዊ super-scalar out-of-order CPU ላይ በcycle ውስጥ ሊሮጡ ይችላሉ፤ ስለዚህ race windowው በግምት መቶ picoseconds ነው።
ይህን race ስናገኝ የመጀመሪያ ምላሻችን የታየውን የክራሽ መጠን ለማብራራት በጣም ያልተለመደ መሆን አለበት የሚል ነበር። በfleet ሁሉ በቀን ከደርዘን በላይ return-to-null ክራሾችን እያየን ነበር። በexception cleanup ጊዜ የአንድ-instruction race በእውነት ይህን ሊያብራራ ይችላል?
ወደ Fermat estimation ዞርን። ተጋላጭው window በ ሰከንዶች ደረጃ ከሆነ እና SIGUSR2 በየ ሰከንዶች የCPU time ከደረሰ፣ እያንዳንዱ exception cleanup handler ወይም catch block raceውን የመሸነፍ ዕድሉ በግምት ነው።
Rockset exceptionsን እንደ ውስጣዊ ingest backpressure ሜካኒዝሙ አካል ይጠቀማል። አንድ የተጫነ host በሰከንድ በ ደረጃ exceptions ሊወረውር ይችላል። ይህ ማለት backpressure የሚጠቀም host መካከለኛው የውድቀት መካከለኛ ጊዜ ሰከንዶች፣ ወይም በየጥቂት ሰዓታት አንድ ክራሽ ነው። በfleet scale ላይ ይህ የታየውን የክራሽ ድግግሞሽ ለማብራራት ከበቂ በላይ ነው።
የGNU libunwind ቡግ አሮጌ ነው—ከ18 ዓመት በላይ፣ C++ exception unwindingን በደገፈው የመጀመሪያ x86_64 ስሪት ውስጥ ነበር።
ታዲያ ለምን አሁን ተገለጠ?
የክራሽ መጠኑ በግምት ከሚወረወሩ exceptions ብዛትና ከሚደርሱ ምልክትs ብዛት ጋር ተመጣጣኝ ነው። እንዲሁም ምልክት handler ምን ያህል stack እንደሚበላ ላይ ይመካል።
Rockset በሶስቱም መጥረቢያዎች ላይ ያልተለመደ ነው። በመደበኛ overload control አካል exceptionsን በከፍተኛ መጠን እንወረውራለን፤ በcoarse_thread_cputime_clock ምክንያት SIGUSR2ን በተለይ ብዙ ጊዜ እናደርሳለን፤ እና በዚህ ዓመት መጀመሪያ የተዋሃዱ ምልክትsን ለመቁጠር የSIGUSR2 handler timer_getoverrun እንዲጠራ በማከል ተጨማሪ stack እንዲጠቀም አደረግን።
ያ የመጨረሻ ለውጥ አስፈላጊ የነበረ ይመስላል። handlerው በቂ ትንሽ stack ከተጠቀመ፣ የቆየውን ucontext_t ማህደረ ትውስታ ላይ ላይደርስና ላይጽፍበት ይችላል። ከዚያ ለውጥ በፊት እነዚህን ክራሾች ፈጽሞ አናይም። ከለውጡ በኋላ ለአንዳንድ የአጠቃቀም ጉዳዮች backpressure mechanismን የሚጫኑ loads እስከምናሳድግ ድረስ መጠኑ ዝቅተኛ ቆየ።
በሌላ አነጋገር፣ የlibunwind ቡግ ሁልጊዜ ነበር፣ ግን የexception መጠናችን፣ የምልክት መጠናችንና የhandler stack አጠቃቀማችን ውጤት በኦፕሬሽን የሚታይበትን ገደብ በቅርቡ ብቻ አልፎ ነበር።
ይህ ሜካኒዝም የሃርድዌር ቡጉና የlibunwind ቡጉ ሁለቱም በአብዛኛው በDocumentTree::updateDocument ውስጥ ለምን እንደተከሰከሱ ያብራራል። ከlibunwind የመጡ ክራሾች ወደዚህ method በጣም ያዘነብሉ ነበር፤ ምክንያቱም ingest backpressure ለመተግበር exception በምንወረውርበት ነጥብ ሁልጊዜ ንቁ ነው። ለ%rsp-misalignment ክራሾችም በጣም ተመርጧል፤ ምክንያቱም መጥፎው የሃርድዌር ማነቃነቅ ለbulk ingest የምንጠቀመው SKU ነበር፣ እና አብዛኛውን CPU time በዚያ method ውስጥ ያሳልፋል።
ፈጣን mitigationያችን ከGNU libunwind ወደ libgcc unwinder መቀየር ነበር። ያ በራሱ ጥሩ ልውውጥ ነበር፤ የlibgcc ትግበራ ወደ ትልቅ VMs ሲስፋፋ lock contentionን ለመቀነስ ከብዙ ስራ ተጠቅሟል።
እንዲሁም ራሱን የቻለ reproducer እና fix(በአዲስ መስኮት ውስጥ ይክፈታል) ወደ GNU libunwind አቅርበን፣ ሌሎቹ unwinders ተመሳሳይ ችግር እንደሌላቸው አረጋገጥን።
ይህ የዲበግ ጉዞ ስለ dynamic linking፣ DWARF unwind metadata፣ Linux ምልክት delivery፣ System V ABI እና C++ exception machinery ዝርዝር ብዙ አስተማረን። ግን ዋናው ትምህርት ከዚያ ሁሉ ይልቅ ቀላል ነበር።
በጣም አስፈላጊው እርምጃ ብልህ የassembly ንባብ ወይም የዝርዝሮች ጥልቅ እውቀት አልነበረም። ከፍተኛ-ጥራት ያለው ውሂብ ስብስብ መገንባት ነበር። ይህ ውሂብ ስብስብ ባለመኖሩ፣ ሁለት የተለዩ ክስተቶችን ወደ አንድ ታሪክ እያቀላቀልን ከውዥንብሩ በምክንያት ለመውጣት እየሞከርን ነበር። ትክክለኛና ሙሉ የህዝብ ውሂብ ከነበረን በኋላ፣ የችግሩ መዋቅር ግልጽ ሆነ፦ አንዱ የክራሽ ህዝብ የመጥፎ host ነበር፣ ሌላው የlibunwind race ነበር። ውሂቡ ከተሻለ በኋላ ዲበግ ማድረጉ ቀለለ።
እንደ Rockset ላሉ infrastructure systems ይህ በጣም አስፈላጊ ነው። ይህ ምርመራ ለጥልቅ instrumentation፣ ለautomated investigations እና በoperational tooling ላይ ለቀጣይ ማሻሻያዎች ያለንን ቁርጠኝነት አጠናከረ። ታማኝነት ቡጎች ከተከሰቱ በኋላ መጠገን ብቻ አይደለም—የማይቻሉ ችግሮችን ሊመረመሩና ሊፈቱ ወደሚችሉ ችግሮች የሚቀይሩ ውሂብ፣ workflows እና ችሎታዎች መገንባት ነው።
ደራሲዎች
By Nathan Bronson እና Member of Technical Staff


