پرش به محتوای اصلی
OpenAI

۹ تیر ۱۴۰۵

مهندسی

بررسی کرش‌های هسته: رفع یک باگ ۱۸ ساله

استفاده از تحلیل سراسری سیستم برای دیباگ کرش‌های پیچیده در زیرساخت داده ما.

در حال بارگذاری…

مدل‌ها و عامل‌های OpenAI هرچه بیشتر برای یافتن داده‌های مرتبط هنگام استنتاج، یعنی وقتی مدل‌ها درباره پرسش شما فکر می‌کنند، به زیرساخت داده مقیاس‌پذیر تکیه می‌کنند. برخی از این سرویس‌ها با C++ نوشته شده‌اند؛ کنترل سطح‌پایین آن بر سیستم به ما امکان می‌دهد کارایی را بیشینه و مصرف حافظه را کمینه کنیم. این مزایای بهره‌وری هنگام مقیاس‌دهی مهم‌اند، اما نبود ایمنی حافظه در C++ یعنی باگ‌ها می‌توانند با نوشتن در نشانی‌های حافظه نادرست یا ناموجود باعث کرش شوند.

چند ماه پیش کرش‌هایی را از درون سرویس Rockset دیدیم؛ بخشی سفارشی از زیرساخت داده ChatGPT که برای بسیاری از افزونه‌های داده و جست‌وجو در گفتگوها کلیدی است. در هر یک از این کرش‌ها، یک تابع عادی C++ ظاهراً تمام می‌شد و سپس به نشانی جعلی برمی‌گشت؛ در نتیجه کرنل برنامه را متوقف می‌کرد چون اشاره‌گر دستور دیگر به کد اشاره نمی‌کرد. گاهی خانه نشانی بازگشت در قاب پشته NULL بود. گاهی خود ثبات اشاره‌گر پشته CPU انگار ۸ بایت جابه‌جا بود، گویی %rsp somehow در میانه اجرای عادی کاهش یافته بود. در هر دو حالت، کرش هنگام بازگشت رخ می‌داد.

این‌ها حالت‌های معمولِ بروز خطا در کدهای برنامه‌نویسی نیستند. یک نوشتنِ تصادفی (stray write) که فقط روی آدرس بازگشتِ ذخیره‌شده اثر بگذارد ممکن است رخ دهد، اما بسیار بعید است. باگی که بدون استفاده از inline assembly، setcontext یا longjmp (که هیچ‌کدام را استفاده نمی‌کنیم) باعث شود %rsp به اندازه ۸ بایت ناهماهنگ شود، حتی عجیب‌تر است، چون کد کامپایل‌شده فقط در prologue و epilogue تابع مستقیماً این رجیستر را تغییر می‌دهد. هر فرضیه‌ای که ما (یا ChatGPT) می‌توانستیم مطرح کنیم، شواهد محکمی علیه آن داشت، بنابراین باگ در ابتدا غیرممکن به نظر می‌رسید.

آنچه تصور می‌کردیم یک مشکل است، در نهایت مشخص شد دو باگ نامرتبط بوده‌اند که به‌طور تصادفی هم‌زمان کشف شدند. نخست، خرابی خاموش سخت‌افزاری روی یکی از میزبان‌های Azure که در آن CPU عملاً محاسبات را درست انجام نمی‌داد. دوم، یک race condition ۱۸ ساله در GNU libunwind؛ یک باگ نادیده در یک کتابخانه متن‌باز پرکاربرد.

این نوشته روایت این است که چگونه با فکر کردن مثل یک همه‌گیرشناس و ساختن مجموعه‌داده‌ای باکیفیت درباره کل جمعیت کرش‌ها، کرش‌های ظاهراً توضیح‌ناپذیر را شناسایی و رفع کردیم.

نخستین تلاش دیباگ: بررسی دقیق چند دامپ هسته

اول، کمی عمیق‌تر به Rockset بپردازیم. این یک سامانه داده ابربومی برای جست‌وجو و تحلیل بلادرنگ است که در OpenAI برای کاربردهای داخلی فراوانی، مانند همگام‌سازها، از آن استفاده می‌کنیم (Rockset در ۲۰۲۴ به OpenAI پیوست). به‌روزرسانی‌های جریانی برای نگه‌داشتن نمایه‌ای به‌روز از پایگاه دانش یک فضای کاری به کار می‌روند تا ChatGPT هنگام پاسخ دادن یا انجام اقدام‌ها بتواند اطلاعات مرتبط را جست‌وجو کند.

