پست

Designing Distributed Systems

A Comprehensive Guide to Designing and Implementing Distributed Systems

Designing Distributed Systems

توضیحات

کتاب Designing Distributed Systems نوشته‌ی Brendan Burns یک راهنمای عملی برای طراحی سیستم‌های توزیع‌شده با استفاده از design patternهای مبتنی بر container است. این کتاب برای اولین بار در تاریخ مهندسی نرم‌افزار، زبان مشترکی از patternها را برای distributed systems معرفی می‌کند — شبیه به کاری که Gang of Four برای object-oriented design انجام دادند.
ایده‌ی محوری کتاب این است که reusable containerized components می‌توانند مثل توابع استاندارد در برنامه‌نویسی، مبنای ساخت سیستم‌های پیچیده‌تر باشند. تمام مثال‌های عملی با Kubernetes پیاده‌سازی شده‌اند که این کتاب را از یک بحث انتزاعی به یک ابزار کاربردی تبدیل می‌کند.

نظر

  • امتیاز : 07/10
  • به دیگران توصیه می‌کنم : بله
  • دوباره می‌خوانم : خیر
  • ایده برجسته : distributed systems هم مثل OOP به design patternهای استاندارد نیاز دارند؛ نام‌گذاری مشترک روی patternها (Sidecar, Ambassador, Scatter/Gather) باعث می‌شود تیم‌ها سریع‌تر communicate کنند و سیستم‌ها قابل‌فهم‌تر طراحی شوند
  • تاثیر در من : نگاه من به معماری microservices از «سرویس‌های جداگانه» به «patternهای ترکیب‌پذیر» تغییر کرد. مفاهیم Sidecar و Ambassador مستقیماً در طراحی API gateway و service mesh کاربرد دارد
  • نکات مثبت : معرفی زبان مشترک برای توصیف distributed patterns، دیاگرام‌های واضح برای هر pattern، مثال‌های عملی با Kubernetes، کتاب نسبتاً کوتاه و متمرکز است و وقت زیادی نمی‌برد، نویسنده از co-creatorهای Kubernetes است و دانش او کاملاً تجربی است
  • نکات منفی : عمق فنی در برخی فصل‌ها کم است و بیشتر به سطح معرفی می‌ماند؛ تمرکز زیاد بر Kubernetes ممکن است برای کسانی که از orchestratorهای دیگر استفاده می‌کنند محدودکننده باشد؛ مباحث consistency، consensus (Raft/Paxos) و fault tolerance در سطح نظری پوشش داده نشده‌اند — برای آنها باید سراغ Designing Data-Intensive Applications رفت

مشخصات

بخش‌هایی از کتاب

نیاز به سیستم‌های توزیع‌شده در عصر مدرن

امروزه برنامه‌های همیشه در دسترس (Always-On) و APIها با نیازمندی‌های شدیدی در زمینه دسترسی‌پذیری (Availability) و قابلیت اطمینان (Reliability) روبرو هستند؛ نیازمندی‌هایی که تا همین چند دهه پیش، تنها برای تعداد انگشت‌شماری از سرویس‌های حیاتی و ماموریت‌محور در سطح جهان مطرح بود. از سوی دیگر، پتانسیل رشد ویروسی و ناگهانی یک سرویس به این معناست که هر برنامه‌ای باید به گونه‌ای طراحی شود که بتواند فوراً در پاسخ به تقاضای کاربران مقیاس‌پذیر (Scale) شود. این محدودیت‌ها و الزامات فنی ایجاب می‌کند که تقریباً تمامی برنامه‌های مدرن—چه یک اپلیکیشن موبایل سمت کاربر و چه یک سرویس پرداخت در سمت Backend—در قالب یک Distributed System پیاده‌سازی شوند.

با این حال، توسعه سیستم‌های توزیع‌شده بسیار چالش‌برانگیز است؛ زیرا این سیستم‌ها غالباً به صورت راه‌حل‌های اختصاصی و یک‌باره (Bespoke/One-off) ساخته می‌شوند. از این حیث، وضعیت فعلی مهندسی سیستم‌های توزیع‌شده شباهت زیادی به وضعیت توسعه نرم‌افزار پیش از ظهور زبان‌های برنامه‌نویسی شیءگرا (Object-Oriented Programming) دارد. خوشبختانه همان‌طور که ظهور تفکر شیءگرایی تحولی بزرگ ایجاد کرد، در دنیای زیرساخت نیز فناوری‌های نوینی مانند کانتینرها (Containers) و ابزارهای مدیریت کانتینر (Container Orchestrators) ظهور کرده‌اند که چالش‌های ساخت سیستم‌های توزیع‌شده را به‌شدت کاهش می‌دهند. این بلوک‌های ساختاری کانتینری، دقیقاً مشابه نقش اشیاء (Objects) در برنامه‌نویسی شیءگرا، اساس شکل‌گیری الگوها (Patterns) و مؤلفه‌های قابل استفاده مجدد (Reusable Components) هستند که فرآیند طراحی و پیاده‌سازی سیستم‌های توزیع‌شده مطمئن را بسیار ساده‌تر و دسترس‌پذیرتر می‌کنند.

سیر تکاملی توسعه سیستم‌ها (A Brief History of Systems Development)

در ابتدا، ماشین‌های محاسباتی با اهدافی کاملاً خاص‌منظوره مانند محاسبات جداول توپخانه یا جزر و مد طراحی می‌شدند. به مرور زمان، این سخت‌افزارها به ماشین‌های قابل‌برنامه‌نویسیِ چندمنظوره (General-Purpose Programmable Machines) تبدیل شدند. گام بعدی، انتقال از اجرای تک‌برنامه‌ای به اجرای هم‌زمان چندین برنامه روی یک ماشین واحد از طریق سیستم‌عامل‌های اشتراک زمانی (Timesharing Operating Systems) بود، هرچند این ماشین‌ها همچنان کاملاً از یکدیگر مجزا بودند.

با پیدایش شبکه‌ها، معماری کلاینت-سرور (Client-Server Architecture) متولد شد؛ در این مدل، کلاینت‌های کم‌قدرت روی میز کاربران می‌توانستند از قدرت پردازشی مِین‌فریم‌ها (Mainframes) بهره ببرند. اگرچه برنامه‌نویسی کلاینت-سرور پیچیدگی بیشتری نسبت به برنامه‌نویسی تک‌ماشینی داشت، اما درک و مدل‌سازی آن ساده بود: کلاینت درخواست ارسال می‌کرد و سرور به آن پاسخ می‌داد.

در اوایل دهه ۲۰۰۰، با گسترش اینترنت و ظهور دیتاسنترهای عظیم متشکل از هزاران کامپیوتر ارزان‌قیمتِ معمولی (Commodity Computers) که به یکدیگر شبکه شده بودند، توسعه گسترده سیستم‌های توزیع‌شده آغاز شد. برخلاف معماری‌های سنتی کلاینت-سرور، برنامه‌های توزیع‌شده مدرن از چندین برنامه متمایز یا نمونه‌های تکثیرشده (Replicas) تشکیل شده‌اند که روی ماشین‌های مختلف اجرا می‌شوند و برای پیاده‌سازی سرویس‌هایی نظیر موتورهای جست‌وجو یا پلتفرم‌های فروشگاهی، با یکدیگر ارتباط برقرار می‌کنند.

سیستم‌های توزیع‌شده در صورت طراحی ساختاریافته و اصولی، مزایای فنی و سازمانی متعددی دارند:

  • قابلیت اطمینان ذاتی (Inherent Reliability): به دلیل ماهیت پراکنده، خرابی یک نقطه لزوماً منجر به فروپاشی کل سیستم نمی‌شود.
  • مقیاس‌پذیری سازمانی (Scalable Organizational Models): به تیم‌های مهندسی اجازه می‌دهد تا به صورت مستقل و موازی روی بخش‌های مختلف سیستم کار کنند.

اما این مزایا با هزینه‌های سنگینی همراه هستند؛ طراحی، ساخت و عیب‌یابی (Debugging) سیستم‌های توزیع‌شده به‌مراتب پیچیده‌تر از برنامه‌های تک‌ماشینی است و مهارت‌های مهندسی بالاتری را می‌طلبد. بنابراین، نیاز شدیدی به ابزارها، الگوها و شیوه‌های استاندارد برای مهار این پیچیدگی وجود دارد؛ نیازی که کانتینرها و ارکستراتورها با ارائه یک جعبه‌ابزار مشترک، به آن پاسخ می‌دهند.

تکامل الگوها در صنعت نرم‌افزار (A Brief History of Patterns in Software Development)

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

۱. فرموله‌سازی برنامه‌نویسی الگوریتمی (Formalization of Algorithmic Programming): با وجود اینکه برنامه‌نویسی از دهه‌ها قبل وجود داشت، انتشار مجموعه کتاب‌های The Art of Computer Programming توسط Donald Knuth در سال ۱۹۶۲ نقطه عطفی بود. او الگوریتم‌ها را مستقل از معماری یک ماشین خاص فرموله کرد. این کار یک کیت ابزار مشترک (Shared Toolkit) به برنامه‌نویسان داد و نشان داد که اصول عمومی الگوریتم‌ها، مستقل از مسئله‌ای که حل می‌کنند، ارزش یادگیری دارند.

۲. الگوهای برنامه‌نویسی شیءگرا (Patterns for Object-Oriented Programming): با افزایش مقیاس برنامه‌ها و تعداد توسعه‌دهندگان، زبان‌های رویه‌ای (Procedural Languages) و الگوریتم‌های خام دیگر پاسخگوی پیچیدگی‌ها نبودند. این امر منجر به پیدایش زبان‌های شیءگرا شد که داده، قابلیت استفاده مجدد (Reusability) و قابلیت توسعه (Extensibility) را در اولویت قرار دادند. در پی این تحول، در اوایل دهه ۱۹۹۰ میلادی کتاب معروف Gang of Four (GoF) تحت عنوان Design Patterns منتشر شد. این کتاب با تعریف الگوهای مبتنی بر اینترفیس (Interface-Based Patterns)، ادبیات مشترکی را ایجاد کرد که امکان پیاده‌سازی کتابخانه‌های عمومی و بارها آزمایش‌شده را فراهم ساخت.

۳. ظهور نرم‌افزارهای متن‌باز (The Rise of Open Source Software): جنبش متن‌باز در اواخر دهه ۹۰ و ۲۰۰۰ اثبات کرد که توسعه نرم‌افزار و به‌خصوص سیستم‌های توزیع‌شده، یک تلاش جمعی و جامعه‌محور (Community Endeavor) است. شایان ذکر است که تمامی فناوری‌های کانتینری که شالوده این کتاب را تشکیل می‌دهند، در بستر متن‌باز توسعه یافته‌اند.

تعریف الگو در سیستم توزیع‌شده (What is a Distributed System Pattern?)

در حالی که دستورالعمل‌های نصب (نظیر راه‌اندازی یک دیتابیس NoSQL) یا معماری‌های خاص (مانند MEAN Stack) بسیار رایج هستند، منظور از Pattern در سیستم‌های توزیع‌شده، یک طرح کلی یا طرح راهنما (Blueprint) برای سازماندهی سیستم بدون تحمیل تکنولوژی یا انتخاب‌های خاص برنامه‌نویسی است. هدف الگو، ارائه یک ساختار فکری کلی و راهنماست که در محیط‌ها و سناریوهای مختلف کاربرد داشته باشد.

ارزش الگوها، شیوه‌ها و مؤلفه‌ها (The Value of Patterns, Practices, and Components)

چرا باید زمان خود را صرف یادگیری این الگوها کنید؟ ارزش واقعی الگوها در سه محور اساسی خلاصه می‌شود:

  • ایستادن بر شانه‌های غول‌ها (Standing on the Shoulders of Giants): مسائلی که با آن‌ها مواجه می‌شوید، به‌ ندرت کاملاً منحصربه‌فرد هستند. مدل کسب‌وکار شما ممکن است جدید باشد، اما چالش‌های فنی در مسیر دستیابی به سیستم‌های مطمئن (Reliable)، چابک (Agile) و مقیاس‌پذیر (Scalable) تکراری هستند. الگوها به شما اجازه می‌دهند از اشتباهات دیگران درس بگیرید و بدون نیاز به تجربه مستقیم خطاهای پرهزینه، مستقیماً بهترین شیوه‌ها را به کار ببندید.
  • یک زبان مشترک برای گفت‌وگو (A Shared Language): الگوها واژگان مشترکی (Shared Vocabulary) را ایجاد می‌کنند که زمان هدررفته برای توافق‌های بیهوده یا تعاریف موازی را کاهش می‌دهد. برای مثال، وقتی اصطلاح Sidecar Container در جامعه کانتینری جا افتاد، توسعه‌دهندگان دیگر نیازی به تعریف مجدد این مفهوم نداشتند و مستقیماً وارد فاز حل مسئله شدند.
  • مؤلفه‌های اشتراکی برای استفاده مجدد (Shared Components for Easy Reuse): الگوها پایه‌ای برای تعریف قطعات نرم‌افزاری هستند که یک‌بار نوشته شده و هزاران بار استفاده می‌شوند. پیاده‌سازی الگوهای سیستم‌های توزیع‌شده در قالب ایمیج‌های کانتینری با اینترفیس‌های مبتنی بر HTTP به این معناست که می‌توان از آن‌ها در زبان‌های برنامه‌نویسی کاملاً متفاوت استفاده مجدد کرد. این کار نه‌ تنها سرعت توسعه را بالا می‌برد، بلکه به دلیل استفاده گسترده در سناریوهای مختلف، کیفیت و ثبات این مؤلفه‌ها را به‌شدت افزایش می‌دهد.

بخش اول: الگوهای تک‌گره‌ای (Single-Node Patterns)

انگیزه‌ها و ضرورت‌های معماری (Motivations)

برنامه‌های توزیع‌شده از مؤلفه‌های متعددی تشکیل شده‌اند که روی ماشین‌های گوناگون اجرا می‌شوند، اما اولین بخش از الگوها به مواردی اختصاص دارد که روی یک گره (Node) منفرد شکل می‌گیرند. اگرچه تقسیم برنامه توزیع‌شده به کانتینرهای مجزا روی ماشین‌های مختلف منطقی به نظر می‌رسد، اما تفکیک اجزای در حال اجرا روی یک ماشین واحد به کانتینرهای متفاوت نیز نیازمند تبیین و انگیزه‌های فنی قوی است. این رویکرد بر پایه سه هدف اصلی کانتینرسازی استوار است:

۱. ایزوله‌سازی منابع (Resource Isolation):
در یک سناریوی واقعی، یک برنامه ممکن است از دو بخش اصلی تشکیل شده باشد: یک سرور برنامه سمت کاربر (User-facing Application Server) و یک بارگذار فایل‌های پیکربندی در پس‌زمینه (Background Configuration File Loader). اولویت اول سیستم، تضمین کمترین میزان تأخیر (Latency) برای درخواست‌های کاربران است؛ بنابراین سرور برنامه باید منابع کافی برای پاسخگویی سریع را در اختیار داشته باشد. در مقابل، بارگذار پیکربندی پس‌زمینه یک سرویس با تلاش حداقلی (Best-effort Service) است که تأخیر جزئی آن در زمان اوج ترافیک، خللی در عملکرد کل سیستم ایجاد نمی‌کند.
با تفکیک این دو مؤلفه به کانتینرهای مجزا، می‌توان تخصیص منابع و اولویت‌های متفاوتی برای آن‌ها تعریف کرد. این ساختار تضمین می‌کند که بارگذار پس‌زمینه تنها در زمان‌های کاهش ترافیک و به صورت فرصت‌طلبانه (Opportunistically) از پردازنده استفاده کند. علاوه بر این، در صورت بروز نشت حافظه (Memory Leak) در مؤلفه پس‌زمینه، سیستم ارکستراتور پیش از آسیب دیدن سرور اصلی، کانتینر پس‌زمینه را به دلیل مصرف بیش از حد منابع متوقف (Terminate) می‌کند.

۲. مقیاس‌پذیری تیم‌های توسعه (Scaling Teams):
اندازه بهینه برای یک تیم مهندسی معمولاً بین شش تا هشت نفر تعریف می‌شود. برای هدایت مؤثر چنین ساختاری، نیاز است سیستم به بخش‌های کوچک و متمرکز تقسیم شود تا هر تیم مالکیت مستقل یک بخش را بر عهده بگیرد. علاوه بر این، برخی از این مؤلفه‌ها در صورت طراحی اصولی، به ماژول‌های قابل استفاده مجدد (Reusable Modules) تبدیل می‌شوند.
به عنوان مثال، ابزار همگام‌سازی دایرکتوری محلی با یک مخزن کد گیت (Git Sync) را در نظر بگیرید. در صورت توسعه این ابزار در یک کانتینر مستقل، می‌توان از آن در کنار محیط‌های اجرایی گوناگون مانند Python، PHP، HTML یا JavaScript استفاده کرد. اما اگر فرآیند همگام‌سازی گیت و محیط اجرایی (مانند پایتون) در یک کانتینر واحد به صورت جدایی‌ناپذیر با هم ادغام شوند، چنین استفاده مجدد ماژولاری عملاً غیرممکن خواهد بود.

۳. تفکیک وظایف (Separation of Concerns):
حتی در پروژه‌های کوچک با یک تیم واحد، تفکیک وظایف باعث می‌شود برنامه راحت‌تر درک، تست، به‌روزرسانی و مستقر شود. وابستگی‌های (Couplings) کمتر در برنامه‌های کوچک و متمرکز، ریسک استقرار را کاهش می‌دهد. به عنوان مثال، شما می‌توانید کانتینر همگام‌سازی گیت را بدون نیاز به استقرار مجدد سرور اصلی برنامه، ارتقا دهید. این امر منجر به کاهش دامنه (Scope) فرآیندهای استقرار و بازگشت به نسخه قبل (Rollback) شده و چابکی تیم را به همراه دارد.

مفهوم پاد (Pod) به عنوان واحد اتمی

الگوهای تک‌گره‌ای بر خلاف الگوهای توزیع‌شده چندگره‌ای، فرضیات سخت‌گیرانه‌ای درباره همبستگی و وابستگی متقابل کانتینرها دارند. در این الگوها فرض می‌شود که تمامی کانتینرهای الگو به طور مطمئن روی یک ماشین واحد به صورت هم‌زمان زمان‌بندی (Coscheduled) می‌شوند. این کانتینرها قابلیت به اشتراک‌گذاری دایرکتوری‌ها یا بخش‌هایی از فایل‌سیستم، فضای نام شبکه (Network Namespace شامل Hostname و IP) و حافظه مشترک (Shared Memory) را دارند. این گروه کانتینری منسجم در معماری کوبرنتیس (Kubernetes) تحت عنوان Pod شناخته می‌شود.


فصل دوم: الگوی سایدکار (The Sidecar Pattern)

تعریف الگو

الگوی سایدکار یک الگوی تک‌گره‌ای متشکل از دو کانتینر است: ۱. کانتینر اصلی برنامه (Application Container): این کانتینر حاوی منطق هسته و اصلی برنامه (Core Logic) است و بدون آن، کل برنامه بی‌معنی خواهد بود.
۲. کانتینر سایدکار (Sidecar Container): نقش این کانتینر، بهبود و تقویت کارایی کانتینر اصلی است، بدون اینکه کانتینر اصلی لزوماً از وجود یا نحوه کارکرد آن مطلع باشد.

این دو کانتینر روی یک ماشین واحد زمان‌بندی شده و منابعی مانند فایل‌سیستم، هاست‌نیم و شبکه را با یکدیگر به اشتراک می‌گذارند.

نمونه کاربردی: افزودن پروتکل HTTPS به یک سرویس قدیمی (Legacy Service)

یک وب‌سرویس قدیمی را تصور کنید که در زمان توسعه آن، امنیت شبکه داخلی اولویت بالایی نداشته و درخواست‌ها را صرفاً بر بستر HTTP بدون رمزنگاری پردازش می‌کند. اکنون سیاست‌های امنیتی سازمان، استفاده از پروتکل HTTPS را برای تمامی وب‌سایت‌ها الزامی کرده است. از طرفی، کد منبع این برنامه قدیمی با نسخه منسوخ‌شده‌ای از سیستم بیلد (Build System) شرکت ساخته شده که دیگر کار نمی‌کند و بازسازی مجدد آن به شدت چالش‌برانگیز است. کانتینری کردن این سرویس با یک توزیع قدیمی لینوکس روی یک هسته مدرن ساده است، اما افزودن HTTPS بدون تغییر در کد اصلی چطور ممکن خواهد بود؟

با استفاده از الگوی سایدکار، این مشکل به سادگی حل می‌شود:

  • وب‌سرویس قدیمی به گونه‌ای پیکربندی می‌شود که فقط روی لوپ‌بک محلی (localhost یا 127.0.0.1) به درخواست‌ها پاسخ دهد. این کار دسترسی مستقیم خارجی را مسدود می‌کند.
  • یک کانتینر سایدکار حاوی Nginx به این پاد اضافه می‌شود. از آنجا که هر دو کانتینر فضای نام شبکه مشترکی دارند، کانتینر Nginx به راحتی به سرویس روی localhost دسترسی خواهد داشت.
  • کانتینر Nginx ترافیک HTTPS بیرونی را روی IP عمومی پاد دریافت و پس از اتمام رمزنگاری (SSL Termination)، ترافیک رمزنشده را از طریق لوپ‌بک محلی (127.0.0.1) به کانتینر قدیمی پروکسی می‌کند.

چون ترافیک رمزنشده تنها در داخل loopback آداپتورِ گروه کانتینری رد و بدل می‌شود، امنیت شبکه برقرار بوده و برنامه بدون نیاز به بیلد مجدد یا تغییر در سورس‌کد، مدرن‌سازی می‌شود.


مدیریت پیکربندی پویا با الگوهای سایدکار (Dynamic Configuration with Sidecars)

بسیاری از برنامه‌های کاربردی سنتی به‌گونه‌ای توسعه یافته‌اند که در زمان استارت‌آپ، فایل‌های تنظیمات خود را (در قالب‌هایی نظیر XML، JSON یا YAML) از روی فایل‌سیستم محلی می‌خوانند. با این حال، در معماری‌های ابربومی (Cloud-Native)، مدیریت ایستای این فایل‌ها ناکارآمد است؛ چرا که نیاز به فشردن و اعمال تغییرات به صورت پویا و از طریق API وجود دارد تا از تغییرات دستی و فرامینی که مستقیماً روی سرورها اعمال می‌شوند جلوگیری شود. این رویکرد مدیریت پویای پیکربندی، علاوه بر سهولت کاربری، قابلیت‌هایی مانند بازگشت به نسخه قبل (Rollback) ایمن‌تر را به ارمغان می‌آورد.

برای تطبیق برنامه‌های قدیمی با این منطق بدون دستکاری سورس‌کد اصلی، الگوی سایدکار ساختاری دوکانتینره ارائه می‌دهد: ۱. کانتینر اصلی سرویس‌دهنده (Serving Application Container) ۲. کانتینر مدیریت پیکربندی (Configuration Manager)

