۱۷ شهریور ۲۵۸۵ · 8 دقیقه مطالعه
ارور ۲۵ ساعت قدیمی بود. کلاینت همهی این مدت سالم بود.
یه producer تو production تقریباً یه روز کامل هر request ای رو reject میکرد. تمام signal های سلامت هم تا آخر سبز موندن: restart count صفر، session های TCP تا broker های Kafka وضعیت ESTABLISHED، probe های liveness و readiness هم پاس. اروری که برمیگردوند byte-identical بود با اروری که client ی زیرش ۲۵ ساعت قبل تولید کرده بود، تا خودِ مقدار elapsed-time. client چند ثانیه بعد از incident recover شده بود. wrapper هیچوقت خبر نشد.
الگویی که کار میکنه
مثل خیلی از platform ها، ما Kafka client هامون رو پشت یه wrapper ی مشترک اجرا میکنیم، و جواب wrapper به از دست رفتن broker عمداً خام و بیپردهست: error ها رو بشمار، و وقتی از یه threshold رد شدی، panic کن. orchestrator پروسه رو restart میکنه و سرویس cold و تمیز برمیگرده.
مسخره کردن crash-to-recover راحته، تا وقتی که یه client رو تو یه حالت نیمهخراب debug کرده باشی، گیرکرده تو یه گوشهی state machine ی reconnect که نه تستی پوششش میده نه runbookی اسمش رو میبره. panic اون فضای بیکرانِ چنین state هایی رو تبدیل میکنه به دقیقاً یه state: یه پروسهی fresh. و با ماشینآلاتی compose میشه که platform از قبل داره. wrapper دوباره recovery نمیسازه؛ اون رو delegate میکنه به restart، که تنها مسیر recovery یه که همه توش سرمایهگذاری کردن.
موقع incidentی که وسط این پسته، platform broker هاش رو roll کرد، و سرویسهای پرمشغول با این wrapper دقیقاً همونجوری که طراحی شده بودن رفتار کردن. stream processor مدام به Kafka دست میزنه، پس یه broker roll تو هر ثانیه چند تا failure تولید میکنه؛ threshold تو چند ثانیه رد میشه، پروسه panic میکنه، orchestrator جایگزینش میکنه، و سرویس تو یه دقیقه تمیزه. بیش از یه دوجین سرویس روی همون نسخهی library دقیقاً همین کار رو کردن، همون لحظهای که broker شون خاموش شد. crash-to-recover اون شب به خودش حق داد.
چرا idle بودن خرابش میکنه
threshold ارورها رو میشماره، و ارور فقط وقتی پیش میاد که سرویس کار انجام بده. کل باگ همینجاست.
یه stream processor به اندازهی کافی سریع error تولید میکنه که هر threshold ای رو رد کنه. ولی یه request-driven producer، سرویسی که فقط وقتی به Kafka دست میزنه که یکی صداش کنه، و روزی یه بار صداش میکنن، تقریباً روزی یه error تولید میکنه. counter هیچوقت از هیچی رد نمیشه. و رفتار wrapper برای threshold ی که هیچوقت رد نمیشه «همچنان تلاش کن» نیست؛ اینه که آخرین error رو latch کنه و برای هر تلاش بعدی تا بینهایت همون رو برگردونه. یکیش رو ۲۲ ساعت دیدیم داری این کارو میکنه، و فقط برای این تموم شد که یه آدم pod رو دستی restart کرد.
recovery ی که با traffic همقدم باشه، recovery نیست. سرویسهایی که کمتر بهش نیاز دارن میگیرنش؛ اونهایی که بیشتر از همه نیاز دارن هیچوقت نمیگیرن. سرویس quiet دقیقاً همون سرویسیه که خرابیش دیده نمیشه، و مکانیزمی که برای نجاتش ساخته شده رو همون چیز یکیه که نداره rate-limit کرده: traffic.
دو بار اشتباه
راه رسیدن به جواب درست از دو تا جواب اشتباه رد شد؛ هر دو نگهداشتنشون میارزه.
اولی correlation بود با لباس cause. log های broker نشون میدادن یه user روزی هزارها بار، flat، برای چند هفته authentication رو فیل میکنه. audit trail هم مشخصش کرد: یه cleanup job خودکار credential اون user رو پاک کرده بود. دقیقاً یه credential پاک شده بود و دقیقاً یه user فیل میکرد. credential رو restore کردم، دیدم فیلها قطع شد، و outage رو root-caused گزارش کردم.
اون outage نبود. سرویسِ فیلکنه بهعنوان یه user کاملاً متفاوت authenticate میشد، که یه بار خوندن deployment config ش قبل از هر دستزدنی نشونش میداد. restore بیآسیب بود و یه client ی واقعاً خراب رو هم درست کرد، ولی یه client ی دیگه بود. «فقط یه چیز فیل شده و فقط یه چیز عوض شده» ویژگی search توئه، نه سیستم. یکتا بودن دعوت به verify کردنشه، نه proof.
یه detail هم pass اول رو عمداً گمراه کرد: سرویسی که همه log ش رو میخوندن، سرویسی نبود که به Kafka produce میکرد. ارور producer رو تو body یه response ی HTTP 500 گرفته بود و بهعنوان ارور خودش لاگ کرده بود؛ یه log ی Java داشت متنِ error یه library ی C رو حمل میکرد. reporter و actor دو تا سرویس متفاوت بودن، و log ی که همه توش grep میکردن داره به سوالی جواب میداد که پرسیده نشده بود.
جواب اشتباه دوم version بود. سرویس سالم روی یه نسخهی جدیدتر از client library اجرا میشد نسبت به سرویس گیرکرده، پس یه version bump شبیه fix بود، و یه ticket هم براش باز شد. بعد یه fleet inventory اومد: build info های Go ی embed شده رو از /proc/1/exe تکتک pod ها grep کن، و دقیقاً میدونی هر سرویس چه نسخهای اجرا میکنه. دهها سرویس روی wrapper، بیشترشون روی همون نسخهی سرویس گیرکرده، و بیش از یه دوجین از همون سرویسهای همنسخه موقع roll panic کردن و خودشون رو heal کردن. version هیچی رو split نمیکنه.
هیچکدوم از دو جهت استدلال معمول اینو تحمل نمیکنه. «یه سرویس دیگه روی این version سالم بود» هیچی رو تبرئه نمیکنه، و «اون سالمه نسخهی جدیدتر داره» هیچی رو محکوم نمیکنه. قبل از اینکه سراغ version diff بری، fleet رو بر اساس traffic profile جدا کن. اشتباه کردن این کار یه نفر رو میفرسته سراغ upgrade ی که نمیتونه مشکل رو درست کنه، و چیزی که باطلش کرد اعتراض یه همکار بود، با یه crash window از log های خود client که story من جاش رو نداشت.
دلیل، توی رقمها
decisive evidence کل این مدت توی خود error string نشسته بود.
ارور سرویس گیرکرده byte-identical بود با اروری که client ی زیرش موقع broker roll تولید کرده بود، و منظورم identical تا خود مقدار elapsed-time هست، اون fragment ی «after N milliseconds». client ی زیرش از اون موقع هیچی log نکرده بود، چون تو چند دقیقه بهطور عادی reconnect کرده بود. wrapper اون object ی error رو cache کرده بود و یه روز داشت همون رو دوباره serve میکرد.
digit های elapsed-time یهسان نمیتونن دو تا event ی مستقل باشن. elapsed time یه اندازهگیریه، و دو تا اندازهگیری تا سطح digit باهم موافق نمیشن. وقتی این بشینه، سوال از «چرا همش فیل میکنه» عوض میشه به «چرا ارور هیچوقت پاک نمیشه»، و این یکی جواب داره. هر وقت متن یه ارور یه duration یا counter توش بود، digit ها رو بین رخدادهاش diff کن. digit های یکسان یعنی یه error ی cache شده که داره دوباره serve میشه. هیچی هزینه نداره و کل investigation رو دور میزنه.
ظاهر یه پروسهی latched شده
هیچی از dashboard های استاندارد، گیرکرده رو از idle تشخیص نمیده.
restart count صفره، چون پروسه به منطق خودش هیچوقت نیاز به نجات نداشت. socket ها تا broker ها ESTABLISHED هستن، چون transport واقعاً recover شده بود؛ وضعیت socket evidence ییه از یه layer پایینتر از ادعا، و من یه بار همین آدم روی همین evidence گفته بودم درست شده، که زود بود. liveness و readiness پاس میشن، چون هیچکدوم از probe ها یه produce رو اجرا نمیکنن. log ها هم هیچی نشون نمیدن، چون سرویس quiet چه خراب باشه چه فقط quiet باشه هیچی لاگ نمیکنه. و retry های app-level چند ثانیه بعد همون error ی cache شده رو میگیرن، که برای همینه «retry اضافه کن» reflex غلطیه. از توی یه cache با retry بیرون نمیآی.
چی درش میبنده
سه تا fix، به ترتیب نزولی ارزش.
پاک کردن error state موقع موفقیت. یه produce ی موفق، یا یه reconnect ی موفق، باید error ی cache شده رو بازنشسته کنه. نشدنِ همین کار کل باگه؛ بقیه mitigation هستن.
probe سلامت باید produce path رو اجرا کنه. ادعای readiness یه producer «من میتونم produce کنم» ـه، پس probe باید دقیقاً همینو تست کنه. producer ی که سالم report کنه در حالی که هر produce رو reject میکنه، روی تنها محوری که کسی poll میکنه دروغ میگه.
threshold باید time-based هم باشه نه فقط count-based. اینطوری سرویس quiet هم مثل سرویس پرمشغول بالاخره recover میشه، با ساعت نه با counter.
و trigger ی تکرارشونده رو هم قبول کن، چون نمیره. یه managed Kafka platform broker هاش رو طبق یه برنامهی نگهداری roll میکنه، تو مور ما ماهانه، و هر roll این experiment رو با تکتک client ها از نو اجرا میکنه. بعد از هر roll سمت provider، producer های request-driven همونهایی هستن که باید چک بشن، و تنها چک قابلاتکا اینه که یه request از توشون رد کنی یا restartشون کنی. یه مجموعهی کوچیک producer کمترافیک و silent هست که بدون رد کردن یه request از توشون، از گیرکرده فرقشون نمیشه؛ میرن رو لیست roll بعدی.
کجا generalize میشه، و اون trap
هر مکانیزم recovery ی که trigger ش rate-based باشه این failure mode رو داره. circuit breaker برای trip شدن به failure نیاز داره. watchdog ها event میشمرن. connection pool موقع checkout validate میکنه، پس pool ی که هیچی ازش checkout نمیشه هیچی رو validate نمیکنه. health check ی که فقط happy path رو اجرا میکنه، کنار یه خرابی تا ابد پاس میشه. traffic کم تکتک اینها رو بیصدا تبدیل به no-op میکنه: هیچی disable نشده، هیچی misconfigure نیست، مکانیزم فقط داره منتظر یه evidence میمونه که نمیآد.
دو تا قانون از این صفحه بیرون میره. وقتی مکانیزم recovery یه rate-based هست، بپرس traffic که به اون rate نرسه چه اتفاقی میافته. و وقتی ارور یه counter توشه، اون counter evidence هست.
trap اینه که recovery رو ویژگیِ خرابی تلقی کنی. ویژگیِ traffice. client ی که فقط موقع fail شدن recover میشه، دقیقاً وقتی recover میشه که به اندازهی کافی زیاد fail کنه، و سرویسی که برای fail شدن کافی quiet باشه هیچوقت فرصتش نمیرسه. همونجا میشینه، روی همهی dashboard ها سالم، و ارورِ دیروز رو serve میکنه.