لایه اجرای Rockset با C++ نوشته شده است. زبان C++ دسترسی سطح‌پایین به CPU می‌دهد که برای کارایی و بهره‌وری خوب است، اما یعنی باگ‌های برنامه می‌توانند به دسترسی نامعتبر به حافظه و segfault منجر شوند. برای ردیابی آن‌ها از fatal signal handler کتابخانه folly استفاده می‌کنیم تا هنگام کرش رد پشته ثبت شود، و دامپ‌های هسته متناظر، یعنی تصویری از وضعیت برنامه هنگام کرش، برای تحلیل بعدی در Azure blob storage بارگذاری می‌شوند. همه برگ‌های پردازش پرس‌وجوی Rockset تکثیر شده‌اند و این اثر کرش بر کاربر را کمینه می‌کند. با این حال، هر segfault نشان‌دهنده باگی است که برای رسیدن به اهداف قابلیت‌اعتماد و کیفیت باید رفع شود.

رویکرد اولیه ما این بود که این دامپ‌ها را مثل یک مسئله دیباگ مرسوم ببینیم: چند دامپ هسته را بسیار دقیق بررسی کنیم، فرضیه بسازیم و یکی‌یکی ردشان کنیم.

بیشتر کرش‌ها در متدی به نام DocumentTree::updateDocument رخ می‌داد. در این کرش‌ها به نظر می‌رسید updateDocument تابع ناشناخته X را فراخوانده، هنگام فعال بودن X پشته خراب شده، و سپس X به نشانی‌ای برگشته که کد اجرایی نبوده است. در برخی موارد قاب تازه خارج‌شده X معتبر به نظر می‌رسید، جز اینکه نشانی بازگشت ذخیره‌شده‌اش NULL بود. در موارد دیگر خود اشاره‌گر پشته غلط به نظر می‌رسید، اما قاب معتبر بعدی هنوز ظاهراً updateDocument بود.

نمی‌دانستیم پشته چه زمانی خراب می‌شود، و این فضای جست‌وجویی عظیم باقی می‌گذاشت. updateDocument متدی بزرگ است که inlining زیادی روی آن انجام می‌شود، بنابراین شمار نامزدهای X سرسام‌آور بود.

آیا این باگی در کد C++ ما بود؟ مشکل کامپایلر یا لینک‌کردن؟ مشکلی در یکی از کتابخانه‌های زمان اجرای ما؟ باگی در کرنل Linux پیرامون تحویل سیگنال یا تعویض زمینه؟ چیزی حتی نادرتر؟ اگر این یک نوشتن سرگردان بود، چرا محیط staging مبتنی بر ASAN ما آن را نگرفت؟

کوشیدیم با لاگ‌های سطح برنامه همه رخدادهای مشکل را شناسایی کنیم، اما باگ‌های خرابی پشته را از روی لاگ تنها سخت می‌توان طبقه‌بندی کرد، چون خود ردهای پشته ثبت‌شده خراب یا غایب‌اند. نتوانستیم پرس‌وجوی لاگی بسازیم که هم مثبت کاذب نداشته باشد و هم منفی کاذب. دامپ‌های بیشتری را دستی بررسی کردیم و چند نمونه دیگر یافتیم، اما این فرایند آن‌قدر پرزحمت بود که مجموعه‌داده‌ای قابل‌اعتماد به ما نمی‌داد.

در این مرحله از بررسی، باگ سخت‌افزاری را (به‌اشتباه) رد کردیم، چون کرش‌ها را در چند منطقه و چند نوع سخت‌افزار می‌دیدیم؛ پس هنوز دنبال علت‌های صرفاً نرم‌افزاری بودیم. چند روز روی یک کرش با %rsp نامیزان بسیار عمیق شدیم و با محتوای پشته و ثبات‌ها تاریخچه پیش از کرش را بازسازی کردیم. این کار سرنخ‌هایی ممکن داد، اما چون از نتیجه‌گیری اولیه‌مان، یعنی اینکه همه باگ‌ها یک علت دارند، دست نکشیدیم، ما را از بن‌بست بیرون نیاورد.

سرنخ‌هایی از پشته

پیش از رسیدن به نقطه عطف بررسی، مهم است توضیح دهیم چه اطلاعاتی از فایل‌های هسته استخراج می‌کردیم.

