علاج واقعه پیش از وقوع باید کرد

در این نوشته توضیح میدم که چرا و چطور تصمیم گرفتیم محصولات امنیت ابری شرکت ابرآروان را بازنویسی کنیم. این بازنویسی با مهاجرت از زبان Lua به Rust صورت گرفت. همچنین مسیری را که قبل از این تصمیم طی شد، توضیح میدم.


معرفی Lua و Rust

اگر Javascript را مجموعه‌ای از خوب، بد و زشت در نظر بگیریم، Lua را می‌توان معادل بخش خوب زبان Javascript در نظر گرفت. این زبان را می‌توان در سه کلمه خلاصه کرد: embeddable scripting language. همین سه کلمه در کنار هم زبان کارامدی را بوجود آورده که به راحتی می‌توان از آن برای نوشتن ماژول و پلاگین یک محصول دیگر (مثل NGINX) استفاده کرد. خاصیت embeddable بودن Lua باعث می‌شود که به‌راحتی بتوان از این زبان برای گسترش برنامه‌هایی که در زبان C و C++ نوشته شده‌اند، استفاده کرد. همین قابلیت Lua باعث استفاده گسترده OpenResty به‌جای NGINX شده است.

زبان Rust خودش را این‌طور معرفی می‌کند: زبانی که به همه قدرت نوشتن برنامه‌های قابل اعتماد (Reliable) و کارامد (Efficient) را می‌دهد. در واقع نوشتن یک برنامه ناامن در Rust بسیار دشوار است. در این زبان خبری از Garbage Collector نیست، پرفورمنس بسیار بالایی دارد و مدلی ارائه می‌کند که memory-safety و thread-safety را تضمین می‌کند. همه این ویژگی‌ها زبان Rust را برای نوشتن برنامه‌های سیستمی متمایز می‌کند.

کدی که کار می‌کند!

زمانی که به تیم CDN ابرآروان اضافه شدم کدی داشتیم که در زبان Lua برای OpenResty نوشته شده بود و چهار محصول اصلی امنیت ابری آروان یعنی L7 DDoS Mitigation, Firewall, WAF و Ratelimit را شامل میشد. کدی که کار می‌کرد و به تعداد زیادی مشتری بزرگ و کوچک سرویس امنیت ابری ارائه می‌کرد. تمام این محصولات از یک محصول متن‌باز Fork شده بودند و با توجه به نیازهای شرکت تغییر داده شده بودند. با اینکه کد کار می‌کرد اما یک دغدغه اصلی برای این محصولات مطرح بود: پرفورمنس. کارکرد اصلی این محصولات در زمان حمله است و باید پرفورمنس بسیار بالایی داشته باشند تا بتوانند در مقابل حملات سنگین مقاومت کنند و خودشان باعث از کار افتادن سیستم نشوند. دغدغه دوم امکان اضافه کردن فیچرهای جدید به محصولات بود.

در قدم اول سعی کردیم که روی دغدغه اصلی (پرفورمنس) تمرکز کنیم. برای این‌کار از سه‌گانه بهینه‌سازی استفاده کردیم: Profile, Refactor, Profile

پروفایل گرفتن از کد Lua روی OpenResty کار دشواری بود که با استفاده از SystemTap این کار را انجام دادیم و تونستیم CPU Flame Graphs را برای محصولات امنیت ابری به دست بیاریم. با بررسی این گراف‌ها اقدام به ریفکتور کد کردیم و با تکرار این چرخه توانستیم برای مثال پرفورمنس محصول WAF را که فورکی از یک محصول متن‌باز بود، تا ۴۰ درصد افزایش دهیم. درنهایت به جایی رسیدیم که امکان افزایش بیشتر پرفورمنس با تغییر کد Lua وجود نداشت و باید سورس کد LuaJIT را تغییر می‌دادیم. تغییر LuaJIT هزینه زیادی به تیم تحمیل می‌کرد و ممکن بود باعث از کار افتادن کل سیستم شود، همچنین تضمینی برای افزایش چشم‌گیر پرفورمنس بعد از اعمال تغییرات وجود نداشت. این ریسک بالا باعث توقف ریفکتور کد در این مرحله شد.