این دو کانتینر در قالب یک پاد سازماندهی شده و یک دایرکتوری مشترک (Shared Volume) را روی فایل‌سیستم با یکدیگر به اشتراک می‌گذارند. فرآیند همگام‌سازی به شرح زیر است:

  • در زمان شروع به کار، کانتینر اصلی فایل تنظیمات را از دایرکتوری مشترک لود می‌کند.
  • کانتینر سایدکار (مدیر پیکربندی) به طور مداوم API پیکربندی ابری را بررسی می‌کند تا تفاوت‌های بین فایل‌سیستم محلی و نسخه ذخیره‌شده در API را شناسایی کند.
  • در صورت وجود مغایرت، سایدکار تنظیمات جدید را دانلود کرده و روی دایرکتوری مشترک بازنویسی می‌کند.
  • سپس سایدکار سیگنالی به کانتینر اصلی می‌فرستد تا خود را مجدداً پیکربندی (Reconfigure) کند. مکانیسم این اطلاع‌رسانی بسته به معماری برنامه متفاوت است؛ برخی برنامه‌ها فایل‌سیستم را برای تغییرات رصد می‌کنند (File Watchers)، برخی به سیگنال SIGHUP پاسخ می‌دهند و در موارد پیچیده‌تر، سایدکار ممکن است با ارسال سیگنال SIGKILL برنامه را متوقف کند تا سیستم ارکستراتور کانتینر را مجدداً راه‌اندازی کرده و تنظیمات جدید لود شود.

کانتینر ابزارهای ماژولار برنامه (Modular Application Containers)

استفاده از الگوی سایدکار صرفاً به انطباق سیستم‌های قدیمی محدود نمی‌شود؛ بلکه یکی از بزرگ‌ترین مزایای آن، پیاده‌سازی اصول ماژولار بودن (Modularity) و استفاده مجدد (Reusability) در ابزارهای مدیریتی و خطایابی (Debugging) است.

به عنوان مثال، برای پایش زنده و عیب‌یابی منابع مصرفی فرآیندها در سطح کانتینر (شبیه به ابزار top در لینوکس)، یک راهکار سنتی این است که هر توسعه‌دهنده یک اینترفیس HTTP مانند /topz را درون سرویس خود پیاده‌سازی کند یا یک کتابخانه اختصاصی برای زبان برنامه‌نویسی خود به پروژه لینک کند. این روش دو چالش جدی دارد: تحمیل هزینه پیاده‌سازی این وب‌هوک به ازای تک‌تک زبان‌های برنامه‌نویسی مورد استفاده در سازمان، و احتمال بروز رفتارهای ناهماهنگ به دلیل تفاوت در پیاده‌سازی‌ها.

در مقابل، می‌توان ابزار topz را به عنوان یک کانتینر سایدکارِ مستقل توسعه داد. این سایدکار فضای نامِ شناسه‌ فرآیند (PID Namespace) مشترکی با کانتینر اصلی دارد. به این ترتیب، کانتینر سایدکار می‌تواند تمام فرآیندهای در حال اجرا در کانتینر اصلی را بررسی کرده و یک اینترفیس پایش یکپارچه و استاندارد ارائه دهد. ارکستراتور می‌تواند این سایدکار را به طور خودکار به تمام پادها تزریق کند تا ابزار پایش هماهنگی در کل زیرساخت فراهم شود.

تحلیل Trade-off: پیاده‌سازی ابزارها به صورت کانتینرهای اشتراکی در مقایسه با کتابخانه‌های ادغام‌شده درون کد (Library-based Approach)، عمومی‌تر بوده و کمتر برای ویژگی‌های خاص برنامه‌ شما بهینه‌سازی شده است که این امر می‌تواند سربار اندکی در عملکرد یا حجم کانتینر ایجاد کند. این تصمیم شبیه به انتخاب بین لباس آماده (Off-the-rack) و لباس سفارشی (Bespoke) است؛ راهکار عمومی سریع‌تر و ارزان‌تر به دست می‌آید اما در صورت نیاز مبرم به کارایی حداکثری (Extreme Performance)، پیاده‌سازی دستی درون برنامه منطقی‌تر خواهد بود.

نمونه پیاده‌سازی عملیاتی topz با داکر: ابتدا کانتینر اصلی برنامه را اجرا کرده و شناسه آن را در متغیر محیطی ذخیره می‌کنیم:

1
$ APP_ID=$(docker run -d <my-app-image>)

سپس کانتینر سایدکار را دقیقاً در همان PID Namespace کانتینر اصلی و با نگاشت پورت اجرا می‌کنیم:

1
2
3
4
$ docker run --pid=container:${APP_ID} \
    -p 8080:8080 \
    brendanburns/topz:db0fa58 \
    /server --address=0.0.0.0:8080

با این ساختار، از طریق آدرس http://localhost:8080/topz می‌توان به اطلاعات فرآیندهای کانتینر اصلی برنامه دسترسی داشت.

ساخت یک پلتفرم به عنوان سرویس (PaaS) ساده با سایدکار

سایدکارها می‌توانند برای پیاده‌سازی کل منطق اجرایی یک سیستم به شیوه‌ای توزیع‌شده و ماژولار به کار روند. یک پلتفرم ابری (PaaS) ساده مبتنی بر جریان کاری گیت (Git Workflow) را در نظر بگیرید که در آن با هر git push سورس‌کد جدید فوراً روی سرور مستقر و اجرا می‌شود:

  • کانتینر اصلی: یک سرور Node.js است که منطق برنامه را اجرا می‌کند. این کانتینر مجهز به ابزار nodemon است تا در صورت بروز هرگونه تغییر در فایل‌های دایرکتوری برنامه، سرور را به صورت خودکار ری‌استارت کند.
  • کانتینر سایدکار (Git Sync): این کانتینر دایرکتوری مشترکی با کانتینر اصلی دارد و در یک حلقه تکرار مداوم، دستورات لازم برای همگام‌سازی فایل‌سیستم محلی با مخزن گیت (Git Repository) را اجرا می‌کند:
1
2
3
4
5
# اسکریپت همگام‌سازی ساده درون سایدکار
while true; do
  git pull origin HEAD
  sleep 10
done

(این اسکریپت برای وضوح بیشتر ساده‌سازی شده است).

زمانی که این دو کانتینر در یک پاد مشترک سازماندهی می‌شوند، همگام‌سازی مداوم سایدکار با گیت مستقیماً فایل‌سیستم مشترک را به‌روز کرده و ابزار nodemon در کانتینر اصلی فوراً تغییرات را تشخیص داده و بدون تداخل در در دسترس بودن کلی سیستم، برنامه را با کد جدید لود می‌کند.

اصول طراحی سایدکارها برای قابلیت استفاده مجدد (Designing for Reusability)

برای اینکه یک سایدکار فراتر از یک پروژه خاص کاربرد داشته باشد و به عنوان یک مؤلفه با قابلیت استفاده مجدد (Reusable Component) عمل کند، رعایت سه اصل کلیدی در طراحی آن ضروری است:

۱. پارامتری‌سازی کانتینرها (Parameterized Containers)

کانتینر سایدکار باید مانند یک تابع در برنامه‌نویسی عمل کند و ورودی‌های متفاوتی را برای تطبیق با محیط‌های گوناگون بپذیرد. برای نمونه، سایدکار SSL برای انعطاف‌پذیری به حداقل دو پارامتر نیاز دارد: نام گواهی امنیتی (Certificate Name) و پورت لوکال سرویس اصلی روی localhost. توصیه استاندارد، انتقال این پارامترها از طریق Environment Variables (متغیرهای محیطی) است:

1
docker run -e=PORT=<port> -d <image>

درون کانتینر، معمولاً یک اسکریپت شل (Shell Script) ساده این متغیرها را لود کرده و فایل‌های پیکربندی ابزار داخلی را بر اساس آن‌ها بازنویسی یا پارامتری‌سازی می‌کند.

۲. تعریف صریح مرزهای API کانتینر (Defining Container API)

تمام نقاط تعاملی سایدکار با دنیای بیرون، بخشی از API آن محسوب می‌شوند. این مرزهای تعاملی باید پایدار باشند تا تغییرات نسخه‌های بعدی سایدکار باعث خرابی کانتینرهای مصرف‌کننده نشود. بروز رفتارهای ناسازگار حتی در قالب تغییرات جزئی نیز رخ می‌دهد. برای مثال، اگر پارامتری مانند UPDATE_FREQUENCY که نرخ همگام‌سازی را به ثانیه دریافت می‌کرد، در نسخه‌های بعدی برای خوانایی بیشتر به فرمت رشته‌ای (مانند 10m یا 5s) تغییر کند، یک Breaking Change در سطح API رخ داده است؛ چرا که مقادیر قدیمی عددی با خطا مواجه می‌شوند. حتی اگر توسعه‌دهنده تصمیم بگیرد مقادیر فاقد واحد را به میلی‌ثانیه تفسیر کند، فرکانس بررسی به شدت افزایش یافته و بار محاسباتی سنگینی به سرورهای بالاخص پیکربندی اعمال می‌شود که خود نوعی گسستگی در سازگاری API است.

۳. مستندسازی عملکرد کانتینر (Documenting Operations)

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


الگوی سفیر (The Ambassador Pattern)

تعریف الگو و انگیزه‌های معماری (Pattern Definition & Architectural Motivation)

الگوی Ambassador (سفیر) یکی دیگر از الگوهای تک‌گره‌ای (Single-Node Patterns) است که در آن، یک کانتینر سفیر وظیفه مدیریت و کارگزاری تعاملات میان کانتینر اصلی برنامه و جهان خارج را بر عهده دارد. در این الگو، کانتینر اصلی و کانتینر سفیر به صورت یک زوج همزیست و به شدت وابسته (Symbiotic Pairing) روی یک ماشین واحد زمان‌بندی و اجرا می‌شوند. این ساختار معماری را می‌توان به عنوان یک پروکسی هوشمند محلی در نظر گرفت که مستقیماً در فضای نام شبکه مشترک با کانتینر اصلی اجرا می‌شود.

ارزش فنی الگوی سفیر در دو محور خلاصه می‌شود:

  1. تفکیک وظایف (Separation of Concerns): کانتینر اصلی برنامه نیازی به داشتن دانش در خصوص جزئیات ارتباطات شبکه، کشف سرویس (Service Discovery) یا منطق‌های توزیع‌شده پیچیده ندارد و این وظایف سنگین بر دوش سفیر قرار می‌گیرد.
  2. قابلیت استفاده مجدد ماژولار (Reusability): کانتینر سفیر به گونه‌ای کاملاً ماژولار طراحی می‌شود که می‌توان از آن بدون تغییر در کنار کانتینرهای مختلف با زبان‌های برنامه‌نویسی گوناگون استفاده کرد. این کار سرعت توسعه را به‌شدت افزایش داده و کیفیت پیاده‌سازی را به دلیل استفاده‌های مکرر و رفع باگ‌های احتمالی تضمین می‌کند.

۱. استفاده از سفیر برای شاردینگ یک سرویس (Using an Ambassador to Shard a Service)

زمانی که حجم داده‌ها در لایه ذخیره‌سازی از ظرفیت پردازشی و ذخیره‌سازی یک ماشین فراتر می‌رود، شاردینگ (Sharding) به عنوان یک راهکار گریزناپذیر برای تقسیم داده‌ها به بخش‌های مجزا و مستقل (Disjoint Pieces) روی ماشین‌های مختلف مطرح می‌شود. یکی از چالش‌های بزرگ در این سناریو، یکپارچه‌سازی لایه ذخیره‌سازی شاردشده با فرانت‌اند یا سرویس‌های میانی (Middleware) است؛ زیرا گنجاندن منطق مسیریابی درخواست‌ها به شارد مربوطه در داخل کدهای اصلی برنامه، منجر به درهم‌تنیدگی کثیف کد و نقض اصول Clean Code می‌شود. همچنین این کار مدیریت محیط‌های توسعه محلی (که معمولاً دارای یک شارد هستند) را در مقایسه با محیط‌های تولید (که شاردهای متعددی دارند) بسیار دشوار می‌سازد.

برای حل این چالش، دو رویکرد وجود دارد:

  • شاردینگ سمت سرور: ایجاد یک لود بالانسر بدون حالت (Stateless Load Balancer) در جلوی لایه ذخیره‌سازی که نقش یک سفیر توزیع‌شده به عنوان سرویس (Distributed Ambassador as a Service) را ایفا می‌کند. این رویکرد اگرچه کلاینت را ساده نگه می‌دارد، اما فرآیند استقرار لایه ذخیره‌سازی را پیچیده می‌کند.
  • شاردینگ سمت کلاینت با سفیر تک‌گره‌ای: کانتینر اصلی فرانت‌اند همواره به گونه‌ای رفتار می‌کند که گویی در حال اتصال به یک پایگاه داده تک‌نسخه‌ای روی localhost است. اما در واقع، این پورت محلی توسط کانتینر سفیر شنود می‌شود. سفیر درخواست‌ها را دریافت کرده، شارد هدف را بر اساس کلید شارد (Shard Key) شناسایی می‌کند، ترافیک را به آن ماشین پروکسی کرده و پاسخ را به کانتینر اصلی بازمی‌گرداند. این ساختار به شدت فرآیند پیاده‌سازی را تمیزتر کرده و تفکیک وظایف را به درستی رعایت می‌کند.

پیاده‌سازی عملی: ردیس شاردشده (Hands On: Implementing a Sharded Redis)

در این سناریو، ابتدا سه کانتینر ردیس شاردشده به کمک آبجکت StatefulSet در کوبرنتیس مستقر می‌شوند تا نام‌های DNS منحصربه‌فرد و پایداری (مانند sharded-redis-0.redis تا sharded-redis-2.redis) در اختیار ما قرار گیرد.

برای راه‌اندازی کانتینر سفیر، از ابزار متن‌باز twemproxy (که با نام nutcracker نیز شناخته می‌شود و توسط توییتر توسعه یافته است) استفاده می‌کنیم. این ابزار یک پروکسی بسیار سریع و سبک برای Redis و Memcached است.

فایل پیکربندی twemproxy (nutcracker.yaml):
1
2
3
4
5
6
7
8
9
10
11
12
13
redis:
  listen: 127.0.0.1:6379
  hash: fnv1a_64
  distribution: ketama
  auto_eject_hosts: true
  redis: true
  timeout: 400
  server_retry_timeout: 2000
  server_failure_limit: 1
  servers:
   - sharded-redis-0.redis:6379:1
   - sharded-redis-1.redis:6379:1
   - sharded-redis-2.redis:6379:1

در این پیکربندی، پروتکل ردیس روی آدرس لوکال 127.0.0.1:6379 شنود می‌شود تا کانتینر اصلی برنامه به راحتی و بدون نیاز به داشتن اطلاعات از توپولوژی شبکه به آن متصل گردد. نحوه توزیع شاردها بر اساس الگوریتم هش متسق ketama تنظیم شده است.

این فایل پیکربندی را به عنوان یک ConfigMap در کوبرنتیس ثبت می‌کنیم:

1
$ kubectl create configmap twem-config --from-file=nutcracker.yaml
فایل مانیفست پاد حاوی سفیر (ambassador-example.yaml):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
apiVersion: v1
kind: Pod
metadata:
  name: ambassador-example
spec:
  containers:
    # این بخش محل قرارگیری کانتینر اصلی برنامه شماست
    # - name: my-serving-app
    #   image: my-app-image
    
    # کانتینر سفیر (Twemproxy)
    - name: twemproxy
      image: ganomede/twemproxy
      command:
      - nutcracker
      - -c
      - /etc/config/nutcracker.yaml
      - -v
      - 7
      - -s
      - 6222
      volumeMounts:
      - name: config-volume
         mountPath: /etc/config
  volumes:
    - name: config-volume
      configMap:
        name: twem-config

برنامه اصلی شما به سادگی به localhost:6379 متصل می‌شود و تمامی پیچیدگی‌های شاردینگ به طور کامل درون کانتینر سفیر مهار می‌گردد.


۲. استفاده از سفیر برای کارگزاری سرویس (Using an Ambassador for Service Brokering)

یکی از چالش‌های حیاتی در زمان انتقال برنامه‌ها بین محیط‌های مختلف (مانند ابر عمومی، دیتاسنتر فیزیکی یا ابر خصوصی)، مدیریت فرآیند کشف سرویس و جفت‌شدن (Service Discovery & Binding) است. به عنوان مثال، یک برنامه فرانت‌اند که برای ذخیره‌سازی به پایگاه داده MySQL متصل می‌شود، در ابر عمومی ممکن است از یک سرویس مدیریت‌شده SaaS استفاده کند، اما در یک ابر خصوصی محلی نیاز باشد به یک ماشین مجازی یا کانتینر MySQL به صورت پویا متصل شود.

برای حفظ پرتابل بودن (Portability) برنامه بدون تغییر در کدهای آن، از الگوی سفیر به عنوان یک Service Broker استفاده می‌شود. کانتینر برنامه اصلی همواره به آدرس ثابت localhost:3306 متصل می‌شود. وظیفه بررسی شرایط محیطی، شناسایی مکان پایگاه داده MySQL و برقراری ارتباط مطمئن با آن به طور کامل به کانتینر سفیر واگذار می‌شود. این کار لایه برنامه‌نویسی را کاملاً از تغییرات زیرساختی شبکه ایزوله می‌کند.


۳. تست‌های آزمایشی و تفکیک ترافیک (Using an Ambassador to Do Experimentation or Request Splitting)

در سیستم‌های توزیع‌شده با مقیاس بالا، بسیار مرسوم است که پیش از استقرار کامل یک نسخه جدید (Beta/Experimental)، بخشی از ترافیک واقعی به آن هدایت شود تا کارایی و پایداری آن سنجیده شود. همچنین در سناریوهایی ترافیک به صورت موازی به هر دو نسخه اصلی و بتا فرستاده می‌شود (Teeing)؛ پاسخ سیستم اصلی به کاربر بازگردانده شده و پاسخ سیستم بتا نادیده گرفته می‌شود تا صرفاً رفتار سیستم جدید تحت بار واقعی (Production Load) مانیتور شود.

پیاده‌سازی این منطق به کمک الگوی سفیر، کدهای سرویس را تمیز و سبک نگه‌می‌دارد. فرانت‌اند به آدرس سرویس روی localhost متصل شده و کانتینر سفیر وظیفه تفکیک ترافیک (Request Splitting) و پروکسی کردن آن به سرویس‌های اصلی و تجربی را بر عهده می‌گیرد.


پیاده‌سازی عملی: آزمایش‌های ۱۰ درصدی با Nginx (Implementing 10% Experiments)

در این بخش، از وب‌سرور قدرتمند Nginx به عنوان کانتینر سفیر برای هدایت ۱۰ درصد ترافیک به نسخه بتا و ۹۰ درصد ترافیک به نسخه اصلی (Production) استفاده می‌شود. استفاده از مکانیسم IP Hashing تضمین می‌کند که یک کاربر خاص همواره به یک نسخه متصل بماند و تجربه کاربری پایداری داشته باشد.

فایل پیکربندی Nginx برای سفیر تفکیک ترافیک (nginx.conf):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
events { worker_connections 1024; }

http {
    upstream backend {
        ip_hash;
        server web:80 weight=9;
        server experiment:80 weight=1;
    }

    server {
        listen 127.0.0.1:80;
        location / {
            proxy_pass http://backend;
        }
    }
}

در این پیکربندی، کلاینت به پورت ۸۰ روی localhost متصل می‌شود. قانون ip_hash ثبات کاربر را تضمین کرده و پارامتر weight نسبت ۹۰ به ۱۰ را برقرار می‌کند.

این تنظیمات را به عنوان ConfigMap ثبت می‌کنیم:

1
$ kubectl create configmap experiment-config --from-file=nginx.conf

سپس سرویس‌های کوبرنتیس مربوط به web (سرویس اصلی) و experiment (سرویس آزمایش) را به پاد معرفی می‌کنیم تا Nginx بتواند فرآیند پروکسی را به درستی هدایت کند.

فایل مانیفست پاد سفیر آزمایش ترافیک (experiment-pod.yaml):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: v1
kind: Pod
metadata:
  name: experiment-example
spec:
  containers:
    # کانتینر اصلی برنامه شما در این قسمت قرار می‌گیرد
    # - name: main-application
    #   image: my-app-image
    
    # کانتینر سفیر Nginx برای تفکیک ترافیک
    - name: nginx
      image: nginx
      volumeMounts:
      - name: config-volume
        mountPath: /etc/nginx
  volumes:
    - name: config-volume
      configMap:
        name: experiment-config

با بهره‌گیری از این ساختار، منطق آزمایش ترافیک کاملاً از سورس‌کد برنامه خارج شده و در قالب یک سفیر زیرساختی مهار می‌شود.


۲. استانداردسازی لاگ‌ها با الگوی آداپتور (Standardized Logging)

همانند حوزه مانیتورینگ، در مدیریت و قالب‌بندی لاگ‌ها نیز ناهمگونی شدیدی (Heterogeneity) وجود دارد. سیستم‌های مختلف ممکن است لاگ‌های خود را به سطوح گوناگونی (مانند Debug ،Info ،Warning و Error) تقسیم کرده و هر سطح را در یک فایل مجزا بنویسند، یا صرفاً خروجی را به stdout و stderr ارسال کنند. این موضوع به‌ویژه در دنیای برنامه‌های کانتینری به یک چالش تبدیل می‌شود؛ چرا که ابزارهای ارکستریشن و مدیریت کانتینر انتظار دارند کانتینرها لاگ‌های خود را مستقیماً روی جریان خروجی استاندارد (stdout) بنویسند تا از طریق دستوراتی مانند docker logs یا kubectl logs قابل بازیابی باشد. علاوه بر این، اطلاعات ساختاریافته لاگ‌ها (مانند فرمت زمانی) بسته به کتابخانه‌های مورد استفاده در زبان‌های مختلف (مثلاً جاوا در مقایسه با گو) به شدت متفاوت است.

برای رفع این ناهمگونی بدون دستکاری کدهای برنامه، الگوی آداپتور یک راهکار ماژولار ارائه می‌دهد. در این الگو، در حالی که کانتینر اصلی برنامه لاگ‌های خود را در یک فایل محلی می‌نویسد، کانتینر آداپتور می‌تواند آن فایل را خوانده و به stdout هدایت کند. همچنین آداپتور می‌تواند قالب‌های ناهمگون را به یک نمایش ساختاریافته واحد (Single Structured Representation) تبدیل کند تا توسط سیستم‌های تجمیع‌کننده لاگ (Log Aggregators) مصرف شود. در واقع، آداپتور یک دنیای همگون از اینترفیس‌های مشترک می‌سازد.


پیاده‌سازی عملی: نرمال‌سازی لاگ‌های ردیس با Fluentd

ابزار Fluentd به دلیل بهره‌مندی از اکوسیستم غنی و پلاگین‌های توسعه‌یافته توسط جامعه کاربری، یکی از بهترین گزینه‌ها برای کانتینر آداپتور است.

در این سناریو، هدف ما نظارت بر فرامین کند در پایگاه داده Redis است. ردیس دستور مفیدی به نام SLOWLOG ارائه می‌دهد که کوئری‌های فراتر از یک بازه زمانی مشخص را لیست می‌کند. با این حال، دسترسی به این لاگ تنها از طریق اجرای دستور روی سرور زنده ردیس امکان‌پذیر است، که این امر تحلیل‌های گذشته‌نگر (Retrospective Analysis) را در زمان بروز خرابی‌ها غیرممکن می‌سازد.

برای حل این مسئله، از یک کانتینر آداپتور Fluentd مجهز به پلاگین fluent-plugin-redis-slowlog در کنار کانتینر اصلی Redis استفاده می‌کنیم. از آنجا که هر دو کانتینر در یک پاد مشترک قرار دارند، فضای نام شبکه آن‌ها یکسان بوده و آداپتور می‌تواند به راحتی از طریق localhost:6379 با ردیس ارتباط برقرار کند.