Rockset با -fno-omit-frame-pointer کامپایل می‌شود، پس قاب پشته فعال همیشه از راه %rbp دسترس‌پذیر است و فراخواننده‌ها فهرستی پیوندی از اشاره‌گرهای قاب می‌سازند.

در Linux x86_64، ABI ‏System V برای AMD64 همچنین ۱۲۸ بایت زیر %rsp را به‌عنوان red zone رزرو می‌کند. این ناحیه در اختیار کد فضای کاربر است و مهم‌تر اینکه طبق قرارداد ABI، کرنل هنگام تحویل سیگنال قول می‌دهد آن را خراب نکند.

red zone در دیباگ کرش پس از بازگشت برای ما محوری بود، چون بخشی از اطلاعات پیش از بازگشت را حفظ می‌کند. وقتی SIGSEGV رخ می‌دهد، fatal signal handler کتابخانه folly روی پشته همان نخِ کرش‌کرده اجرا می‌شود. قاب‌های پشته‌ای که دیگر فعال نیستند، چون تابعشان برگشته، به‌جز ۱۲۸ بایت آخر، توسط signal handler بازنویسی می‌شوند. به همین دلیل می‌توانیم بگوییم «قاب پشته تازه خارج‌شده X معتبر به نظر می‌رسید، جز یک نشانی بازگشت NULL.» red zone بخشی از قاب‌های غیرفعال، یا گاهی فقط انتهای یکی از آن‌ها، را حفظ می‌کند.

نمودار پشته که قاب‌های پشته خراب را نشان می‌دهد که می‌توانند نشانی‌های بازگشت را بازنویسی کنند و باعث کرش شوند.

یک کرش با پشته نامیزان یافتیم که همه تابع‌های درگیر در آن بسیار کوچک بودند. این باعث شد ببینیم %rsp هنگام اجرای تابعی نسبتاً ساده نامیزان شده و پس از آن چند فراخوانی دیگر هم موفق بوده‌اند. برنامه فقط وقتی کرش کرد که تابع فعال سرانجام خواست برگردد. هیچ‌یک از آن مسیرهای کد از exception، assembly درون‌خطی، setcontext یا longjmp استفاده نمی‌کرد؛ پس اگر اشاره‌گر پشته واقعاً همان‌طور که دامپ نشان می‌داد تغییر کرده بود، هیچ باگ محتملی در کد فضای کاربر مسئله را توضیح نمی‌داد.

این ما را به سمت کرنل هل داد.

Rockset از سیگنال‌ها تهاجمی‌تر از بیشتر برنامه‌ها استفاده می‌کند. اجرای پرس‌وجو به وظیفه‌های سبک‌وزن فراوانی شکسته می‌شود که داده ردوبدل می‌کنند. این برای مدیریت کارآمد بارهای QPS بالا مهم است، اما حسابداری CPU به‌ازای هر پرس‌وجو را دشوار می‌کند، چون کارِ بسیاری از پرس‌وجوها روی یک مخزن نخ مشترک multiplex می‌شود.

راه‌حل ما چیزی است که coarse_thread_cputime_clock می‌نامیم و clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) را آن‌قدر ارزان تقریب می‌زند که بتوان در مرز هر وظیفه نمونه‌برداری کرد. API ‏timer_create می‌تواند تحویل دوره‌ای سیگنال را بر پایه چند برداشت از گذر زمان، از جمله انباشت زمان CPU، زمان‌بندی کند. ما سیگنالی (SIGUSR2) را زمان‌بندی می‌کنیم تا هر چند میلی‌ثانیه زمان CPU تحویل شود؛ آن‌گاه signal handler یک مقدار محلیِ نخ را به‌روز می‌کند. هرچند بسیاری از وظیفه‌ها هنگام اجرا جلو رفتن ساعت درشت‌دانه را نمی‌بینند، جمع همه دلتاها برآوردی بی‌طرف از زمان واقعی CPU برای یک پرس‌وجو می‌دهد.

چون سیگنال‌ها را بسیار پرتکرار تحویل می‌دهیم، باگی نادر در کرنل پیرامون تعویض زمینه یا تحویل سیگنال محتمل به نظر می‌رسید. زمانی را صرف خواندن گزارش‌های باگ، کد منبع کرنل و وصله‌های کرنل ویژه Azure کردیم. آزمون‌های فشار را امتحان کردیم. نتوانستیم چیزی مرتبط پیدا کنیم.