مشکلات دیگری نیز برای توسعه ماژول‌های Lua وجود داشت. اضافه کردن فیچرهای جدید با محدودیت‌های جدی مواجه بود. برای مثال اضافه کردن PubSub برای Redis در Lua بدلیل محدودیت در Threading ممکن نبود که باعث میشد کانفیگ جدید هر محصول بعد از یک فاصله زمانی اعمال شود و همینطور نتوان به مدت طولانی کانفیگ محصول را کش کرد. بسیاری از کتابخانه‌های داخلی استفاده شده در کدهای Lua به زبان C نوشته شده بودند و مدت زمان طولانی بود که روی آن‌ها تغییری داده نشده بود. برخی از این کتابخانه‌ها مشکلات جدی در مدیریت حافظه داشتند و باعث بروز مموری‌لیک می‌شدند. از طرف دیگر سرعت آپدیت شدن OpenResty نسبت به تغییرات NGINX خیلی کند بود و عموما نسخه نهایی OpenResty چند نسخه از NGINX عقب‌تر است که گاهی این عقب‌ماندگی باعث بروز باگ‌های امنیتی در محصول می‌شود. همچنین بعلت وابستگی بسیار زیاد کدهای نوشته شده به ماژول اصلی Lua روی OpenResty نوشتن یونیت تست با مشکل همراه بود.

جام زهر: بازنویسی محصولات

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

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

اما ما در آروان با مشکلات زیر مواجه شدیم که باعث شد جام زهر را بنوشیم و تصمیم بگیریم محصولات امنیت ابری را بازنویسی کنیم:

  • بعلت ساختار محصولات، امکان اتوماتیک کردن Deployment به صورت کامل وجود نداشت.
  • با توجه به Dynamic بودن Lua بسیاری از باگ‌ها تنها در برخی شرایط خاص روی پروداکشن دیده می‌شدند و عملا دیباگ روی محصول پروداکشن انجام می‌شد.
  • امکان تغییر سورس کد Lua روی سرور پروداکشن وجود داشت (خاصیت اسکریپتی Lua) که گاهی بعلت حساسیت زیاد بخشی از کد با عجله و مستقیم روی سرور پروداکشن تغییر می‌کرد و باعث بروز Inconsistency بین محصول منتشر شده و ریپازیتوری Git محصول می‌شد.
  • حتی رفع باگ‌های جزئی بعلت ساختار نامطلوب محصول نیاز به زمان طولانی داشت.
  • در آروان پرفورمنس محصول نقش حیاتی دارد. توسعه برخی از فیچرهای جدید که باعث افزایش پرفورمنس محصول می‌شدند بعلت محدودیت‌های Lua اصلا ممکن نبود.
  • کد نوشته شده Document نشده بود. تاریخچه Git خیلی نامرتب بود و متن کامیت با تغییرات انجام شده متناسب نبود. کدهای بسیاری وجود داشت که فقط برای تست یک فیچر نوشته شده بودند و در حال حاضر استفاده نمی‌شدند.
  • بعلت وجود وابستگی زیاد بین کد محصولات مختلف، نوشتن تست برای محصول با مشکلات جدی مواجه بود. Coverage یونیت تست در حد قابل قبول نبود.
  • با توجه به قابلیت‌های محدود Interpreter در Lua بسیاری از بهینه‌سازی‌ها بعهده توسعه‌دهنده است و گاهی این الزامات توسط توسعه‌دهنده رعایت نمیشد که باعث کاهش پرفورمنس محصول میشد. اغلب این بهینه‌سازی‌ها فقط مختص Lua هستند و در زبان‌های دیگر دیده نمی‌شوند.
  • کتابخانه‌های متن‌باز استفاده شده در محصولات بروز نمی‌شدند و باگ‌های حساس امنیتی در برخی از آن‌ها وجود داشت که سال‌ها بود به حال خود رها شده بودند.

