در این مطلب به نقش const pool و نحوه استفاده از Cloudflare Workers در pipeline نگهداری داخلی می‌پردازیم تا بدانید چگونه معماری گراف‌محور و scheduler بتواند ایمنی و کارایی را هم‌زمان حفظ کند.


استفاده از const pool در طراحی scheduler برای نگهداری داخلی


در این بخش به بررسی دقیق مفهوم const pool و تاثیر آن در کاهش مصرف منابع و بهبود پاسخ‌گویی نگهداری داخلی می‌پردازیم.

چطور Cloudflare Workers محور pipeline زمان‌بندی نگهداری داخلی ما شده است — یک توضیح فنی و دوستانه برای مخاطب‌های DevOps / SRE 😊

Cloudflare در بیش از 330 شهر دیتاسنتر دارد، اما این‌که بتوانیم به‌راحتی چند دیتا‌سنتر را در هر زمان از دسترس خارج کنیم و کاربران متوجه نشوند، تصوری ساده‌انگارانه است. نگهداری‌هایی که اختلال‌زا هستند نیاز به برنامه‌ریزی دقیق دارند و وقتی شرکت بزرگ شد، هماهنگی دستی بین تیم‌های infrastructure و network operations به‌سختی قابل مدیریت شد.

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

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

اینجا می‌خواهم توضیح بدهم چطور این سیستم را پیاده‌سازی کردیم و چه نتایجی گرفته‌ایم — با تاکید بر جزییات فنی که برای همکاران SRE مهم است.

تصور کنید یک edge router که جزو یک خوشه کوچک و redundant از gatewayهاست و اینترنت عمومی را به دیتاسنترهای متعددی در یک metro وصل می‌کند. در یک شهر شلوغ باید مطمئن باشیم که دیتاسنترهای پشت این خوشه به‌صورت تصادفی همه با هم آفلاین نشوند؛ یعنی نباید همه روترها هم‌زمان از مدار خارج شوند.

یک چالش دیگر مربوط به محصول Zero Trust ما و قابلیت Dedicated CDN Egress IPs است که به مشتریان اجازه می‌دهد دیتا سنترهای مشخصی را برای خروج ترافیک انتخاب کنند تا ترافیک به origin‌های نزدیک جغرافیایی برود و latency پایین بماند. برای اختصار در این مطلب، این محصول را با نام سابقش “Aegis” می‌نامیم. اگر همه دیتاسنترهایی که یک مشتری انتخاب کرده آفلاین شوند، آن مشتری ممکن است با افزایش latency یا خطاهای 5xx مواجه شود که این برای ما قابل قبول نیست.

Scheduler نگهداری ما برای حل همین مسائل ساخته شده: مطمئن می‌شویم حداقل یک edge router در منطقه فعال بماند و پیش از انجام نگهداری می‌توانیم ترکیب رویدادهای زمان‌بندی‌شده را بررسی کنیم تا ببینیم آیا ممکن است هم‌زمان تمام دیتاسنترهای مربوط به poolهای Aegis یک مشتری آفلاین شوند یا نه. اگر تداخل بالقوه‌ای وجود داشته باشد، scheduler به اپراتورها هشدار می‌دهد و می‌توانیم زمان جدیدی پیشنهاد دهیم تا همپوشانی رخ ندهد 🔔

ما این سناریوهای عملیاتی مثل در دسترس بودن edge routerها و قوانین مشتری را به عنوان maintenance constraints تعریف می‌کنیم تا نگهداری‌ها قابل‌پیش‌بینی و ایمن‌تر شوند.

هر constraint با مجموعه‌ای از آیتم‌های پیشنهادی نگهداری شروع می‌شود، مثلاً یک network router یا لیستی از سرورها. سپس تمام رویدادهای تقویم که با بازه زمانی پیشنهادی همپوشانی دارند پیدا می‌شوند. بعد اطلاعات محصول مثل لیست Aegis customer IP pools جمع‌آوری می‌شود. Aegis یک مجموعه از محدوده‌های IP را برمی‌گرداند که مشتری درخواست egress از دیتا‌سنترهای مشخص داده است، مثالی از خروجی به شکل زیر است:

[
  {
    "cidr": "104.28.0.32/32",
    "pool_name": "customer-9876",
    "port_slots": [
      { "dc_id": 21, "other_colos_enabled": true },
      { "dc_id": 45, "other_colos_enabled": true }
    ],
    "modified_at": "2023-10-22T13:32:47.213767Z"
  }
]

در این مثال دیتاسنتر 21 و 45 برای Aegis مشتری 9876 وابسته به هم هستند چون حداقل یکی از آن‌ها باید آنلاین باشد تا آن مشتری ترافیک خروجی دریافت کند. اگر بخواهیم هم‌زمان 21 و 45 را خاموش کنیم، coordinator ما هشدار می‌دهد که این کار می‌تواند باعث پیامدهای ناخوشایند برای آن بارکاری شود.

ابتدا یک راه‌حل ساده و naive داشتیم که همه داده‌ها (روابط سرورها، پیکربندی محصول، متریک‌های سلامت) را در یک Worker بارگذاری می‌کرد تا constraints را محاسبه کند. حتی در مرحله proof-of-concept به خطاهای out of memory برخوردیم. بنابراین لازم بود محدودیت‌های پلتفرم Workers را جدی بگیریم و فقط به اندازه لازم داده بارگذاری کنیم. مثلاً اگر درخواست نگهداری برای یک روتر در Frankfurt باشد، عملاً کاری به اتفاقات استرالیا نداریم؛ پس باید فقط دیتاهای همسایه در آلمان را بارگذاری کنیم تا حافظه و زمان پردازش صرفه‌جویی شود.

وقتی به الگوی constraints دقت کردیم، دیدیم که هر constraint عملاً از دو مفهوم تشکیل شده: objects و associations — در نظریه گراف همان vertices و edges. یک object می‌تواند یک network router باشد و یک association می‌تواند لیست Aegis pools در دیتاسنتری باشد که آن روتر را نیاز دارد آنلاین بماند. از مقاله TAO فیسبوک الهام گرفتیم و یک interface گرافی روی داده‌های محصول و زیرساختی‌مان ساختیم. رابط API تقریباً شبیه این بود:

type ObjectID = string
interface MainTAOInterface {
  object_get(id: ObjectID): Promise
  assoc_get(id1: ObjectID, atype: TAssocType): AsyncIterable
}

مثال کدی که یک constraint را نشان می‌دهد (برای روشن شدن منطق بازیابی associations و جمع‌بندی pools):

async function constraint(c: AppContext, aegis: TAOAegisClient, datacenters: string[]): Promise {
  const datacenterEntries = await Promise.all(
    datacenters.map(async (dcID) => {
      const iter = aegis.assoc_get(c, dcID, AegisAssocType.DATACENTER_INSIDE_AEGIS_POOL)
      const pools: string[] = []
      for await (const assoc of iter) {
        pools.push(assoc.id2)
      }
      return [dcID, pools] as const
    }),
  )
  const datacenterToPools = new Map(datacenterEntries)
  const uniquePools = new Set()
  for (const pools of datacenterToPools.values()) {
    for (const pool of pools) uniquePools.add(pool)
  }
  const poolTotalsEntries = await Promise.all(
    [...uniquePools].map(async (pool) => {
      const total = aegis.assoc_count(c, pool, AegisAssocType.AEGIS_POOL_CONTAINS_DATACENTER)
      return [pool, total] as const
    }),
  )
  const poolTotals = new Map(poolTotalsEntries)
  const poolAnalysis: Record = {}
  for (const [dcID, pools] of datacenterToPools.entries()) {
    for (const pool of pools) {
      poolAnalysis[pool] = {
        affectedDatacenters: new Set([dcID]),
        totalDatacenters: poolTotals.get(pool),
      }
    }
  }
  return poolAnalysis
}

ما دو نوع association در کد بالا استفاده می‌کنیم:
– DATACENTER_INSIDE_AEGIS_POOL: دیتاسنترهایی که در poolهای Aegis قرار دارند را برمی‌گرداند.
– AEGIS_POOL_CONTAINS_DATACENTER: دیتاسنترهایی که یک Aegis pool باید برای آن‌ها ترافیک سرو کند را برمی‌گرداند.