در آن نقطه تصمیم گرفتیم عقب بایستیم و رویکردی متفاوت را امتحان کنیم.

پزشک یا همه‌گیرشناس؟

برای دیباگ چنین مسئله‌ای دو راه کلی وجود دارد.

یکی این است که مثل نوعی پزشک عمل کنید: روی یک بیمار تمرکز کنید، آزمایش‌های زیاد بگیرید و بکوشید یک مورد را از شواهد دقیق تشخیص دهید.

دیگری این است که بیشتر شبیه همه‌گیرشناس باشید: کل جمعیت را ببینید و بپرسید آیا الگوهایی هست که یک مورد تنها نمی‌تواند آشکار کند. آیا باگ از یک انتشار مشخص شروع شد؟ آیا با یک SKU سخت‌افزاری خاص، یعنی مدل مشخص CPU و سرور، یک منطقه، یا یک نسخه کرنل هم‌بستگی دارد؟ آیا چند خوشه متمایز در دل چیزی پنهان شده‌اند که یک سندرم واحد به نظر می‌رسد؟

ما بیشتر در حالت پزشک بودیم. تغییر کلیدی این بود که تصمیم گرفتیم داده جمعیتی باکیفیت گردآوری کنیم.

پاک‌سازی داده‌ها

تلاش‌های قبلی ما برای یافتن خودکار همه نمونه‌های مشکل شکست خورد، چون می‌خواستیم روی لاگ‌ها جست‌وجوی متنی انجام دهیم. خود دامپ‌های هسته اطلاعات بسیار بیشتری دارند، اما بررسی دستی آن‌ها مقیاس‌پذیر نبود. تصمیم گرفتیم برای ساخت خط لوله‌ای که بتواند دامپ‌های هسته را خودکار تحلیل کند وقت بگذاریم.

از ChatGPT خواستیم اسکریپتی بنویسد که پیشوند هر فایل هسته را دانلود کند، ثبات‌ها را استخراج کند، مثبت‌های کاذب شناخته‌شده را با لاگ‌ها فیلتر کند، و کرش را خودکار به return-to-null، misaligned-stack یا سایر برچسب بزند. سپس آن اسکریپت را به‌صورت موازی روی همه دامپ‌های هسته تولیدی Rockset از سال گذشته اجرا کردیم.

این نقطه عطف بود.

به‌محض داشتن مجموعه‌داده‌ای پاک، هم‌بستگی‌ها فوراً نمایان شدند. آنچه یک باگ عجیب می‌پنداشتیم، در واقع دو جمعیت کرش جداگانه بود.

دامپ‌های return-to-null در خوشه‌ها و مناطق جغرافیایی زیادی پخش بودند. فراوانی‌شان اخیراً بالا رفته بود، اما تاریخ شروع دقیق و مرز زیرساختی روشنی نداشتند.

کرش‌های پشته نامیزان کاملاً متفاوت به نظر می‌رسیدند. همه از یک منطقه می‌آمدند، تاریخ شروع روشنی داشتند، و هرگز روی گره‌هایی که مدت طولانی اجرا شده بودند رخ نمی‌دادند. با اینکه چند VM ‏Azure را درگیر می‌کردند، الگو شبیه یک ماشین فیزیکی با سخت‌افزار خراب بود که برای هر VM که اتفاقاً روی آن می‌افتاد مشکل ایجاد می‌کرد.

نمودار نقطه‌ای نرخ کرش برحسب خوشه در طول زمان، که نشان می‌دهد بیشتر کرش‌ها در خوشه‌های ۲، ۳ و ۶ متمرکزند و نزدیک پایان دوره در خوشه ۱ جهشی رخ داده است.

همان لحظه فهمیدیم در ذهنمان دو باگ را یکی کرده بودیم. چون پادنمونه‌های هر دو باگ را با هم قاطی می‌کردیم، نمی‌توانستیم به توضیحی واحد و منسجم برسیم.

باگ شماره ۱: میزبان خراب

با داشتن فهرستی پاک از گره‌های Kubernetes و زمان‌ها، توانستیم کرش‌های پشته نامیزان را به یک میزبان فیزیکی واحد برگردانیم که denylist کردنش آسان بود.

