چگونه کش کنترلر زمان اجرا واقعاً کار می‌کند و چرا کنترلر شما سرور API را خراب نمی‌کند — یک نگاه عملی برای مهندسین DevOps / SRE 💡

Kubernetes مدت‌هاست پلتفرم پیش‌فرض برای بارهای کاری توزیع‌شده است و نوشتن یک کنترلر جدید حالا می‌تواند ظرف چند ساعت انجام شود. مسیر متداول این است: Go با kubebuilder و استفاده از controller-runtime که یک اسکلت پروژه، انواع (types) و یک reconciler در اختیار شما می‌گذارد. برای سناریوهای معمولی این ترکیب بیش از حد کافی است، اما به محض بالا رفتن بار یا رفتار غیرمنتظره‌ی کنترلر، مجموعه‌ای از مشکلات لبه‌ای ظاهر می‌شوند که اغلب به یک علت اصلی برمی‌گردند: درک مبهم از نحوهٔ واقعی کارکرد کنترلر و runtime در پشت صحنه 🧭.

یک تصور رایج اما اشتباه این است که فراخوانی‌هایی مثل r.Get() یا r.List() در داخل Reconcile مستقیماً به kube-apiserver ارسال می‌شوند و r.Update() هم فوراً وضعیت جدید را قابل خواندن می‌کند. در عمل اما عکس این قضیه صادق است: controller-runtime معمولاً مقابل یک کش محلی (local cache) کار می‌کند که با یک عمل list اولیه پر می‌شود و سپس با یک watch از طریق زمان به‌روز نگه داشته می‌شود. نتیجهٔ این طراحی این است که خواندن‌ها بسیار ارزان‌اند و با صدها فراخوانی در ثانیه صفحه کنترل و etcd را تحت فشار قرار نمی‌دهند، اما trade-off این است که:

• خواندن‌ها بلافاصله پس از نوشتن، لزوماً سازگار (strongly consistent) نیستند. 🔁
• کش محلی می‌تواند حافظهٔ زیادی مصرف کند و مجموعه‌ای از نمایه‌ها (indexes) حافظه را افزایش دهند.💾
• اسکن‌های خطی (O(n)) روی ده‌ها هزار شیء می‌تواند اتفاق بیفتد و هزینه‌زا باشد.⚠️

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

خلاصهٔ فنی ساده (TL;DR) 🧠:
r.Get() و r.List() در داخل یک Reconciler معمولاً از کش محلی می‌خوانند، نه مستقیماً از سرور API. کش را Reflector با یک list اولیه و سپس یک watch به‌روز نگه می‌دارد. نوشتن‌ها مستقیم به سرور API می‌روند (نه به کش)، و بنابراین خواندن‌ها ارزان‌اند اما بلافاصله پس از نوشتن، کاملاً جدید نیستند. در بعضی موارد واقعی نیاز به API reader دارید، اما این موردها نادر هستند.

کمی زمینه: مدل حلقهٔ Reconcile

در مدل پایه، Reconcile تلاش می‌کند وضعیت فعلیِ یک شیء را با وضعیت مطلوب هماهنگ کند. در عمل جریان معمولی این‌طور است: یک کاربر یا کنترلر دیگر یک شی را تغییر می‌دهد، رویدادی در صف قرار می‌گیرد، Reconcile وضعیت فعلی را می‌خواند، تصمیم می‌گیرد چه چیزهایی را ایجاد/به‌روزرسانی/حذف کند، عملیات را به API ارسال می‌کند و در ادامه سیستم رویدادها/آگاه‌سازی‌ها را تولید می‌کند و چرخه ادامه می‌یابد.

برای دیدن این مدل به‌صورت عملی، کافی است kubectl get pods –watch را اجرا کنید: شما زنجیره‌ای از تغییرات را می‌بینید (سازمان‌دهی، اختصاص گره، به‌روزرسانی kubelet و غیره). کنترلرها به‌صورت پیوسته polling نمی‌کنند؛ آن‌ها یک جریان رویداد (watch) مصرف می‌کنند و یک وضعیت محلی را حفظ می‌کنند.

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

Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var pod corev1.Pod
    if err := r.Get(ctx, req.NamespacedName, &pod); err != nil {
        return ctrl.Result{}, err
    }
    // ... منطق معنی دار ...
}

اما وقتی r.Get فراخوانی می‌شود، معمولاً این کار یک درخواست HTTP جدید به API نیست؛ بلکه خواندن از کش محلی است. اگر قرار بود هر Reconcile یک GET یا LIST از API انجام دهد، در خوشه‌های بزرگ یا تحت بار بالا، سرور API و etcd به سرعت تحت فشار قرار می‌گرفتند. برای جلوگیری از این، Kubernetes از مدل list+watch استفاده می‌کند که توسط controller-runtime به‌شکل یک framework دوستانه بسته‌بندی شده است.

اجزای اصلی که باید بدانید (واژه‌نامه کوتاه):

