Shedding old code with ecdysis: راهکاری برای graceful restarts سرویس‌های Rust در Cloudflare 🔁🦎

چطور می‌توان سرویس‌های شبکه‌ای که میلیون‌ها درخواست در ثانیه را در سراسر جهان پردازش می‌کنند به‌روزرسانی کرد بدون اینکه حتی یک اتصال زنده قطع شود؟ پاسخی که در Cloudflare برای این چالش استفاده کرده‌ایم، کتابخانهٔ Rust به نام ecdysis است که امکان graceful process restart را فراهم می‌کند: در زمان ارتقا هیچ اتصال زنده‌ای رها نمی‌شود و هیچ اتصال جدیدی هم رد نمی‌گردد.

ما ecdysis را ماه گذشته به‌صورت متن‌باز منتشر کردیم. این ابزار پس از پنج سال استفادهٔ عملیاتی در زیرساخت حیاتی Rust ما، ثابت کرده که می‌تواند ارتقاهای بدون داون‌تایم را ممکن کند و با هر بازراه‌اندازی میلیون‌ها درخواست را نجات دهد.

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

باید به‌روزرسانی‌ها و پچ‌های امنیتی را بلا‌درنگ اعمال کنیم. راهکار ساده و naїve این است که فرایند قدیمی را متوقف کنیم و فرایند جدید را بالا بیاوریم؛ اما این رویکرد یک پنجره زمانی ایجاد می‌کند که در آن اتصالات جدید رد می‌شوند و درخواست‌ها از بین می‌روند. برای سرویسی که هزاران درخواست در ثانیه را در یک نقطه پردازش می‌کند، ضرب در صدها دیتاسنتر، یک ری‌استارت کوتاه می‌تواند به میلیون‌ها درخواست ناموفق در سطح جهانی منجر شود.

چرا graceful restart سخت است

رویکرد ساده مشکلات کلیدی دارد: وقتی فرایند قدیمی متوقف می‌شود، socketهای listening بسته می‌شوند و سیستم‌عامل بلافاصله با خطای ECONNREFUSED اتصالات جدید را رد می‌کند. حتی اگر فرایند جدید فوراً شروع شود، همیشه یک شکاف زمانی وجود دارد — چه چند میلی‌ثانیه چه چند ثانیه — که هیچ فرایندی در حال accept کردن نیست. برای سرویس‌های با نرخ بالا، شکافی به کوتاهی 100ms هم می‌تواند صدها اتصال را از بین ببرد.

هم‌چنین متوقف کردن فرایند قدیمی باعث می‌شود تمام اتصالات فعلی نیز کشته شوند: یک کاربر در حال آپلود فایل یا استریم ویدئو به سمت قطع شدن می‌رود، و ارتباط‌های طولانی‌مدت مثل WebSocket یا gRPC mid-stream قطع می‌شوند. از دید کلاینت، سرویس به‌طور ناگهانی ناپدید شده است.

یک ایدهٔ دیگر این است که فرایند جدید را قبل از خاموش کردن فرایند قدیمی bind کنیم، اما این هم مسائل خودش را دارد. به‌طور پیش‌فرض کرنل تنها به یک فرایند اجازهٔ bind به یک address:port را می‌دهد؛ گزینهٔ SO_REUSEPORT اجازه می‌دهد چند فرایند bind کنند اما در طول انتقال فرایندها مشکل‌زا می‌شود. وقتی SO_REUSEPORT فعال است، کرنل برای هر فرایند یک listening socket جدا ایجاد می‌کند و توزیع بار روی آن‌ها انجام می‌شود. مشکل این است که هنگامی که پکت SYN اولیه دریافت شده و به یک فرایند تخصیص‌یافته، سپس اتصال تا زمان accept() در صف آن فرایند می‌نشیند؛ اگر آن فرایند قبل از accept کردن خارج شود، آن ارتباط orphan شده و کرنل آن را نابود می‌کند. این مساله توسط تیم مهندسی GitHub هنگام ساخت GLB Director مستندسازی شده است.