حتی پس از چند هفته آزمون فشار، نتوانستیم خرابی ثبات‌ها را روی آن میزبان در محیطی کنترل‌شده بازتولید کنیم. اما وقتی میزبان مشکل‌دار از سرویس خارج شد، کرش‌های پشته نامیزان ناپدید شدند.

حذف میزبان خراب راه‌حلی دائمی نیست، به این معنا که از رخداد دوباره همان مشکل جلوگیری نمی‌کند. اما می‌توانیم نرم‌افزار را طوری تغییر دهیم که اگر مسئله‌ای مشابه تکرار شد، به‌سادگی شناسایی و مدیریت شود. fatal signal handler خود را بهبود دادیم تا وضعیت ثبات‌ها را هم شامل شود و بتوانیم تکرار مشکل را فقط از روی لاگ‌ها تشخیص دهیم، بی‌نیاز از دامپ هسته. صفحه کنترل را تغییر دادیم تا VMها معمولاً به‌جای بازیافت، دوباره استفاده شوند؛ این کار تشخیص گره بد را در سطح ما از پشته زیرساخت بسیار آسان‌تر می‌کند. همچنین runbookهای خود و مدل‌های ذهنی تیم را به‌روز کردیم تا این احتمال را هم دربر بگیرند.

با جدا شدن کرش‌های میزبان خراب، استدلال درباره دامپ‌های return-to-null باقی‌مانده بسیار آسان‌تر شد. پیش‌تر unwinding مربوط به exception را رد کرده بودیم، چون فکر می‌کردیم پادنمونه داریم: کرش‌هایی در مسیرهای کدی که قطعاً از exception استفاده نمی‌کردند. اما همه آن پادنمونه‌ها از خوشه خرابی سخت‌افزاری بودند.

وقتی با این نکته دوباره به دامپ‌های باقی‌مانده نگاه کردیم، فهمیدیم نتیجه دقیقاً برعکس بوده است: همه کرش‌ها هنگام unwinding مربوط به exception رخ می‌دادند.

مدیریت exception انتقال کنترل پویاست

وقتی C++ یک exception پرتاب می‌کند، runtime باید پیدا کند کدام catch block باید آن را دریافت کند و کدام destructorها یا cleanup handlerها باید در مسیر اجرا شوند. کامپایلر این فراداده را تولید می‌کند، اما تطبیق واقعی به‌صورت پویا در زمان اجرا انجام می‌شود.

unwinding مربوط به exception در واقع توسط تابعی که throw را فراخوانی می‌کند انجام نمی‌شود، بلکه توسط تابع‌های کمکی فراخوانده‌شده از کد کامپایل‌شده حاصل انجام می‌شود. این روال‌های runtime پشته را بررسی می‌کنند، فراداده تابع‌های موجود روی پشته را می‌گیرند، به‌صورت پویا به دنبال cleanup handlerها و catch blockها می‌گردند، و سپس کنترل را به یکی از آن مکان‌ها منتقل می‌کنند. انتقال کنترل شامل unwind کردن همه قاب‌های پشته میانی، از جمله قاب‌های تابع‌های کمکی، است.

از نظر عملیاتی، این بسیار بیشتر شبیه longjmp یا جابه‌جایی fiber است تا یک فراخوانی و بازگشت عادی. ثبات‌های callee-save باید بازیابی شوند، همچنین ثبات‌های قاب پشته %rbp و %rsp.

باینری ما به دو کتابخانه لینک می‌شود که پیاده‌سازی تابع‌های انجام‌دهنده unwinding برای exceptionهای C++ را دارند: libgcc و GNU libunwind. تعریف‌های GNU libunwind همان‌هایی بودند که dynamic linker انتخاب کرده بود. این ما را غافلگیر کرد؛ انتظار داشتیم به‌دلیل قواعد نسخه‌بندی symbol، پیاده‌سازی libgcc برنده شود؛ اما بررسی باینری‌های در حال اجرا نشان داد چنین نیست.

کنار گذاشتن آخرین فرض

در این نقطه فرضیه کاری ما عوض شد، چون فرض دیگری را که هنگام تصور وجود تنها یک باگ ساخته بودیم، نرم‌تر کردیم.

شاید بازگشت عادی یک تابع به NULL را نمی‌دیدیم. شاید انتقال unwind را می‌دیدیم؛ در عمل بازیابی ثبات‌ها به سبک setcontext، جایی که اشاره‌گر دستور مقصد پیش از انتقال کنترل NULL شده بود. به بیان دیگر، داده نادرست از کتابخانه unwind، نه خانه نشانی بازگشت نادرست روی پشته.

