მეხსიერების დამპის ეპიდემიოლოგია: 18-წლიანი ხარვეზის გასწორება
პოპულაციის დონის ანალიზით რთული ავარიული გათიშვების გამართვა ჩვენს მონაცემთა ინფრასტრუქტურაში.
OpenAI-ს მოდელები და აგენტები სულ უფრო მეტად ეყრდნობა მასშტაბირებად მონაცემთა ინფრასტრუქტურას, რათა ინფერენციის დროს (როცა მოდელები თქვენს კითხვაზე ფიქრობენ) შესაბამისი მონაცემები მოძებნონ. ამ სერვისების ნაწილი C++ ენაზეა დაწერილი. ამ ენას დაბალ დონეზე სისტემის მართვის შესაძლებლობა აქვს, რაც წარმადობის მაქსიმიზაციასა და მეხსიერების გამოყენების შემცირებაში გვეხმარება. მასშტაბირებისას ეს ეფექტიანობა მნიშვნელოვანია, მაგრამ C++ ენაში მეხსიერების უსაფრთხოების არქონის გამო ხარვეზებმა შეიძლება ავარიული გათიშვა გამოიწვიოს მეხსიერების არასწორ ან არარსებულ მისამართებზე ჩაწერით.
რამდენიმე თვის წინ Rockset-ის სერვისში ავარიული გათიშვები შევნიშნეთ. ეს სერვისი სპეციალური ნაწილია ჩვენი ChatGPT‑ს მონაცემთა ინფრასტრუქტურისა, რომელიც მნიშვნელოვანია მონაცემთა მრავალი პლაგინისა და საუბრებში ძიებისთვის. ყოველ ასეთ ავარიულ გათიშვაში ჩვეულებრივი C++ ფუნქცია თითქოს სრულდებოდა და შემდეგ ყალბ მისამართზე ბრუნდებოდა, რის გამოც ბირთვი აჩერებდა პროგრამას, რადგან ბრძანებების მიმთითებელი კოდზე აღარ მიუთითებდა. ზოგჯერ სტეკის ფრეიმში დაბრუნების მისამართის სლოტი NULL (ნულოვანი) იყო. ზოგჯერ თავად სტეკის მიმთითებლის CPU-რეგისტრი თითქოს 8 ბაიტით იყო აცდენილი, თითქოს %rsp ჩვეულებრივი შესრულების შუაში როგორღაც შემცირებულიყო. ორივე შემთხვევაში ავარიული გათიშვა დაბრუნებისას ხდებოდა.
ეს აპლიკაციის კოდისთვის ავარიული გათიშვის ნორმალური რეჟიმები არ არის. შემთხვევითი ჩაწერა, რომელიც მხოლოდ შენახულ დაბრუნების მისამართზე ხვდება, შესაძლებელია, მაგრამ უკიდურესად ნაკლებადალბათური. ხარვეზი, რომელიც %rsp-ს 8-ით აცდენას იწვევს და არ მოიცავს ჩაშენებულ ასემბლერულ კოდს, setcontext-სა თუ longjmp-ს (ჩვენ არცერთ მათგანს არ ვიყენებთ), კიდევ უფრო უცნაურია, რადგან კომპილირებული კოდი ამ რეგისტრს პირდაპირ მხოლოდ ფუნქციის პროლოგსა და ეპილოგში ცვლის. ყველა ჰიპოთეზას, რომლებიც ჩვენ (ან ChatGPT‑მ) მოვიფიქრეთ, ძლიერი საწინააღმდეგო მტკიცებულება ჰქონდა, ამიტომ პროგრამული ხარვეზი შეუძლებელი ჩანდა.
რაც ერთ პრობლემად მიგვაჩნდა, საბოლოოდ, აღმოჩნდა ორი ერთმანეთთან დაუკავშირებელი ხარვეზი, რომლებიც შემთხვევით ერთდროულად აღმოვაჩინეთ. პირველი იყო ჩუმი აპარატული დაზიანება Azure-ის ჰოსტზე, სადაც CPU უბრალოდ მათემატიკურ გამოთვლებს სწორად არ ასრულებდა. მეორე — 18-წლიანი რბოლის მდგომარეობა GNU libunwind-ში, შეუმჩნეველი ხარვეზი ფართოდ გამოყენებულ ღია კოდის ბიბლიოთეკაში.
ეს პუბლიკაცია მოგითხრობთ, როგორ ამოვიცანით და გავასწორეთ თითქოს აუხსნელი ავარიული გათიშვები: ფიქრი დავიწყეთ ეპიდემიოლოგივით და ავარიული გათიშვების მთელ პოპულაციაზე მაღალი ხარისხის მონაცემთა ნაკრები შევქმენით.
ჯერ Rockset-ი უფრო ღრმად შევისწავლოთ. ეს არის ღრუბელზე ორიენტირებული მონაცემთა სისტემა ძიებისა და რეალურ დროში ანალიტიკისთვის. მას OpenAI-ში ბევრ შიდა შემთხვევაში ვიყენებთ, მაგალითად სინქრონიზაციის მაერთებლებისთვის (Rockset-ი 2024 წელს შეიძინა OpenAI-მ). ნაკადური განახლებები გამოიყენება სამუშაო სივრცის ცოდნის ბაზის განახლებული ინდექსის შესანარჩუნებლად, რათა ChatGPT‑მ კითხვებზე პასუხისას ან მოქმედებების შესრულებისას შესაბამისი ინფორმაცია მოძებნოს.
Rockset-ის შესრულების ფენა C++ ენაზეა დაწერილი. C++ ენა CPU-ზე წვდომას იძლევა დაბალ დონეზე, რაც წარმადობისა და ეფექტიანობისთვის კარგია, მაგრამ ნიშნავს, რომ აპლიკაციის ხარვეზებმა შეიძლება მეხსიერებაზე არასწორი წვდომა და სეგმენტაციის შეცდომები (segfault) გამოიწვიოს. ამის საპოვნელად folly-ს ფატალური სიგნალების დამმუშავებელს ვიყენებთ, რომ ავარიული გათიშვისას სტეკის კვალი ჩაიწეროს, ხოლო შესაბამის მეხსიერების დამპებს (პროგრამის მდგომარეობის ანაბეჭდს ავარიული გათიშვის მომენტში) Azure-ის BLOB-ობიექტების საცავში ვტვირთავთ შემდგომი ანალიზისთვის. Rockset-ის მოთხოვნათა დამუშავების ყველა ფოთლოვანი კვანძი რეპლიცირებულია, რაც ამცირებს ავარიული გათიშვის გავლენას კლიენტზე. თუმცა სეგმენტაციის ყოველი შეცდომა ნიშნავს ხარვეზს, რომელიც უნდა გასწორდეს ჩვენი მიზნების მისაღწევად საიმედოობისა და ხარისხის მხრივ.
საწყის ეტაპზე მეხსიერების ამ დამპებს გამართვის ტრადიციულ პრობლემასავით მივუდექით: მეხსიერების რამდენიმე დამპი ზედმიწევნით დეტალურად შევამოწმეთ, ჰიპოთეზები შევადგინეთ და სათითაოდ გამოვრიცხეთ.
ავარიული გათიშვების უმეტესობა ხდებოდა მეთოდში, სახელად DocumentTree::updateDocument. ამ ავარიულ გათიშვებში ჩანდა, თითქოს updateDocument-მა უცნობი ფუნქცია X გამოიძახა, X-ის მუშაობისას სტეკი დაზიანდა, შემდეგ კი X დაბრუნდა მისამართზე, რომელიც შესრულებადი კოდი არ იყო. ზოგ შემთხვევაში X-ის ახლახან ამოღებული ფრეიმი ვალიდურად გამოიყურებოდა, მაგრამ მისი შენახული დაბრუნების მისამართი NULL იყო. სხვა შემთხვევებში თავად სტეკის მიმთითებელი ჩანდა არასწორი, მაგრამ მომდევნო ვალიდური ფრეიმი ისევ updateDocument იყო.
არ ვიცოდით, როდის ზიანდებოდა სტეკი, რაც ძიებისთვის უზარმაზარ სივრცეს ტოვებდა. updateDocument დიდი მეთოდია და ბევრი ჩაშენებული კოდი აქვს, ამიტომ X-ის კანდიდატების რაოდენობა გამაოგნებლად დიდი იყო.
იყო ეს ხარვეზი ჩვენს C++ ენაზე დაწერილ კოდში? კომპილატორის ან დაკავშირების პრობლემა? პრობლემა ჩვენს შესრულების გარემოს ბიბლიოთეკათაგან რომელიმეში? ლინუქსის ბირთვის ხარვეზი სიგნალის მიწოდების ან კონტექსტის გადართვის მხრივ? კიდევ უფრო იშვიათი რამ? თუ ეს შემთხვევითი ჩაწერა იყო, რატომ ვერ დაიჭირა იგი ჩვენმა ASAN შუალედურმა გარემომ?
პრობლემის ყველა შემთხვევის ამოსაცნობად აპლიკაციის დონის ჟურნალების გამოყენება ვცადეთ, მაგრამ სტეკის დაზიანების ხარვეზების მხოლოდ ჟურნალებით კლასიფიცირება რთულია, რადგან ჩაწერილი სტეკის კვლებიც დაზიანებული ან დაკარგულია. ვერ ავაგეთ ჟურნალის მოთხოვნა, რომელსაც ერთდროულად მცდარი დადებითებიცა და მცდარი უარყოფითებიც არ ექნებოდა. ხელით კიდევ მეხსიერების რამდენიმე დამპი შევამოწმეთ და დამატებითი მაგალითებიც ვიპოვეთ, მაგრამ ეს პროცესი ზედმეტად შრომატევადი იყო სანდო მონაცემთა ნაკრების მისაღებად.
გამოძიების ამ ეტაპზე (არასწორად) გამოვრიცხეთ აპარატული ხარვეზი, რადგან ავარიულ გათიშვებს მრავალ რეგიონსა და აპარატურის მრავალ ტიპზე ვხედავდით; ამიტომ კვლავ მხოლოდ პროგრამულ მიზეზებს ვეძებდით. რამდენიმე დღის განმავლობაში შეუთანხმებელი %rsp-ის ავარიულ გათიშვას ძალიან ჩავუღრმავდით და ავარიამდელი ისტორია სტეკისა და რეგისტრების შიგთავსით აღვადგინეთ. ამან რამდენიმე შესაძლო მინიშნება მოგვცა, მაგრამ ჩიხიდან ვერ გამოვედით რადგან ხელს არ ვუშვებდით საწყის დასკვნას, რომ ყველა ხარვეზს ერთი მიზეზი ჰქონდა.
სანამ გამოძიების გარდამტეხ მომენტამდე მივალთ, მნიშვნელოვანია ავხსნათ, რა ტიპის ინფორმაციას ვიღებდით მეხსიერების დამპის ფაილებიდან.
Rockset-ი კომპილირებულია -fno-omit-frame-pointer პარამეტრით, ამიტომ სტეკის აქტიური ფრეიმი ყოველთვის ხელმისაწვდომია %rbp-ით, ხოლო გამომძახებლები ფრეიმების მიმთითებლების დაკავშირებულ სიას ქმნის.
ლინუქსის x86_64 ვერსიაში AMD64 System V ABI ასევე %rsp-ის ქვემოთ 128 ბაიტს წითელ ზონად არეზერვებს. ეს არე სამომხმარებლო სივრცის კოდისთვის ხელმისაწვდომია და, რაც მთავარია, ABI-ს კონტრაქტის ფარგლებში ბირთვი იძლევა იმის ნიშანს, რომ სიგნალის მიწოდებისას მას არ დააზიანებს.
„წითელი ზონა“ გადამწყვეტი იყო დაბრუნებისშემდგომი ავარიული გათიშვის გამართვაში, რადგან დაბრუნებამდელი ინფორმაციის ნაწილს ინახავს. როცა SIGSEGV ირთვება, folly-ს ფატალური სიგნალების დამმუშავებლის გაშვება ხდება ავარიული ნაკადის სტეკზე. სტეკის ფრეიმები, რომლებიც აღარ არის აქტიური (რადგან მათი ფუნქცია დაბრუნდა), სიგნალების დამმუშავებლის მიერ გადაიწერება ბოლო 128 ბაიტის გარდა. ამიტომ შეგვიძლია, ვთქვათ: „X-ის ახლახან ამოღებული სტეკის ფრეიმი ვალიდურად გამოიყურებოდა, გარდა NULL (ნულოვანი) დაბრუნების მისამართისა“. „წითელი ზონა“ ინახავს არააქტიური ფრეიმების ნაწილს, ზოგჯერ კი ერთი არააქტიური ფრეიმის მხოლოდ კუდს.
ვიპოვეთ სტეკის შეუთანხმებლობით გამოწვეული ერთი ავარიული გათიშვა, სადაც ყველა ფუნქცია ძალიან პატარა იყო. ამან დაგვანახა, რომ %rsp შედარებით მარტივი ფუნქციის შესრულებისას აცდა სწორ პოზიციას და ამის შემდეგ კიდევ რამდენიმე გამოძახება წარმატებით შესრულდა. პროგრამა მხოლოდ მაშინ გაითიშა ავარიულად, როცა აქტიურმა ფუნქციამ ბოლოს დაბრუნება სცადა. ამ კოდის გზებიდან არცერთი იყენებდა გამონაკლისებს, ჩაშენებულ ასემბლერულ კოდს, setcontext-სა და longjmp-ს, ამიტომ თუ სტეკის მიმთითებელი მართლაც ისე შეიცვალა, როგორც მეხსიერების დამპი მიუთითებდა, სამომხმარებლო სივრცის კოდში სარწმუნო ხარვეზი ამას ვერ ხსნიდა.
ამან ბირთვისკენ გვიბიძგა.
Rockset-ი სიგნალებს უფრო აქტიურად იყენებს, ვიდრე პროგრამების უმეტესობა. მოთხოვნის შესრულება იყოფა ბევრ იოლ დავალებად, რომლებიც მონაცემებს ცვლის. ეს მაღალი QPS-დატვირთვების ეფექტიანად დასამუშავებლად მნიშვნელოვანია, მაგრამ თითო მოთხოვნაზე CPU-ს აღრიცხვას ართულებს, რადგან მრავალი მოთხოვნის სამუშაოს მულტიპლექსირება ნაკადების ერთსა და იმავე პულზე ხდება.
ჩვენი გადაწყვეტაა coarse_thread_cputime_clock, რომელიც clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...)-ის აპროქსიმაციას საკმარისად იაფად ახდენს, რომ დავალების ყოველ საზღვარზე სემპლირება იყოს შესაძლებელი. timer_create API შეიძლება გამოყენებულ იქნეს პერიოდული სიგნალის მიწოდების დასაგეგმად დროის სვლის რამდენიმე ცნებაზე დაყრდნობით, მათ შორის, პროცესორული დროის დაგროვების მიხედვით. ვგეგმავთ სიგნალს (SIGUSR2), რომლის მიწოდებაც პროცესორული დროის ყოველ რამდენიმე მილიწამში უნდა მოხდეს, რა მომენტშიც სიგნალების დამმუშავებელი ნაკადის ლოკალურ მნიშვნელობას აახლებს. მიუხედავად იმისა, რომ ბევრი დავალება შესრულებისას არ ხედავს მიახლოებითი დროის ცვლილებას, ყველა დელტას შეჯამება მოთხოვნის რეალური პროცესორული დროის მიუკერძოებელ სავარაუდო მნიშვნელობას იძლევა.
რადგან სიგნალებს ასე ხშირად ვაწვდით, დამაჯერებლად ჩანდა ბირთვის იშვიათი ხარვეზი კონტექსტის შეცვლის ან სიგნალის მიწოდების მხრივ. დრო დავუთმეთ ხარვეზების შესახებ ანგარიშების, ბირთვის საწყისი კოდისა და Azure-სპეციფიკური ბირთვის პატჩების წაკითხვას. სტრეს-ტესტებიც ვცადეთ. მიუხედავად ამისა, ვერაფერს მივაგენით.
ამ ეტაპზე გადავწყვიტეთ უკან დაგვეხია და სხვა მიდგომა გვეცადა.
ასეთი პრობლემის გამართვის ორი ფართო გზა არსებობს.
ერთი არის ექიმივით მოქმედება: ერთ პაციენტზე ფოკუსირება, ბევრი ტესტის ჩატარება და ერთი შემთხვევის დიაგნოსტირება დეტალური მტკიცებულებებით.
მეორე უფრო ეპიდემიოლოგის მიდგომაა: მთელ პოპულაციას შეხედო და ჰკითხო, არსებობს თუ არა ნიმუშები, რომლებსაც ერთი შემთხვევა ვერ გამოავლენს. ხარვეზი კონკრეტული გამოშვებიდან დაიწყო? უკავშირდება აპარატული უზრუნველყოფის ერთ SKU-ს (კონკრეტულ CPU-სა და სერვერის მოდელს), ერთ რეგიონს ან ბირთვის ერთ ვერსიას? იმალება თუ არა რამდენიმე განსხვავებული კლასტერი იმაში, რაც ერთ სინდრომს ჰგავს?
ჩვენ ძირითადად ექიმის რეჟიმში ვიყავით. მთავარი ცვლილება იყო გადაწყვეტილება, რომ მაღალი ხარისხის პოპულაციური მონაცემები უნდა შეგვეგროვებინა.
პრობლემის ყველა შემთხვევის ავტომატურად პოვნა ადრე ვერასდროს ვერ მოვახერხეთ, რადგან ჟურნალებზე ტექსტურ ძიებას ვიყენებდით. თავად მეხსიერების დამპებში ბევრად მეტი ინფორმაციაა, მაგრამ შეუძლებელი იყო მისი მანუალური დათვალიერების მასშტაბირება. გადავწყვიტეთ, ძალისხმევა ჩაგვედო კონვეიერის აგებაში, რომელიც მეხსიერების დამპებს ავტომატურად გააანალიზებდა.
ChatGPT‑ს დავაწერინეთ სკრიპტი, რომელიც მეხსიერების თითოეული დამპის ფაილის პრეფიქსს ტვირთავდა, რეგისტრებს იღებდა, ჟურნალების გამოყებით ფილტრავდა ცნობილ ცრუ დადებითებს და ავარიულ გათიშვას ავტომატურად აკრავდა იარლიყს: return-to-null (ნულზე დაბრუნება), misaligned-stack (შეუთანხმებელი სტეკი) ან other (სხვა). შემდეგ ეს სკრიპტი პარალელურად გავუშვით Rockset-ის წინა წლის საექსპლუატაციო გარემოს თითოეულ მეხსიერების დამპზე.
ეს იყო გარდამტეხი მომენტი.
როგორც კი სუფთა მონაცემთა ნაკრები მოვიპოვეთ, კორელაციები მაშინვე გამოჩნდა. ის, რაც ერთ უცნაურ ხარვეზად მიგვაჩნდა, სინამდვილეში ავარიული გათიშვების ორი ცალკეული პოპულაცია იყო.
ნულზე დაბრუნებასთან ასოცირებული მეხსიერების დამპები ბევრ კლასტერსა და გეოგრაფიულ რეგიონში იყო მიმოფანტული. მათი სიხშირე ბოლო დროს გაიზარდა, მაგრამ მკაფიო დაწყების თარიღი და სუფთა ინფრასტრუქტურული საზღვარი არ ჩანდა.
შეუთანხმებელ სტეკთან ასოცირებული ავარიული გათიშვები სრულად სხვანაირად გამოიყურებოდა. ყველა ერთი რეგიონიდან მოდიოდა, მკაფიო დაწყების თარიღი ჰქონდა და არასოდეს ხდებოდა კვანძებზე, რომლებიც დიდი ხანი მუშაობდა. მიუხედავად იმისა, რომ ისინი რამდენიმე Azure VM-ს (ღრუბელში განთავსებულ ვირტუალურ მანქანას) მოიცავდა, კანონზომიერება ისე გამოიყურებოდა, თითქოს ცუდი აპარატული უზრუნველყოფის მქონე ერთი მანქანა იწვევდა პრობლემებს უქმნიდა იმ ვირტუალურ მანქანას, რომელიც მასზე მოხვდებოდა.
ამ მომენტში მივხვდით, რომ გონებაში ორ ხარვეზს ერთმანეთში ვურევდით. რადგან ორივე ხარვეზის საწინააღმდეგო მაგალითებს ერთმანეთში ვურევდით, ერთიან, თანმიმდევრულ ახსნას ვერ ვპოულობდით.
Kubernetes-ის კვანძებისა და დროის შტამპების სუფთა სიის გამოყენებით სტეკის შეთანხმებლობასთან დაკავშირებულმა ავარიულმა გათიშვებმა მიგვიყვანა ერთ ფიზიკურ ჰოსტამდე, რომელიც ადვილი დასამატებელი იყო უარყოფილთა სიაში (denylist).
აღნიშნულ ჰოსტზე რეგისტრების დაზიანების გამეორება კონტროლირებულ გარემოში ვერ შევძელით რამდენიმეკვირიანი სტრეს-ტესტირების შემდეგაც კი. თუმცა როგორც კი პრობლემური ჰოსტი სერვისიდან ამოვიღეთ, სტეკის შეუთანხმებლობასთან დაკავშირებული ავარიული გათიშვები გაქრა.
ცუდი ჰოსტის ამოღება მუდმივი გადაწყვეტა არ არის იმ გაგებით, რომ იმავე პრობლემის ხელახლა გაჩენას ვერ აღკვეთს. მაგრამ შეგვიძლია პროგრამული უზრუნველყოფა ისე შევცვალოთ, რომ მსგავსი პრობლემა განმეორებისას ადვილად გამოვლინდეს და დამუშავდეს. ფატალური სიგნალების დამმუშავებელი გავაუმჯობესეთ ისე, რომ რეგისტრების მდგომარეობაც მოიცვას, რათა განმეორება მხოლოდ ჟურნალებიდან აღმოვაჩინოთ (მეხსიერების დამპი საჭირო არ იყოს). მართვის სიბრტყე შევცვალეთ ისე, რომ, ჩვეულებრივ, ვირტუალური მანქანები ხელახლა გამოიყენებოდეს, თავიდან კი არ იქმნებოდეს; ეს ჩვენს ინფრასტრუქტურულ დონეზე ცუდი კვანძის აღმოჩენას ბევრად ამარტივებს. ასევე განვაახლეთ ოპერაციული რეგლამენტები (runbook) (და ჩვენი გუნდის მენტალური მოდელები), რომ ეს შესაძლებლობაც მოგვეცვა.
ცუდ ჰოსტთან ასოცირებული ავარიების გამოცალკევების შემდეგ ბევრად ადვილი გახდა მსჯელობა დარჩენილ მეხსიერების დამპებზე, რომლებიც ნულზე დაბრუნებასთნ იყო დაკავშირებული. ადრე გამონაკლისების განხვევა (unwinding) გამოვრიცხეთ, რადგან გვეგონა კონტრმაგალითები გვქონდა: ავარიული გათიშვები კოდის შესრულების გზებში, სადაც გამონაკლისები ნამდვილად არ გამოიყენებოდა. მაგრამ ყველა კონტრმაგალითი აპარატული დაზიანების კლასტერიდან იყო.
როცა მეხსიერების დარჩენილ დამპებს ამის გავითვალისწინებით დავუბრუნდით, ვნახეთ, რომ ეს დასკვნა სავსებით შებრუნებული იყო: ყველა ავარიული გათიშვა გამონაკლისების განხვევის დროს ხდებოდა.
როცა C++ კოდი გამონაკლისს ისვრის, შესრულების გარემომ უნდა გაარკვიოს, რომელ catch-ბლოკს უნდა მიუვიდეს ის და რომელი დესტრუქტორებისა თუ გასუფთავების დამმუშავებლების გაშვება უნდა მოხდეს ამასთან ერთად. კომპილატორი ამ მეტამონაცემს გამოყოფს, მაგრამ რეალური შეჯერება დინამიკურად ხდება შესრულების გარემოში.
გამონაკლისების განხვევას სინამდვილეში სროლის გამომძახებელი ფუნქცია კი არ ასრულებს, არამედ შედეგად მიღებული კომპილირებული კოდის მიერ გამოძახებული დამხმარე ფუნქციები. შესრულების გარემოს ეს რუტინული დავალებები ამოწმებს სტეკს, იღებს მეტამონაცემებს სტეკში ნაპოვნი ფუნქციების შესახებ, დინამიკურად ეძებს წმენდის დამმუშავებლებსა და catch-ბლოკებს და შემდეგ მართვას ერთ-ერთ ასეთ ლოკაციას გადასცემს. მართვის გადაცემა მოიცავს სტეკის ყველა შუალედური ფრეიმის განხვევას (დამხმარე ფუნქციების ფრეიმების ჩათვლით).
ოპერაციულად ეს ბევრად უფრო ახლოსაა longjmp-თან ან ბოჭკოების გადართვასთან, ვიდრე ჩვეულებრივ გამოძახებასა და დაბრუნებასთან. უნდა აღდგეს callee-save რეგისტრები (გამოძახებული ფუნქციის მიერ შენახული რეგისტრები) და ასევე, სტეკის ფრეიმის რეგისტრები: %rbp და %rsp.
ჩვენი ბინარული ფაილი უკავშირდება ორ ბიბლიოთეკას, რომლებიც შეიცავს იმ ფუნქციების იმპლემენტაციებს, რომლებიც C++ კოდის გამონაკლისების განხვევას ასრულებს: libgcc-სა და GNU libunwind-ს. დინამიკურმა გადამბმელმა (dynamic linker) აირჩია GNU libunwind-ის დეფინიციები. ეს გაგვიკვირდა; ველოდით, რომ სიმბოლოების ვერსიონირების წესების გამო libgcc-ს იმპლემენტაცია გაიმარჯვებდა, თუმცა გაშვებული ბინარული ფაილების შემოწმებამ აჩვენა, რომ ეს ასე არ იყო.
ამ ეტაპზე სამუშაო ჰიპოთეზა შეგვეცვალა, რადგან უარი ვთქვით კიდევ ერთ ვარაუდზე, რომელიც მაშინ გავაკეთეთ, როცა ერთი გვეგონა, რომ ხარვეზი ერთი იყო.
შესაძლოა, NULL-ზე ფუნქციის ჩვეულებრივ დაბრუნებას არ ვხედავდით. შესაძლოა, ვხედავდით განხვევის გადაცემას — არსებითად, რეგისტრების აღდგენას setcontext-ის სტილში — სადაც დანიშნულების ინსტრუქციის მიმთითებელი მართვის გადაცემამდე ხდებოდა NULL-ი (ნულოვანი). სხვა სიტყვებით რომ ვთქვათ, არასწორი მონაცემი განხვევის ბიბლიოთეკიდან, და არა დაბრუნების მისამართის არასწორი სლოტი სტეკზე.
ამან პრობლემა მკვეთრად დაავიწროვა. ან GNU libunwind ითვლიდა არასწორ დანიშნულების მდგომარეობას, ან სწორ მდგომარეობას ითვლიდა და რაღაც მას გამოყენებამდე აზიანებდა.
GNU libunwind-ის საწყისი კოდი წავიკითხეთ და ვნახეთ, რომ ის სტეკზე ucontext_t-ს ქმნის, გასუფთავების დამმუშავებლის ფრეიმისთვის სასურველ რეგისტრების მდგომარეობას ავსებს და შემდეგ ამ სტრუქტურულ ობიექტზე მიმთითებელს გადასცემს შიდა ასემბლერულ რუტინულ პროცედურას: _Ux86_64_setcontext.
ამ ეტაპზე ყველა საჭირო ნაწილი გვქონდა.
სინთეზირებული ucontext_t სტეკის ერთ-ერთ ფრეიმშია, რომლის განხვევასაც _Ux86_64_setcontext ახდენს აღნიშნული ფუნქციის შესრულებისას. კითხულობდა თუ არა _Ux86_64_setcontext სტრუქტურული ობიექტიდან მას შემდეგ, რაც %rsp შეცვალა, როცა სტრუქტურული ობიექტი უკვე აქტიური სტეკის ნაწილი აღარ იყო? ეს მას მოწყვლადს გახდიდა ისეთი სიგნალის მიწოდებისგან გამოწვეული გადაწერისადმი, როგორიცაა ჩვენი ხშირი SIGUSR2.
პასუხი დადებითი იყო.
აქ წარმოდგიდგენთ _Ux86_64_setcontext-ის ბოლო ექვს ინსტრუქციას GNU libunwind-ის იმ ვერსიაში, რომელსაც ვიყენებდით; ისინი ძირითადად mov ინსტრუქციებია, რომლებიც მონაცემებს მეხსიერებიდან დანიშნულების რეგისტრში ტვირთავს:
(%rdi სტეკზე გამოყოფილ ucontext_t-ზე მიუთითებს, ხოლო UC_MCONTEXT_* მაკროსები უბრალოდ იმ ფიქსირებულ გადანაცვლებამდე ფართოვდება, სადაც კონკრეტული რეგისტრი ინახება.)
პირველი ინსტრუქცია რბოლის ფანჯრის დასაწყისია. ის %rsp-ს აახლებს, რომ მან აქტიური სტეკის ახალ ფსკერზე მიუთითოს. როგორც კი ეს მოხდება, სტრუქტურული ობიექტი, რომელზეც %rdi მიუთითებს, აქტიური სტეკის (ან „წითელი ზონის“) ნაწილი აღარ იქნება და ბირთვისთვისაც ხელმისაწვდომი გახდება.
ჩვეულებრივ, ეს პრობლემას არ იწვევს, მაგრამ თუ სიგნალი ზუსტად სწორ (ან არასწორ?) მომენტში მოვა, ბირთვი სიგნალის ფრეიმს ააწყობს %rsp-128-ზე. ამან შეიძლება ახალი მონაცემები გადააწეროს მეხსიერებას, რომელზეც %rdi მიუთითებს.
თუ ეს მოხდება მანამ, სანამ მომდევნო ინსტრუქცია UC_MCONTEXT_GREGS_RIP(%rdi) კომპონენტს წაიკითხავს, აღდგენილი ინსტრუქციული მიმთითებელი შეიძლება დაზიანდეს. ჩვენს ავარიულ გათიშვებში ის NULL (ნულოვანი) გახდა.
ესაა ხარვეზი.
This assembly also explains one of the observations that confused us: why function X had a NULL in the return address slot of the preceding stack frame.
setcontext was written to restore all registers, including %rdi, so it can’t use that register to read UC_MCONTEXT_GREGS_RIP(%rdi) at the final moment of the control transfer. Instead, it reads the value earlier, saves it to the stack, restores a few more registers, then uses retq to read the saved value and transfer control.
What looked in the cores like “a function returned to NULL” was actually “the unwinder synthesized a target return address on the stack, but that target had been corrupted before the transfer completed.” We had assumed that corruption of the return address slot must happen in-place, because we didn’t know of any places where (corruptible) data was written to the return address slot on purpose.
What makes this bug seem absurd is how narrow this race window is. In this kind of race condition, the external event (the signal) needs to happen in between two steps taken by another thread. The closer those steps are to each other, the less likely the race condition is to happen.
In this case the vulnerable window is literally one instruction wide! A signal must be delivered after %rsp has been changed, but before the next instruction loads %rip. Several simple instructions like this can be run per cycle on a modern super-scalar out-of-order CPU, so the race window is roughly a hundred picoseconds.
When we found this race, our first reaction was that it must be too rare to explain the observed crash rate. We were seeing more than a dozen return-to-null crashes per day across the fleet. Could a one-instruction race during exception cleanup really account for that?
We turned to Fermi estimation. If the vulnerable window is on the order of seconds and SIGUSR2 arrives every seconds of CPU time, then each exception cleanup handler or catch block has a roughly probability of losing the race.
Rockset uses exceptions as part of its internal ingest backpressure mechanism. A single overloaded host can throw on the order of exceptions per second. That implies the mean time between failures of a host using backpressure is seconds, or one crash every few hours. At fleet scale, that is more than enough to explain the observed crash frequency.
The GNU libunwind bug is old—more than 18 years old, present in the first x86_64 version that supported C++ exception unwinding.
So why did it show up now?
The crash rate is roughly proportional to how many exceptions are thrown and how many signals are delivered. It’s also dependent on how much stack the signal handler consumes.
Rockset is unusual on all three axes. We throw exceptions at high rates as part of normal overload control; we deliver SIGUSR2 unusually often because of coarse_thread_cputime_clock; and earlier this year we made the SIGUSR2 handler use more stack by adding a call to timer_getoverrun, so we could account for merged signals.
That last change seems to have been important. If the handler uses little enough stack, it may not reach and overwrite the stale ucontext_t memory. Before that change, we do not observe these crashes at all. After the change the rate remained low until we ramped up load for some use cases that stressed the backpressure mechanism.
In other words, the libunwind bug has always been there, but the product of our exception rate, signal rate, and handler stack usage had only recently crossed the threshold where it became operationally visible.
This mechanism also explains the coincidence that both the hardware bug and the libunwind bug crashed mostly inside DocumentTree::updateDocument. Crashes from libunwind were heavily biased toward this method, because it’s always active at the point we throw an exception to apply ingest backpressure. It was also heavily selected for the %rsp-misalignment crashes because the bad hardware node was of a SKU that we use for bulk ingest, which spends the majority of its CPU time in that method.
Our immediate mitigation was to switch from GNU libunwind to libgcc’s unwinder. That was a good trade on its own: libgcc’s implementation has benefited from a lot of work to reduce lock contention, which matters when scaling to large VMs.
We also upstreamed a self-contained reproducer and a fix(იხსნება ახალ ფანჯარაში) to GNU libunwind, and verified that the other unwinders don’t have a similar issue.
This debugging journey taught us a lot about the specific details of dynamic linking, DWARF unwind metadata, Linux signal delivery, the System V ABI, and C++ exception machinery. But the main lesson was simpler than any of that.
The most important step was not the clever assembly reading or deep knowledge of the details. It was building a high-quality data set. In the absence of this data set, we were mixing two distinct phenomena into one story and trying to reason our way out of the confusion. Once we had accurate and complete population data, the structure of the problem became obvious: one crash population belonged to a bad host, and the other belonged to a race in libunwind. Once the data got better, the debugging got easier.
For infrastructure systems like Rockset, that matters a lot. This investigation reinforced our commitment to deep instrumentation, automated investigations, and continual improvements in our operational tooling. Reliability is not just about fixing bugs after they happen—it’s about building the data, workflows, and skills that turn impossible problems into diagnosable and solvable ones.
ავტორები
By Nathan Bronson და Member of Technical Staff