تصمیم برای بازنویسی محصولات به سختی گرفته شد و برای بازنویسی محصولات تیم امنیت ابری را از تیم CDN جدا کردیم تا تمرکز کافی روی محصولات امنیت ابری در قالب یک تیم مستقل وجود داشته باشد. برای بازنویسی محصولات استراتژی زیر را دنبال کردیم:

  • ساختار محصول جدید از ابتدا طراحی شود و تنها یک برگردان از زبان Lua به زبان جدید نباشد.
  • کد جدید Documentation کافی و لازم را داشته باشد. Coverage یونیت تست حتی‌الامکان بیش از ۸۰ درصد باشد و هیچ کدی بدون Review مرج نشود. بخش‌هایی که قابلیت نوشتن یونیت تست برایشان وجود ندارد در مرحله Integration کاملا تست شوند.
  • هر محصول کاملا مستقل بتواند اجرا شود و به محصولات دیگر هیچ‌گونه وابستگی نداشته باشد.
  • به جای OpenResty از NGINX اصلی استفاده شود و با انتشار نسخه جدید بلافاصله بتوان از آن استفاده کرد.
  • این امکان وجود داشته باشد که بتوان Deployment محصولات را کاملا بصورت اتوماتیک انجام داد و در یک Pipeline اتوماتیک کد کاملا تست و بعد منتشر شود.
  • طراحی و فیچرهای محصول جدید کاملا منطبق بر نیازهای بیزینسی محصول باشد.
  • امکان اضافه کردن فیچرهای جدید به سادگی وجود داشته باشد.
  • تکنولوژی جدید انتخابی Zero-Cost Abstraction باشد تا بتوان به حداکثر پرفورمنس دست یافت.
  • انتقال تنظیمات کاربران از محصول قدیم به محصول جدید به ساده‌ترین شکل ممکن انجام شود و این فرایند انتقال به محصول جدید توسط کاربران محصول حس نشود.
  • هر محصول لاگ مناسب تولید کند که قابل پردازش باشد و یک ساختار مشخص برای همه لاگ‌ها تعریف شود که بین محصولات مشترک باشد.

انتخاب Rust بعنوان تکنولوژی جدید

محصولات جدید باید هم قابلیت استفاده بعنوان ماژول NGINX را می‌داشتند و هم می‌توانستیم از آن‌ها در یک بستر دیگر بصورت مستقل استفاده کنیم. با توجه به اینکه NGINX به زبان C نوشته شده است، تکنولوژی جدید باید می‌توانست به راحتی با NGINX API صحبت کند و از کتابخانه‌های NGINX بصورت FFI استفاده کند. همچنین برای دستیابی به بالاترین پرفورمنس ممکن، تکنولوژی جدید باید Zero-Cost Abstraction می‌بود و حجم Runtime در تکنولوژی جدید باید کوچک می‌بود. برای مثال استفاده از Garbage Collector بمنظور مدیریت حافظه یکی از موانع انتخاب تکنولوژی جدید برای توسعه ماژول‌های NGINX بود. در تکنولوژی جدید سادگی ساختن و مدیریت Thread جدید یک ویژگی اساسی بود. با کنار هم گذاشتن این ویژگی‌ها به سه گزینه C, C++ و Rust برای تکنولوژی جدید رسیدیم (زبان Go نمی‌توانست بعنوان یک گزینه مطرح باشد). اما با توجه به حساسیت بالای امنیتی در محصولات و تضمین Safety در Rust با یک مدل کارامد براساس ownership و باتوجه به تجربه موفق گذشته در استفاده از Rust، این زبان را بعنوان تکنولوژی جدید برای بازنویسی محصولات امنیت ابری انتخاب کردیم.