این دامنه مسئله را به‌شدت محدود کرد. یا GNU libunwind وضعیت مقصد را غلط محاسبه می‌کرد، یا آن را درست محاسبه می‌کرد و چیزی پیش از اعمال شدن خرابش می‌کرد.

منبع GNU libunwind را خواندیم و دیدیم یک ucontext_t را روی پشته می‌سازد، وضعیت ثبات دلخواه برای قاب cleanup handler را در آن پر می‌کند، و سپس اشاره‌گری به آن struct را به یک روال assembly داخلی می‌دهد: _Ux86_64_setcontext.

در این نقطه همه قطعه‌ها را داشتیم.

ucontext_t ساخته‌شده در یکی از قاب‌های پشته‌ای زندگی می‌کند که هنگام اجرای _Ux86_64_setcontext توسط همان تابع unwind می‌شود. آیا _Ux86_64_setcontext پس از تغییر %rsp از struct می‌خواند، یعنی زمانی که struct دیگر بخشی از پشته فعال نبود؟ این آن را در برابر بازنویسی توسط تحویل سیگنال، مانند SIGUSR2 پرتکرار ما، آسیب‌پذیر می‌کرد.

باگ شماره ۲: باگ libunwind

پاسخ مثبت بود.

اینجا شش دستور پایانی _Ux86_64_setcontext در نسخه GNU libunwind مورد استفاده ما آمده است؛ این دستورها عمدتاً mov هستند که از حافظه به یک ثبات مقصد بارگذاری می‌کنند:

متن ساده

1
74: mov UC_MCONTEXT_GREGS_RSP(%rdi),%rsp
2
75:
3
76: /* push the return address on the stack */
4
77: mov UC_MCONTEXT_GREGS_RIP(%rdi),%rcx
5
78: push %rcx
6
79:
7
80: mov UC_MCONTEXT_GREGS_RCX(%rdi),%rcx
8
81: mov UC_MCONTEXT_GREGS_RDI(%rdi),%rdi
9
82: retq

(%rdi به ucontext_t تخصیص‌یافته روی پشته اشاره می‌کند، و ماکروهای UC_MCONTEXT_* فقط به offset ثابت محل ذخیره یک ثبات خاص گسترش می‌یابند.)

دستور نخست آغاز پنجره مسابقه است. این دستور %rsp را به‌روز می‌کند تا به تهِ جدید پشته فعال اشاره کند. به‌محض این اتفاق، structی که %rdi به آن اشاره می‌کند دیگر بخشی از پشته فعال یا red zone نیست و دیگر برای کرنل محدوده ممنوعه محسوب نمی‌شود.

معمولاً این مشکلی ایجاد نمی‌کند، اما اگر سیگنال دقیقاً در لحظه درست، یا شاید غلط، برسد، کرنل قاب سیگنال را در %rsp-128 می‌سازد. این می‌تواند حافظه‌ای را که %rdi به آن اشاره می‌کند بازنویسی کند.

اگر این پیش از آن رخ دهد که دستور بعدی UC_MCONTEXT_GREGS_RIP(%rdi) را بخواند، اشاره‌گر دستور بازیابی‌شده می‌تواند خراب شود. در کرش‌های ما، NULL شد.

باگ همین بود.

چرا کرش‌ها که به‌صورت بازگشتِ معمولیِ بد به نظر می‌رسیدند، در واقع پنهان‌کنندهٔ هسته‌ها بودند

این اسمبلی همچنین یکی از مشاهداتی را توضیح می‌دهد که ما را سردرگم کرده بود: چرا تابع X در اسلاتِ آدرسِ بازگشتِ فریمِ پشتهٔ قبلی مقدار NULL داشت.

setcontext طوری نوشته شده بود که همهٔ رجیسترها، از جمله %rdi را بازیابی کند؛ بنابراین در لحظهٔ نهاییِ انتقالِ کنترل نمی‌تواند از آن رجیستر برای خواندنِ UC_MCONTEXT_GREGS_RIP(%rdi) استفاده کند. در عوض، مقدار را زودتر می‌خواند، آن را روی پشته ذخیره می‌کند، چند رجیستر دیگر را بازیابی می‌کند، سپس با retq مقدار ذخیره‌شده را می‌خواند و کنترل را منتقل می‌کند.

