بلاگ

اتفاقات روزمره، اخبار مهم و دیگر مطالب ابر آروان

دسته‌بندی‌های بلاگ دسته‌بندی‌های بلاگ دسته‌بندی‌های بلاگ

۶ بهمن ۹۸
مقالات
نوید فتح‌الله زاده
۶ بهمن ۹۸
مقالات
زبان Rust

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

 

معرفی Lua و Rust

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

خاصیت embeddable بودن Lua سبب می‌شود که به‌راحتی بتوان از این زبان برای گسترش برنامه‌هایی که در زبان C و C++ نوشته شده‌اند، استفاده کرد. همین قابلیت Lua باعث استفاده‌ی گسترده از OpenResty به‌جای NGINX شده است.

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

 

کدی که کار می‌کند!؛ نگاهی بر گذشته

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

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

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

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

مشکلات دیگری نیز برای توسعه‌ی ماژول‌های Lua وجود داشت. اضافه کردن فیچرهای جدید با محدودیت‌های جدی مواجه بود. برای نمونه، اضافه کردن PubSub برای Redis در Lua به‌دلیل محدودیت در Threading نه‌تنها ممکن نبود، که باعث می‌شد کانفیگ جدید هر محصول بعد از یک فاصله زمانی اعمال شود و از سوی دیگر نتوان به مدت طولانی config محصول را cache کرد.

بسیاری از کتابخانه‌های داخلی استفاده شده در کدهای Lua به زبان C نوشته شده بودند و مدت زمان زیادی بود که روی آن‌ها تغییری داده نشده بود. برخی از این کتابخانه‌ها مشکلات جدی در مدیریت حافظه داشتند و سبب بروز memory leak می‌شدند. از سوی دیگر، سرعت آپدیت شدن OpenResty نسبت به تغییرات NGINX خیلی کند و معمولن نسخه‌ی نهایی OpenResty چند نسخه از NGINX عقب‌تر بود که گاهی این عقب‌ماندگی سبب بروز باگ‌های امنیتی در محصول می‌شد. هم‌چنین به دلیل وابستگی بسیار زیاد کدهای نوشته شده به ماژول اصلی Lua روی OpenResty، نوشتن unit test با مشکل همراه بود.

 

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

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

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

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

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

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

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

 

انتخاب زبان 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 بسیار سریع است، Runtime و Garbage Collector ندارد و به‌راحتی می‌تواند با زبان‌های دیگر (بخصوص C) ارتباط برقرار کند، از کتابخانه‌های C استفاده کند یا به‌وسیله‌ی C استفاده شود. این ویژگی سبب شد که بدون دردسر بتوانیم از NGINX API در توسعه‌ی ماژول‌ها استفاده کنیم. نوشتن unit test در Rust بسیار ساده است و به‌شکل Builtin در زبان پشتیبانی می‌شود. برنامه‌ی نوشته شده با زبان 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 بالا و عدم وابستگی محصولات به یکدیگر
  • امکان تحلیل log و توسعه‌ی محصولات جدید برای پردازش log محصولات
  • صفر شدن نیاز به دیباگ در production
  • استفاده از NGINX اصلی و آپدیت به آخرین نسخه در کم‌تر از یک ساعت
  • افزایش سرعت اضافه کردن فیچر جدید، رفع باگ و توسعه‌ی محصول

این مقاله را با دوستان خود به اشتراک بگذارید

نظرات
محمد ۳۰ فروردین ۱۳۹۹
پاسخ
این برای بنده هم سؤال هست. مقاله‌ی شما لذتبخش هست چون تمامش از دلایل تشکیل شده، اما بدون ذکر هیچ دلیلی می‌فرمایید «زبان Go نمی‌توانست به‌عنوان یک گزینه مطرح باشد»! مانند اینکه یک موضوع خیلی بدیهی باشه، یا حداقل موضوع واضح و مسلمی باشه که با کمی فکر قابل فهم باشه و بنابراین بی‌نیاز از توضیح باشه. این در حالی هست که مقاله‌هایی هستند که تلاش Go در راستای عمودی بودن (orthogonality) رو در طولانی مدت ارجح میدونن بر مزایایی که Rust نسبت به Go داره، مثل سرعت بیشتر (در حد ۳۰ تا ۳۵ درصد) و صحت صددرصدی Concurrency (در مقابل یک Concurrency آسون بسیار خوب در Go). بنده ساخت یک سرور Go مدنظر دارم و این انتخاب رو بعد از جستجوی دقیق انجام دادم، و برای بنده دونستن دلیل شما واقعاً مغتنم هست.
اسرا حسینی ۱۸ اسفند ۱۳۹۸
پاسخ
مرسی بابت اشتراک گذاری تجربتون
مهدی شکوری ۴ اسفند ۱۳۹۸
پاسخ
سلام، در حین مطالعه این مقاله حداقل دوبار به صورت مستقیم اشاره کردید که از زبان GO نمی تونستید استفاده کنید و حتی جزء گزینه های شما نبوده. من و مجموعه ی همکارانم بیش از 3 سال هستش که با زبان برنامه نویسی GO کار میکنیم و محصولی نظیر کپچا (captcha) و سامانه ورود یکپارچه (sso) رو با این زبان راه اندازی کردیم (https://manlogin.com). اما نکته ای که برام جالب بود و میخواستم بدونم اینه که چرا زبان Go رو از گزین ههاتون خارج کردید و حتی تا این حد تاکید داشتید که Go جزء گزینه هاتون نبوده و حتی نوشتید توسعه دهنده نرم افزار و نه Go developer ؟ ;) متشکرم
نوید ۲۶ بهمن ۱۳۹۸
پاسخ
سلام، انتخاب زبان بستگی به نوع استفاده‌تون داره. ممکنه در حالتی لوآ بهتر از راست باشه
رضا ۲۵ بهمن ۱۳۹۸
پاسخ
از به اشتراک گذاشتن تجربتون سپاسگذاریم فقط کاش یکم در مورد کتابخانه هایی که استفاده کردید توضیح میدادید ...
مصطفی ۲۳ بهمن ۱۳۹۸
پاسخ
مطلب خوبی بود، خسته نباشید.
امیرحسین ۱۲ بهمن ۱۳۹۸
پاسخ
قابل تامل
M Giv ۷ بهمن ۱۳۹۸
پاسخ
بسیار کامل و جامع بود. موفق باشید.
علی ۶ بهمن ۱۳۹۸
پاسخ
سلام، ممنون بابت اشتراک گذاری تجربتون، امکانش هست در مورد اینکه از چه کتابخونه‌هایی برای پردازش درخواست‌ها استفاده کردید توضیح بدید؟ آیا از کتابخونه tokio برای این منظور استفاده کردید یا از توابعی که خود زبان در اختیار قرار داده؟ ممنون.
شهاب ۶ بهمن ۱۳۹۸
پاسخ
جالب بود. فکر میکردم مناسبترین زبان برای کار در کنار nginx زبان lua بود که با خوندن این مطلب نظرم رو تحت الشعاع قرار داد. خدارو شکر که مساله شما هم حل شد.