چطور ecdysis کار می‌کند

در طراحی ecdysis چهار هدف کلیدی را مشخص کردیم:
– امکان خاموش کردن کامل کد قدیمی پس از ارتقا.
– به فرایند جدید یک دورهٔ grace برای initialization بدهیم.
– اگر کد جدید در زمان initialization کرش کند، سرویس جاری نباید متأثر شود.
– فقط یک ارتقا هم‌زمان اجرا شود تا از خطاهای آبشاری جلوگیری شود.

ecdysis این اهداف را با رویکردی که NGINX سال‌هاست استفاده می‌کند دنبال می‌کند:
– فرایند والد fork() می‌کند و یک child جدید ایجاد می‌شود.
– فرزند با execve() خودش را با نسخهٔ جدید کد جایگزین می‌کند.
– فرایند فرزند file descriptorهای socket را از طریق یک مکانیسم بین‌فرایندی (مثلاً یک Unix domain socket که file descriptor را با SCM_RIGHTS منتقل می‌کند؛ در متن اصلی به نام pipe اشاره شده) از والد به ارث می‌برد.
– والد منتظر می‌ماند تا فرزند اعلام کند که آماده است، سپس والد به تدریج shutdown می‌کند.
نکتهٔ حیاتی این است که socket در طول انتقال باز می‌ماند.

در طول initialization فرزند، هر دو فرایند مشترکاً از همان ساختار دادهٔ کرنل استفاده می‌کنند؛ بنابراین والد همچنان می‌تواند اتصالات جدید و موجود را قبول و پردازش کند. وقتی فرزند تکمیل شد، به والد اعلام آمادگی می‌دهد و شروع به accept کردن می‌کند. پس از دریافت این اعلان، والد نسخهٔ خودش از listening socket را می‌بندد و تنها به تخلیهٔ (drain) اتصالات قبلی ادامه می‌دهد. این مدل شکاف پوششی را حذف می‌کند و در عوض یک پنجرهٔ کوتاه هم‌پذیری (concurrent accept) ایجاد می‌کند که عمداً وجود دارد: اتصالاتی که والد قبول می‌کند به‌صورت عادی تا انتها سرویس‌دهی می‌شوند و سپس خاتمه می‌یابند.

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

پشتیبانی‌ها و نکات پیاده‌سازی

ecdysis مدل fork-exec را پیاده‌سازی کرده و پشتیبانی native برای async با Tokio و هم‌چنین ادغام با systemd را ارائه می‌دهد:
– ادغام با Tokio: wrapperهای async بومی برای Tokio وجود دارد تا socketهای به ارث رسیده بدون کد کمکی اضافی به listener تبدیل شوند. برای سرویس‌های هم‌زمان نیز ecdysis بدون نیاز به runtime async کار می‌کند.
– systemd-notify: با فعال کردن feature مربوطه، ecdysis به‌طور خودکار با systemd اطلاع‌رسانی می‌کند. با تنظیم Type=notify-reload در unit فایل، systemd می‌تواند ارتقاها را درست ردیابی کند.
– systemd named sockets: با فعال کردن systemd_sockets، ecdysis می‌تواند socketهای فعال‌شده توسط systemd را مدیریت کند؛ به این شکل سرویس شما هم socket-activated است و هم از graceful restart پشتیبانی می‌کند.

نکتهٔ پلتفرمی: ecdysis به syscalls مختص Unix برای به ارث بردن socket و مدیریت فرایند متکی است و روی Windows کار نمی‌کند؛ این محدودیت بنیادین مدل fork-exec است.

ملاحظات امنیتی