پیکربندی پلاگین Fluentd برای ردیس (fluentd-redis.conf):
1
2
3
4
5
6
<source>
  type redis_slowlog
  host localhost
  port 6379
  tag redis.slowlog
</source>

به طور مشابه، می‌توان همین الگو را برای سیستم‌های دیگر مانند Apache Storm پیاده‌سازی کرد. سیستمی مثل Storm داده‌ها را از طریق یک RESTful API ارائه می‌دهد که مانیتورینگ مداوم آن دشوار است. با استفاده از آداپتور Fluentd و پلاگین fluent-plugin-storm، اطلاعات API به صورت محلی خوانده شده و به یک سری زمانی از لاگ‌های قابل کوئری تبدیل می‌شود.

پیکربندی پلاگین Fluentd برای Apache Storm:
1
2
3
4
5
6
7
<source>
  type storm
  tag storm
  url http://localhost:8080
  window 600
  sys 0
</source>

۳. افزودن مانیتورینگ سلامت غنی (Adding a Rich Health Monitor)

بررسی‌های اولیه سلامت کانتینرها (مانند زنده بودن فرآیند یا باز بودن یک پورت TCP) برای مانیتورینگ‌های پایه کافی است، اما کارایی لازم را برای تشخیص خرابی‌های عمیق لایه اپلیکیشن ندارد. برای پایگاه‌های داده، ما نیازمند بررسی‌های سلامت غنی‌تر (Rich Health Checks) هستیم که در آن‌ها تراکنش‌ها و کوئری‌های واقعی (که نماینده بار کاری واقعی سیستم هستند) اجرا شوند.

تغییر ایمیج اصلی پایگاه داده (مانند MySQL) برای گنجاندن این اسکریپت‌ها رویکرد نامناسبی است؛ زیرا فرآیند به‌روزرسانی نسخه‌های پایگاه داده و اعمال پچ‌های امنیتی را به شدت پرهزینه می‌کند. به کمک الگوی آداپتور، کانتینر اصلی پایگاه داده MySQL بدون کوچک‌ترین تغییری در کنار یک کانتینر آداپتور مستقر می‌شود. کانتینر آداپتور صرفاً حاوی ابزارها و اسکریپت‌های لازم برای اجرای کوئری‌های سلامت روی localhost است و نتایج این ارزیابی عمیق را در قالب یک اینترفیس HTTP استاندارد به سیستم ارکستراتور (مانند کوبرنتیس) اکسپوز می‌کند.

مانیفست پاد کوبرنتیس برای آداپتور سلامت MySQL (mysql-adapter.yaml):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Pod
metadata:
  name: adapter-example-health
  namespace: default
spec:
  containers:
    # کانتینر اصلی پایگاه داده بدون دستکاری
    - image: mysql
      name: mysql
      
    # کانتینر آداپتور سلامت که اینترفیس HTTP استاندارد را پیاده‌سازی می‌کند
    - image: brendanburns/mysql-adapter
      name: adapter

در این معماری، کانتینر mysql کاملاً دست‌نخورده باقی می‌ماند. کانتینر آداپتور که به زبان Go پیاده‌سازی شده، کوئری‌های لازم را روی پایگاه داده محلی اجرا کرده و نتیجه را از طریق وب‌سرور خود به بیرون ارائه می‌دهد. این تفکیک ماژولار، قابلیت استفاده مجدد (Reusability) بالایی را به همراه دارد؛ به گونه‌ای که آداپتور سلامت توسعه‌یافته می‌تواند توسط تیم‌های مختلف و بدون نیاز به داشتن دانش عمیق درباره نحوه مانیتورینگ تخصصی MySQL مورد استفاده قرار گیرد.


بخش دوم: الگوهای سرویس‌دهی چندگره‌ای (Part II: Multi-Node Serving Patterns)

مقدمه‌ای بر میکروسرویس‌ها (Introduction to Microservices)

معماری‌های توزیع‌شده چندگره‌ای در تضاد مستقیم با سیستم‌های یکپارچه (Monolithic Systems) قرار دارند. در یک سیستم یکپارچه، تمام قابلیت‌ها و منطق‌های برنامه در قالب یک فرآیند واحد و به صورت کاملاً هماهنگ اجرا می‌شوند. در مقابل، معماری میکروسرویس سیستم را به مجموعه‌ای از مؤلفه‌های متمایز، کوچک و متمرکز تقسیم می‌کند که هر کدام در یک فرآیند جداگانه اجرا شده و از طریق APIهای به‌دقت تعریف‌شده با یکدیگر ارتباط برقرار می‌کنند.

مزایای اصلی رویکرد میکروسرویس حول دو محور پایداری (Reliability) و چابکی (Agility) می‌چرخد:

  1. کاهش دامنه و تمرکز تیم‌ها (Two-Pizza Teams): هر میکروسرویس بر روی ارائه یک قابلیت مشخص متمرکز است. این کاهش دامنه به تیم‌های مهندسی کوچک اجازه می‌دهد مالکیت کامل یک سرویس را بر عهده بگیرند و سربار هماهنگی‌های درون‌سازمانی را به حداقل برسانند.
  2. مقیاس‌پذیری مستقل و بهینه (Decoupled Scaling): قطعات مختلف یک سیستم با نرخ یکسانی رشد نمی‌کنند. در یک سیستم یکپارچه، برای مقیاس‌دهی یک بخش پرمصرف، ناچارید کل سیستم را تکثیر کنید. اما میکروسرویس‌ها اجازه می‌دهند لایه‌های بدون حالت (Stateless) را به صورت افقی و لایه‌های با حالت (Stateful) را از طریق الگوهای شاردینگ به طور مجزا و بر اساس نیاز واقعی مقیاس‌دهی کنید.

با این حال، ورود به دنیای چندگره‌ای پیچیدگی‌های شبکه‌ای، ناهمگونی و مدیریت حالت (State Management) را به همراه دارد که مهار آن نیازمند الگوهای استاندارد سرویس‌دهی است .


فصل پنجم: سرویس‌های تکثیرشده با توازن بار (Replicated Load-Balanced Services)

این الگو، ساده‌ترین و رایج‌ترین الگوی توزیع‌شده چندگره‌ای است. در این ساختار، مجموعه‌ای از سرورهای کاملاً همگون و مشابه (Identical Replicas) در پشت یک لود بالانسر (Load Balancer) قرار می‌گیرند. لود بالانسر درخواست‌ها را به صورت گردشی (Round-Robin) یا با تکیه بر چسبندگی سشن (Session Stickiness) بین این نسخه‌ها توزیع می‌کند.

۱. سرویس‌های بدون حالت (Stateless Services)

سرویس‌های Stateless به برنامه‌هایی اطلاق می‌شود که برای پردازش صحیح درخواست‌ها، نیازی به ذخیره‌سازی حالت (State) در حافظه محلی خود ندارند. به دلیل این ویژگی، هر درخواست به صورت کاملاً مستقل پردازش شده و لود بالانسر می‌تواند هر درخواست منفرد از یک کاربر را به نمونه متفاوتی از سرویس هدایت کند. وب‌سرورهای ارائه محتوای استاتیک یا سیستم‌های واسط (Middleware) تجمیع‌کننده پاسخ‌های Backend، نمونه‌های بارزی از سرویس‌های بدون حالت هستند.

ریاضیاتِ دسترس‌پذیری بالا (SLA & Redundancy Mathematics)

تکثیر سرویس‌های بدون حالت دو هدف اصلی را دنبال می‌کند: ایجاد افزونگی (Redundancy) و پاسخ به مقیاس بالا (Scale). فارغ از حجم ترافیک، شما برای ارائه یک موافقت‌نامه سطح سرویس (SLA) با قابلیت دسترسی بالا (Highly Available)، به حداقل دو نسخه تکثیرشده (Replicas) نیاز دارید.

برای درک عمیق این موضوع، الزامات یک سرویس با دسترس‌پذیری سه‌نه (99.9% Availability) را بررسی می‌کنیم:

  • دسترس‌پذیری 99.9% به این معناست که سیستم مجاز است حداکثر 1.4 دقیقه در روز (یا حدود 10 ساعت در سال) قطعی داشته باشد.
  • فرض کنید نرم‌افزار شما کاملاً بدون باگ است و هرگز کرش نمی‌کند. حتی در این شرایط ایده‌آل، اگر بخواهید نسخه جدیدی از نرم‌افزار را مستقر (Deploy) کنید، فرآیند خاموش کردن نسخه قدیمی و اجرای نسخه جدید باید در کمتر از 1.4 دقیقه انجام شود تا SLA نقض نشود (آن هم به فرض اینکه فقط یک بار در روز دیپلوی داشته باشید).
  • حال اگر تیم شما متعهد به تحویل مداوم (Continuous Delivery) باشد و بخواهد هر ساعت یک نسخه جدید مستقر کند، سهمیه قطعی مجاز برای هر استقرار به 3.6 ثانیه کاهش می‌یابد. استقرار یک فرآیند پیچیده در 3.6 ثانیه روی یک ماشین واحد عملاً غیرممکن است.

با قرار دادن حداقل دو نمونه تکثیرشده در پشت لود بالانسر، این محدودیت سخت‌گیرانه ریاضی به راحتی حل می‌شود. در طول فرآیند ارتقاء (Rollout)، لود بالانسر ابتدا ترافیک را از روی نسخه اول برمی‌دارد، آن را ارتقا می‌دهد و سپس همین فرآیند را برای نسخه دوم تکرار می‌کند (Rolling Update). به این ترتیب، کاربران بدون تجربه حتی یک میلی‌ثانیه قطعی، همواره به یک نسخه فعال متصل خواهند بود.

۲. پروب‌های آمادگی در توازن بار (Readiness Probes for Load Balancing)

در طراحی سیستم‌های توزیع‌شده، تفاوت حیاتی میان پروب‌های سلامت/زنده بودن (Liveness Probes) و پروب‌های آمادگی (Readiness Probes) وجود دارد:

  • Liveness Probe: به سیستم ارکستراتور اعلام می‌کند که آیا فرآیند کانتینر زنده است یا خیر؛ در صورت شکست این پروب، کانتینر ری‌استارت می‌شود.
  • Readiness Probe: مشخص می‌کند که آیا کانتینر زنده آماده دریافت ترافیک واقعی کاربران هست یا خیر.

بسیاری از برنامه‌ها پس از اجرا شدن فرآیند اصلی، فوراً آماده پاسخگویی نیستند. آن‌ها برای شروع کار به زمان نیاز دارند تا اتصال‌های پایگاه داده را برقرار کنند، پلاگین‌ها را لود کرده یا فایل‌های پیکسلی بزرگ و داده‌های اولیه را از شبکه دانلود کنند. در این فاز فاقد آمادگی، کانتینر زنده است اما نباید ترافیکی دریافت کند؛ زیرا ارسال ترافیک به آن منجر به بروز خطاهای HTTP 5xx برای کاربران خواهد شد.

بنابراین، پیاده‌سازی یک اینترفیس اختصاصی (مثلاً یک آدرس URL مانند /ready) که به طور دقیق وضعیت آمادگی لایه‌های داخلی را ارزیابی کند، در این الگو الزامی است. لود بالانسر تا زمانی که این پروب موفقیت‌آمیز نباشد، کانتینر را وارد چرخه ترافیک نخواهد کرد.


سرویس‌های ردیابی سشن (Session-Tracked Services)

در سرویس‌های بدون حالت (Stateless)، ترافیک کاربران به صورت کاملاً یکنواخت و تصادفی بین نمونه‌ها توزیع می‌شود؛ اما در بسیاری از سناریوها، دلایل فنی قوی برای هدایت مداوم درخواست‌های یک کاربر خاص به یک ماشین واحد وجود دارد. این نیاز معمولاً ناشی از دو عامل است: افزایش نرخ اصابت کش (Cache Hit Rate) در حافظه محلی نمونه، یا وجود تعاملات طولانی‌مدت (Long-running Sessions) که نیازمند حفظ لایه حالت روی یک نمونه خاص هستند. در این مدل که تحت عنوان Session-Tracked Services شناخته می‌شود، تمام درخواست‌های یک کاربر به یک Replica ثابت ارسال می‌گردد.

مکانیزم استاندارد در لایه شبکه برای ردیابی سشن، هش کردن ترکیب IP مبدأ و مقصد (IP-based Session Tracking) است. تا زمانی که این دو آدرس IP ثابت بمانند، کاربر همواره به یک نمونه متصل می‌شود. با این حال، این رویکرد در لایه کلان اینترنت کارایی مناسبی ندارد؛ زیرا به دلیل وجود مکانیزم‌های ترجمه آدرس شبکه (NAT) در سمت کلاینت، آدرس‌های IP عمومی مدام دستخوش تغییر می‌شوند. بنابراین، ردیابی سشن در سطح اینترنت و ارتباطات خارجی باید در لایه اپلیکیشن و با ابزارهایی مانند کوکی‌ها (Cookies) یا هدرهای سفارشی HTTP پیاده‌سازی شود.

نقش تابع هش همساز (Consistent Hashing)

زمانی که یک سیستم چندگره‌ای را مقیاس‌دهی (Scale Up/Down) می‌کنید، تعداد نمونه‌ها تغییر می‌کند. در فرآیند هشینگ معمولی (مانند hash(key) % N)، تغییر تعداد نمونه‌ها (N) منجر به بازتعریف و تغییر مسیر ناگهانی (Remapping) تقریباً تمامی کلیدها به ماشین‌های جدید می‌شود. در یک سرویس مجهز به لایه کش، این اتفاق فاجعه‌بار است؛ زیرا کل حافظه کش در یک لحظه بی‌اعتبار شده و بار سنگینی به دیتابیس وارد می‌شود.

برای حل این چالش، از Consistent Hashing استفاده می‌شود. این توابع هش تضمین می‌کنند که با اضافه یا حذف شدن یک گره، تنها بخش بسیار کوچکی از کلیدها (نسبت معکوس تعداد ماشین‌ها) جابه‌جا شوند؛ این ویژگی، اثرات منفی ناشی از مقیاس‌دهی را به حداقل ممکن کاهش می‌دهد.

سرویس‌های تکثیرشده در لایه اپلیکیشن (Application-Layer Replicated Services)

لود بالانسرها به طور سنتی در لایه شبکه (TCP/IP) بدون آگاهی از نوع پروتکل تبادل‌یافته عمل می‌کنند. اما اگر لایه توزیع‌کننده ترافیک از پروتکل لایه اپلیکیشن (مانند HTTP) آگاه باشد، می‌توان تصمیمات هوشمندانه‌تری اتخاذ کرد. شناخت پروتکل لایه هفت به سیستم اجازه می‌دهد تا منطق‌های پیشرفته‌تری نظیر مسیردهی بر اساس آدرس URL، مدیریت هوشمند سشن‌ها و مدیریت کش را در سطح کلان پیاده‌سازی کند.

معرفی لایه کش (Introducing a Caching Layer)

حتی در سرویس‌های بدون حالت، پردازش درخواست‌ها می‌تواند بسیار هزینه‌بر (Expensive) باشد؛ پردازش‌هایی شامل کوئری‌های سنگین به پایگاه داده، رندرینگ پویای صفحات یا تلفیق داده‌های پیچیده (Data Mixing). در چنین معماری‌هایی، قرار دادن یک لایه پیش‌نویس کش (Caching Layer) بین کاربر و سرویس بدون حالت لایه Backend بسیار حیاتی است. ساده‌ترین شکل کش، یک پروکسی وب کش (Caching Web Proxy) مانند ابزار متن‌باز Varnish است که پاسخ درخواست‌های تکراری را مستقیماً از حافظه رم (RAM) و با کمترین تأخیر ممکن به کاربر برمی‌گرداند.

استراتژی‌های استقرار لایه کش (Cache Deployment Strategies)

برای پیاده‌سازی این الگو، دو رویکرد معماری وجود دارد:

۱. کاشتن کش به صورت سایدکار (Sidecar Cache):
در این مدل، کانتینر کش به عنوان یک سایدکار در کنار هر نمونه از وب‌سرور درون یک پاد قرار می‌گیرد. اگرچه این طراحی ساده است، اما یک نقص بزرگ دارد: لایه کش به شدت با لایه برنامه جفت می‌شود و مجبورید هر دو را با یک نرخ مقیاس‌دهی کنید. کش به منابع سخت‌افزاری متفاوتی نسبت به وب‌سرور نیاز دارد؛ برای کش، ما نیازمند تعداد کمی Replica با حافظه رم بسیار بالا هستیم (مثلاً ۲ نسخه با ۵ گیگابایت رم به جای ۱۰ نسخه با ۱ گیگابایت رم). دلیل آن این است که اگر کش‌ها را به صورت کوچک و پراکنده تعریف کنید، صفحات تکراری در تک‌تک این نمونه‌ها به صورت موازی ذخیره می‌شوند؛ این پدیده فضای رم کل سیستم را هدر داده و نرخ اصابت کش (Hit Rate) کل سیستم را به‌شدت کاهش می‌دهد.

۲. لایه کش مستقل و اختصاصی (Multi-Tier Caching):
طراحی اصولی‌تر، تفکیک لایه کش به عنوان یک لایه بدون حالتِ مجزا در بالای لایه سرویس‌دهی وب است. در این ساختار، یک لایه سبک از وب‌سرورها (که شاید به دلیل تک‌رشته‌ای بودن مانند Node.js نیازمند نمونه‌های کوچک متعدد باشند) در پشت لایه کش‌های متمرکز و بزرگ قرار می‌گیرند.

⚠️ یک هشدار مهم در مانیتورینگ ترافیک: معرفی لایه کش می‌تواند ردیابی سشن مبتنی بر IP را مختل کند. از آنجا که تمام درخواست‌های ارسالی به وب‌سرورها از آدرس‌های IP لایه کش عبور می‌کنند، لود بالانسر داخلی وب‌سرورها آدرس IP واقعی کاربر را ندیده و ترافیک را به درستی توزیع نمی‌کند. برای جلوگیری از این مشکل، استفاده از کوکی‌ها یا هدرهای HTTP (مانند X-Forwarded-For) الزامی است.

توسعه کارکردهای لایه لبه (Expanding the Caching Layer)

پروکسی‌های معکوس وب مانند Varnish فراتر از یک حافظه موقت ساده عمل کرده و ابزارهای قدرتمندی برای مدیریت رفتارهای لبه سیستم ارائه می‌دهند:

۱. دفاع در برابر حملات DoS و محدودسازی نرخ درخواست (Rate Limiting)

طراحی سیستم‌های نوین بدون مکانیزم‌های دفاعی لبه، ریسک بالایی دارد؛ چرا که یک خطای کوچک در کلاینت یا اجرای یک تست بار تصادفی توسط مهندسان SRE می‌تواند کل سرویس را فلج کند. با پیکربندی ماژول‌های محدودکننده (مانند Throttle Module در Varnish)، می‌توان نرخ درخواست‌ها را بر اساس آدرس IP، مسیر درخواست و وضعیت احراز هویت کنترل کرد.
رویکرد استاندارد، تخصیص سهمیه بسیار محدود برای کاربران ناشناس و سهمیه بالاتر برای کاربران احراز هویت‌شده است. همچنین، بازگرداندن هدرهای استانداردی نظیر X-RateLimit-Remaining به کلاینت کمک می‌کند تا نرخ ارسال فرسته‌های خود را مدیریت کند. در صورت عبور از حد مجاز، سیستم بلافاصله خطای HTTP 429 Too Many Requests را صادر می‌کند.

۲. پایان‌دهی به پروتکل امن (SSL Termination)

امنیت لایه‌به‌لایه درون کلاستر از اصول پدافند غیرعامل است و هر میکروسرویس باید از گواهی‌های امنیتی مستقل خود استفاده کند. اما برای ارتباط خارجی، مدیریت تراکنش‌های سنگین ریاضی پروتکل SSL/TLS در سطح تک‌تک کانتینرهای وب سربار پردازشی زیادی ایجاد می‌کند. راهکار بهینه، پایان‌دهی SSL (SSL Termination) در بیرونی‌ترین لایه لبه سیستم است.

از آنجا که Varnish به طور بومی از SSL Termination پشتیبانی نمی‌کند، یک لایه پروکسی سبک مانند Nginx در جلوی آن قرار می‌گیرد. این لایه ترافیک HTTPS بیرونی را رمزگشایی کرده و ترافیک سبک HTTP داخلی را به سمت Varnish هدایت می‌کند تا از آنجا به لایه وب‌سرورها ارسال شود.


پیاده‌سازی عملی: ایجاد یک سرویس تکثیرشده در کوبرنتیس (Hands On: Creating a Replicated Service in Kubernetes)

برای بررسی عینی و پیاده‌سازی الگوی سرویس‌های تکثیرشده با توازن بار (Replicated Load-Balanced Services)، یک سناریوی واقعی شامل راه‌اندازی یک وب‌سرور سبک توسعه‌یافته با Node.js را در نظر می‌گیریم که وظیفه ارائه معانی کلمات از روی یک واژه‌نامه (Dictionary Server) را بر عهده دارد.

برای تست مقدماتی این سرویس به صورت محلی در قالب یک کانتینر تک‌گره‌ای، می‌توان دستور زیر را اجرا کرد:

1
docker run -p 8080:8080 brendanburns/dictionary-server

با اجرای این دستور، سرور واژه‌نامه روی پورت لوکال ۸۰۸۰ فعال می‌شود. به عنوان مثال، با ارسال درخواست به آدرس http://localhost:8080/dog می‌توان معنی کلمه dog را دریافت کرد.

بررسی لاگ‌های این کانتینر یک رفتار معماری بسیار مهم را نشان می‌دهد: کانتینر بلافاصله پس از اجرا شدن، پردازش درخواست‌ها را آغاز می‌کند، اما تا زمانی که فرآیند دانلود فایل دیتای واژه‌نامه (با حجم تقریبی ۸ مگابایت) را از روی شبکه به طور کامل به پایان نرساند، آمادگی خود را اعلام نمی‌کند. این رفتار دقیقاً ضرورت تفکیک Liveness Probe و Readiness Probe را که پیش‌تر مطرح شد، اثبات می‌کند؛ سرویس زنده است اما تا پایان دانلود دیتا، آماده خدمت‌رسانی نیست.

برای استقرار پایدار این سرویس در محیط چندگره‌ای کلاستر کوبرنتیس به عنوان یک Deployment تکثیرشده (Replicated)، مانیفستی به نام dictionary-deploy.yaml تعریف شده و با دستور زیر مستقر می‌شود:

1
kubectl create -f dictionary-deploy.yaml

پس از ایجاد نمونه‌ها (Replicas)، نیازمند یک Load Balancer در لایه جلویی هستیم تا ترافیک ورودی را بین نمونه‌ها توزیع کند. لود بالانسر علاوه بر توزیع بار، نقش یک لایه انتزاعی (Abstraction) را ایفا می‌کند که مصرف‌کنندگان سرویس را از آدرس‌های IP متغیر نمونه‌های لایه Backend بی‌نیاز ساخته و یک نام پایدار و قابل حل در DNS کلاستر ارائه می‌دهد.

سرویس توازن بار را می‌توان با استفاده از مانیفست dictionary-service.yaml و دستور زیر در کلاستر ایجاد کرد:

1
kubectl create -f dictionary-service.yaml

با این کار، سرویس واژه‌نامه تحت نام پایدار و بومیِ dictionary-server-service در شبکه کلاستر در دسترس خواهد بود.


پیاده‌سازی عملی: استقرار لایه کش ورنیش (Hands On: Deploying the Caching Layer)

