خلاصه سریع: سرویس Images که با Rust روی Workers اجرا می‌شود، در لبه شبکه Cloudflare روی هر ماشین اجرا می‌شود و برای مدیریت ارتباطات کلاینت از کتابخانه HTTP متن‌باز hyper استفاده می‌کند. ما سال گذشته Images binding را معرفی کردیم تا پردازش برنامه‌پذیر تصاویر در Workers ممکن شود. در انتهای 2025 معماری binding را تغییر دادیم تا ارتباط میان runtimeِ Workers و سرویس Images به‌صورت محلی و مستقیم‌تر برقرار شود — اما پس از rollout چند گزارش درباره شکست‌های نامنظم در درخواست‌های تبدیل تصاویر بزرگ رسید؛ پاسخ‌ها 200 برمی‌گرداندند ولی دادهٔ تصویر ناقص بود. 🔍

وقتی یک پاسخ باید دو مگابایت می‌بود، گاهی فقط چندصد کیلوبایت می‌رسید. شش هفته طول کشید تا یک باگ تقریباً نامرئی — یک race condition که تنها در شرایط خاص رخ می‌داد — پیدا شود. این باگ در کتابخانه hyper بود و بر نحوهٔ برگرداندن دادهٔ پردازش‌شده به کلاینت توسط Images binding تأثیر می‌گذاشت. در نهایت، چهار خط کد مشکل را حل کرد. 🐞➡️✅

معماری کلی و مسیر داده: وقتی توسعه‌دهنده‌ها روی Cloudflare برنامه می‌سازند، از مجموعه‌ای از سرویس‌های پلتفرم که از طریق bindings در Workers قابل‌دسترس‌اند استفاده می‌کنند. Images binding پردازش تصویر را از تحویل جدا می‌کند: می‌توانید تصویر را تران‌اسکد، کامپوزیت یا دست‌کاری کنید بدون اینکه الزاماً خروجی را به‌صورت مستقیم به‌عنوان HTTP response برگردانید. همچنین می‌توانید اپتیمایزیشن‌ها را به هر ترتیبی اعمال کنید، نه ترتیب ایستا که URL interface تحمیل می‌کند.

مثالی از فراخوانی binding در Worker:

const result = await env.IMAGES
  .input(image)
  .transform({ width: 800, rotate: 90 })
  .output({ format: "image/avif" });
return result.response();

به‌صورت کلی، جریان داده این‌طوری است: یک socket بین intermediary و Images برقرار می‌شود و داده‌ها از یک پروسس به پروسس بعدی از طریق بافرهای کرنل منتقل می‌شوند. binding از طریق یک socket که runtimeِ Workers آن را مدیریت می‌کند با Images ارتباط برقرار می‌کند. هر سرِ socket بافرهایی دارد که توسط kernel اداره می‌شود؛ این بافرها محل موقت نگهداری داده هستند تا زمانی که یکی بنویسد و دیگری بخواند.

در سمت سرویس Images، hyper مدیریت اتصال را بر عهده دارد: خواندن درخواست‌ها از socket و نوشتن پاسخ‌ها به socket. وقتی یک درخواست از طریق binding می‌آید، Images دادهٔ ورودی را می‌خواند، عملیات بهینه‌سازی را اجرا و نتیجه را encode می‌کند و سپس کل تصویر encode شده را به‌عنوان یک بلوک در حافظه به hyper تحویل می‌دهد. hyper این دادهٔ پاسخ را در بافر داخلی خودش می‌نویسد و آن‌وقت کار encode برای hyper کامل تلقی می‌شود. مرحلهٔ بعدی فلش کردن بافر داخلی hyper به outbound bufferِ socket است، یعنی انتقال داده از Images به intermediary.

اگر ریدر آن‌طرف سریع باشد، hyper می‌تواند همه چیز را در یک پاس ارسال کند و سپس shutdown روی socket بزند تا نشان دهد که نوشتن به پایان رسیده. اما اگر ریدر کندتر باشد، outbound buffer پر می‌شود و hyper باید منتظر بماند تا فضای کافی آزاد شود. همین نقطه جایی است که رفتارهای race و edge-caseها می‌توانند بروز کنند.

تغییر معماری: تمام ترافیک ورودی در شبکه از سرویس داخلی FL عبور می‌کند که کارهای امنیتی و performance و routing را انجام می‌دهد. در طراحی اولیه، داده‌ها از طریق FL و روی sockets شبکه به Images می‌رفت که همخوان با معماری URL interface بود. اما coupling با FL محدودیت‌هایی ایجاد کرد: هر تغییر در binding باید چرخهٔ انتشار FL را دنبال می‌کرد.