Graceful restart ملاحظات امنیتی به همراه دارد چون در یک بازهٔ کوتاه دو نسل پروسس با دسترسی به همان listening socketها هم‌زمان وجود دارند و ممکن است فایل دسکریپتورهای حساس نیز در میان باشند. ecdysis با طراحی خود این موارد را مدیریت می‌کند:
– fork-then-exec: ecdysis از الگوی سنتی Unix تبعیت می‌کند: fork() و بلافاصله execve(). این باعث می‌شود فرزند با فضای آدرس جدید، کد تازه و بدون حافظهٔ به ارث رسیده اجرا شود و تنها file descriptorهای صریحاً منتقل‌شده عبور کنند.
– inheritance صریح: فقط listening socketها و لوله‌های ارتباطی منتقل می‌شوند؛ بقیه file descriptorها با CLOEXEC بسته می‌شوند تا از نشت handleهای حساس جلوگیری شود.
– سازگاری با seccomp: اگر از seccomp استفاده می‌کنید، باید fork() و execve() را اجازه دهید؛ این یک trade-off است: graceful restart به این syscalls نیاز دارد و نمی‌توان آن‌ها را مسدود کرد.

برای اغلب سرویس‌های شبکه‌ای، این تریدآف‌ها قابل قبول‌اند. امنیت مدل fork-exec خوب شناخته شده و در نرم‌افزارهایی مثل NGINX و Apache دهه‌هاست استفاده شده و آزمون شده است.

مثال کد

در ادامه یک مثال ساده از یک TCP echo server که از graceful restart پشتیبانی می‌کند آورده شده است:

use ecdysis::tokio_ecdysis::{SignalKind, StopOnShutdown, TokioEcdysisBuilder};
use tokio::{net::TcpStream, task::JoinSet};
use futures::StreamExt;
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    // Create the ecdysis builder
    let mut ecdysis_builder = TokioEcdysisBuilder::new(
        SignalKind::hangup() // Trigger upgrade/reload on SIGHUP
    ).unwrap();

    // Trigger stop on SIGUSR1
    ecdysis_builder
        .stop_on_signal(SignalKind::user_defined1())
        .unwrap();

    // Create listening socket - will be inherited by children
    let addr: SocketAddr = "0.0.0.0:8080".parse().unwrap();
    let stream = ecdysis_builder
        .build_listen_tcp(StopOnShutdown::Yes, addr, |builder, addr| {
            builder.set_reuse_address(true)?;
            builder.bind(&addr.into())?;
            builder.listen(128)?;
            Ok(builder.into())
        })
        .unwrap();

    // Spawn task to handle connections
    let server_handle = tokio::spawn(async move {
        let mut stream = stream;
        let mut set = JoinSet::new();
        while let Some(Ok(socket)) = stream.next().await {
            set.spawn(handle_connection(socket));
        }
        set.join_all().await;
    });

    // Signal readiness and wait for shutdown
    let (_ecdysis, shutdown_fut) = ecdysis_builder.ready().unwrap();
    let shutdown_reason = shutdown_fut.await;
    log::info!("Shutting down: {:?}", shutdown_reason);

    // Gracefully drain connections
    server_handle.await.unwrap();
}

async fn handle_connection(mut socket: TcpStream) {
    // Echo connection logic here
}

نکات کلیدی از این نمونه:
– build_listen_tcp یک listener می‌سازد که توسط فرزندها به ارث خواهد رسید؛ این یعنی socket در طول ارتقا باز می‌ماند و شکاف پذیرش حذف می‌شود.
– ready() به والد علامت می‌دهد که initialization کامل شده و والد می‌تواند امن خارج شود؛ shutdown_fut مسیر shutdown را به والد اطلاع می‌دهد تا بتواند اتصالات را به‌صورت تدریجی تخلیه کند.

نتیجه‌گیری: اگر شما در حوزهٔ DevOps/SRE با سرویس‌های Rust سروکار دارید و نیاز به ارتقاهای بدون وقفه دارید، ecdysis یک ابزار عملی و اثبات‌شده است که مدل fork-exec را با توجه به نیازهای دنیای مدرن (async با Tokio، ادغام با systemd و رعایت ملاحظات امنیتی) فراهم می‌کند. استفاده از این الگو به شما اجازه می‌دهد تا بدون ساختن پنجره‌های خطا یا از دست دادن اتصالات طولانی‌مدت، نسخه‌های جدید را بدون ریسک اعمال کنید. ⚙️