چیزی که در coreها شبیه «یک تابع به NULL برگشت» به نظر می‌رسید، در واقع این بود: «unwinder یک آدرسِ بازگشتِ هدف را روی پشته سنتز کرد، اما آن هدف پیش از کامل‌شدنِ انتقال خراب شده بود.» ما فرض کرده بودیم که خراب‌شدنِ اسلاتِ آدرسِ بازگشت باید در همان‌جا (in-place) رخ دهد، چون از جایی خبر نداشتیم که در آن دادهٔ (قابل‌خراب‌شدن) عمداً در اسلاتِ آدرسِ بازگشت نوشته شود.

یک پنجرهٔ رقابتیِ تک‌دستور

چیزی که این باگ را پوچ به نظر می‌رساند این است که این پنجرهٔ رقابتی چقدر باریک است. در این نوع شرایط رقابتی، رویداد خارجی (سیگنال) باید بین دو گامی رخ دهد که یک ترد دیگر برمی‌دارد. هرچه آن گام‌ها به هم نزدیک‌تر باشند، احتمال رخ‌دادنِ شرایط رقابتی کمتر است.

در این مورد، پنجرهٔ آسیب‌پذیر واقعاً فقط به اندازهٔ یک دستور است! یک سیگنال باید بعد از تغییرِ %rsp تحویل داده شود، اما قبل از این‌که دستور بعدی %rip را بارگذاری کند. چندین دستور ساده مثل این می‌تواند در هر سیکل روی یک CPU مدرنِ سوپراسکالرِ خارج‌ازترتیب اجرا شود، بنابراین پنجرهٔ رقابتی حدود ۱۰۰ پیکوثانیه است.

وقتی این رقابت را پیدا کردیم، واکنش اول ما این بود که باید آن‌قدر نادر باشد که نتواند نرخ کرش مشاهده‌شده را توضیح دهد. ما در سراسر ناوگان روزانه بیش از یک دوجین کرشِ بازگشت‌به-NULL می‌دیدیم. آیا واقعاً یک رقابتِ تک‌دستور در پاک‌سازیِ استثنا می‌تواند آن را توضیح دهد؟

به برآورد فرمی (Fermi estimation) روی آوردیم. اگر پنجرهٔ آسیب‌پذیر در حد 101010^{-10} ثانیه باشد و SIGUSR2 هر 10210^{-2} ثانیه از زمان CPU برسد، آنگاه هر هندلرِ پاک‌سازیِ استثنا یا بلوک catch تقریباً 10810^{-8} احتمال دارد رقابت را ببازد.

Rockset از استثناها به‌عنوان بخشی از سازوکار داخلیِ backpressure برای ingest استفاده می‌کند. یک میزبانِ منفردِ بیش‌ازحد بارگذاری‌شده می‌تواند در حد 10410^{4} استثنا در ثانیه پرتاب کند. این یعنی میانگین زمان بین خرابی‌های یک میزبانِ استفاده‌کننده از backpressure برابر 10410^{4} ثانیه است، یا هر چند ساعت یک کرش. در مقیاس ناوگان، این بیش از اندازه کافی است تا فراوانیِ کرش مشاهده‌شده را توضیح دهد.

چرا باگ libunwind حالا ظاهر شد؟

باگ GNU libunwind قدیمی است—بیش از ۱۸ سال قدمت دارد و در اولین نسخهٔ x86_64 که از unwind کردنِ استثناهای C++ پشتیبانی می‌کرد وجود داشته است.

پس چرا حالا خودش را نشان داد؟

نرخ کرش تقریباً متناسب است با تعداد استثناهایی که پرتاب می‌شوند و تعداد سیگنال‌هایی که تحویل داده می‌شوند. این موضوع همچنین به میزان پشته‌ای که هندلر سیگنال مصرف می‌کند نیز بستگی دارد.

Rockset در هر سه محور غیرمعمول است. ما به‌عنوان بخشی از کنترلِ اضافه‌بارِ معمول، با نرخ‌های بالا استثنا پرتاب می‌کنیم؛ SIGUSR2 را به‌طور غیرمعمول زیاد تحویل می‌دهیم چون coarse_thread_cputime_clock را داریم؛ و اوایل امسال هندلرِ SIGUSR2 را با افزودنِ یک فراخوانی به timer_getoverrun طوری تغییر دادیم که از پشتهٔ بیشتری استفاده کند تا بتوانیم سیگنال‌های ادغام‌شده را حساب کنیم.