در دسامبر 2025 تیم Images FL را با یک intermediary جدید جایگزین کرد: یک internal worker binding که روی همان ماشین اجرا می‌شود. به‌جای حرکت داده‌ها از طریق شبکه، اتصال‌ها با Unix sockets برقرار شد و بار پردازشی و overheadِ شبکه حذف شد. این تغییر مسیر را سریع‌تر و زمان‌بندی ریلیزها را مستقل کرد. اما چند روز پس از rollout، گزارش مشتریان آمد: پاسخ‌هایی که 200 داشتند اما تصویر ناقص بود — یعنی 200 OK ولی اصلاً OK نبود. 😵

مورد واقعی مشتری: یک مشتری با یک پیپلاین خاص دو لایه مشکل را نشان داد؛ یک pipeline داخلی که چند تصویر بزرگ از R2 را کامپوزیت می‌کرد (JPEG پس‌زمینه و لایه‌های PNG) و یک pipeline بیرونی که خروجی را از طریق URL interface برای تحویل نهایی فشرده و ترانسکد می‌کرد. باگ در مسیر بازگشت پاسخ از pipeline داخلی رخ می‌داد: پاسخ قبل از رسیدن به pipeline بیرونی قطع می‌شد، اما pipeline داخلی هنوز 200 و Content-Length درست برمی‌گرداند. در یک نمونه، به‌جای 3.3 MB فقط ~200 KB رسید — که عددی قابل‌تأمل بود چون بسیار نزدیک به سایز بافر socket در تولید بود.

وقتی مرورگر یک تصویر ناقص می‌گیرد، بسته به فرمت یا بخشی از تصویر را رندر می‌کند یا کلاً decode شکست می‌خورد و یک broken image نمایش داده می‌شود. این رفتار بیرونی واضح بود اما منبع خطا در لایه‌های پایین‌تر می‌توانست باشد: binding، intermediary، Images، یا هر نقطهٔ بینشان.

فرآیند دیباگ: از بیرون به درون مسیر را بررسی کردیم و هر لایه را آزمودیم تا محل قطع شدن را پیدا کنیم. بعضی تلاش‌ها بن‌بست می‌شد؛ اما آثاری باقی ماند که دامنه مشکل را محدود کرد:

– بازتولید: یک Worker ساختیم که تنظیمات تو در تو مشتری را شبیه‌سازی می‌کرد و سپس لایه‌ها را کم کردیم تا فقط با binding مشکل را trigger کنیم. با یک اسکریپت درخواست‌ها را دسته‌ای شلیک کردیم؛ در یک اجرای اولیه 19 از 25 درخواست fail شد. مقدار دادهٔ رسیده (~200 KB) مشکوک بود و ما را به سمت فرضیهٔ بافر socket هدایت کرد.

– بررسی timeoutها: احتمال دادیم مشکل از timeout باشد اما مشاهده کردیم که قطع شدن با مدت زمان درخواست همبستگی ندارد.

– تغییر نسخهٔ hyper: نسخهٔ موجود 0.14.x بود و ما تست کردیم روی 1.7 و 1.8 هم؛ باگ در هر نسخه ظاهر می‌شد، پس مشکل ظاهراً با آپ‌استریمِ hyper برطرف نشده بود.

– بازتولید محلی: روی macOS و Debian VM با بار بالا تست کردیم اما هرگز failure نداشتیم؛ حتی ارسال مستقیم با curl و replayِ درخواست‌های ضبط‌شده هم کار می‌کرد. باگ تنها در مسیر تولید، تحت concurrency واقعی و با یک runtimeِ Workers واقعی در آن سوی socket ظاهر می‌شد؛ این ما را به سمت شک نسبت به runtime برد.

– رد شدن runtime: لاگ‌ها و traces از هر دو سمت نشان نداد که syscall غیرمنتظره یا close زودهنگام وجود داشته باشد. کلاینت runtime درست رفتار می‌کرد و سرویس‌های دیگر هم از همان کلاینت بدون مشکل استفاده می‌کردند.

– tracing توزیع‌شده: با بررسی کامل request traces دیدیم که بدنهٔ truncate شده قبل از رسیدن به pipeline بیرونی آماده بوده؛ یعنی مشکل در pipeline داخلی — مسیر binding از طریق Images — رخ می‌داد.

