یک آزمایش سریع شعلهور
A Blazing Fast Experiment

هدف و محتوای این پست احتمالا از عنوانش قابل برداشت باشه، توی این پست قصد داریم آزمایشی که توی اون، یکی از سرویس های Golangای اسنپ رو با Rust بازنویسی کردیم توضیح بدیم.
سرویس تست A/B
سرویسی که برای این آزمایش سراغش رفتیم سرویس تست A/B اسنپ هست که طیف گستردهای از آزمایشها با اون انجام میشن و فیچرهای جدید اسنپ با کمک اون برای کاربرها منتشر میشن. وظیفهی این سرویس اینه که مشخص کنه یک قابلیت جدید برای چه کاربرهایی فعال باشه، که اینکار رو با کنترل Feature Flagها انجام میده. این Feature Flagها برای گروههای خاصی از کاربرها تعریف میشن، که این گروهها یا بر اساس معیارهای تصادفی دستهبندی میشن یا با بعضی ویژگیهای منحصر به فردتر مثل نوع دستگاه یا ورژن اپلیکیشن.
قوانینی که باهاش این گروهها مشخص میشن داخل ساختار دادهای به اسم Rule تعریف میشن. پس سرویس تست A/B در واقع یک mapping از Rule ها به Feature Flagها هست.
و اصلیترین endpoint این سرویس، Evaluate نام داره، که با گرفتن مشخصات یک کاربر، Feature Flagهایی که برای این کاربر فعال هست رو برمیگردونه.
مقیاس کاری سرویس
تقریبا منتشر کردن تمام ویژگیهای جدید اسنپ، با این سرویس شروع میشه. پس، از نقطه شروع کار برنامه، تا انجام دادن خیلی از کارهای جزئیتر مثل گرفتن سفر، دیدن مشخصات راننده و... همگی به این سرویس نیاز دارن.
هرکدوم از این درخواستها شامل:
- یک یا چند کاربر برای بررسی
- تعداد مشخص Feature Flag برای بررسی
هست و خروجی هر درخواست شامل لیست Feature Flagهای فعال برای هرکدوم از کاربرهای فرستاده شده خواهد بود.
استک فنی
- Language: Go
- Platform: Kubernetes
- Web Framework: Echo, grpc-go
- Runtime: Native
- Runtime Container: Alpine
عملکرد و سرعت
نکات زیر در نمودارها و آزمایش رعایت شده:
- تمامی اندازهگیریها با درصدی از درخواستهای واقعی و در محیط Production انجام شده.
- در هر Request بین 50 الی 500 کاربر وجود داره. این درخواستها جزو سنگینترین درخواستها از نظر بار محاسباتی محسوب میشن.
- پادها در Worker Nodeهای یکسان اجرا شدهاند و از نظر زیرساختی کاملا مشابه هستن.
- منابع مشخص شده (request) برای هر Pod شامل:
- ۱ هسته CPU (Request)
- ۱ گیگابایت Memory (Request)
در این نمودارها لود، بین دو پاد از اپلیکیشن پخش شده:





استک فنی جدید (Rust)
- Language: Rust (musl target)
- Platform: Kubernetes
- Web Framework: Axum(hyper, tower-http), tonic
- Runtime: Tokio
- Runtime Container: Alpine
اجرای اولیه آزمایش
- شرایط و محیط آزمایش دقیقا مشابه نسخه اصلی برنامه است.
- این نتایج با اجرای برنامه با 5 پاد به دست اومده که مجموعا ۵ گیگابایت مموری و ۵ هسته CPU مصرف شده.





همونطور که مشخص هست، این نتایج فاصله خیلی زیادی از انتظارات ما داره. در واقع هدف از انتخاب Rust برای این آزمایش سریعتر شدن سرویس، درگیری کمتر منابع و افزایش Throughtput بود که هیچ کدوم داخل اجرای اولیه آزمایش مشاهده نشد!
بعد از انجام دادن کمی Profiling و تحلیل رفتار اپلیکیشن، متوجه مصرف قابل توجه CPU برای Memory Allocation ها شدیم و با دنبال کردن این سرنخ به نتایج مشابه در اپلیکیشنهای مشابه رسیدیم.
توی این پست، به عملکرد ضعیف musl allocator، که پیادهسازی پیشفرض target انتخاب شده در مرحله build و مخصوص توزیع alpine هست، اشاره شده و نویسنده ادعای بهبود قابل توجه performance با glibc allocator رو کرده.
در اولین تلاش برای بهبود، base image و build target برنامه رو تغییر دادیم که از glibc allocator استفاده بشه.
این تغییر، رفتار غیر منطقی برنامه در لودهای بالا رو بهبود داد. ولی یک رفتار ناخواسته جدید اضافه شد.