همان‌طور که تحلیل شد، برای جلوگیری از تکرار پردازش‌های سنگین روی Backend، یک لایه کش اختصاصی با استفاده از Varnish در بالای لایه سرویس‌دهی وب قرار می‌دهیم. پیکربندی Varnish به گونه‌ای تنظیم می‌شود که ترافیک ورودی را دریافت کرده و در صورت بروز Cache Miss، درخواست را به نام سرویس لایه وب یعنی dictionary-server-service هدایت کند.

برای استقرار این لایه به عنوان یک لایه تکثیرشده مستقل (مشتمل بر دو Replica با تخصیص حافظه رم ۲ گیگابایت برای هر نمونه)، مانیفست زیر تعریف می‌شود:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: varnish-cache
spec:
  replicas: 2
  template:
    metadata:
      labels:
        app: varnish-cache
    spec:
      containers:
      - name: cache
        resources:
          requests:  # تخصیص دو گیگابایت حافظه موقت برای هر کانتینر کش
            memory: 2Gi
        image: brendanburns/varnish
        command:
        - varnishd
        - -F
        - -f
        - /etc/varnish-config/default.vcl
        - -a
        - 0.0.0.0:8080
        - -s  # این تخصیص حافظه باید دقیقاً با مقدار memory request بالا مطابقت داشته باشد
        - malloc,2G
        ports:
        - containerPort: 8080
        volumeMounts:
        - name: varnish
          mountPath: /etc/varnish-config
      volumes:
      - name: varnish
        configMap:
          name: varnish-config

(نکته فنی: مقدار پارامتر تخصیص حافظه در آرگومان‌های موتور ورنیش یعنی malloc,2G باید کاملاً با منابع درخواستی سخت‌افزاری کانتینر در بخش resources.requests.memory همخوانی داشته باشد تا از هدررفت منابع یا توقف ناگهانی کانتینر توسط سیستم ارکستراتور جلوگیری شود).

برای اعمال این لایه کش روی کلاستر، دستور زیر اجرا می‌شود:

1
kubectl create -f varnish-deploy.yaml

سپس برای ایجاد توازن بار در ورودی لایه کش، یک سرویس کوبرنتیس مانیتورینگ روی پورت ۸۰ تعریف می‌شود که ترافیک را به پورت ۸۰۸۰ کانتینرهای کش ورنیش پروکسی می‌کند:

1
2
3
4
5
6
7
8
9
10
11
kind: Service
apiVersion: v1
metadata:
  name: varnish-service
spec:
  selector:
    app: varnish-cache
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080

برای ثبت این سرویس لود بالانسر کش در کلاستر، دستور زیر را اجرا می‌کنیم:

1
kubectl create -f varnish-service.yaml

پیاده‌سازی عملی: استقرار Nginx و پایان‌دهی به SSL (Hands On: Deploying nginx and SSL Termination)

در نهایت، برای تامین امنیت ارتباطات خارجی و مدیریت متمرکز فرآیند رمزگشایی در لبه کلاستر، یک لایه تکثیرشده مستقل متشکل از ۴ نمونه وب‌سرور Nginx برای پیاده‌سازی SSL Termination ایجاد می‌کنیم.

در گام نخست، گواهی امنیتی عمومی (server.crt) و کلید خصوصی سرور (server.key) را به عنوان یک شیء Secret از نوع TLS در کوبرنتیس آپلود می‌کنیم:

1
kubectl create secret tls ssl --cert=server.crt --key=server.key

در گام بعدی، مانیفست استقرار لایه Nginx را ایجاد می‌کنیم. این لایه پیکربندیِ وب‌سرور را از یک ConfigMap و کلیدهای رمزنگاری را مستقیماً از Secret ایجادشده در مرحله قبل لود می‌کند:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: nginx-ssl
spec:
  replicas: 4
  template:
    metadata:
      labels:
        app: nginx-ssl
    spec:
      containers:
      - name: nginx
        image: nginx
        ports:
        - containerPort: 443
        volumeMounts:
        - name: conf
          mountPath: /etc/nginx
        - name: certs
          mountPath: /etc/certs
      volumes:
      - name: conf
        configMap:  # ارجاع به پیکربندی ثبت‌شده Nginx
          name: nginx-conf
      - name: certs
        secret:  # ارجاع به سکرت حاوی کلیدهای گواهی امنیتی
          secretName: ssl

برای مستقر کردن این لایه پایان‌دهی امنیتی روی کلاستر، دستور زیر را صادر می‌کنیم:

1
kubectl create -f nginx-deploy.yaml

در گام نهایی، برای اکسپوز کردن این معماری سه‌لایه یکپارچه به دنیای خارج از کلاستر، یک سرویس از نوع LoadBalancer در سطح ابر عمومی ایجاد می‌کنیم تا یک IP عمومی پایدار (Public IP) برای دریافت ترافیک روی پورت امن ۴۴۳ به ما اختصاص یابد:

1
2
3
4
5
6
7
8
9
10
11
12
kind: Service
apiVersion: v1
metadata:
  name: nginx-service
spec:
  selector:
    app: nginx-ssl
  type: LoadBalancer
  ports:
    - protocol: TCP
      port: 443
      targetPort: 443

این لود بالانسر نهایی را با اجرای دستور زیر فعال می‌کنیم:

1
kubectl create -f nginx-service.yaml

برای دریافت آدرس IP عمومی تخصیص‌یافته و دسترسی به برنامه از طریق مرورگر، دستور زیر وضعیت سرویس‌ها را نمایش می‌دهد:

1
kubectl get services

با تکمیل این فرآیند، کل معماری سه‌لایه شامل رمزنگاری SSL در لبه (Nginx)، لایه کش میانی (Varnish) و سرویس‌های بدون حالت اصلی (Dictionary Pods) به طور کامل و با رعایت استانداردهای دسترس‌پذیری بالا عملیاتی می‌شود.


فصل ششم: سرویس‌های شاردشده (Sharded Services)

تفکیک مفهومی: سرویس تکثیرشده در مقابل سرویس شاردشده (Replicated vs. Sharded Services)

در سرویس‌های تکثیرشده (Replicated Services)، هر نسخه تکثیرشده (Replica) کاملاً همگون بوده و توانایی پاسخگویی به هرکدام از درخواست‌های ورودی سیستم را دارد. این ساختار برای لایه‌های بدون حالت (Stateless) کارآمد است. در مقابل، در سرویس‌های شاردشده (Sharded Services)، هر نسخه یا شارد (Shard) تنها قادر به پاسخگویی به بخش مشخصی از کل درخواست‌ها است. یک گره توزیع‌کننده ترافیک (ریشه یا Root) وظیفه دارد هر درخواست ورودی را تحلیل کرده و آن را به شارد متناظر ارسال کند. این الگو معمولاً در پیاده‌سازی سرویس‌های دارای حالت (Stateful) که حجم داده‌های در حال نگهداری آن‌ها فراتر از ظرفیت سخت‌افزاری یک ماشین واحد است، کاربرد دارد.


کش شاردشده (Sharded Caching) و بهینه‌سازی حافظه RAM

استفاده از لایه کش تکثیرشده (Replicated Cache) پایداری بالایی فراهم می‌کند، اما به شدت از نظر بهره‌وری حافظه ناکارآمد است؛ چرا که تمامی نمونه‌ها نسخه‌های کپی و مشابهی از داده‌ها را نگهداری می‌کنند.

برای درک بهتر این موضوع، یک مدل عددی را بررسی می‌کنیم: فرض کنید مجموعه داده‌های کل سیستم ۲۲۰ گیگابایت باشد و هر گره کش ما نیز حداکثر ۱۰ گیگابایت حافظه رم در اختیار داشته باشد. اگر سیستم را به صورت کش تکثیرشده با ۱۰ نمونه مستقر کنیم، ظرفیت کل لایه کش همچنان محدود به ۱۰ گیگابایت (یعنی حداکثر ۵ درصد از کل مجموعه داده‌ها) خواهد بود؛ زیرا هر ۱۰ نمونه تقریباً داده‌های کاملاً یکسانی را کش می‌کنند. اما اگر همین ۱۰ گره را در قالب یک کش شاردشده ۱۰ مسیره (10-way Sharded Cache) سازماندهی کنیم، از آنجا که هر گره وظیفه نگهداری از داده‌های کاملاً متمایزی را بر عهده دارد، ما توانایی کش کردن ۱۰۰ گیگابایت داده (یعنی معادل ۵۰ درصد از کل مجموعه داده‌های سیستم) را خواهیم داشت. این رویکرد، کارایی مصرف حافظه را در زیرساخت نرم‌افزاری شما تا ده برابر افزایش می‌دهد.


نقش لایه کش در پایداری و کارایی سیستم (System Performance & Stability)

ارزیابی کارایی لایه کش با معیار نرخ اصابت کش (Hit Rate) سنجیده می‌شود که نشان‌دهنده درصد پاسخگویی به درخواست‌ها از درون لایه کش است.

در یک معماری شاردشده، اثرات خرابی یک گره به شدت جدی‌تر از مدل تکثیرشده است. سقوط یک گره در کش شاردشده به معنای بروز یک Cache Miss قطعی و دائم برای تمام درخواست‌های تخصیص‌یافته به آن شارد است تا زمانی که گره مجدداً احیا و راه‌اندازی شود. این پدیده بار پردازشی شدیدی را به دیتابیس یا سرورهای Backend تحمیل می‌کند.

فرض کنید لایه پردازش اصلی Backend شما تحت بار ترافیکی ۱۰۰۰ درخواست در ثانیه (RPS) قرار دارد و سقف توان پاسخگویی آن نیز دقیقاً ۱۰۰۰ RPS است. اگر یک لایه کش مجهز به نرخ اصابت ۵۰ درصد را در مقابل آن مستقر کنید، حداکثر توان پذیرش ترافیک سیستم به ۲۰۰۰ RPS افزایش می‌یابد؛ چرا که ۱۰۰۰ درخواست توسط کش پاسخ داده شده و تنها ۱۰۰۰ درخواست به Backend منتقل می‌شود. حال اگر کل لایه کش دچار خرابی ناگهانی شود، ترافیک ورودی به Backend فوراً به ۲۰۰۰ RPS ارتقا یافته، سرورها Overload می‌شوند و شروع به بازگرداندن خطاهای HTTP 500 می‌کنند. به همین دلیل، در مهندسی پایداری توصیه می‌شود حداکثر ظرفیت عملیاتی سیستم را برای سناریوهای ترافیکی بر روی ۱۵۰۰ RPS کالیبره کنید تا سیستم توانایی تحمل پایداری در صورت سقوط نیمی از گره‌های کش را داشته باشد.


کش‌های شاردشده و تکثیرشده به صورت هم‌زمان (Replicated, Sharded Caches)

جهت جلوگیری از بروز اختلالات ناشی از خرابی گره‌های شارد، می‌توان الگوی سرویس‌های تکثیرشده را با ساختار شاردینگ ترکیب کرد. در این معماری که تحت عنوان Replicated, Sharded Caches شناخته می‌شود، هر شارد منفرد به جای اجرا بر روی یک ماشین، به عنوان یک سرویس تکثیرشده متشکل از چندین Replica همگون مستقر می‌شود. این طراحی نه‌ تنها در برابر خرابی‌ها پایداری بالایی دارد، بلکه به شما اجازه می‌دهد در زمان اوج ترافیک روزانه، فرآیند ارتقا و Rollout کانتینرها را بدون کوچک‌ترین افت در دسترس‌پذیری کش انجام دهید.


پیاده‌سازی عملی: استقرار سفیر twemproxy و Memcached برای کش شاردشده

برای شبیه‌سازی این معماری، ابتدا ۳ نمونه کانتینر Memcached را از طریق StatefulSet در کوبرنتیس مستقر کرده تا نام‌های DNS پایدار (مانند memcache-0 تا memcache-2) دریافت کنیم. سپس ابزار twemproxy (توسعه‌یافته توسط توییتر) را به عنوان پروکسی و سفیر لایه شاردینگ اضافه می‌کنیم.

دو روش برای پیاده‌سازی این پروکسی شاردینگ وجود دارد:

  1. استفاده از سفیر تک‌گره‌ای (Ambassador): قرار دادن پروکسی twemproxy به عنوان یک کانتینر سفیر در داخل پاد کلاینت. این طراحی پیچیدگی استقرار کلاینت را اندکی افزایش می‌دهد اما نیاز به هدررفت منابع و ایجاد گام‌های اضافی شبکه (Extra Network Hop) را برطرف می‌کند.
  2. استفاده از سرویس مسیردهی مشترک (Shared Routing Service): استقرار twemproxy به عنوان یک لایه متمرکز و لود بالانس‌شده در کلاستر. این روش پیچیدگی استقرار در کلاینت‌ها را کاهش می‌دهد، اما یک گام شبکه اضافی (Extra Network Hop) به مسیر درخواست‌ها اضافه کرده و منجر به افزایش تأخیر (Latency) و مصرف بیشتر پهنای باند شبکه کلاستر می‌شود.
پیکربندی twemproxy برای حالت مسیردهی مشترک (shared-nutcracker.yaml):
1
2
3
4
5
6
7
8
9
10
11
12
memcache:
  listen: 0.0.0.0:11211
  hash: fnv1a_64
  distribution: ketama
  auto_eject_hosts: true
  timeout: 400
  server_retry_timeout: 2000
  server_failure_limit: 1
  servers:
   - memcache-0.memcache:11211:1
   - memcache-1.memcache:11211:1
   - memcache-2.memcache:11211:1

در این پیکربندی، twemproxy روی پورت ۱۱۲۱۱ تمامی اینترفیس‌ها شنود کرده و از الگوریتم هش همساز ketama برای توزیع شاردها بین سه سرور Memcached استفاده می‌کند.


توابع شاردینگ و انتخاب کلید هش (Sharding Functions & Keys)

تابع شاردینگ وظیفه دارد یک درخواست معین را تحلیل کرده و مشخص کند که به کدام شارد عددی (مثلاً از ۰ تا ۹) ارسال شود. یک تابع شاردینگ اصولی باید دو ویژگی بنیادین داشته باشد:

  • قطعیت (Determinism): تضمین کند که یک درخواست مشخص، همواره به شارد یکسانی ارسال می‌شود.
  • یکنواختی (Uniformity): ترافیک و بار محاسباتی را به صورت کاملاً عادلانه و متوازن بین تمام شاردهای موجود تقسیم کند.

فرمول متداول شاردینگ به صورت زیر تعریف می‌شود: \[\text{Shard} = \text{hash(Req)} \pmod{10}\]

چالش انتخاب کلید (Selecting a Key)

اشتباه رایج در توسعه این لایه، استفاده از کل شیء درخواست به عنوان کلید هش است. یک درخواست HTTP معمولی شامل پارامترهایی چون زمان درخواست، IP مبدأ کلاینت و مسیر درخواست (/some/page.html) است. از آنجا که IP کلاینت‌ها و زمان ارسال درخواست‌ها مدام تغییر می‌کنند، هش کردن کل درخواست باعث می‌شود درخواست‌های مشابه برای دریافت یک صفحه وب به شاردهای کاملاً متفاوتی هدایت شوند که کارایی لایه کش را عملاً نابود می‌سازد.

بنابراین، کلید هش باید به صورت دقیق و بهینه‌سازی شده انتخاب شود:

  • برای صفحات عمومی، کلید هش باید صرفاً مسیر درخواست باشد: shard(request.path).
  • اگر پاسخ صفحات به متغیرهایی چون موقعیت جغرافیایی کاربر بستگی دارد، برای ممانعت از ارسال محتوای اشتباه (مثلاً نمایش زبان انگلیسی به کاربر فرانسوی)، کلید باید به صورت ترکیبی تعریف شود: shard(country(request.ip), request.path).

توابع هش همساز (Consistent Hashing Functions)

زمانی که نیاز به تغییر اندازه کلاستر کش دارید (مثلاً افزایش شاردها از ۱۰ به ۱۱ گره)، استفاده از فرمول‌های پیمانه‌ای ساده (hash(Req) % N) فاجعه‌بار است؛ زیرا تغییر در پارامتر \(N\) منجر به بازنگاشت و آدرس‌دهی مجدد (Remapping) تقریباً تمامی کلیدها به ماشین‌های جدید می‌شود که معادل از دست رفتن موقت کل ظرفیت کش و مواجهه دیتابیس با هجوم ناگهانی ترافیک است.

برای مهار این چالش، از Consistent Hashing استفاده می‌شود. این توابع هش تضمین می‌کنند که در فرآیند تغییر سایز کلاستر، جابه‌جایی کلیدها به شدت محدود بوده و تنها به نسبت معکوس تعداد گره‌ها (در سناریوی مذکور، کمتر از ۱۰ درصد کلیدها) رخ دهد؛ این ثبات محاسباتی، پایداری زیرساخت را در زمان مقیاس‌دهی تضمین می‌کند.


سرویس‌های شاردشده و تکثیرشده با حالت (Sharded, Replicated Serving)

الگوی شاردینگ صرفاً به بهینه‌سازی حافظه کش محدود نمی‌شود؛ بلکه در لایه‌های پردازشی دارای حالت نیز نقشی حیاتی دارد. برای نمونه، در بازی‌های آنلاین چندنفره بزرگ (MMORPG) که جهان بازی بیش از حد وسیع است و در حافظه یک ماشین واحد نمی‌گنجد، کل جهان بازی بر اساس فاکتور موقعیت مکانی بازیکنان شارد می‌شود؛ بازیکنانی که در نقاط دوری از یکدیگر قرار دارند به ماشین‌های متمایزی هدایت می‌شوند تا حافظه پردازشی گره‌ها ایزوله باقی بماند.


سیستم‌های شاردینگ داغ (Hot Sharding Systems)

در حالت ایده‌آل بار ترافیکی روی شاردها کاملاً متوازن است، اما در سناریوهای واقعی به دلیل رفتارهای ارگانیک ترافیک، پدیده‌ای به نام Hot Shard رخ می‌دهد؛ برای مثال، اگر عکسی در یک شبکه اجتماعی وایرال شود، شارد نگهدارنده آن تصویر با هجوم غیرعادی ترافیک مواجه می‌گردد.

به کمک معماری Hot Sharding، سیستم مانیتورینگ به صورت پویا و خودکار، شارد داغ (مثلاً شارد A) را روی ماشین دومی تکثیر (Replicate) کرده و ترافیک را بین آن‌ها لود بالانس می‌کند. هم‌زمان برای بهینه‌سازی هزینه‌ها، شاردهای کم‌ترافیک دیگر (مانند شارد B و C) را روی یک گره سخت‌افزاری واحد با یکدیگر ادغام می‌کند تا ظرفیت محاسباتی کلاستر همواره در بهینه‌ترین حالت ممکن باقی بماند.


فصل هفتم: الگوی پراکنده‌سازی/تجمیع (The Scatter/Gather Pattern)

تعریف الگو و انگیزه‌های معماری (Pattern Definition & Architectural Motivation)

تا این بخش از کتاب، الگوهایی را بررسی کردیم که یا برای افزایش ظرفیت پردازش درخواست‌ها در ثانیه طراحی شده بودند (مانند الگوی Replicated Stateless Serving) یا برای افزایش حجم داده‌های قابل ذخیره‌سازی پیکربندی شده بودند (مانند الگوی Sharded Services). اما الگوی Scatter/Gather با هدفی متفاوت طراحی شده است: مقیاس‌پذیری در ابعاد زمان (Scalability in terms of Time). این الگو با موازی‌سازی فرآیندهای محاسباتی سنگین، زمان پاسخ‌دهی (Latency) به درخواست‌های کاربر را به طور چشمگیری کاهش می‌دهد؛ به گونه‌ای که پردازشِ فوق‌العاده سریع ترافیک، فراتر از توان عملیاتیِ اجرای متوالی (Sequential Execution) خواهد بود.

ساختار کلی الگوی Scatter/Gather مبتنی بر یک ساختار درختی (Tree Pattern) است که از دو جزء اصلی تشکیل می‌شود:

  • گره ریشه (Root Node): نقش هماهنگ‌کننده و توزیع‌کننده درخواست‌ها را ایفا می‌کند. این گره فرسته‌های ورودی را به کارهای موازی مستقل تبدیل کرده و آن‌ها را میان گره‌های برگ توزیع می‌کند . در نهایت نیز نتایج جزئی دریافت شده را با یکدیگر ترکیب (Merge/Collate) کرده و پاسخ نهایی را به کاربر بازمی‌گرداند.
  • گره برگ (Leaf Nodes): پردازنده‌های نهایی هستند که هرکدام بخش کوچکی از کار پردازش را بر عهده دارند.

از دیدگاه متدولوژی سیستم، این الگو به جای شاردینگ داده، به شاردینگ محاسبات (Sharding of Computation) می‌پردازد.


۱. الگوی Scatter/Gather با توزیع ریشه (Scatter/Gather with Root Distribution)

ساده‌ترین شکل این الگو زمانی مطرح می‌شود که تمامی گره‌های برگ همگون (Homogeneous) هستند و وظیفه موازی‌سازی یک کار شدیداً موازی‌پذیر (Embarrassingly Parallel) را بر عهده دارند. در این مدل، کلان‌داده به طور مساوی توزیع شده یا پردازش کار برای حل مسئله به قطعات کاملاً مستقل تقسیم می‌شود.

محدودیت موازی‌سازی تک‌ماشینی در مقابل توزیع چندگره‌ای

فرض کنید پردازش یک درخواست سنگین روی یک هسته پردازنده (Single Core) دقیقاً یک دقیقه زمان می‌برد. در صورت استفاده از یک سرور قدرتمند چندرشته‌ای (Multi-threaded Application) با ۳۰ هسته پردازشی، می‌توان این زمان را روی همان ماشین به ۲ ثانیه کاهش داد. اما دستیابی به حداکثر موازی‌سازی روی یک سخت‌افزار واحد (Single Machine) به دلیل بروز گلوگاه‌های جدی در پهنای باند حافظه (Memory Bottleneck)، دیسک (Disk I/O) و رابط شبکه (Network Bandwidth) عملاً ناممکن است.

الگوی Scatter/Gather با توزیع وظایف روی فرآیندهای مستقل مستقر در چندین ماشین مجزا، این محدودیت را مهار می‌کند:

  • حذف گلوگاه سخت‌افزاری: از آنجا که پهنای باند حافظه، دیسک و شبکه در میان ده‌ها ماشین توزیع می‌شود، پردازنده (CPU) تنها گلوگاه واقعی سیستم باقی می‌ماند.
  • مدیریت پویای ترافیک: ریشه درخت می‌تواند با نظارت بر وضعیت پاسخ‌دهی برگ‌ها، بار ترافیکی را به صورت پویا مدیریت کند. اگر یک گره برگ به دلیل همسایه پر سر و صدا (Noisy Neighbor) دچار افت کارایی شود، ریشه ترافیک را به سایر گره‌های آماده هدایت می‌کند تا از تاخیر کل سیستم جلوگیری شود .

سیستم جست‌وجویی را تصور کنید که وظیفه دارد اسناد حاوی کلمات “cat” و “dog” را پیدا کند. رویکرد متوالی (خواندن تک‌تک اسناد از ابتدا تا انتها) به شدت کند است. برای بهینه‌سازی عملکرد، یک اندیس معکوس (Inverted Index) در قالب یک جدول هش (Hashtable) ایجاد می‌شود که کلمات کلیدی را به لیست شناسه اسناد حاوی آن‌ها نگاشت می‌کند.