ویژگی اساسی Rust این است که جلوی انواع مختلفی از باگ‌ها را در زمان کامپایل می‌گیرد و memory-safety و thread-safety را با استفاده از مدل ownership تضمین می‌کند. با استفاده از این مدل و type system زبان Rust علاج واقعه پیش از وقوع می‌توان کرد! زبان Rust بسیار سریع است، Garbage Collector ندارد و براحتی می‌تواند با زبان‌های دیگر (بخصوص C) ارتباط برقرار کند و از کتابخانه‌های C استفاده کند و یا توسط C استفاده شود. این ویژگی باعث شد که بدون دردسر بتوانیم از NGINX API در توسعه ماژول‌ها استفاده کنیم. نوشتن یونیت تست در Rust خیلی ساده است و بصورت Builtin در زبان پشتیبانی می‌شود. برنامه نوشته شده با زبان Rust را در یک جمله می‌توان خلاصه کرد: اگر کد برنامه کامپایل شد دقیقا طبق انتظار کار خواهد کرد.

برای استفاده از زبان Rust با دغدغه‌های زیر مواجه شدیم:

  • نیروی کار: زبان Rust (در سطح جهانی و نه فقط ایران) جامعه کوچکی از برنامه‌نویس‌های سیستمی را شامل می‌شود و یادگیری زبان Rust بعلت استفاده از مدل ownership کمی زمانبر است. بنابراین نیاز به افرادی داشتیم که برنامه‌نویس نرم‌افزار باشند (نه Go Developer یا C Developer یا یه چیزی Developer) و برای یادگیری زبان جدید مشتاق باشند.
  • کتابخانه‌های نابالغ: با توجه به جوان بودن زبان Rust گاهی کتابخانه‌های کافی برای توسعه یک محصول وجود ندارد (و یا کیفیت مناسبی ندارند) و باید کد بیشتری برای توسعه یک محصول از صفر نوشته شود. البته این دغدغه، مشکلی در توسعه محصولات جدید برای ما ایجاد نکرد. برای مثال قبل از شروع توسعه ماژول NGINX نیاز داشتیم که یک Bindings از NGINX API در Rust داشته باشیم، یک نمونه متن‌باز وجود داشت که توسط تیم NGINX توسعه داده شده بود اما کیفیت لازم را نداشت، بنابراین یک روز زمان گذاشتیم و این Bindings را خودمان توسعه دادیم و به‌صورت متن‌باز منتشر کردیم.

بیش از یک سال زمان گذاشتیم و هر چهار محصول اصلی امنیت ابری را در Rust با یک طراحی کاملا جدید بازنویسی کردیم. نتیجه کار بسیار چشم‌گیر بود و برای مثال در محصول WAF توانستیم تا ۳۰ درصد پرفورمنس را افزایش دهیم، این درحالیست که فیچرهای بیشتری مثل نگهداری State بین دو Request و PubSub هم براحتی به محصول اضافه شدند. همچنین به تمام مواردی که در استراتژی بازنویسی محصولات در نظر گرفته بودیم، دست پیدا کردیم و در حال حاضر در حال انتقال کاربران از محصولات قدیمی به محصولات جدید هستیم که این کار به نوبت و طبق یک برنامه بر اساس میزان تفاوت محصول قدیمی و محصول جدید انجام می‌شود.

مهاجرت از Lua به زبان Rust و تغییر ساختار، ویژگی‌های زیر را به محصولات ما اضافه کرد:

  • افزایش چشم‌گیر پرفورمنس محصولات
  • افزایش Reliability بعد از انتشار محصول و جلوگیری کامل از باگ‌های مربوط به memory و multithreading
  • اتوماتیک شدن Pipeline توسعه تا انتشار محصول
  • نوشتن تست با Coverage بالا و عدم وابستگی محصولات به یکدیگر
  • امکان تحلیل لاگ و توسعه محصولات جدید برای پردازش لاگ محصولات
  • صفر شدن نیاز به دیباگ در پروداکشن
  • استفاده از NGINX اصلی و آپدیت به آخرین نسخه در کمتر از یک ساعت
  • افزایش سرعت اضافه کردن فیچر جدید، رفع باگ و دیپلوی محصول