سرویس ما برای بهبود سرعت، بخشی از اطلاعات مورد نیاز رو به صورت دورهای از دیتابیس دریافت میکنه و اون رو داخل Memory ذخیره میکنه تا در استفادههای بعدی دسترسی به دیتابیس نیاز نباشه.
حجم این اطلاعات در نهایت عددی بین ۱۰۰ تا ۱۵۰ مگابایت میشه ولی تبدیل این اطلاعات از یک داده با فرمت yaml/json به ساختماندادههای آماده سمت برنامه نیازمند یکسری memory allocation هاست. مثل هر memory allocation دیگه (مثلا حافظه لازم برای پردازش یک درخواست) انتظار ما از برنامه اینه که بعد از انجام و از اسکوپ خارج شدن متغیرهای میانی، حافظه به سیستم عامل برگرده و بتونیم از اون حافظه برای بقیه عملیاتهای برنامه استفاده کنیم.
ولی اتفاقی که با malloc (glibc) افتاد، این بود که قسمت «برگشتن حافظه به سیستم عامل» برای این memory allocationهای میانی (که توسط کتابخونه serde سمت rust انجام میشد) فقط در یک سری شرایط خاص انجام میشد. نتیجه این رفتار رو توی نمودار بالا میبینید، برنامه در حالت idle قسمتهای حافظه اختصاص داده شده رو به سیستم عامل برنمیگردونه و در نهایت وقتی به memory limit میرسه توسط پلتفرم اجرایی (کوبرنتیز) restart میشه.
رفتاری تا حدودی مشابه در این پست شرح داده شده و کنترل این رفتار (نمونه) خارج از اسکوپ برنامه اصلیه و نیازمند ارتباط مستقیم با allocator و کرنل هست. البته باید اشاره کنیم که این رفتار در حالت idle مشاهده میشه و allocation های کوچکتر (برای هندل کردن requestها) باعث افزایش مصرف مموری با این الگو نمیشن.
راه حل
خب همونطور که دیدیم نه musl allocator و نه glibc allocator نتونستن به راحتی مشکل رو حل کنن. دو گزینه مطرح دیگه که با هدف استفاده توی اپلیکیشنهای concurrent وجود دارن،
هستن. بنچمارکهایی از این allocator ها توی Rust انجام شده(یک نمونه) و از نظر پرفورمنس عملکرد مشابهی با malloc (glibc) داشتن. در قدم بعدی آزمایش، ما اپلیکیشن رو با استفاده از jemalloc به عنوان global allocator اجرا کردیم که نتایج رو در نمودار های زیر می بینیم.
لازم به ذکره در این آزمایش لود به صورت موازی بین اپلیکیشنهای Go و Rust پخش شده و هر دو نمودار جهت مقایسه قرار داده شدن.
اجرای اول: تقسیم لود بین ۲ پاد از هر اپلیکیشن (۲ پاد Rust و ۲ پاد Go)





اجرای دوم: تقسیم لود بین یک پاد از هر اپلیکیشن با هدف ارزیابی عملکرد با منابع محدود





نتیجه
نسخه نوشته شده با Rust با وجود منابع یکسان، هم سرعت پاسخگویی بهتر داشت و هم در مدیریت منابع بهینهتر عمل کرد. خلاصهای از نتیجه آزمایش رو توی جدول زیر آوردیم.

این نتایج خیلی هم دور از انتظار نیست، performance و سرعت اجرای برنامه یکی از اصلی ترین اهداف زبان Rust هست در صورتی که هدف اصلی Go الزاما سرعت نیست. این موارد کمک قابل توجهی به این تفاوت سرعت عملکرد میکنن:
- عدم نیاز به Garbage Collector
- سختگیریهای کامپایلر برای استفاده صحیح از Heap و Stack نسبت به Go که از Heap Escape جلوگیری بشه.
- اولویتدادن به performance نسبت به قابلیتها و ویژگیهای نهایی ارائه شده به کاربر در Rust
- وجود macroها و جامع بودن Genericها که باعث میشن یکسری محاسبات و بهینهسازیها به جای انجامشدن در runtime در compile time برنامه انجام بشن.
- البته همین مورد باعث پیچیده و زمانگیرتر شدن compile برنامههای Rust میشه.
اهداف دیگهای که در Go با اولویت بالاتری بهشون پرداخته شده شامل:
- پشتیبانی بسیار گستردهتر standard library و در نتیجهاش پایداری بیشتر برنامهها.
- انتقال پیچیدگیهای پیادهسازی به هسته زبان و standard library و اولویت دادن به API پایدارتر و سادهتر.
- ترکیب دو مورد بالا باعث راحتتر توسعه دادن کتابخونهها و framework های جدید شده که اکوسیستم خیلی غنیتری برای Go به وجود آورده.
- مدیریت چالشهای اجرایی متعدد (مثل چالش مدیریت حافظه که در پیاده سازی اولیه با Rust با اون مواجه شدیم) در native runtime زبان.
طراحی و پیاده سازی یک سیستم نیازمند در نظر گرفتن پارامترهای مختلفی از جمله نرخ تغییرات، اهمیت زمان پاسخ، میزان بار سیستم و... هست و نمیشه یک نسخه برای همه سیستمها نوشت. با وجود سریعتر بودن Rust در این استفاده، همچنان عملکرد Go برای این برنامه با درنظر گرفتن حجم درخواستها کاملا قابل قبوله. همینطور اکوسیستم قوی Go و در نتیجهاش سرعت زیاد در توسعه ویژگیهای جدید با اون باعث شده که ما برای این سرویس از Go استفاده کنیم.
ممنونیم که تا اینجا همراهی کردید و این پست رو خوندید :) اگر به کار کردن با این تکنولوژیها و در این مقیاس علاقه دارید، خوشحال میشیم که رزومههاتون رو از طریق آدرس engineering@snapp.cab یا از سایت فرصت های شغلی Careers - Jobs - Snapp برای ما ارسال کنید.
موفق باشید
مطلبی دیگر از این انتشارات
سرویسورکر در پروژه CRA؛ مزایا و چالشها
مطلبی دیگر از این انتشارات
ارتباط HorizontalPodAutoscaler و تعیین تعداد پادها به صورت دستی در کوبرنتیس
مطلبی دیگر از این انتشارات
Snapp.ir v2 solution design [SSR without server]