این associations در واقع اندیس‌های معکوس یکدیگر هستند. الگوی دسترسی مشابه قبل است، اما حالا پیاده‌سازی گراف کنترل بیشتری روی میزان و نحوه کوئری‌کردن داده دارد. پیش از این مجبور بودیم همه Aegis pools را در حافظه بار کنیم و در منطق constraint فیلتر کنیم؛ اما حالا فقط داده‌های مرتبط را مستقیم می‌گیریم. این رابط قدرتمند به ما اجازه می‌دهد که بدون پیچیده‌کردن منطق کسب‌وکار، عملکرد را در پشت صحنه بهبود دهیم و از قابلیت مقیاس‌پذیری Workers و CDN استفاده کنیم تا داده‌ها سریع از سیستم‌های داخلی‌مان fetch شوند.

بعد از تغییر به گراف هدفمند، اندازه پاسخ‌ها یک‌شبه حدود 100 برابر کوچک‌تر شد — از چند request عظیم به تعداد زیادی requestهای کوچک رفتیم. این مشکل بار حافظه را حل کرد اما یک مشکل جدید ایجاد شد: افزایش تعداد subrequestها. یک‌دفعه شروع کردیم به رد شدن از محدودیت subrequestها.

برای رفع این موضوع، یک لایه middleware هوشمند بین پیاده‌سازی گراف و API fetch ساختیم:

export const fetchPipeline = new FetchPipeline()
  .use(requestDeduplicator())
  .use(lruCacher({ maxItems: 100 }))
  .use(cdnCacher())
  .use(backoffRetryer({ retries: 3, baseMs: 100, jitter: true }))
  .handler(terminalFetch);

اگر با Go آشنا باشید، احتمالا singleflight را شناخته‌اید — ما از همین ایده الهام گرفتیم. اولین middleware درخواست‌های inflight را deduplicate می‌کند تا همه روی یک Promise مشترک منتظر بمانند به‌جای اینکه چند درخواست تکراری تولید شود. بعد یک LRU cache سبک برای کش‌کردن داخلی درخواست‌هایی که قبلا دیده‌ایم اضافه کردیم. سپس از caches.default.match در Cloudflare برای کش کردن GETها در ناحیه‌ای که Worker اجرا می‌شود استفاده کردیم و برای هر منبع TTL مناسب تعیین کردیم: داده‌های real-time ممکن است فقط 1 دقیقه کش شوند، داده‌های نسبتا static بین 1 تا 24 ساعت و داده‌های بسیار نادری که دستی تغییر می‌کنند می‌توانند مدت طولانی‌تری کش شوند.

علاوه بر این‌ها، مکانیزم استاندارد exponential backoff، retries و jitter را گذاشتیم تا وقتی یک منبع downstream موقتا در دسترس نیست، از درخواست‌های هدرشده جلوگیری کنیم. اگر Worker بدون backoff مرتب درخواست بفرستد و origin شروع به برگرداندن 5xx کند، خیلی سریع از محدودیت subrequestها عبور می‌کند — با backoff شانس موفقیت درخواست بعدی بالاتر می‌رود.

با جمع‌بندی این تغییرات، نرخ cache hit ما به حدود ~99% رسید؛ این یعنی اکثر پاسخ‌ها از cache تامین می‌شوند و تعداد subrequestهای واقعی به origin به‌شدت کاهش یافته است. نتیجه عملیاتی این بوده که توانستیم در عین حفظ سرعت و چابکی اپراتورها، ریسک نگهداری‌های همپوشان را کم کرده و ثبات سرویس‌ها را بهتر تضمین کنیم ✅

خلاصه برای همکاران SRE: ساخت یک scheduler متمرکز روی Workers با مدل گرافی، fetch pipeline هوشمند و caching درست، به ما اجازه داد بدنه‌ بزرگی از منطق پیچیده روابط زیرساختی و قوانین مشتری را بدون مصرف بیش از حد منابع اجرا کنیم و همزمان از عبور از سقف‌های پلتفرم جلوگیری کنیم. اگر می‌خواهید می‌توانم تجربه پیاده‌سازی جزئیات middleware یا الگوی association را عمیق‌تر شرح بدهم — بفرمایید کدام بخش برای تیم شما جذاب‌تر است؟ 🔧