به گزارش از وبسایت cncf، در یکی از کلاسترها احتمالاً سرویسی وجود دارد که سال‌ها پیش، بدون دردسر و در همان namespace پیش‌فرض بالا آمده و حالا تقریباً همه به آن وابسته‌اند. هیچ‌کس هم از روی عمد آن را آنجا نگذارده؛ فقط در روزهای اول، وقتی هنوز کسی حساسیتی روی namespace hygiene نداشت، این اتفاق افتاده و حالا جابه‌جایی‌اش به یک چالش جدی تبدیل شده است. 😅

دقیقاً چنین وضعیتی برای سرویسی با نام auth-svc پیش آمده بود؛ یک authentication service که ده‌ها سرویس دیگر مدام از آن استفاده می‌کردند، سال‌ها در default namespace مانده بود و درست زمانی دردسرساز شد که نیاز پیدا کرد به چیزهایی مثل namespace-scoped policies، ingress rules اختصاصی و مواردی از این دست؛ چیزهایی که namespace پیش‌فرض عملاً نمی‌توانست در اختیارش بگذارد.

جابه‌جایی این سرویس دیگر یک انتخاب اختیاری نبود، اما یک شرط مهم هم داشت: حتی برای چند ثانیه هم نباید از دسترس خارج می‌شد. این فقط یک ریسک مبهم از جنس «شاید بقیه سرویس‌ها شکایت کنند» نبود؛ auth-svc احراز هویت کل کلاستر آن منطقه را انجام می‌داد و اگر از کار می‌افتاد، هیچ‌کس در آن منطقه نمی‌توانست login کند. تمام.

قبل از اینکه روشن شود چرا این کار واقعاً سخت است، باید دقیق مشخص کنیم منظور از «مهاجرت سرویس» چیست. دو مسیر کاملاً جداگانه برای رسیدن به auth-svc وجود داشت و هر دو باید در تمام مدت انتقال فعال می‌ماندند؛ وگرنه حل کردن یکی، فقط یک outage در مسیر دیگر ایجاد می‌کرد.

درون کلاستر، سرویس‌ها به شکل معمول و از طریق Kubernetes internal DNS به auth-svc.default.svc.cluster.local دسترسی پیدا می‌کردند و به pod مربوطه هدایت می‌شدند؛ همان الگوی استاندارد Service. اما درخواست‌های بیرونی از طریق ingress وارد می‌شدند؛ سازوکاری کاملاً جدا که هیچ ارتباطی با آن DNS name نداشت. بنابراین هر راه‌حل مؤثری باید هر دو مسیر را پوشش می‌داد، نه فقط مسیری که در ظاهر ساده‌تر به نظر می‌رسید.

اولین نگاه ساده این بود: deployment را منتقل کن، referenceها را به‌روز کن و تمام. اما این روش همان لحظه‌ای فرو می‌ریزد که ببینی چه تعداد سرویس واقعاً به auth-svc وصل هستند. همان‌طور که گفته شد، ده‌ها سرویس با DNS name داخلی کلاستر به آن متصل بودند، آن هم سرویس‌هایی متعلق به تیم‌های مختلف و با release cycleهای متفاوت. هیچ لحظه جادویی‌ای وجود ندارد که با یک switch، همه آن‌ها هم‌زمان نام جدید را استفاده کنند. بعضی سرویس‌ها ماه‌هاست redeploy نشده‌اند و اصلاً نباید لازم باشد برای این موضوع همه را هماهنگ کنید.

ابزارهای deploy هم محدودیت خودشان را داشتند. pipeline ما فقط می‌توانست یک سرویس را در یک namespace منتشر کند. گزینه‌ای برای «deploy به دو جا هم‌زمان» وجود نداشت و تغییر دادن shared pipeline logic که بقیه تیم‌ها هم به آن وابسته بودند، دقیقاً همان blast radiusای بود که نمی‌خواستیم ایجاد کنیم. پس راه‌حل باید داخل یک single-namespace deploy جا می‌شد و نیازی به بازنویسی زیرساخت مشترک نداشت. 🛠️

علاوه بر این، یک OPA policy داشتیم که اجازه نمی‌داد دو ingress rule یکسان به‌طور هم‌زمان در دو namespace فعال باشند؛ قانونی منطقی که برای جلوگیری از migrationهای نصفه‌نیمه و routing مبهم طراحی شده بود.

نکته‌ای که مسئله را حل‌کردنی کرد این بود: لازم نبود فهم همه مصرف‌کننده‌ها از محل واقعی auth-svc را مهاجرت بدهم. فقط باید خود سرویس را جابه‌جا می‌کردم و در عین حال، هر کسی را که هنوز آدرس قدیمی را صدا می‌زد، بی‌سروصدا به مسیر جدید هدایت می‌کردم.

Kubernetes دقیقاً چنین امکانی دارد، هرچند معمولاً آدم‌ها یادشان می‌رود که وجود دارد: ExternalName service. این نوع Service به‌جای pod، به یک DNS name دیگر اشاره می‌کند و عملاً مثل یک CNAME عمل می‌کند. اگر بخواهید سازوکار DNS را بهتر بشناسید، همان مدل آشنای forwarding است.

سرویس واقعی در namespace جدید deploy شد و سپس Service object قدیمی به یک forwarding address تبدیل شد:

apiVersion: v1
kind: Service
metadata:
  name: auth-svc
  namespace: default
spec:
  type: ExternalName
  externalName: auth-svc.authentication.svc.cluster.local

از این لحظه به بعد، هر مصرف‌کننده‌ای که هنوز auth-svc.default.svc.cluster.local را صدا می‌زد، بی‌صدا به سرویس واقعی در namespace جدید هدایت می‌شد. در سمت آن‌ها هیچ تغییر کدی لازم نبود.