• GVK — تقریباً همهٔ APIها در runtime بر اساس Group-Version-Kind کار می‌کنند. بعضی جاها این فیلد به‌صورت رشته نمایان می‌شود ولی مفهومی که باید در ذهن بماند همین GVK است.
• resourceVersion — دو نقش مهم دارد: برای کنترل همزمان خوشبینانه (optimistic concurrency) هنگام update و برای resume کردن watch از یک نقطهٔ مشخص. Reflector این مقدار را هنگام list از سرور API می‌گیرد و از آن برای باز کردن watch از همان نسخه استفاده می‌کند.
• Manager (ctrl.Manager) — شیٔی که بیس runtime را ساخته و اجرا می‌کند: کش مشترک، client، کنترلرها، وبهوک‌ها و endpointهای healthz. معمولاً یک فرآیند، یک Manager و چندین کنترلر دارد.🧰

• Informer — موجودیتی در client-go که یک watch را برای یک GVK نگه می‌دارد، یک Store محلی دارد و رویدادها را برای مشترک‌ها ارسال می‌کند. در controller-runtime وقتی اولین Get/List را روی یک نوع مشخص انجام می‌دهید یا وقتی آن نوع را ثبت می‌کنید، یک Informer به‌صورت خودکار ساخته می‌شود.
• Store / Indexer — محل نگهداری واقعی اشیا در حافظه همراه با شاخص‌ها (indexes) روی آنها.
• ResourceEventHandler — رابطی با OnAdd/OnUpdate/OnDelete که Informer برای هر رویداد آن را فراخوانی می‌کند. Store هنگام فراخوانی handler قفل می‌شود تا handler همیشه جدیدترین نسخهٔ شی را ببیند.
• workqueue — صفی از کلیدهای شی (namespace/name) با قابلیت retry و نرخ‌دهی. هنگام وقوع رویداد، کنترلر معمولاً یک کلید به صف می‌فرستد و workerها آن را از صف برمی‌دارند و به Reconcile پاس می‌دهند.

نگاهی سریع به پشتهٔ کش: k8s.io/client-go/tools/cache

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

• Reflector — تنها مؤلفه‌ای که مستقیماً با سرور API صحبت می‌کند: یک list اولیه انجام می‌دهد و سپس یک watch را باز نگه می‌دارد. دلتاها را به یک صف می‌فرستد.
• DeltaFIFO — صفی که آن دلتاها را نگه می‌دارد و بر اساس کلید namespace/name، ترتیب دلتاها را حفظ می‌کند.
• Indexer (Store) — خودِ ذخیرهٔ اشیا در حافظه همراه با شاخص‌ها.
• SharedIndexInformer — رابطی که همه را به هم وصل می‌کند، دلتاها را از DeltaFIFO می‌گیرد، Store را به‌روز می‌کند و Event handlers را فراخوانی می‌کند.

خط لولهٔ کلی به شکل ساده این است: Reflector → DeltaFIFO → Indexer/Store → Event handlers (کنترلرهای شما و هر ناظر دیگری).

Reflector و نقش resourceVersion

Reflector دو کار کلیدی دارد: گرفتن یک لیست واحد هنگام شروع و سپس باز نگه داشتن یک watch از آن نسخه به بعد. هنگام برگرداندن لیست، سرور API یک resourceVersion همراه با عکس‌العمل برمی‌گرداند؛ Reflector از آن برای باز کردن watch از همان نقطه استفاده می‌کند تا هیچ رویدادی بین پایان لیست و آغاز watch از دست نرود. اگر اتصال قطع شود، Reflector با آخرین resourceVersion تلاش می‌کند دوباره وصل شود؛ اگر API به‌صورت 410 Gone پاسخ دهد، یعنی نسخه خیلی قدیمی است، Reflector باید یک relist انجام دهد و از نو شروع کند.

DeltaFIFO … (در ادامهٔ مقاله می‌توان جزئیات پیاده‌سازی DeltaFIFO، رفتارهای lock، فشرده‌سازی دلتاها و چگونگی تأثیر این موارد بر memory/CPU و latency را بررسی کرد). 🧩

نکات عملی برای نوشتن کنترلرهای قابل اطمینان در تولید:

• فرض کنید همهٔ خواندن‌های داخل Reconcile از کش است؛ برای خواندن قاعدتاً جدید، از API reader صریح استفاده کنید، اما با احتیاط — این کار هزینهٔ شبکه و بار روی etcd را بالا می‌برد.🔎
• مراقب اندازهٔ کش و تعداد ایندکس‌ها باشید؛ ایندکس‌های اضافی حافظه را زیاد می‌کنند.💾
• از کار روی مجموعه‌های بزرگ به‌صورت مرتب (full scans) پرهیز کنید؛ طراحی reconciliation و predicates را طوری انجام دهید که فقط وقتی واقعاً لازم است چیزی را وارد صف کنید، وارد شود.⚙️
• وقتی به سازگاری قوی نیاز دارید، طراحی کنید که پس از write صبر یا مکانیزمی برای revalidate داشته باشید، چون کش محلی ممکن است بازتاب فوری تغییر را نداشته باشد.

اگر بخواهید، می‌توانم در ادامه بخش‌هایی مثل جزئیات DeltaFIFO، نمونه‌های عملی وقتی باید از API reader استفاده کرد، یا الگوهای بهینهٔ index و predicate را با مثال‌های اجرایی نشان دهم — فقط بگویید کدام بخش برای شما مهم‌تر است. 🙂