هنگامی که درخواست جست‌وجوی ترکیبی “cat” و “dog” به گره ریشه می‌رسد، فرآیند به شکل زیر مدیریت می‌شود:

  • ریشه درخواست را تحلیل کرده و آن را به دو گره برگ مجزا ارسال می‌کند؛ یکی برای کلمه “cat” و دیگری برای کلمه “dog”.
  • گره اول لیست اسناد مربوط به “cat” (مثلاً {doc1, doc2, doc4}) و گره دوم لیست مربوط به “dog” (مثلاً {doc1, doc3, doc4}) را بازمی‌گردانند.
  • گره ریشه نتایج برگ‌ها را دریافت کرده، عملیات اشتراک مجموعه (Set Intersection) را روی آن‌ها اجرا می‌کند و پاسخ نهایی یعنی {doc1, doc4} را به کاربر بازمی‌گرداند.

۲. الگوی Scatter/Gather با شاردینگ برگ (Scatter/Gather with Leaf Sharding)

اگرچه رویکرد قبلی زمان پاسخ‌دهی را به شدت کاهش می‌دهد، اما سقف مقیاس‌پذیری آن به حجم داده‌ای محدود است که در دیسک یا حافظه یک ماشین واحد می‌گنجد. وقتی حجم داده‌ها فراتر از توان سخت‌افزاری یک سرور باشد، باید اصول شاردینگ داده (Data Sharding) را با محاسبات Scatter/Gather تلفیق کرد.

تفاوت کلیدی با شاردینگ معمولی (Standard Sharding)

در شاردینگ معمولی، بخشی از درخواست کاربر (کلید شارد) مشخص می‌کند که درخواست باید به کدام گره خاص ارسال شود؛ آن کانتینر کل محاسبات را انجام داده و پاسخ را برمی‌گرداند. اما در الگوی Scatter/Gather Sharding، درخواست کاربر به تمام گره‌های برگ (شاردها) ارسال می‌شود. هر برگ درخواست را روی قطعه داده محلی مستقر در شارد خود پردازش کرده، پاسخ جزئی را به ریشه برمی‌گرداند و در نهایت ریشه تمامی این پاسخ‌ها را برای ساخت پاسخ جامع ترکیب می‌کند.

به عنوان مثال، در یک سیستم ثبت اختراعات در سطح جهان، حجم داده به قدری عظیم است که روی یک ماشین جا نمی‌شود. بنابراین، اسناد بر اساس فرمول باقی‌مانده تقسیم شناسه سند بر تعداد شاردها (مثلاً Patent ID % Shards) در گره‌های برگ متعددی توزیع می‌شوند . با ارسال کوئری جست‌وجوی واژه “rockets” توسط کاربر، ریشه این درخواست را به تک‌تک شاردها ارسال می‌کند؛ هر شارد داده‌های محلی خود را کاوش کرده و موارد انطباق را به ریشه گزارش می‌دهد تا ریشه پاسخ نهایی را تلفیق کند.


در سیستم‌های شاردشده، توزیع داده‌ها بر اساس سند (Document-Sharding) انجام می‌شود؛ یعنی هر گره برگ میزبان مجموعه متفاوتی از اسناد است.

فرآیند اجرای کوئری اشتراکی “cat” و “dog” در این سناریو به شرح زیر است:

  • ریشه درخواست را به تک‌تک گره‌های برگ ارسال می‌کند؛ زیرا هر برگ اسناد متفاوتی را نگهداری می‌کند و ممکن است در میان اسناد هر شارد، اسناد منطبق بر هر دو کلمه وجود داشته باشد.
  • هر گره برگ به طور مستقل فرآیند جست‌وجو و اشتراک نتایج را برای اسناد محلی خود انجام می‌دهد.
  • برگ اول (شامل اسناد ۱ تا ۱۰) نتایج {doc1, doc5}، برگ دوم (شامل اسناد ۱۱ تا ۲۰) نتیجه {doc15} و برگ سوم (شامل اسناد ۲۱ تا ۳۰) نتایج {doc22, doc28} را به ریشه بازمی‌گردانند.
  • گره ریشه وظیفه دارد عملیات اجتماع مجموعه (Set Union) را روی پاسخ‌های دریافتی اعمال کرده و لیست یکپارچه {doc1, doc5, doc15, doc22, doc28} را بسازد .

۳. چالش انتخاب تعداد بهینه گره‌های برگ (Choosing the Right Number of Leaves)

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

۱. سربار ثابت پردازش درخواست (Fixed Request Overhead)

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

۲. مسئله گره کند (The Straggler Problem)

در سیستم‌های Scatter/Gather، سرعت پاسخ‌دهی کل سیستم توسط کندترین گره برگ تعیین می‌شود؛ زیرا ریشه باید منتظر دریافت آخرین پاسخ بماند تا بتواند عملیات تجمیع را کامل کند.

برای درک ریاضی این چالش، فرض کنید احتمال اینکه یک گره در صدک ۹۹ام (99th percentile) پاسخی با تاخیر ۲ ثانیه بدهد، ۱ درصد باشد:

  • اگر درخواست را به ۵ گره برگ ارسال کنیم، احتمال اینکه حداقل یکی از آن‌ها با تاخیر ۲ ثانیه‌ای مواجه شود حدود ۵ درصد است: \[1 - (0.99)^5 \approx 0.05\] در این حالت، تاخیر صدک ۹۹ام تک‌تک گره‌ها، تبدیل به تاخیر صدک ۹۵ام کل سیستم می‌شود.
  • حال اگر کارهای موازی را به ۱۰۰ گره برگ تقسیم کنیم، این احتمال به شدت افزایش می‌یابد: \[1 - (0.99)^{100} \approx 0.63\] یعنی در ۶۳ درصد مواقع (بیش از نیمی از درخواست‌ها)، کل سیستم با تاخیر ۲ ثانیه پاسخ خواهد داد و کارایی عملاً سقوط می‌کند.

این تحلیل اثبات می‌کند که در سیستم‌های Scatter/Gather، مهار تاخیرهای صدک ۹۹ام (Tail Latency) و تثبیت پایداری تک‌تک گره‌ها بسیار حیاتی‌تر از سیستم‌های معمولی است. همین قانون عینا برای دسترسی‌پذیری (Availability) نیز صادق است؛ اگر ضریب خطای هر برگ ۱ درصد باشد، در یک ساختار ۱۰۰ برگی، خطای کل سیستم تقریباً تضمین‌شده خواهد بود.


۴. مقیاس‌دهی Scatter/Gather برای پایداری و مقیاس (Scaling for Reliability and Scale)

استفاده از یک نسخه منفرد (Single Replica) برای هرکدام از شاردها در لایه برگ خطرات معماری جدی دارد؛ خرابی تنها یک برگ منجر به از دست رفتن موقت کل محاسبات کلاستر شده و امکان اجرای فرآیندهای ارتقای بدون قطعی (Rolling Upgrades) را سلب می‌کند.

برای حل این چالش، باید الگوی Sharded, Replicated Scatter/Gather را پیاده‌سازی کرد. در این معماری، هر شارد در لایه برگ خود به عنوان یک سرویس تکثیرشده (Replicated Service) با چندین نسخه همگون پیاده‌سازی می‌شود که در پشت یک لود بالانسر محلی قرار دارند.

زمانی که ریشه درخواستی را صادر می‌کند، بار محاسباتی مربوط به هر شارد به صورت متوازن (Load-balanced) میان کانتینرهای سالم آن شارد توزیع می‌شود. این پایداری چندلایه تضمین می‌کند که خرابی کانتینرها هرگز برای کاربر نهایی ملموس نبوده و توسعه‌دهندگان می‌توانند فرآیند بیلد و دپلویمنت نسخه‌های جدید را به صورت تدریجی (مرحله‌به‌مرحله) روی تک‌تک شاردها بدون کوچک‌ترین افت کارایی اعمال کنند.


بخش سوم: توابع و پردازش مبتنی بر رویداد (Functions and Event-Driven Processing - FaaS)

مقدمه و تفکیک مفهومی: تفاوت میان FaaS و رایانش بدون سرور (Serverless Computing)

تا این بخش، تمرکز ما بر روی طراحی سیستم‌هایی با محاسبات طولانی‌مدت (Long-running Computation) بود؛ سیستم‌هایی که در آن‌ها سرورهای پاسخ‌دهنده به درخواست‌های کاربران همواره در حال اجرا (Always up and running) هستند. این مدل برای برنامه‌هایی تحت بار مداوم و سنگین، یا سیستم‌هایی که نیازمند پردازش‌های پس‌زمینه و نگهداری حجم عظیمی از داده‌ها در حافظه (In-Memory) هستند، رویکرد درستی محسوب می‌شود. با این حال، دسته دیگری از برنامه‌ها وجود دارند که تنها برای پاسخ به یک درخواست منفرد یا یک رویداد (Event) خاص نیاز به حیات موقت دارند. این مدلِ مبتنی بر درخواست یا رویداد، با توسعه محصولات Function-as-a-Service (FaaS) توسط ارائه‌دهندگان ابرهای عمومی بزرگ رونق بسیاری یافته است. امروزه پیاده‌سازی‌های FaaS بر روی ارکستراتورهای کلاستر در محیط‌های خصوصی یا فیزیکی نیز در دسترس هستند.

در ادبیات فنی، اغلب اصطلاحات FaaS و Serverless Computing به جای یکدیگر به کار می‌روند؛ اما تفکیک دقیق این دو مفهوم اهمیت معماری زیادی دارد:

  • رایانش بدون سرور (Serverless): مفهومی بسیار گسترده‌تر است که می‌تواند به انواع خدمات محاسباتی چندمستأجری (Multi-tenant CaaS مانند Container-as-a-Service) اطلاق شود که در آن‌ها مدیریت مستقیم سرورها از دید کاربر پنهان است، هرچند ممکن است رویدادمحور نباشند.
  • FaaS متن‌باز روی کلاستر اختصاصی: یک محیط کاملاً رویدادمحور (Event-driven) است، اما از آنجا که سخت‌افزارها و کلاسترها توسط خود شما اداره و مدیریت می‌شوند، عملاً Serverless نیست.

درک این تفاوت به معمار نرم‌افزار کمک می‌کند تا تصمیم بگیرد که آیا طراحی رویدادمحور، مدل بدون سرور یا ترکیبی از هر دو برای نیازمندی‌های سیستم او مناسب است یا خیر.

ارزیابی زمان مناسب برای به‌کارگیری FaaS (Determining When FaaS Makes Sense)

FaaS ابزاری قدرتمند است، اما نباید به آن به عنوان یک چکش همه‌کاره نگریست. تلاش برای گنجاندن تمامی برنامه‌ها در این قالب، منجر به ایجاد معماری‌های به شدت پیچیده، شکننده (Brittle) و ناکارآمد خواهد شد. برای اتخاذ یک تصمیم مهندسی درست، بررسی مزایا، چالش‌ها و سناریوهای بهینه این تکنولوژی ضروری است.

۱. مزایای FaaS برای توسعه‌دهندگان (The Benefits of FaaS)

اصلی‌ترین مزایای FaaS در تسهیل فرآیند توسعه خلاصه می‌شود:

  • کاهش فاصله کد تا اجرا: به دلیل عدم نیاز به ایجاد (Build) و انتشار (Push) پکیج‌های سنگین یا ایمیج‌های کانتینری، توسعه‌دهنده می‌تواند سورس‌کد خام خود را مستقیماً از روی لپ‌تاپ یا مرورگر به ابر منتقل کرده و اجرا کند.
  • مدیریت و مقیاس‌پذیری خودکار: با افزایش ترافیک، ارکستراتور به طور خودکار نمونه‌های جدیدی از تابع را متولد می‌کند و در صورت بروز خرابی سخت‌افزاری یا خطای نرم‌افزاری، فرآیند را روی ماشین دیگری احیا می‌نماید.
  • افزایش ماژولاریتی و تفکیک وظایف: توابع به دلیل ماهیت بدون حالت (Stateless) خود، نسبت به باینری‌های یکپارچه (Monoliths) مؤلفه‌های بسیار ماژولارتر و مستقل‌تری (Decoupled) هستند.
۲. چالش‌های عملیاتی و ساختاری (The Challenges of FaaS)

همین گسستگی و استقلال کامل (Forced Decoupling) که به عنوان مزیت مطرح شد، بزرگ‌ترین چالش‌های عملیاتی را نیز به همراه دارد:

  • ارتباطات صرفاً شبکه‌ای: توابع هیچ‌گونه حافظه محلی مشترکی ندارند و تمام تبادلات داده‌ای و حفظ لایه حالت (State) باید از طریق فراخوانی‌های مکرر شبکه به یک سیستم ذخیره‌سازی بیرونی (Storage Service) انجام شود.
  • پیچیدگی شدید در مانیتورینگ و عیب‌یابی: به دست آوردن یک دید جامع از رفتار کل سیستم، ردیابی جریان تعاملات توابع با یکدیگر و فهم دقیق علل خرابی‌ها بسیار دشوار است؛ چرا که نمی‌توان یک سیستم چندگره‌ایِ توزیع‌شده با توابع مجزا را به راحتی در یک دیباگر محلی لود کرد.
  • ریسک بروز حلقه‌های بی‌نهایت توزیع‌شده (Infinite Loops): سناریویی را تصور کنید که در آن functionA() تابع functionB() را فرامی‌خواند، functionB() به functionC() متصل می‌شود و functionC() مجدداً functionA() را صدا می‌زند. از آنجا که توابع کاملاً از یکدیگر ایزوله هستند و هیچ بازنمایی رسمی (Representation) از وابستگی‌های متقابل آن‌ها وجود ندارد، این چرخه متقابل تا زمان اتمام محدودیت زمانی درخواست (Timeout) یا اتمام بودجه مالی پردازش‌های ابری شما در یک حلقه بی‌نهایت و بسیار پرهزینه به گردش خود ادامه می‌دهد. برای مهار این ریسک‌ها، پیاده‌سازی مانیتورینگ‌ها و هشدارهای (Alerting) سخت‌گیرانه برای رصد مداوم رفتار زیرسیستم‌ها پیش از ورود به فاز تولید الزامی است.
۳. نیازمندی به پردازش‌های پس‌زمینه (The Need for Background Processing)

FaaS ذاتاً یک مدل محاسباتی مبتنی بر رویدادهای گسسته و زمان‌بند (Time-bounded) است. به دلیل اعمال محدودیت‌های سخت‌گیرانه بر روی مدت‌زمان اجرای هر نمونه، FaaS گزینه‌ نامناسبی برای کارهای پس‌زمینه طولانی‌مدت و با اولویت پایین (مانند انکودینگ ویدیوهای سنگین، فشرده‌سازی فایل‌های لاگ بزرگ یا محاسبات دوره‌ای پرحجم) است. اگرچه می‌توان به طور مصنوعی و با تعریف محرک‌های زمانی (Scheduled Triggers)، رویدادهایی را برای بیدار کردن توابع ایجاد کرد، اما ساختار FaaS همچنان زیرساخت مناسبی برای کارهای پس‌زمینه مداوم ارائه نمی‌دهد. برای این سناریوها، اجرای کد در محیط‌های با طول عمر بالا (Long-running Environments) و مبتنی بر مدل پرداخت به ازای مصرف سخت‌افزار (Pay-per-consumption) به جای پرداخت به ازای درخواست (Pay-per-request)، گزینه پایدارتر و ارزان‌تری است.

۴. چالش داده‌های مقیم در حافظه و تأخیر سرد (The Need to Hold Data in Memory)

بسیاری از سرویس‌های پیشرفته (مانند موتورهای جست‌وجو یا پردازش اسناد) برای پاسخگویی سریع به کوئری‌ها نیازمند بارگذاری حجم عظیمی از داده‌ها و ایندکس‌ها در حافظه RAM هستند. فرآیند خواندن و لود این اطلاعات حتی روی حافظه‌های ذخیره‌سازی بسیار سریع نیز زمان‌بر است. از آنجا که نمونه‌های FaaS ممکن است در پاسخ به درخواست یک کاربر پس از یک دوره خاموشی به طور پویا اجرا شوند (پدیده Cold Start)، هزینه زمانی لود اطلاعات اولیه مستقیماً به تأخیر درک‌شده توسط کاربر نهایی (User-perceived Latency) اضافه شده و کارایی سیستم را به شدت کاهش می‌دهد. اگرچه با تداوم درخواست‌ها، هزینه این راه‌اندازی اولیه در میان درخواست‌های بعدی سرشکن می‌شود، اما اگر ترافیک به حدی بالا باشد که توابع را همواره فعال نگه دارد، استفاده از مدل اقتصادی FaaS دیگر توجیه نخواهد داشت.

۵. تحلیل اقتصادی و هزینه‌ای پردازش‌های مداوم (The Costs of Sustained Request-Based Processing)

مدل قیمت‌گذاری FaaS در ابرهای عمومی بر پایه پرداخت به ازای درخواست (Per-request Pricing) استوار است. این مدل برای برنامه‌های کم‌ترافیک یا سرویس‌هایی با الگوهای ترافیکی بسیار پراکنده فوق‌العاده است؛ زیرا در زمان‌های بیکاری (Idle)، شما هیچ هزینه‌ای برای ترافیک پرداخت نمی‌کنید. در مقابل، در سرورهای سنتی یا ماشین‌های مجازی همواره روشن، شما برای چرخه‌های پردازنده‌ای که بدون مصرف به هدر می‌روند هزینه پرداخت می‌کنید.

اما با رشد ترافیک سیستم و رسیدن به نرخ درخواست‌های پایدار و مداوم (به گونه‌ای که هسته‌های پردازنده به صورت ۱۰۰ درصد و بدون وقفه در حال کار باشند)، برابری اقتصادی به شدت به ضرر FaaS تغییر می‌کند:

  • هزینه استفاده از ماشین‌های مجازی یا هسته‌های پردازشی با افزایش حجم و استفاده از تخفیف‌های تعهد مصرف (Reserved/Sustained Use Discounts) به طور چشمگیری کاهش می‌یابد.
  • در مقابل، هزینه پردازش درخواست‌ها در مدل FaaS به صورت کاملاً خطی با افزایش تعداد درخواست‌ها بالا می‌رود که منجر به هزینه‌های مالی بسیار گزاف خواهد شد.

یک استراتژی بهینه برای حل این تناقض اقتصادی، استقرار یک پلتفرم FaaS متن‌باز (مانند Kubeless) بر روی یک ارکستراتور کانتینر مانند Kubernetes است. این ساختار ترکیبی به شما اجازه می‌دهد تا از یک سو از تمام مزایای برنامه‌نویسی چابک و توسعه سریع توابع (FaaS Developer Experience) بهره‌مند شوید و از سوی دیگر، از مزایای اقتصادی و تعرفه‌های به مراتب ارزان‌تر ماشین‌های مجازی زیرساخت کلاستر خود استفاده کنید.


الگوهای حوزه Function-as-a-Service (FaaS)

۱. الگوی دکوراتور: تبدیل درخواست یا پاسخ (The Decorator Pattern: Request or Response Transformation)

سرویس‌های FaaS بستری ایده‌آل برای استقرار توابعی ساده هستند که وظیفه دارند یک ورودی را دریافت کرده، تغییری روی آن اعمال کنند (Transform) و سپس خروجی را به سرویس دیگری تحویل دهند . این معماری که تحت عنوان Decorator Pattern شناخته می‌شود، نزدیک‌ترین معادل معماری برای مفهوم دکوراتورها در زبان برنامه‌نویسی پایتون است. از آنجا که فرآیند تبدیل داده‌ها کاملاً بدون حالت (Stateless) است و این منطق‌ها معمولاً در طول زمان و پس از تکامل یافتن سرویس‌های اصلی به سیستم اضافه می‌شوند، پیاده‌سازی آن‌ها در قالب توابع موقت و سبک FaaS بسیار کارآمد و پایدار است.

یک نمونه کاربردی بسیار مهم برای الگوی دکوراتور، اعمال مقادیر پیش‌فرض (Defaulting) به فیلدهای خالی در درخواست‌های یک HTTP RESTful API است. برای مثال، اگر فیلدی در فرمت JSON ارسال نشود، مقدار آن به صورت پیش‌فرض null در نظر گرفته می‌شود. برای حل این مسئله دو رویکرد وجود دارد:

  • قرار دادن منطق تعریف مقادیر پیش‌فرض در وب‌سرور لبه یا درون خود کدهای برنامه (مانند عبارات شرطی if (field == null)). این روش مطلوب نیست؛ زیرا فرآیند مدیریت مقادیر پیش‌فرض را با منطق اصلی پردازش درخواست در هم می‌تند.
  • استفاده از الگوی دکوراتور FaaS به عنوان یک لایه واسط میان کاربر و سرور اصلی برای اصلاح و غنی‌سازی ساختار سند JSON پیش از رسیدن به Backend.
چرا آداپتور نه؟ (FaaS Decorator vs. Single-Node Adapter)

شاید این سؤال مطرح شود که چرا این منطق را به عنوان یک کانتینر آداپتور (Adapter) در پادِ وب‌سرور قرار ندهیم؟ در صورت استفاده از آداپتور، شما نرخ مقیاس‌پذیری لایه اعمال مقادیر پیش‌فرض را به طور مستقیم به نرخ مقیاس‌پذیری سرویس اصلی متصل (Couple) می‌کنید. از آنجا که اعمال پیش‌فرض‌ها یک کار محاسباتی بسیار سبک است، سیستم شما احتمالاً به نمونه‌های فعال به مراتب کمتری از دکوراتور در مقایسه با سرویس اصلی پردازشی نیاز خواهد داشت. تفکیک این لایه در قالب FaaS، مدیریت بهینه منابع را ممکن می‌سازد.


پیاده‌سازی عملی: افزودن مقادیر پیش‌فرض به درخواست‌ها با Kubeless

در این بخش، از فریم‌ورک FaaS بومی کوبرنتیس یعنی Kubeless استفاده می‌شود. ابتدا تابع دکوراتور را به زبان پایتون توسعه می‌دهیم تا در صورت خالی بودن فیلدهای name و color در هدر JSON، مقادیر پیش‌فرضی به آن‌ها اختصاص داده و سپس API اصلی را فراخوانی کند:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# defaults.py
import random_name
import call_my_api

# هندلر اصلی برای افزودن مقادیر پیش‌فرض به ساختار درخواست
def handler(context):
    # دریافت آبجکت ورودی JSON
    obj = context.json
    
    # در صورت عدم وجود فیلد name، یک نام تصادفی تولید و جایگزین می‌شود
    if obj.get("name", None) is None:
        obj["name"] = random_name()
        
    # در صورت عدم وجود فیلد color، مقدار پیش‌فرض blue قرار می‌گیرد
    if obj.get("color", None) is None:
        obj["color"] = "blue"
        
    # فراخوانی API اصلی برنامه با داده‌های اصلاح‌شده و بازگرداندن پاسخ
    return call_my_api(obj)

(توجه: برای اجرای واقعی، متد call_my_api باید به آدرس وب‌سرویس اصلی شما در کلاستر اشاره کند).

پس از نوشتن فایل فوق و ذخیره آن با نام defaults.py، تابع را با دستور زیر در پلتفرم Kubeless مستقر می‌کنیم:

1
2
3
4
$ kubeless function deploy add-defaults \
    --runtime python27 \
    --handler defaults.handler \
    --from-file defaults.py

(با این استقرار، Kubeless یک شیء بومی در کوبرنتیس ایجاد کرده و با افزایش ترافیک ورودی، تعداد نمونه‌های این تابع را به طور خودکار مقیاس‌دهی می‌کند) .