به نظر می‌رسد همان تغییر آخر مهم بوده است. اگر هندلر به اندازهٔ کافی از پشته کم استفاده کند، ممکن است به حافظهٔ کهنهٔ ucontext_t نرسد و آن را بازنویسی نکند. قبل از آن تغییر، ما اصلاً این کرش‌ها را مشاهده نمی‌کنیم. بعد از تغییر، نرخ پایین ماند تا این‌که برای برخی use caseها بار را بالا بردیم؛ use caseهایی که سازوکار backpressure را تحت فشار قرار می‌دادند.

به عبارت دیگر، باگ libunwind همیشه آن‌جا بوده است، اما حاصل‌ضربِ نرخ استثنا، نرخ سیگنال، و میزان استفادهٔ پشته توسط هندلرِ ما تازه اخیراً از آستانه‌ای عبور کرده که آن را از نظر عملیاتی قابل مشاهده کرده است.

این سازوکار همچنین هم‌زمانیِ این نکته را توضیح می‌دهد که هم باگ سخت‌افزاری و هم باگ libunwind عمدتاً داخل DocumentTree::updateDocument کرش می‌کردند. کرش‌های ناشی از libunwind به‌شدت به سمت این متد متمایل بودند، چون این متد همیشه در نقطه‌ای فعال است که ما برای اعمال backpressure در ingest یک استثنا پرتاب می‌کنیم. همچنین برای کرش‌های ناشی از بدترازشدنِ %rspهم به‌شدت انتخاب می‌شد، چون گرهٔ سخت‌افزاریِ بد از یک SKU بود که ما برای ingest حجیم استفاده می‌کنیم و بخش عمدهٔ زمان CPU خود را در آن متد می‌گذراند.

اقدام کاهشیِ فوریِ ما این بود که از GNU libunwind به unwinderِ libgcc سوئیچ کنیم. این به‌خودیِ خود یک معاملهٔ خوب بود: پیاده‌سازیِ libgcc از کار زیادی برای کاهش lock contention بهره برده است، که هنگام مقیاس‌دادن به VMهای بزرگ اهمیت دارد.

ما همچنین یک بازتولیدکنندهٔ خودکفا و یک رفع(در یک پنجره جدید باز می‌شود) را به GNU libunwind upstream کردیم و بررسی کردیم که unwinderهای دیگر مشکل مشابهی ندارند.

قدرتِ تشخیص در سطح جمعیت

این سفرِ دیباگ به ما چیزهای زیادی دربارهٔ جزئیات خاصِ لینک‌دهی پویا، متادیتای unwind در DWARF، تحویل سیگنال در لینوکس، ABI سیستم V، و سازوکار استثنای C++ یاد داد. اما درس اصلی ساده‌تر از همهٔ این‌ها بود.

مهم‌ترین گام، خواندنِ هوشمندانهٔ اسمبلی یا دانش عمیق از جزئیات نبود. بلکه ساختنِ یک مجموعه‌دادهٔ باکیفیت بود. در نبودِ این مجموعه‌داده، ما دو پدیدهٔ متمایز را در یک روایت قاطی کرده بودیم و تلاش می‌کردیم با استدلال از سردرگمی بیرون بیاییم. وقتی داده‌های جمعیتیِ دقیق و کامل داشتیم، ساختار مسئله آشکار شد: یک جمعیتِ کرش متعلق به یک میزبانِ بد بود و دیگری متعلق به یک رقابت در libunwind. هرچه داده بهتر شد، دیباگ هم آسان‌تر شد.

برای سامانه‌های زیرساختی مثل Rockset، این خیلی مهم است. این بررسی تعهد ما را به instrumentation عمیق، بررسی‌های خودکار، و بهبودهای مستمر در ابزارهای عملیاتی‌مان تقویت کرد. قابلیت اطمینان فقط به رفع اشکال‌ها پس از وقوع آن‌ها محدود نمی‌شود—بلکه به ساختن داده‌ها، گردش‌کارها و مهارت‌هایی مربوط است که مسائلِ به‌ظاهر غیرممکن را به مسائلی قابل عیب‌یابی و قابل حل تبدیل می‌کنند.

نویسندگان

By Nathan Bronson،‏ Member of Technical Staff