– instrument کردن intermediary: اندازهٔ body قبل از فوروارد کردن ثبت شد و دیدیم که بدن‌ها وقتی از Images خارج می‌شوند already truncated هستند؛ بنابراین intermediary هم کنار رفت.

– ردیابی عمیق‌تر در Images: سطح سرویس نشان می‌داد درخواست پردازش و تصویر encode شده و پاسخ با HTTP 200 ارسال شده است. تنها سیگنال ثابت این بود که باگ وابسته به زمان و concurrency و فقط برای تصاویر بزرگ بود.

رسیدن به سطح کرنل: ابزارهای application-level فقط آنچه سیستم «فکر» می‌کرد را نشان می‌دادند: tracing می‌گفت پاسخ ارسال شده، لاگ‌ها خطا نشان نمی‌دادند، و Images برای هر درخواست 200 برمی‌گرداند. برای دیدن آنچه واقعاً اتفاق می‌افتد، strace به پروسس Images وصل کردیم تا syscalls را ضبط کنیم. با این کار شدنی است رفتارهای نوشتن روی socket، return valueهای write، و فراخوانی shutdown را دقیق ببینیم — چیزهایی که لاگ اپلیکیشن معمولاً نشان نمی‌دهد.

آنچه strace نشان داد این بود که در شرایطی که outbound bufferِ socket پر می‌شد، hyper تا حدی داده‌ها را نوشت، اما سپس در یک مسیر زمان‌بندی‌شده shutdown را صدا می‌زد قبل از اینکه تمام بایت‌ها توسط کرنل ارسال/قرارداده شده باشند. به عبارت دیگر، از نگاه اپلیکیشن همه داده‌ها در بافرهای داخلی hyper بودند و hyper فرض می‌کرد کار تمام شده؛ اما کرنل هنوز مقداری از داده‌ها را در صف داشت که قرار بود قبل از پایان اتصال منتقل شوند. در نتیجه طرف خواننده EOF می‌دید و body قطع می‌شد. 🧩

راه حل و درس فنی: اصلاح نهایی کوچک اما هدفمند بود: اطمینان از اینکه عملیات shutdown تا زمانی که همهٔ بایت‌ها واقعاً توسط write/flush قبول شده‌اند انجام نشود و مدیریت درست حالات partial write و EAGAIN/EWOULDBLOCK انجام شود. چهار خط کدی که اضافه شدند باعث شد hyper منتظر خالی شدن outbound buffer (یا تکمیل چرخهٔ write) شود و فقط سپس shutdown بزند. این تغییر race condition را حذف کرد بدون اینکه روی latency یا throughput تأثیر منفی محسوس داشته باشد.

چند نکته مهم برای SRE/DevOps که از این تجربه می‌شود گرفت:

– وقتی مسیر ارتباطی از network sockets به Unix sockets یا بالعکس تغییر می‌کند، رفتارهای timing و بافر می‌توانند متفاوت شوند؛ تست در شرایط concurrency واقعی ضروری است. ⚠️

– tracing و logging سطح اپلیکیشن کافی نیستند؛ گاهی باید به سطح syscalls نگاه کنید (strace یا equivalent) تا ببینید کرنل و پروسس ها دقیقاً چه می‌کنند. 🔎

– content-length و چک‌های end-to-end مفیدند: اگر possible است، middleware یا لایه‌های بالاتر باید اندازهٔ واقعی بدنه را تایید کنند و در صورت تناقض alert تولید کنند تا خطا در همان مرز دیده شود.

– کوچک‌ترین تغییرات در نحوهٔ handoff بین پروسس‌ها می‌تواند race ایجاد کند؛ طراحی ساده برای وضعیت‌های پایان اتصال (graceful shutdown) و حالت‌های partial write باید واضح و محکم باشد.

خلاصه: یک باگ timing-dependent در hyper که فقط تحت بار و شرایط خاص ظهور پیدا می‌کرد باعث می‌شد پاسخ‌های بزرگ truncate شوند؛ پیدا کردن آن شش هفته طول کشید ولی در نهایت با چند خط کد و اضافه کردن انتظار درست برای flush و مدیریت writeهای جزئی حل شد. تجربه نشان داد که برای سرویس‌های لبه (edge) و مسیرهایی که به‌شدت به concurrency وابسته‌اند، ترکیب تست‌های بار واقعی، tracing توزیع‌شده، و بررسی سطح syscall ابزارهایی ضروری‌اند تا مشکلات subtle و زمان‌بندی‌شده را پیدا و رفع کنیم. 🚀