۲. الگوی مدیریت رویدادها (Handling Events)

در دنیای سیستم‌های توزیع‌شده، تفاوت ساختاری عمیقی میان درخواست‌ها (Requests) و رویدادها (Events) وجود دارد:

  • Requests: بخشی از یک سشن یا زنجیره بزرگی از تعاملات زنده کاربر با اپلیکیشن هستند که معمولاً به صورت هم‌زمان (Synchronous) پردازش می‌شوند.
  • Events: تک‌نمونه، مستقل و ذاتاً ناهم‌زمان (Asynchronous) هستند. رویدادها از تعامل اصلی کاربر شلیک شده و در پس‌زمینه پردازش می‌شوند. مواردی مانند ثبت‌نام کاربر جدید (که ایمیل خوش‌آمدگویی را تحریک می‌کند)، آپلود فایل در یک فولدر اشتراکی (که به تمام اعضا نوتیفیکیشن می‌فرستد) یا رویداد خاموش شدن یک ماشین سخت‌افزاری، نمونه‌های بارزی از رویدادها هستند.

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


پیاده‌سازی عملی: احراز هویت دو مرحله‌ای (Two-Factor Authentication)

پیاده‌سازی فرآیند احراز هویت دو مرحله‌ای (2FA) با چالش‌های لایه‌ای همراه است؛ زیرا کارهای نسبتاً کندی مانند تولید یک کد تصادفی، ثبت آن در دیتابیس سرویس لاگین و ارسال آن از طریق پیامک (SMS) باید انجام شوند. اگر این کارهای ناهم‌زمان و با تأخیر شبکه (Network Latency) بالا مستقیماً درون کدهای وب‌سرور اصلی لاگین نوشته شوند، پردازش‌های وب‌سرور معطل مانده و تجربه کاربری (UX) به شدت آسیب می‌بیند.

راهکار بهینه، تفکیک این فرآیند با یک هماهنگ‌کننده FaaS ناهم‌زمان است:

  1. کاربر پسورد خود را وارد می‌کند. وب‌سرور لاگین بلافاصله صحت پسورد را تایید کرده و یک درخواست وب‌هوک ناهم‌زمان (Asynchronous Webhook Request) به سمت سرویس FaaS شلیک می‌کند.
  2. وب‌سرور لاگین بدون معطلی صفحه ورود کد تایید را به کاربر نشان می‌دهد.
  3. در همین حین، تابع FaaS در پس‌زمینه کد تصادفی را تولید کرده، آن را در سیستم احراز هویت ثبت می‌کند و پیامک حاوی کد را به گوشی کاربر ارسال می‌نماید.
  4. با دریافت کد توسط کاربر و وارد کردن آن در صفحه وب، سیستم لاگین کد وارد شده را با کد ثبت‌شده توسط FaaS مطابقت داده و فرآیند ورود را با کمترین تأخیر تکمیل می‌کند .

۳. خط لوله‌های مبتنی بر رویداد (Event-Based Pipelines)

برخی فرآیندهای کسب‌وکار ذاتاً شبیه به یک فلوچارت یا یک نمودار جهت‌دار بدون دور (DAG - Directed Acyclic Graph) هستند که در آن‌ها هر گره یک تابع یا وب‌هوک مجزاست و یال‌های اتصال‌دهنده، فراخوانی‌های تحت شبکه (مانند HTTP) هستند. تفاوت اصلی این پپلاین‌ها با معماری‌های میکروسرویس سنتی در این است که خط لوله‌های رویدادمحور بر پایه توابع موقت و رویدادهای کاملاً ناهم‌زمان شکل می‌گیرند، در حالی که میکروسرویس‌ها مجموعه‌ای از سرورهای همواره روشن و در حال کار هستند.


خط لوله‌های مبتنی بر رویداد (Event-Based Pipelines)

برخی از برنامه‌ها و فرآیندهای محاسباتی را می‌توان به شکل یک خط لوله (Pipeline) متشکل از رویدادهای مستقل و مجزا مدل‌سازی کرد. این پپلاین‌ها شباهت زیادی به فلوچارت‌های سنتی دارند و به صورت یک نمودار جهت‌دار بدون دور (DAG - Directed Acyclic Graph) نمایش داده می‌شوند که در آن هر گره (Node) یک تابع FaaS یا یک وب‌هوک (Webhook) متمایز است و یال‌های اتصال‌دهنده، فراخوانی‌های شبکه (مانند HTTP) هستند. در این مدل، عموماً هیچ حالت اشتراکی (Shared State) میان اجزای مختلف پپلاین وجود ندارد، اما می‌توان یک بافت یا شناسه مرجع (Context) را درون کل خط لوله عبور داد تا در صورت نیاز، اطلاعات لازم از یک فضای ذخیره‌سازی مشترک بازیابی شود.

تفاوت‌های بنیادین این معماری با ساختار میکروسرویس‌های سنتی عبارتند از:

  • ماهیت موقت و رویدادمحور: خط لوله مبتنی بر رویداد صرفاً در پاسخ به یک محرک گسسته فعال می‌شود و اجزای آن پس از پایان کار متوقف می‌شوند، در حالی که میکروسرویس‌ها سرورهایی همواره روشن (Long-running) هستند.
  • ناهمگونی و ناهم‌زمانی بالا: این پپلاین‌ها به شدت ناهم‌زمان هستند و به راحتی می‌توانند فرآیندهای بسیار متنوعی—حتی مداخله‌های انسانی نظیر تایید یک بلیت در سیستم Jira—را به عنوان گره‌ای از زنجیره پردازش خود بپذیرند؛ کاری که ادغام آن در یک معماری میکروسرویس سنتی کارآمد نخواهد بود.

پیاده‌سازی عملی: خط لوله ثبت‌نام کاربران جدید (New-User Signup Pipeline)

یک فرآیند ثبت‌نام کاربر جدید را در نظر بگیرید. در این فرآیند برخی اقدامات همیشه اجباری هستند (مانند ارسال ایمیل تایید یا ساخت پروفایل) و برخی دیگر اختیاری هستند (مانند ثبت‌نام در خبرنامه تبلیغاتی).

قرار دادن تمام این منطق‌ها در یک وب‌سرور یکپارچه (Monolithic)، چابکی تیم را از بین برده و مانع از اجرای سناریوهای تجربی و تست‌های موازی (A/B testing) روی بخش‌های مختلف سفر کاربری می‌شود. به کمک معماری FaaS، می‌توان وب‌سرور ثبت‌نام اصلی را کاملاً مستقل نگه داشت؛ به گونه‌ای که تنها وظیفه شلیک به لیست‌های مشخصی از وب‌هوک‌ها را بر عهده داشته باشد.

کد تابع هماهنگ‌کننده ثبت‌نام (User Signup Coordinator) در Kubeless:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import requests

def signup(context):
    user = context.json
    
    # لیست وب‌هوک‌های اجباری برای ثبت‌نام موفق
    required_actions = [
        "http://welcome-email-service.default.svc.cluster.local",
        "http://profile-setup-service.default.svc.cluster.local"
    ]
    
    # لیست وب‌هوک‌های اختیاری (مثلاً عضویت در خبرنامه)
    optional_actions = [
        "http://newsletter-subscription-service.default.svc.cluster.local"
    ]
    
    # اجرای فراخوانی‌های شبکه به صورت موازی یا متوالی برای اکشن‌های اجباری
    for action in required_actions:
        requests.post(action, json=user)
        
    # اجرای اکشن‌های اختیاری در صورت تایید و رضایت کاربر (Opt-in)
    if user.get("opt_in_newsletter", False):
        for action in optional_actions:
            requests.post(action, json=user)
            
    return {"status": "User signup workflow initiated successfully"}
پیاده‌سازی یکی از هندلرهای خط لوله (عضویت در خبرنامه) به صورت FaaS مجزا:
1
2
3
4
5
6
7
def handler(context):
    user = context.json
    email = user.get("email", None)
    if email:
        # منطق ذخیره ایمیل در دیتابیس سیستم بازاریابی
        return {"status": f"Email {email} subscribed to promo list"}
    return {"status": "No email provided", "error": True}

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


فصل نهم: انتخاب مالکیت و مستر (Ownership Election)

الگوهای پیشین بر روی توزیع درخواست‌ها جهت مقیاس‌دهی به ترافیک تمرکز داشتند، اما این فصل به موضوع مقیاس‌دهی تخصیص‌ها (Scaling Assignment) می‌پردازد. در بسیاری از سیستم‌های توزیع‌شده، مفهوم مالکیت (Ownership) مطرح است؛ حالتی که در آن یک فرآیند یا پردازنده مشخص، مسئولیت انحصاری اجرای یک وظیفه خاص را بر عهده دارد (مانند مالکان شاردها در الگوهای شاردینگ پویا).

در سطح یک برنامه تک‌گره‌ای، تضمین مالکیت ساده است؛ زیرا می‌توان از مکانیسم‌های قفل درون‌برنامه‌ای (In-process Locks) بهره برد. اما در محیط‌های توزیع‌شده چندگره‌ای، دستیابی به یک مالکیت انحصاری و پایدار نیازمند الگوهای پیچیده‌تر و هماهنگی‌های توزیع‌شده است.

تعیین ضرورت نیاز به انتخاب مستر (Determining If You Even Need Master Election)

ساده‌ترین و تمیزترین شکل مدیریت مالکیت، معماری تک‌نمونه‌ای یا تک‌موتوره (Singleton Pattern) است. از آنجا که در این لایه تنها یک کانتینر فعال وجود دارد، این نمونه به طور پیش‌فرض مالک کلید یا فرآیند است و چالش هماهنگی وجود ندارد.

اگر این تک‌نمونه را روی یک ارکستراتور مدرن مانند کوبرنتیس اجرا کنید، تضمین‌های زیر را خواهید داشت:

  • در صورت کرش کردن کانتینر، فرآیند بلافاصله ری‌استارت می‌شود.
  • در صورت بروز بن‌بست (Hang) در سطح اپلیکیشن، در صورت پیاده‌سازی درست پروب‌های سلامت، کانتینر مجدداً راه‌اندازی می‌شود.
  • در صورت خرابی ماشین فیزیکی، کانتینر به ماشین سالم دیگری مهاجرت داده می‌شود.
تحلیل ریاضی دسترس‌پذیری یک تک‌نمونه (SLA Calculation)

با وجود این تضمین‌ها، دسترس‌پذیری این تک‌نمونه به شرح زیر تحلیل می‌شود: ۱. خرابی‌های نرم‌افزاری: اگر کانتینر شما روزی یک بار کرش کند و فرآیند احیا و لود مجدد آن ۲ ثانیه طول بکشد، دسترس‌پذیری کلی شما بالای چهار نُه (99.99%) خواهد بود که عملاً عالی است. ۲. خرابی‌های سخت‌افزاری: اگر کل سروری که کانتینر روی آن است بسوزد، تشخیص این خرابی توسط ارکستراتور و مهاجرت پاد به یک ماشین جدید تقریباً ۵ دقیقه زمان می‌برد. اگر این فاجعه فیزیکی روزی یک بار رخ دهد، دسترس‌پذیری شما به حدود ۹۹.۶٪ (دو نُه) کاهش می‌یابد.

اما چالش اصلی در مدل Singleton، فرآیند ارتقای نرم‌افزار (Rolling Upgrades) است. از آنجا که سیستم شما یک Singleton است، شما مجاز نیستید هم‌زمان هر دو نسخه قدیمی و جدید را فعال نگه دارید (زیرا دو مالک هم‌زمان برای یک منبع بوجود می‌آید). بنابراین ناچارید ابتدا نسخه قدیمی را خاموش کرده، ایمیج جدید را دانلود و سپس آن را بیدار کنید؛ فرآیندی که می‌تواند چندین دقیقه طول بکشد.
اگر فرآیند استقرار شما روزانه انجام شود و هر بار ۲ دقیقه طول بکشد، دسترس‌پذیری سیستم هرگز فراتر از دو نُه (99.8%) نخواهد رفت و در صورت استقرار ساعتی، دسترس‌پذیری شما حتی به یک نُه (کمتر از ۹۰ درصد) سقوط خواهد کرد.

بنابراین، برای تسک‌های پس‌زمینه ناهم‌زمان که تاخیرهای چند دقیقه‌ای در آن‌ها قابل چشم‌پوشی است، مدل Singleton به دلیل سادگی فوق‌العاده‌اش بهترین گزینه معماری است. اما برای سرویس‌هایی که نیازمند دسترسی بالا (بالای 99.99%) هستند، اجرای چندین نسخه هم‌زمان که در میان آن‌ها همواره یک نسخه از طریق انتخاب مستر (Master Election) به عنوان مالک قطعی تعیین شود، گریزناپذیر خواهد بود.


مبانی انتخاب مستر (The Basics of Master Election)

در بسیاری از سناریوها، سرویسی (مانند Foo) با چندین نسخه تکثیرشده (Foo-1، Foo-2 و Foo-3) وجود دارد که باید مسئولیت پردازش یا مدیریت یک موجودیت یا منبع خاص (مانند Bar) را به یکی از این نسخه‌ها واگذار کند. این کانتینر منتخب، مستر (Master) یا مالک منبع نامیده می‌شود و فرآیند تعیین آن را انتخاب مستر (Master Election) می‌گویند.

برای پیاده‌سازی انتخاب مستر، دو رویکرد کلی وجود دارد: ۱. پیاده‌سازی الگوریتم‌های اجماع توزیع‌شده (مانند Paxos یا Raft): توسعه این الگوریتم‌ها از صفر به شدت پیچیده و مستعد خطا است و شبیه به بازنویسی سیستم‌های قفل سخت‌افزاری با دستورات اسمبلی است. ۲. استفاده از سیستم‌های ذخیره‌سازی کلید-مقدار توزیع‌شده (مانند etcd، ZooKeeper یا Consul): این سیستم‌ها الگوریتم‌های اجماع را درون خود پیاده‌سازی کرده‌اند و ابزارها و توابع ابتدایی (Primitives) لازم برای ساخت قفل‌ها و مکانیسم‌های انتخاب مستر را در اختیار ما قرار می‌دهند.

دو تابع ابتدایی و حیاتی که این سیستم‌ها ارائه می‌دهند عبارتند از:

  • عملیات مقایسه و جابه‌جایی (Compare-and-Swap - CAS): یک عملیات اتمی (Atomic) که مقدار جدیدی را تنها در صورتی روی یک کلید می‌نویسد که مقدار فعلی آن با مقدار مورد انتظار کلاینت مطابقت داشته باشد.
  • زمان طول عمر (Time-to-Live - TTL): قابلیتی که پس از انقضای یک بازه زمانی مشخص، کلید مربوطه را به صورت خودکار از سیستم حذف می‌کند.

پیاده‌سازی قفل توزیع‌شده (Implementing Locks)

ساده‌ترین شکل هماهنگی چندگره‌ای، قفل انحصار متقابل (Mutex) است که می‌توان آن را با تکیه بر عملیات اتمی CAS در حافظه توزیع‌شده پیاده‌سازی کرد.

مکانیسم ساده ثبت قفل (با فرض اینکه مقدار 0 یعنی آزاد و 1 یعنی قفل‌شده) به شکل زیر است:

1
2
3
4
5
6
7
8
9
10
func (Lock l) simpleLock() boolean {
  // تلاش برای نوشتن اتمی مقدار "1" به جای "0"
  locked, error = compareAndSwap(l.lockName, "1", "0")
  
  // اگر کلید قفل از قبل وجود نداشته باشد (اولین درخواست)، تلاش برای نوشتن "1" به جای تهی (nil)
  if error != nil {
    locked, _ = compareAndSwap(l.lockName, "1", nil)
  }
  return locked
}

در پیاده‌سازی‌های سنتی، برنامه تا زمان آزاد شدن قفل در یک حلقه تکرار (Polling) منتظر می‌ماند:

1
2
3
4
5
func (Lock l) lock() {
  while (!l.simpleLock()) {
    sleep(2)
  }
}

برای جلوگیری از اتلاف پردازنده در مدل بالا، اکثر ذخیره‌سازهای توزیع‌شده قابلیت پایش تغییرات کلید (Watch) را ارائه می‌دهند تا به محض آزاد شدن قفل، سیگنالی به برنامه ارسال شود:

1
2
3
4
5
func (Lock l) lock() {
  while (!l.simpleLock()) {
    waitForChanges(l.lockName)
  }
}

آزاد کردن قفل نیز با بازنویسی مقدار 0 به جای 1 انجام می‌شود:

1
2
3
func (Lock l) unlock() {
  compareAndSwap(l.lockName, "0", "1")
}

چالش سقوط گره و باگ TTL در قفل‌های توزیع‌شده (The TTL Bug)

اگر فرآیندی که قفل را در اختیار دارد، پیش از فراخوانی متد unlock دچار خرابی (Crash) یا سقوط سخت‌افزاری شود، قفل برای همیشه مسدود مانده و کل سیستم توزیع‌شده دچار بن‌بست دائم می‌شود. برای مهار این آسیب، ما قفل را همراه با پارامتر TTL ثبت می‌کنیم تا در صورت قطع علائم حیاتی گره، قفل به صورت خودکار آزاد شود.

اما افزودن TTL یک چالش فنی فوق‌العاده ظریف و شکننده به نام باگ انقضای قفل را ایجاد می‌کند:

  1. گره اول (Process-1) قفل توزیع‌شده را با زمان انقضای محدود (مثلاً t) تصاحب می‌کند.
  2. به دلیل بروز وقفه‌های طولانی در اجرای لایه اپلیکیشن (مانند فعال شدن Garbage Collector سنگین یا کندی پردازنده)، پردازش گره اول بیش از حد طولانی شده و از بازه زمانی t عبور می‌کند.
  3. زمان انقضای قفل (TTL) به پایان می‌رسد و قفل در سرور مرکزی پاک می‌شود.
  4. گره دوم (Process-2) با فرض آزاد بودن سیستم، قفل را تصاحب کرده و شروع به پردازش داده‌ها می‌کند.
  5. گره اول سرانجام پردازش کند خود را تمام کرده و متد unlock را فراخوانی می‌کند.
  6. از آنجا که گره اول مطلع نیست که قفل را به دلیل انقضای زمانی از دست داده، فرسته خروجش با موفقیت اجرا شده و قفلی را که هم‌اکنون در مالکیت گره دوم است، آزاد می‌سازد.
  7. گره سوم (Process-3) بلافاصله قفل آزادشده را تصاحب می‌کند.
  8. در این لحظه، هر دو گره دوم و سوم به طور هم‌زمان خود را مستر و مالک انحصاری می‌دانند و این تداخل منجر به فساد گسترده در لایه داده‌ها می‌شود.

راه‌حل: استفاده از نسخه‌گذاری منابع (Resource Versioning)

برای رفع کامل این باگ، سیستم‌های ذخیره‌سازی توزیع‌شده به همراه هر عملیات نوشتن، یک شناسه یا شماره نسخه عددی منحصربه‌فرد (Resource Version) به کلاینت بازمی‌گردانند. در این حالت، متد unlock کلاینت موظف است نه تنها مقدار کلید، بلکه شماره نسخه ثبت‌شده را نیز در بدنه شرطِ عملیات CAS اعمال کند. اگر در این فاصله قفل به دلیل TTL منقضی شده و توسط گره دیگری بازنویسی شده باشد، شماره نسخه تغییر کرده و تلاش گره اول برای آزادسازی قفل دیگران با خطا مواجه خواهد شد.


پیاده‌سازی اجاره‌های تمدیدپذیر (Implementing Ownership / Leases)

برای تسک‌های مستمر (مانند فرآیند زمان‌بندی یا Scheduler کلاستر)، یک گره باید تا زمان زنده بودن خود مالکیت قفل را حفظ کند. تنظیم یک زمان TTL بسیار طولانی (مثلاً یک هفته) راهکار درستی نیست؛ زیرا در صورت سقوط واقعی گره مستر، کلاستر باید یک هفته صبر کند تا قفل منقضی شده و مستر جدیدی انتخاب شود.

برای حل این چالش، از الگوی قفل تمدیدپذیر یا اجاره (Lease) استفاده می‌کنیم. در این الگو، مستر قفل را با یک TTL کوتاه (مثلاً ۱۰ ثانیه) تصاحب کرده و یک فرآیند یا ترد پس‌زمینه (Watchdog Thread) را مامور می‌سازد تا در بازه‌های زمانی منظم (بهترین حالت هر ttl/2 ثانیه برای پوشش تاخیرهای شبکه) قفل را با ارسال دستور تمدید به صورت مداوم تمدید (Renew) کند:

1
2
3
4
5
func (Lock l) renew() boolean {
  // تمدید اتمی اجاره با ارسال نسخه و زمان TTL جدید
  locked, _ = compareAndSwap(l.lockName, "1", "1", l.version, ttl)
  return locked
}

ترد پس‌زمینه به طور مداوم تمدید را انجام می‌دهد:

1
2
3
4
5
6
7
for {
  if !l.renew() {
    // در صورت شکست در تمدید قفل، بلافاصله باید پردازش‌های حساس متوقف شوند
    handleLockLost()
  }
  sleep(ttl/2)
}

در صورت بروز هرگونه قطعی شبکه یا اختلال در تمدید، متد handleLockLost فراخوانی می‌شود. در برنامه‌های ابربومی، مطمئن‌ترین روش پیاده‌سازی این متد، متوقف کردن کل فرآیند کانتینر (Termination) است تا ارکستراتور کانتینر را مجدداً راه‌اندازی کرده و در وضعیت شنونده ثانویه (Secondary Listener) قرار دهد.


پیاده‌سازی عملی: مدیریت قفل و اجاره با etcd

برای درک عینی نحوه پیاده‌سازی این سیستم‌های هماهنگی توزیع‌شده، سناریویی را در نظر بگیرید که در آن دو پردازش مستقل به نام‌های آلیس (Alice) و باب (Bob) تلاش می‌کنند مالکیت یک قفل مشترک به نام my-lock را در پایگاه داده توزیع‌شده etcd تصاحب کنند.

در گام نخست، آلیس با فرض اینکه وضعیت اولیه قفل unlocked است، دستور زیر را برای نوشتن اتمی نام خود بر روی کلید ارسال می‌کند:

1
2
3
$ kubectl exec my-etcd-cluster-0000 -- sh -c \
    "ETCD_API=3 etcdctl --endpoints=${ETCD_ENDPOINTS} \
    set --swap-with-value unlocked my-lock alice"

این دستور به etcd اعلام می‌کند که تنها در صورت مطابقت مقدار فعلی با unlocked (پیش‌شرط)، مقدار جدید را به alice تغییر دهد. با موفقیت‌آمیز بودن این عملیات، آلیس قفل را تصاحب می‌کند. حالا اگر باب تلاش کند دقیقاً همین دستور را برای نوشتن نام خود ارسال کند:

1
2
3
$ kubectl exec my-etcd-cluster-0000 -- sh -c \
    "ETCD_API=3 etcdctl --endpoints=${ETCD_ENDPOINTS} \
    set --swap-with-value unlocked my-lock bob"

سرور etcd با خطای زیر مواجه شده و درخواست باب را رد می‌کند؛ زیرا پیش‌شرط عدم انحصار برقرار نیست و قفل در مالکیت آلیس قرار دارد:

1
Error: 101: Compare failed ([unlocked != alice])

پیاده‌سازی اجاره‌های کوتاه‌مدت (Leased Locks)