این دقیقاً شبیه تغییر آدرس در اداره پست است: لازم نیست تک‌تک فرستنده‌ها را پیدا کنید و آدرسشان را عوض کنید؛ فقط مشخص می‌کنید که نامه‌ها باید به کجا بروند و تا وقتی همه آرام‌آرام آدرس را به‌روز کنند، همه چیز به‌صورت خودکار redirect می‌شود. 📮

البته این ترفند forwarding فقط وقتی ارزش دارد که قبل از تکیه کردن به آن، مطمئن شوید واقعاً کار می‌کند. بعد از فعال شدن proxy، مرحله بعدی پایین آوردن ناگهانی چیزی نبود؛ بلکه رصد دقیق metrics در نقطه crossover بود تا معلوم شود ترافیکی که به آدرس قدیمی می‌رود، واقعاً به deployment جدید می‌رسد و نه اینکه silently fail شود یا در چرخه‌ای اشتباه بیفتد. فقط وقتی همه‌چیز سالم به نظر رسید، podهای قدیمی به zero scale شدند، نه اینکه فوراً حذف شوند.

scale to zero هیچ هزینه‌ای ندارد و در عوض یک rollback فوری می‌دهد؛ کافی است دوباره scale up کنید، اگر بعداً چیزی در downstream مشکل نشان داد. حذف کامل podها یعنی اگر چیزی اشتباه پیش برود، باید همه چیز را از صفر rebuild کنید؛ بنابراین هیچ دلیلی نداشتیم زودتر از موعد آن safety net را از دست بدهیم. پاک‌سازی را می‌شد برای بعد گذاشت.

اما یک گره دیگر باقی مانده بود: ingress. ترافیک خارجی auth-svc اصلاً از مسیر DNS-based Service وارد نمی‌شود، بلکه از طریق ingress می‌آید و من برای حذف امن ingress قدیمی، ابتدا باید ingress جدید را در namespace تازه بالا می‌آوردم. مشکل اینجا بود که policy engine اجازه نمی‌داد هر دو هم‌زمان وجود داشته باشند؛ چون داشتن identical ingress rules در دو namespace همان حالت مبهمی است که این policy برای جلوگیری از آن ساخته شده است.

این‌جا دقیقاً یک chicken-and-egg کلاسیک شکل می‌گرفت: بدون exception نمی‌شد ingress جدید را ساخت، و اگر اول قدیمی را حذف می‌کردیم، traffic gap ایجاد می‌شد. راه‌حل، جنگیدن با policy نبود؛ یک exception موقت و شفاف تعریف شد. namespace جدید با یک annotation به‌طور موقت از duplicate-ingress check مستثنا شد، ingress جدید کنار ingress قدیمی برای یک بازه کوتاه overlap بالا آمد، ترافیک بررسی شد و پس از اطمینان از عبور درست درخواست‌ها به deployment جدید، ingress قدیمی حذف شد و exception هم بعد از مدتی از اعتبار افتاد.

apiVersion: v1
kind: Namespace
metadata:
  name: authentication
  annotations:
    policy.example.com/allow-duplicate-ingress: "true"

این تغییر مستقیم وارد production نشد. ابتدا در dev آزمایش شد، بعد cutover در staging انجام گرفت و سپس چند هفته صبر کردند تا ببینند آیا در ترافیک واقعی، نکته ظریفی از قلم افتاده یا نه. این مکث کمک کرد تا قبل از اینکه مسیر حیاتی را به خطر بیندازند، از پایداری طرح مطمئن شوند. ✅

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

نکته مهم‌تر این است که پراکندگی serviceها در default namespace معمولاً از بی‌دقتی نمی‌آید؛ بیشتر از این می‌آید که هیچ فشاری برای اصلاح آن وجود نداشته است. کسی یک روز policy می‌گذارد و می‌گوید از این به بعد سرویس جدید در default ساخته نشود؛ کار درستی هم هست. اما برای سرویس‌های قدیمی نه deadline می‌گذارند و نه plan مهاجرت. آن‌ها همان‌جا می‌مانند؛ در این مورد خاص، سال‌ها.

تا وقتی تیمی به چیزی namespace-scoped نیاز پیدا نکند، این بدهی پنهان می‌ماند و فقط همان لحظه است که صورت‌حسابش می‌رسد. اگر شما هم با یک سرویس گیرکرده در چنین وضعی روبه‌رو هستید، الگوی کلی فراتر از این مهاجرت خاص است: یک ExternalName proxy می‌تواند راهی بدون هماهنگی گسترده برای جابه‌جایی هر سرویسی باشد که با DNS آدرس‌دهی شده، به شرطی که برای مدت کوتاهی دو نسخه را با دقت و به‌صورت هم‌پوشان نگه دارید، نه اینکه بخواهید همه چیز را یک‌باره cut over کنید.

دفعه بعدی که کسی گفت یک سرویس را نمی‌شود جابه‌جا کرد چون چیزهای زیادی به آن وابسته‌اند، معمولاً یعنی هنوز دنبال seamای نگشته‌اند که بر اساس DNS از هم جدا می‌شود. البته این روش فقط وقتی تمیز جواب می‌دهد که همه به auth-svc از طریق DNS name وصل شده باشند، نه با IP هاردکد شده یا ClusterIP. اگر مصرف‌کننده‌هایی دارید که DNS را دور می‌زنند، و بعضی سیستم‌های legacy چنین‌اند، مسئله‌ای که دارید کاملاً متفاوت و سخت‌تر است.