برای حل چالش سقوط فرآیندها و جلوگیری از مسدود شدن دائمی سیستم، از قابلیت اجاره (Lease) همراه با TTL استفاده می‌شود. در این مدل، به جای استفاده از مقدار صریح unlocked، عدم وجود فیزیکی کلید روی سرور به معنای آزاد بودن قفل تفسیر می‌شود. برای تضمین این موضوع، از دستور mk در ابزار etcdctl استفاده می‌شود که تنها در صورت عدم وجود کلید به موفقیت می‌رسد.

آلیس یک قفل اجاره‌ای با طول عمر ۱۰ ثانیه ایجاد می‌کند:

1
2
3
$ kubectl exec my-etcd-cluster-0000 -- \
    sh -c "ETCD_API=3 etcdctl --endpoints=${ETCD_ENDPOINTS} \
    --ttl=10 mk my-lock alice"

جهت تمدید مداوم این اجاره و ممانعت از آزادسازی خودکار آن پس از ۱۰ ثانیه، آلیس موظف است در بازه‌های زمانی منظم (مثلاً هر ۵ ثانیه)، دستور به‌روزرسانی زیر را با شرط مطابقت مالکیت ارسال کند:

1
2
3
$ kubectl exec my-etcd-cluster-0000 -- \
    sh -c "ETCD_API=3 etcdctl --endpoints=${ETCD_ENDPOINTS} \
    set --ttl=10 --swap-with-value alice my-lock alice"

اگر به هر دلیلی (مانند خرابی شبکه یا خطای نرم‌افزاری) این تمدید انجام نشود، زمان انقضا به پایان رسیده، کلید به طور خودکار حذف می‌شود و بستر برای تصاحب قفل توسط سایر گره‌ها (مانند باب) فراهم می‌گردد.


تحلیل معماری: مهار تداخل هم‌زمانی در دستکاری داده‌ها

حتی با طراحی دقیق قفل‌های مجهز به TTL، در زیرساخت‌های توزیع‌شده با مقیاس بالا همچنان احتمال بروز پدیده تداخل هم‌زمانی و وجود هم‌زمان دو مستر فعال وجود دارد.

یک سناریوی بحرانی و واقعی به شرح زیر است:

  1. گره اول (Shard-1) قفل توزیع‌شده را تصاحب کرده و مستر می‌شود.
  2. این گره یک درخواست سنگین به نام \(R_1\) را در زمان \(T_1\) برای اعمال روی گره‌های پردازشی (Worker) آماده می‌کند.
  3. ناگهان ماشین میزبان گره اول تحت بار کاری شدیدی قرار می‌گیرد و سیستم با یک وقفه پردازشی بسیار طولانی (مانند توقف‌های سنگین Garbage Collection) مواجه می‌شود.
  4. در طول این وقفه، زمان TTL قفل منقضی شده و سیستم ارکستراتور با فرض سقوط گره اول، قفل را آزاد می‌کند.
  5. گره دوم (Shard-2) قفل را تصاحب کرده، به عنوان مستر جدید تعیین می‌شود و درخواستی با نام \(R_2\) را در زمان \(T_2\) به سمت کارگران ارسال می‌کند.
  6. کارگران درخواست جدید \(R_2\) را دریافت کرده و آن را پردازش می‌کنند.
  7. گره دوم کار خود را تمام کرده و سقوط می‌کند (یا قفل را از دست می‌دهد)؛ قفل مجدداً آزاد شده و گره اول که از وقفه پردازشی بیدار شده است، بار دیگر قفل را تصاحب می‌کند.
  8. در این لحظه، درخواست اول \(R_1\) که به دلیل نوسانات شبکه با تأخیر در مسیر مواجه شده بود، سرانجام به گره‌های کارگر می‌رسد. کارگران وضعیت قفل را بررسی می‌کنند و می‌بینند که گره اول واقعاً هم‌اکنون مستر است؛ بنابراین درخواست \(R_1\) را پردازش می‌کنند.
    این فرآیند یک فاجعه عملیاتی است؛ زیرا درخواست قدیمی و منقضی‌شده \(R_1\) روی نتایج پردازش جدیدتر \(R_2\) بازنویسی شده و به فساد داده‌ها (Data Corruption) منجر می‌گردد.

راه‌حل اصولی: نشان‌های حصارکشی (Fencing Tokens) و نسخه‌گذاری منابع

برای برطرف کردن کامل این باگ معماری، از مکانیزم Resource Versioning استفاده می‌شود. در این مدل، سیستم ذخیره‌سازی توزیع‌شده (etcd) به همراه هر عملیات تصاحب یا تمدید قفل، یک شماره نسخه افزایشی و منحصربه‌فرد (Resource Version) تولید کرده و به مستر تحویل می‌دهد.

فرآیند اصلاح‌شده به شرح زیر است:

  • زمانی که گره درخواست خود را ارسال می‌کند، باید نسخه منحصربه‌فرد قفل را نیز به عنوان یک نشان حصارکشی (Fencing Token) ضمیمه درخواست خود کند؛ به عنوان مثال درخواست اول به صورت \((R_1, Version_1)\) ارسال می‌شود.
  • گره‌های کارگر (Workers) پیش از پردازش هر فرسته، نسخه ارسال‌شده را با آخرین نسخه ثبت‌شده و تاییدشده در سرور قفل مرکزی مطابقت می‌دهند.
  • در سناریوی فوق، از آنجا که نسخه فعال سرور مرکزی به دلیل تصاحب قفل توسط گره دوم ارتقا یافته است، کارگران متوجه خواهند شد که شناسه نسخه \(Version_1\) منقضی شده است؛ بنابراین درخواست قدیمی را فوراً رد کرده و از تداخل داده‌ها جلوگیری می‌کنند.

بخش سوم: الگوهای محاسباتی دسته‌ای (Part III: Batch Computational Patterns)

تا این بخش از کتاب، تمرکز ما بر روی الگوهای سرویس‌دهی مستمر و طولانی‌مدت (Serving Patterns) بود که پاسخگوی ترافیک آنلاین کاربران هستند. اما بخش سوم کتاب به الگوهای پردازش دسته‌ای (Batch Processing Patterns) اختصاص دارد. برخلاف برنامه‌های همواره روشن، پردازش‌های دسته‌ای برای اجرا در بازه‌های زمانی کوتاه و موقت طراحی شده‌اند.

تولید نمودارها و گزارش‌های آماری از روی ترافیک، پردازش و فشرده‌سازی فایل‌های لاگ بزرگ، و تراکنش‌های سنگین مالتی‌مدیا (مانند انکودینگ و تبدیل ویدیوها) نمونه‌های بارزی از این پردازش‌ها هستند. ویژگی بنیادین این الگوها، نیاز به پردازش حجم عظیمی از داده‌ها در کمترین زمان ممکن از طریق موازی‌سازی (Parallelism) سنگین است. اگرچه مدل معروف MapReduce شاخص‌ترین الگو در این حوزه است، اما الگوهای ساختاریافته دیگری نیز وجود دارند که فرآیند توسعه این پلتفرم‌ها را تسهیل می‌کنند.


فصل دهم: سیستم‌های صف کار (Work Queue Systems)

الگوی صف کار (Work Queue) یکی از ایده‌آل‌ترین روش‌ها برای اثبات قدرت کانتینرسازی و توسعه ماژولار در سطح سیستم‌های توزیع‌شده است. در یک ساختار سنتی، پیاده‌سازی صف کار مستلزم ادغام منطق صف با بخش پردازشی برنامه بود. اما با تفکیک وظایف و استفاده از الگوهای کانتینری، بخش عمده‌ای از بدنه سیستم صف کار کاملاً مستقل از ماهیت واقعی وظایف بوده و قابل استفاده مجدد است.

یک معماری صف کار استاندارد از سه لایه مجزا تشکیل می‌شود:

1
[ Work Source ]  <--- (Ambassador API) --->  [ Work Queue Manager ]  <--- (File API) --->  [ Workers ]

۱. رابط کانتینر منبع کار (The Source Container Interface)

هر صف کار برای شروع پردازش، نیازمند دریافت لیستی از وظایف (Work Items) است. بسته به سناریو، منبع کار ممکن است یک پوشه از تصاویر در یک فضای ابری، یک مخزن ذخیره‌سازی فایل‌های اشتراکی در شبکه، یا صف‌هایی در پیام‌رسان‌های توزیع‌شده نظیر Kafka یا Redis باشد.

برای حفظ بی‌طرفی و همگرایی سیستم مدیریت صف، لایه منبع کار را به عنوان یک کانتینر سفیر (Ambassador) در کنار کانتینر مدیریت صف (Primary Container) قرار می‌دهیم. مدیر صف برای دریافت لیست کارها، صرفاً یک فراخوانی استاندارد وب بر بستر پروتکل HTTP RESTful به آدرس localhost صادر می‌کند:

  • دریافت لیست کل کارهای آماده پردازش:
    1
    
    GET http://localhost/api/v1/items
    
  • دریافت جزئیات ساختار یافته یک کار مشخص:
    1
    
    GET http://localhost/api/v1/items/<item-name>
    

پاسخ این فراخوانی در قالب یک سند ساختاریافته JSON به مدیر صف بازگردانده می‌شود:

1
2
3
4
5
6
7
8
{
  "kind": "Item",
  "apiVersion": "v1",
  "data": {
    "some": "json",
    "object": "here"
  }
}

در این معماری، کانتینر سفیر منبع وظیفه سنگین تعامل با دنیای ناهمگون خارج را به عهده می‌گیرد، در حالی که لایه مدیریت صف کاملاً استاندارد و ایزوله باقی می‌ماند.

۲. رابط کانتینر کارگر (The Worker Container Interface)

پس از استخراج وظایف توسط مدیر صف، هر آیتم باید برای پردازش به یک کانتینر کارگر (Worker Container) سپرده شود. برخلاف رابطه لوکال مدیریت صف با سفیر منبع، کارگرها روی ماشین‌های متفاوتی در کلاستر اجرا شده و کانتینرهایی یک‌بارمصرف (One-off) هستند.

به دلیل پراکندگی گره‌های کارگر در سطح شبکه، برقراری ارتباط با آن‌ها از طریق وب‌سرورهای فعال پورت مخاطرات امنیتی و سربار پردازشی دارد. از این رو، رابط کارگر بر پایه فایل‌سیستم محلی (File-based API) طراحی می‌شود. در زمان ایجاد کانتینر کارگر توسط ارکستراتور، مدیر صف داده‌های مربوط به کار را در قالب یک ConfigMap ثبت کرده و آن را به عنوان یک فایل روی دیسک محلی کانتینر مپ می‌کند. آدرس این فایل از طریق متغیر محیطی زیر به کانتینر کارگر معرفی می‌شود:

WORK_ITEM_FILE=/path/to/my/task.json

کارگر فایل را خوانده، پردازش را آغاز می‌کند و پس از اتمام موفقیت‌آمیز محاسبات، فرآیند خود را خاتمه می‌دهد تا ارکستراتور پایان موفقیت‌آمیز تسک را به مدیر صف گزارش کند.


پیاده‌سازی عملی: سیستم تولید تصاویر بندانگشتی ویدیوها (Video Thumbnailer)

برای درک عینی نحوه پیاده‌سازی یک سیستم صف کار توزیع‌شده (Work Queue)، سناریوی تولید تصاویر بندانگشتی (Thumbnails) برای فایل‌های ویدیویی را پیاده‌سازی می‌کنیم.

این سیستم به دو نوع کانتینر کاربر (User Containers) نیاز دارد: ۱. کانتینر منبع کار (Work Item Source Container): ساده‌ترین رویکرد برای طراحی این لایه، قرار دادن فایل‌های ویدیویی خام بر روی یک دیسک اشتراکی در سطح شبکه (مانند یک NFS Share) است. کانتینر منبع کار صرفاً وظیفه دارد فایل‌های موجود در دایرکتوری اشتراکی را لیست کرده و نام آن‌ها را به عنوان کارهای آماده پردازش، از طریق یک API ساده مبتنی بر Node.js به مدیر صف کار گزارش دهد.

کد کانتینر منبع کار (Node.js API):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
const port = 8080;

// مسیر دایرکتوری اشتراکی حاوی ویدیوها
const workDir = '/var/videos';

app.get('/api/v1/items', (req, res) => {
    fs.readdir(workDir, (err, files) => {
        if (err) {
            return res.status(500).json({ error: err.message });
        }
        // فیلتر کردن فایل‌های ویدیویی کاندید
        const videoFiles = files.filter(f => f.endsWith('.mp4') || f.endsWith('.mkv'));
        res.json(videoFiles);
    });
});

app.get('/api/v1/items/:name', (req, res) => {
    const itemName = req.params.name;
    const itemPath = path.join(workDir, itemName);
    
    if (fs.existsSync(itemPath)) {
        res.json({
            kind: 'Item',
            apiVersion: 'v1',
            data: {
                fileName: itemName,
                filePath: itemPath,
                outputDir: '/var/thumbnails'
            }
        });
    } else {
        res.status(404).json({ error: 'Item not found' });
    }
});

app.listen(port, () => {
    console.log(`Work source API listening on port ${port}`);
});

۲. کانتینر کارگر (Worker Container): برای انجام محاسبات سنگین و فشرده‌سازی ویدیوها، کانتینری متشکل از ابزار قدرتمند ffmpeg ایجاد می‌کنیم. این کانتینر طبق استاندارد رابط کارگر، نام فایل هدف را از طریق متغیر محیطی WORK_ITEM_FILE که به صورت فیزیکی بر روی دیسک کانتینر مپ شده، خوانده و پردازش را آغاز می‌کند.

فرمان اجرای ffmpeg در لایه کانتینر کارگر به شرح زیر است:

1
2
# خواندن مسیر فایل و اجرای ffmpeg برای استخراج تک فریم در ثانیه ۱۰ام ویدیو
ffmpeg -i "${INPUT_FILE_PATH}" -ss 00:00:10 -vframes 1 "${OUTPUT_DIR}/${FILE_NAME_BASE}.png"

پس از اتمام موفقیت‌آمیز فشرده‌سازی، کارگر با خروجی وضعیت صفر (exit 0) متوقف شده و سیستم ارکستراتور (Kubernetes Job) پایان موفقیت‌آمیز کار را ثبت می‌کند.


مقیاس‌دهی پویای کارگران (Dynamic Scaling of the Workers)

اجرای بی‌رویه و موازی تمام کارهای موجود در صف می‌تواند منجر به تحمیل بارهای انفجاری (Bursty Loads) سنگین به کلاستر شود. برای جلوگیری از این چالش، می‌توان سقف تعداد اشیاء Job مجاز برای اجرا به صورت هم‌زمان را محدود کرد؛ هرچند این کار در زمان‌های اوج ترافیک، زمان پاسخ‌دهی (Latency) هر آیتم را افزایش می‌دهد.

برای متوازن‌سازی مصرف منابع و حفظ پایداری سیستم، مدیر صف کار باید مجهز به یک مکانیزم مقیاس‌دهی پویا (Autoscaler) بر اساس معیارهای ریاضی باشد.

برای این کار، دو شاخص کلیدی را در بازه‌های زمانی طولانی‌مدت پایش می‌کنیم: ۱. زمان بین ورود کارهای جدید (Interarrival Time - \(T_{arrival}\)): میانگین زمان ورود کارهای جدید به سیستم (مثلاً تعداد کل کارهای جدید دریافت شده در ۲۴ ساعت گذشته تقسیم بر کل ثانیه‌های یک شبانه‌روز). ۲. زمان پردازش هر کار (\(T_{process}\)): میانگین زمان اجرای کدهای محاسباتی توسط کانتینر کارگر، بدون در نظر گرفتن زمان تعلیق در صف.

برای تضمین پایداری کلاستر و ممانعت از رشد نامحدود صف، نرخ پردازش سیستم باید همواره سریع‌تر از نرخ ورود کارهای جدید باشد: \[\frac{T_{process}}{P} < T_{arrival}\]

که در آن \(P\) نشان‌دهنده میزان موازی‌سازی (Parallelism) یا تعداد کارگرهای فعال هم‌زمان است.

مثال محاسباتی:
  • اگر به طور میانگین در هر ۶۰ ثانیه یک ویدیوی جدید به سیستم اضافه شود (\(T_{arrival} = 60s\)) و پردازش هر ویدیو به طور میانگین ۱۲۰ ثانیه طول بکشد (\(T_{process} = 120s\)): بدون موازی‌سازی (\(P=1\))، سیستم مدام عقب افتاده و طول صف به سمت بی‌نهایت میل می‌کند. برای مهار این عقب‌افتادگی، سیستم باید حداقل موازی‌سازی را روی ۳ گره تنظیم کند: \[\frac{120}{3} = 40s < 60s\] در این حالت، سیستم توانایی جذب نوسانات ترافیکی و مهار تاخیرها را خواهد داشت.
مهندسی حاشیه امنیت (Safety Margin Heuristic)

برای کوچک کردن اندازه کلاستر (Scale Down) جهت بهینه‌سازی هزینه‌ها، نباید سیستم را در وضعیت توازن مطلق محاسباتی رها کرد؛ زیرا توانایی مواجهه با تأخیرهای ناگهانی شبکه یا کدهای کند را از دست می‌دهد. توصیه می‌شود یک حاشیه امنیت (مثلاً ۱۰ درصد ظرفیت اضافی) در فرمول کاهش منابع لحاظ شود تا زمانی که زمان پردازش معادل ۹۰ درصد زمان ورود کارهای جدید شد، فرآیند کاهش کارگرها متوقف گردد.


الگوی کارگر چندگانه (The Multi-Worker Pattern)

یکی از اهداف اصلی کانتینرسازی، ترویج اصول کپسوله‌سازی (Encapsulation) و قابلیت استفاده مجدد (Reusability) است. فرض کنید می‌خواهید روی تصاویر ورودی سه کار متمایز انجام دهید:

  1. تشخیص وجود چهره در تصویر (Face Detection)
  2. شناسایی هویت افراد (Identity Tagging)
  3. تار کردن/مات کردن چهره‌ها جهت حفظ حریم خصوصی (Face Blurring)

اگر تمام این مراحل را در یک کانتینر واحد و به صورت یکپارچه توسعه دهید، یک راهکار اختصاصی (Bespoke Solution) ساخته‌اید که فاقد قابلیت استفاده مجدد خواهد بود؛ برای مثال، اگر در آینده بخواهید بدون نیاز به شناسایی چهره، پلاک خودروها را تار کنید، نمی‌توانید از مؤلفه تارکننده به صورت مجزا بهره ببرید.

برای حل این چالش، الگوی کارگر چندگانه (Multi-Worker Pattern) وارد عمل می‌شود . این الگو که نوعی تخصص‌یافتگی از الگوی آداپتور (Adapter Pattern) در سطح پردازش‌های دسته‌ای است، کارهای پیچیده را به مجموعه‌ای از کانتینرهای تک‌وظیفه‌ای و مستقل تفکیک می‌کند . سپس یک کانتینر تجمیع‌کننده کارگر چندگانه (Multi-Worker Aggregator) به عنوان اینترفیس اصلی قرار می‌گیرد تا وظایف را دریافت کرده و جریان کاری را میان کانتینرهای تک‌کاره به صورت داخلی هدایت و متمرکز سازد.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
                +-------------------------------------------------+
                |             Container Group (Pod)               |
                |                                                 |
                |          +----------------------------+         |
                |          | Work Queue Worker Interface|         |
                |          +--------------+-------------+         |
                |                         |                       |
                |                         v                       |
                |          +----------------------------+         |
                |          |   Multi-Worker Aggregator  |         |
                |          +-------+------------+-------+         |
                |                  |            |                 |
                |        (Internal)|            |(Internal)       |
                |                  v            v                 |
                |          +-------+----+  +----+-------+         |
                |          | Face Det.  |  |  Blurring  |         |
                |          |  Worker    |  |   Worker   |         |
                |          +------------+  +------------+         |
                +-------------------------------------------------+

(نمودار مفهومی ساختار کارگر چندگانه در سطح کانتینرها).

با این طراحی، کانتینر تارکننده (Blurring Container) کاملاً استاندارد و مجزا باقی می‌ماند و می‌توان در پروژه‌های موازی دیگر نیز بدون نیاز به لود کدهای تشخیص چهره، از همان ایمیج کانتینری استفاده مجدد کرد. این امر، هزینه بیلد، تست و نگهداری نرم‌افزار را در سطح سازمان به طور چشمگیری کاهش می‌دهد.


فصل یازدهم: پردازش دسته‌ای مبتنی بر رویداد (Event-Driven Batch Processing)

چرایی نیاز به زنجیره‌سازی صف‌های کاری (Workflow Systems / DAG)

در فصل گذشته، سیستم‌های صف کار ساده (Work Queue Systems) را بررسی کردیم که برای تبدیل یک ورودی منفرد به یک خروجی منفرد طراحی شده بودند. با این حال، در سناریوهای واقعی پردازش دسته‌ای، اغلب به فرآیندهای پیچیده‌تری نیاز داریم؛ فرآیندهایی که در آن‌ها باید بیش از یک اقدام روی داده‌ها انجام شود، یا از یک ورودی واحد چندین خروجی متفاوت تولید گردد.

در این سناریوها، بهترین رویکرد معماری، زنجیره‌سازی صف‌های کار (Chaining Work Queues) است، به طوری که خروجی یک صف به عنوان ورودی صف یا صف‌های بعدی عمل کند. اتمام پردازش در هر مرحله، رویدادی (Event) است که مرحله بعدی را فعال می‌سازد. این اتصال‌های زنجیره‌ای، ساختاری به نام سیستم‌های جریان کار (Workflow Systems) را ایجاد می‌کنند که رفتاری شبیه به یک نمودار جهت‌دار بدون دور (DAG - Directed Acyclic Graph) دارند. بدون وجود الگوهای سازمان‌دهی و زبان مشترک برای طراحی این جریان‌ها، درک و مانیتور کردن نحوه هدایت کارها در سیستم بسیار دشوار خواهد بود.


الگوهای طراحی در پردازش دسته‌ای مبتنی بر رویداد (Patterns of Event-Driven Processing)

فراتر از زنجیره‌سازی ساده و متوالی، الگوهای هماهنگی پیشرفته‌ای برای سازماندهی و تقسیم ترافیک میان صف‌ها وجود دارند:

۱. الگوی کپی‌کننده (Copier)

وظیفه این الگو، دریافت یک جریان واحد از آیتم‌های کاری و تکثیر (Duplicate) دقیق آن به دو یا چند جریان کاملاً مشابه است. این الگو زمانی کاربرد دارد که نیاز باشد چندین کار پردازشی مستقل و موازی روی یک داده ورودی یکسان انجام شود.

  • یک سناریوی واقعی (ویدیو ترنسکدینگ): برای ارائه یک ویدیو در پلتفرم‌های پخش آنلاین، باید آن را به چندین فرمت و رزولوشن تبدیل کرد: فرمت 4K برای پخش خانگی، 1080p برای استریم دیجیتال، نسخه‌ای با رزولوشن پایین برای موبایل با پهنای باند ضعیف، و در نهایت یک فایل GIF انیمیشنی به عنوان تصویر بندانگشتی. ورودی تمام این پردازش‌ها یک فایل ویدیوی خام است. الگوی Copier این ورودی را تکثیر کرده و به صف‌های پردازشی مجزای هر کدام از این فرمت‌ها هدایت می‌کند.
۲. الگوی فیلتر (Filter)

نقش این الگو، کاهش تعداد اعضای یک جریان کاری از طریق حذف آیتم‌هایی است که معیارهای تعیین‌شده (Criteria) را برآورده نمی‌کنند.

  • پیاده‌سازی تمیز (Clean Implementation): توصیه معماری این است که کانتینر فیلتر را در قالب یک سفیر (Ambassador) در کنار منبع اصلی صف کار قرار دهید. منبع اصلی لیست کامل آیتم‌ها را ارائه می‌دهد، سپس کانتینر فیلتر بر اساس قوانین تعیین‌شده (مثلاً حذف شماره‌های فرد یا بررسی وضعیت انتخاب کاربر) لیست را تصفیه کرده و تنها خروجی‌های معتبر را به زیرساخت صف تحویل می‌دهد.
۳. الگوی تقسیم‌کننده (Splitter)

برخلاف الگوی فیلتر که داده‌های غیرمنطبق را دور می‌ریزد، الگوی Splitter ترافیک ورودی را ارزیابی کرده و آن را بر اساس معیارهای مشخص به صف‌های متفاوتی تقسیم می‌کند، بدون اینکه داده‌ای حذف شود.

  • مثال کاربردی: در سیستم پردازش سفارشات آنلاین، مشتریان روش‌های متفاوتی را برای دریافت نوتیفیکیشن ارسال کالا انتخاب می‌کنند (ایمیل، پیامک یا هر دو). الگوی Splitter صف سفارشات ارسال شده را دریافت کرده، تنظیمات کاربری را تحلیل می‌کند و کارها را به صف ارسال ایمیل، صف ارسال SMS یا هر دو هدایت می‌نماید. اگرچه پیاده‌سازی این رفتار با ترکیب یک کپی‌کننده و دو فیلتر مجزا نیز ممکن است، اما الگوی Splitter نمایش خلاصه، منسجم و متمرکزی از این منطق ارائه می‌دهد.
۴. الگوی شاردکننده (Sharder)

فرم پیشرفته‌تری از الگوی تقسیم‌کننده است که وظیفه دارد یک صف بزرگ کار را بر اساس یک تابع شاردینگ (Sharding Function) به طور کاملاً یکنواخت و عادلانه بین مجموعه‌ای از صف‌های مجزا تقسیم کند. به کارگیری شاردینگ در لایه محاسباتی دسته‌ای، دو مزیت زیرساختی به همراه دارد:

  • قابلیت اطمینان بالا (Reliability): فرض کنید نسخه جدیدی از کدهای محاسباتی کارگر (Worker Container) دارای باگ است و باعث کرش کردن پردازنده‌ها می‌شود. اگر تنها از یک صف استفاده کنید، کل سرویس دچار قطعی کامل (Complete Outage) خواهد شد. اما با شارد کردن صف به ۴ بخش متمایز و اجرای استقرار تدریجی (Staged Rollout)، نقص فنی کدهای جدید تنها بر ۲۵ درصد کاربران اثرگذار خواهد بود و کلاستر فرصت کافی برای تشخیص باگ و بازگشت به نسخه قبل را دارد.
  • توزیع بهینه منابع (Resource Distribution): شاردینگ به شما اجازه می‌دهد بار محاسباتی کارها را به صورت یکنواخت در میان دیتاسنترها یا ریجن‌های مختلف کلاستر پخش کنید تا از هدررفت منابع جلوگیری شود. در صورت خرابی یک شارد، الگوریتم شاردینگ بار ترافیکی آن را به صورت پویا بین شاردهای فعال باقی‌مانده توزیع می‌کند.
۵. الگوی ادغام‌کننده (Merger)

این الگو دقیقاً نقطه مقابل Copier است و وظیفه دارد دو یا چند صف کار متمایز را دریافت کرده و آن‌ها را در قالب یک صف کار واحد و یکپارچه تلفیق کند.

  • یک سناریوی واقعی: در یک سازمان بزرگ با صدها مخزن کد گیت (Repositories)، توسعه‌دهندگان به صورت مداوم کدهای خود را کامیت می‌کنند. ایجاد یک زیرساخت بیلد و تست (Build & Test Infrastructure) اختصاصی به ازای هر مخزن مقرون‌به‌صرفه نیست. به کمک الگوی Merger که در قالب یک آداپتور چندگانه (Multi-adapter) پیاده‌سازی می‌شود، کامیت‌های تمام مخازن در یک جریان کاری واحد ادغام شده و به سیستم بیلد مرکزی هدایت می‌شوند.

پیاده‌سازی عملی: خط لوله ثبت‌نام کاربر جدید (Hands On: New User Sign-Up Workflow)

برای درک عینی از نحوه ترکیب این الگوها، سناریوی ثبت‌نام کاربر جدید در یک محصول نرم‌افزاری را مدل‌سازی می‌کنیم. این فرآیند دو فاز اصلی دارد: تایید هویت (Verification) و تنظیمات کاربری (Notification & Welcome):

  1. فاز اول (تایید کاربر): به محض ثبت‌نام اولیه، اطلاعات کاربر وارد سیستم می‌شود. جهت تضمین تداوم پردازش‌ها حتی در صورت بروز خرابی‌های محلی، کاربران را با استفاده از الگوی Shard در چندین منطقه جغرافیایی مختلف توزیع می‌کنیم. شارد مربوطه ایمیل حاوی لینک تایید را برای کاربر ارسال کرده و فاز اول به پایان می‌رسد.
  2. فاز دوم (شروع فرآیند پس از تایید): با کلیک کاربر روی لینک تایید، رویداد تایید ثبت‌نام ثبت شده و خط لوله دوم بیدار می‌شود. در نخستین گام، رویداد کاربر وارد یک الگوی Copier می‌شود تا دو جریان کاری موازی و هم‌زمان ایجاد شود:
    • جریان اول: کاربر را به صف ارسال ایمیل خوش‌آمدگویی (Welcome Email Queue) می‌فرستد.
    • جریان دوم: کاربر را به صف تنظیمات اطلاع‌رسانی (Notification Work Queue) هدایت می‌کند .
  3. فاز سوم (تصفیه و ارسال نهایی): صف تنظیمات اطلاع‌رسانی با استفاده از الگوی Filter/Splitter اولویت‌های ثبت‌شده کاربر را ارزیابی کرده و او را بر اساس تمایلش به صف ایمیل‌های تبلیغاتی، صف پیامک‌های اطلاع‌رسانی یا هر دو هدایت می‌کند.

زیرساخت مدیریت جریان داده: الگوهای انتشار/اشتراک (Publisher/Subscriber Infrastructure)

پس از طراحی منطقی الگوهای فوق، چالش اصلی نحوه ذخیره‌سازی پایدار و انتقال پیام‌ها میان این صف‌های متوالی در سطح شبکه کلاستر است. ذخیره موقت پیام‌ها در فایل‌سیستم محلی به شدت ناکارآمد است؛ زیرا کل خط لوله را به پردازش روی یک ماشین واحد (Single Node) محدود می‌سازد. استفاده از سیستم‌های فایل مشترک در شبکه (NFS) نیز پیچیدگی‌های استقرار و خطایابی سیستم را به شدت بالا می‌برد.

استاندارد نوین و اصولی، استفاده از زیرساخت‌های پیام‌رسانی مبتنی بر الگوی انتشار/اشتراک (Publisher/Subscriber - Pub/Sub) است. در این مدل، کانال‌های ارتباطی تحت عنوان موضوعات (Topics) تعریف می‌شوند:

  • کانتینرهای تولیدکننده (Publishers) پیام‌ها را به درون موضوعات می‌فرستند.
  • کانتینرهای مصرف‌کننده (Subscribers) با اشتراک در این موضوعات، پیام‌ها را به صورت کاملاً مطمئن و ناهم‌زمان دریافت می‌کنند.

در طراحی خط لوله‌های محاسبات دسته‌ای، خروجی هر ماژول نرم‌افزاری به عنوان یک Topic مجزا در ابزارهایی مانند Kafka تعریف می‌شود. به عنوان مثال، در صورت استفاده از الگوی شاردینگ برای پردازش تصاویر کانتینرها (مانند Photos)، کارهای پردازش‌شده پس از اعمال تابع شاردینگ به موضوعاتی نظیر Photos-1، Photos-2 و Photos-3 ارسال می‌شوند تا کارگران لایه‌های بعدی بدون تداخل در جریان کاری، داده‌های مربوط به شارد خود را برای پردازش‌های تکمیلی لود کنند. این جداسازی ماژولار زیرساخت ارتباطی، پایداری فوق‌العاده و مقیاس‌پذیری پایداری را برای پلتفرم‌های توزیع‌شده به همراه دارد.


بخش چهارم: پردازش دسته‌ای هماهنگ‌شده (Coordinated Batch Processing)

انگیزه‌ها و ضرورت‌های معماری (Architectural Motivations)

در فصل‌های گذشته، الگوهایی را برای تقسیم کار و توزیع موازی ترافیک در قالب صف‌های کار میان گره‌های مختلف بررسی کردیم. کپی کردن (Copying)، فیلتر کردن (Filtering) و شارد کردن (Sharding) داده‌ها و تسک‌ها، الگوهایی کارآمد برای توزیع هستند. اما در بسیاری از سناریوهای پردازش دسته‌ای، برای ارائه خروجی نهایی، ناگزیر به ادغام و تجمیع دوباره خروجی‌های پراکنده (Result Aggregation) هستیم.

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


۱. الگوی الحاق یا همگام‌سازی مانع (Join / Barrier Synchronization)

گاهی در طراحی یک جریان کاری توزیع‌شده، لازم است پیش از ورود به فاز بعدی، پردازش فاز فعلی به طور کامل (روی تمام شاردها و داده‌ها) به پایان رسیده باشد.

اگرچه در فصل قبل الگوی ادغام‌کننده (Merger) معرفی شد، اما آن الگو صرفاً خروجی صف‌ها را بدون بررسی وضعیت اتمام آن‌ها ترکیب می‌کرد و هیچ تضمینی در خصوص کامل بودن کل دیتاست پیش از شروع مرحله بعد ارائه نمی‌داد. برای چنین تضمینی، ما نیازمند الگوی الحاق (Join) یا همان Barrier Synchronization (همگام‌سازی مانع) هستیم.

در این الگو، وظایف به صورت موازی در گره‌های برگ پردازش می‌شوند، اما خروجی‌ها در سدِ مانع (Barrier) متوقف می‌گردند. گره الحاق‌کننده (Join Node) به عنوان یک ناظر عمل کرده و تا زمانی که سیگنال اتمام پردازش از تک‌تک وظایف موازی صادر نشود، هیچ داده‌ای را به مرحله بعدی خط لوله آزاد نمی‌کند.

تحلیل هزینه و کارایی (Trade-off Analysis):
  • مزیت اصلی: تضمین ۱۰۰ درصدی کامل بودن و یکپارچگی داده‌ها (Completeness) پیش از انجام محاسبات آماری یا پردازش‌های حیاتی بعدی.
  • چالش اصلی: کاهش شدید موازی‌سازی سیستم (Loss of Parallelism) و افزایش زمان پاسخ‌دهی کل سیستم (Latency). از آنجا که تمام سیستم باید معطل کندترین گره موازی (Straggler) بماند، کارایی کل کلاستر تحت تأثیر سرعت ضعیف‌ترین گره قرار می‌گیرد.

۲. الگوی کاهش (Reduce)

اگر شارد کردن صف کار را معادل فاز نگاشت (Map) در الگوریتم معروف MapReduce بدانیم، فاز مکمل آن الگوی کاهش (Reduce) است. الگوهای Join و Reduce هر دو برای تجمیع پاسخ‌ها کاربرد دارند، اما تفاوت فلسفی عمیقی میان آن‌ها وجود دارد:

  • در الگوی Join، سیستم تا زمان اتمام پردازش تمام بخش‌ها به صورت غیرفعال منتظر می‌ماند.
  • در الگوی Reduce، سیستم منتظر اتمام کل فرآیند نمی‌شود؛ بلکه به صورت خوش‌بینانه و پویا، خروجی‌های موازی آماده‌شده را مرحله به مرحله با یکدیگر ترکیب کرده و حجم داده‌های فعال را کاهش می‌دهد تا در نهایت به یک خروجی واحد و جامع برسد.

از آنجا که کاهش داده‌ها می‌تواند به صورت موازی و هم‌زمان با فاز مپ/شارد شروع شود، سرعت اجرای کل خط لوله به شدت بهبود می‌یابد.

پیاده‌سازی‌های رایج الگوی کاهش:
  • شمارش (Count): به عنوان مثال، برای شمارش تعداد تکرار کلمات در یک کتاب، صفحات بین ۱۰ نفر توزیع (Shard) می‌شود. هر نفر بر روی کاغذ خود تعداد کلمات را به صورت جداگانه ثبت می‌کند (مثلاً the: 17 و cat: 2). فاز Reduce نتایج تک‌تک برگه‌ها را به صورت اتمی با یکدیگر جمع کرده و خروجی فشرده‌تر و نهایی را تحویل می‌دهد.
  • جمع کل (Sum): برای محاسبه جمعیت کل کشور، شمارش ابتدا در سطح شهرستان‌ها (County) و سپس استان‌ها (State) انجام می‌شود. لایه کاهش بدون نیاز به آگاهی از ساختار سلسله‌مراتب شاردینگ، تاپل‌های عددی ورودی مانند (Seattle, 4,000,000) و (Northampton, 25,000) را دریافت کرده و آن‌ها را به تاپل جدید و فشرده‌تر (Seattle-Northampton, 4,025,000) تبدیل می‌کند.
  • تلفیق نمودارهای فراوانی (Histogram): یکی از مسائل پیچیده‌تر، ادغام نمودارهای توزیع فراوانی (مانند توزیع اندازه خانواده‌ها از ۰ تا ۱۰ فرزند) به دست آمده از شارد‌های مختلف است. از آنجا که نمی‌توان درصدها را مستقیماً با یکدیگر جمع ریاضی کرد، الگوی کاهش برای ادغام اصولی، فرمول وزن‌دار زیر را به کار می‌بندد:

    ۱. مقادیر درصدی هیستوگرام هر شهر در کل جمعیت آن شهر ضرب می‌شود تا فراوانی مطلق داده‌ها به دست آید. ۲. فراوانی‌های مطلق شهرهای مختلف با یکدیگر جمع می‌شوند. ۳. مقدار به دست آمده بر مجموع جمعیت کل شهرهای ادغام‌شده تقسیم می‌شود تا هیستوگرامِ هنجارشده و استاندارد نهایی کشور حاصل شود.


پیاده‌سازی عملی: خط لوله تگ‌گذاری و پردازش تصاویر (Hands On: Image Tagging and Processing Pipeline)

برای درک عینی نحوه پیاده‌سازی این الگوها، سیستم توزیع‌شده‌ای را طراحی می‌کنیم که وظیفه دارد تصاویر دوربین‌های کنترل ترافیک اتوبان‌ها را پردازش کند. اهداف این خط لوله عبارتند از: تار کردن پلاک خودروها جهت حفظ حریم خصوصی، حذف تصاویر اصلی پس از ویرایش، تشخیص نوع خودرو (سواری، کامیون، موتور) و توزیع رنگ خودروها به همراه ارائه آمار نهایی.

این جریان محاسباتی پیچیده از ترکیب پنج مرحله کلیدی زیر شکل می‌گیرد:

مرحله اول: شاردینگ و تار کردن تصاویر (Sharded Blurring)

لینک تصاویر ورودی بر اساس شناسه شارد (Image URL % Shards) در صف‌های موازی توزیع می‌شوند. کارگر هر صف از الگوی کارگر چندگانه (Multi-Worker Pattern) استفاده می‌کند که از دو کانتینر مجزا در یک پاد تشکیل شده است: کانتینر اول محل پلاک را تشخیص داده و کانتینر دوم (FFmpeg/Pillow) پلاک را تار می‌کند. این جداسازی باعث می‌شود بتوانیم از کانتینر تارکننده در پروژه‌های موازی دیگر نیز استفاده مجدد کنیم.

مرحله دوم: اعمال الگوی الحاق (The Join Barrier)

پس از ویرایش تصاویر، فایل‌های جدید آپلود می‌شوند و تصاویر خام اصلی باید حذف شوند. اما برای ممانعت از نابودی اطلاعات در صورت بروز خطای فاجعه‌بار کلاستر در زمان پردازش، ما مجاز نیستیم تصاویر اصلی را فوراً حذف کنیم. از این رو، خروجی تمام صف‌های شارد پردازش تصویر را به یک گره Join متصل می‌کنیم. این گره تا زمان اتمام پردازشِ موفق تک‌تک تصاویر، هیچ سیگنالی صادر نمی‌کند.

مرحله سوم: کپی‌سازی جریان داده (The Copier Stage)

به محض موفقیت کامل مرحله الحاق (Join) و عبور از سد همگام‌سازی، گره الحاق یک سیگنال خروجی صادر می‌کند. این خروجی وارد یک الگوی Copier می‌شود تا دو جریان کاری مستقل و موازی زیر بیدار شوند:

  • جریان اول: تسک‌های حذف فیزیکی تصاویر خام و اولیه را به صف حذف ارسال می‌کند.
  • جریان دوم: لینک تصاویر ویرایش‌شده و تارشده را برای تحلیل محتوایی به صف هوش مصنوعی می‌فرستد.
مرحله چهارم: شاردینگ محاسبات تشخیص تصویر (Vehicle & Color Tagging)

ترافیک تصاویر برای پردازش‌های سنگین یادگیری ماشین مجدداً شارد می‌شود. کارگران این مرحله نیز مبتنی بر الگوی کارگر چندگانه هستند: کانتینر اول نوع خودرو را تشخیص می‌دهد و کانتینر دوم پیکسل‌های محدوده خودرو را برای شناسایی رنگ آن تحلیل می‌کند. خروجی هر کارگر برای یک تصویر مشخص، به فرمت ساختاریافته زیر ارائه می‌شود:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "vehicles": {
    "car": 12,
    "truck": 7,
    "motorcycle": 4
  },
  "colors": {
    "white": 8,
    "black": 3,
    "blue": 6,
    "red": 6
  }
}
مرحله پنجم: لایه نهایی کاهش (The Final Reduce Pipeline)

در نهایت، برای به دست آوردن گزارش نهایی کل اتوبان‌های کشور، این اسناد JSON خروجی توسط کانتینرهای لایه Reduce جمع‌آوری شده و از طریق تجمیع گام‌به‌گام و موازی (Recursive Aggregation)، تمام اعداد فوق را با یکدیگر جمع ریاضی کرده تا آمار جامع کل سیستم در اختیار مهندسان سازمان قرار گیرد.


فصل سیزدهم: نتیجه‌گیری؛ یک آغاز جدید؟ (Conclusion: A New Beginning?)

دگرگونی دیجیتال و بحران تقاضا (The Digital Transformation)

امروزه مرز میان شرکت‌های فناوری و سنتی کاملاً از بین رفته است؛ در واقع، هر شرکتی در حال تبدیل شدن به یک شرکت دیجیتال (Digital Company) است. این دگرگونی بنیادین، ارائه بی‌پایان APIها و سرویس‌های مختلف را برای مصرف توسط برنامه‌های موبایل، دستگاه‌های اینترنت اشیاء (IoT)، سیستم‌های خودران و سیستم‌های هوشمند زنده الزامی می‌سازد. در چنین اتمسفری، الزامات شدید دسترس‌پذیری بالا (High Availability)، افزونگی (Redundancy) و تحمل خطا (Fault Tolerance) دیگر ویژگی‌هایی لوکس و فرعی نیستند، بلکه پیش‌شرط‌هایی حیاتی برای بقای هر سیستم نرم‌افزاری به شمار می‌روند.

هم‌زمان، سرعت بازار اقتضا می‌کند که تیم‌های مهندسی از چابکی (Agility) فوق‌العاده‌ای برای توسعه، استقرار، بهبود مداوم، ارتقاء یا آزمایش تدریجی فرانت‌اندها و APIها برخوردار باشند. تقاطع این دو نیازمند متباین—یعنی لزوم پایداری و ثبات حداکثری سیستم در عین حفظ سرعت بسیار بالای تغییرات—منجر به رشد تصاعدی و مرتبه بزرگی (Order of Magnitude) در تعداد سیستم‌های توزیع‌شده‌ای شده است که باید طراحی و پیاده‌سازی شوند.

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

تکرار تاریخ؛ تکامل لایه‌های انتزاع (The Evolution of Abstraction Layers)

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

۱. کد ماشین به زبان‌های برنامه‌نویسی و کامپایلرها: نخستین جهش بزرگ زمانی رخ داد که زبان‌های برنامه‌نویسی سطح بالا و اولین کامپایلرها جایگزین کدهای خام و اختصاصی سخت‌افزار شدند. این امر الگوریتم‌های کلی را مستقل از معماری یک ماشین خاص فرموله کرد و به ابزارهایی عمومی تبدیل نمود. ۲. تفکر رویه‌ای به برنامه‌نویسی شیءگرا (OOP) و کدهای مدیریت‌شده: جهش بعدی با پیدایش تفکر شیءگرایی و محیط‌های اجرای مدیریت‌شده (Managed Code) شکل گرفت. این تحول، دانش و تجربیات خبرگان صنعت نرم‌افزار را در قالب پترن‌های طراحی واسط‌محور (Interface-based Design Patterns) و کتابخانه‌های قابل استفاده مجدد متمرکز کرد تا فرآیند توسعه نرم‌افزارهای پیچیده به شدت دموکراتیزه شود .

در هر دو نقطه عطف تاریخی، معرفی لایه‌های انتزاعی استاندارد (Abstraction Layers) و تبدیل الگوهای تکراری به کدهای اشتراکی (Crystallization of Patterns) منجر به گسترش فوق‌العاده دایره توسعه‌دهندگان توانا، تنوع محصولات نرم‌افزاری و بهبود کیفیت کلی خروجی‌ها گردید.

دموکراتیزه کردن سیستم‌های توزیع‌شده (Democratizing Distributed Systems)

ما امروز بار دیگر در میانه یک جهش تاریخی بزرگ قرار داریم؛ جایی که نیاز مبرم به سیستم‌های توزیع‌شده بسیار فراتر از توانایی فعلی کل صنعت برای تحویل آن‌ها است. اما خوشبختانه، ظهور کانتینرها (Containers) و ابزارهای مدیریت کانتینر (Container Orchestration) زیرساخت لازم برای حل این بحران را فراهم آورده‌اند.

ترکیب این ابزارهای نوین زیرساختی با الگوها و پترن‌های طراحی ارائه‌شده در این کتاب—نظیر Sidecars، Ambassadors، Adapters، Replicated Serving، Sharded Services، FaaS و ساختارهای پردازش دسته‌ای Work Queues—شالوده مهندسی سیستم‌های توزیع‌شده نوین را تشکیل می‌دهد .

عصر توسعه سیستم‌های توزیع‌شده به صورت راه‌حل‌های اختصاصی و یک‌باره (Bespoke Solutions) از صفر، برای همیشه به پایان رسیده است. آینده این صنعت متعلق به توسعه‌دهندگانی است که به جای بازنویسی چرخ از ابتدا، بر روی پیاده‌سازی‌های اشتراکی، ایمن، تست‌شده و بسیار صیقل‌خورده از این پترن‌های استاندارد با یکدیگر همکاری می‌کنند. این همان آغاز جدیدی است که مهندسی سیستم‌های توزیع‌شده را از یک «هنر جادویی و مرموز (Black Art)» به یک «دانش ساختاریافته، علمی و دموکراتیزه‌شده (Science)» برای تمام توسعه‌دهندگان تبدیل خواهد کرد.