Controllers, Informers, and Workqueues
ไอเดียหลักในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียหลักในหนึ่งประโยค”controller ทุกตัวใน Kubernetes ไม่ว่าจะ built-in หรือ custom มีรูปร่างเดียวกัน Reflector list ครั้งเดียวแล้ว watch ต่อ ป้อนเข้า DeltaFIFO ที่ Indexer/Lister อ่านเป็น cached state ในขณะที่ workqueue แยกต่างหากที่ rate-limited แยก “event มาถึง” ออกจาก “reconcile รันแล้ว” ทำให้อธิบาย controller ได้เสมอว่าคือ watch → queue → reconcile ไม่ใช่ loop ที่ poll
pattern จริง ตรงจาก client-go
หัวข้อที่มีชื่อว่า “pattern จริง ตรงจาก client-go”package tools/cache ของ client-go (pin ตาม Kubernetes v1.37.0 ของคอร์สนี้) คือ library ที่ controller ทุกตัวใน codebase — และ custom controller เกือบทุกตัวที่เคยเขียนด้วย Go หรือ TypeScript คุยกับ Kubernetes API — สร้างขึ้นบนมัน
- Reflector (
staging/src/k8s.io/client-go/tools/cache/reflector.go) มีหน้าที่เดียวคือเรียกListAndWatch(บรรทัด 463 ใน source ที่คอร์สนี้ pin ไว้)list()(บรรทัด 674) ครั้งเดียว แล้วwatch()(บรรทัด 561) ต่อจากresourceVersionนั้นไปเรื่อย ๆ ตลอดไป restart watch (ด้วยการ re-list ใหม่ที่ถูกกว่า เฉพาะเมื่อ watch เองพัง) ทุกครั้งที่ connection หลุด - DeltaFIFO (
tools/cache/delta_fifo.go) คือ queue ที่ Reflector push เข้าไป ค่าDeltaTypeจริงที่ export ออกมา (บรรทัด 182 เป็นต้นไป) คือAdded,Updated,Deleted,Replaced(ใช้ตอน resync หรือ watch error บังคับให้ re-list เต็ม),Sync(การส่งซ้ำเป็นช่วง ๆ ของ state ที่มีอยู่แล้ว ไม่ทำอะไรถ้าไม่ตอบสนอง) และBookmark - Indexer/Lister คือ cache ในหน่วยความจำที่สร้างจาก Delta stream เดียวกันนี้ นี่คือสิ่งที่ทำให้โค้ดที่ใช้
client-goเร็วLister().Get(...)ของ controller ไม่เคยเรียก network เลย มันอ่าน cache ของ informer เอง - workqueue (
staging/src/k8s.io/client-go/util/workqueue) คือรอยต่อที่ตั้งใจไว้ระหว่าง “event มาถึง” กับ “reconcile รันแล้ว” event handler ของ informer ไม่ทำอะไรนอกจากคำนวณ key แล้วเรียกqueue.Add(key)(หรือAddRateLimited(key)ตอน retry) ส่วน worker goroutine อีกชุดเรียกqueue.Get()เป็น loop แล้วรัน reconcile function จริง นี่คือเหตุผลที่ controller reconcile key เดียวกันซ้ำสองครั้งติดกันได้อย่างปลอดภัย หรือรวม update สิบครั้งติด ๆ กันของ object เดียวเป็น reconcile ครั้งเดียว — queue ทำ dedupe ด้วย key ไม่ใช่ด้วย event
flowchart LR api["kube-apiserver: watch stream"] --> reflector["Reflector\nListAndWatch()"] reflector --> deltafifo["DeltaFIFO\nAdded / Updated / Deleted / Replaced / Sync / Bookmark"] deltafifo --> indexer["Indexer / Lister\n(local cache, ไม่มีการเรียก network)"] deltafifo --> handlers["Event handlers\nคำนวณ key เท่านั้น"] handlers --> queue["workqueue\n(rate-limited, dedupe ด้วย key)"] queue --> worker["Worker goroutine: Get() -> reconcile(key) -> Done()/AddRateLimited()"] worker --> indexer
Level-triggered ไม่ใช่ edge-triggered: ทำไม Sync/Replaced ถึงมีอยู่
หัวข้อที่มีชื่อว่า “Level-triggered ไม่ใช่ edge-triggered: ทำไม Sync/Replaced ถึงมีอยู่”ข้อเท็จจริงที่ละเอียดอ่อนและพลาดง่าย Delta type ข้างบนไม่ใช่แค่ “อะไรเปลี่ยน” มันมี Sync กับ Replaced ที่ fire โดยไม่มีอะไรเปลี่ยนกับ object เลยด้วยซ้ำ — resync เต็มเป็นช่วง ๆ (ตั้งค่าผ่าน resyncPeriod ของ informer) ส่ง object ทุกตัวที่มีอยู่ใน cache ซ้ำเป็น delta Sync และ watch ที่ต้อง restart ด้วย list ใหม่ส่ง object ทุกตัวซ้ำเป็น Replaced (comment ที่ delta_fifo.go บรรทัด 186-193 ใน source ที่คอร์สนี้ pin ไว้ “Replaced is emitted when we encountered watch errors and had to do a relist”) นี่คือเหตุผลเชิงกลไกที่ทำไม controller ของ Kubernetes เรียกว่า level-triggered reconcile function ที่ถูกต้องอ่าน desired-vs-actual state ปัจจุบันแล้ว converge มันเข้าหากัน ไม่ว่า ทำไม มันถึงถูกเรียก — เปลี่ยนจริง, resync หรือ relist — แทนที่จะตอบสนองต่อ delta นั้นเหมือนเป็นคำสั่งแก้ไข controller ที่จัดการแค่ Added/Updated/Deleted แล้วเมิน Sync อาจจะยังโอเค (delta Sync มักจะ reconcile ไปเป็น no-op) แต่ controller ที่พยายามตีความ delta ว่าเป็น “อะไรเปลี่ยนไปเป๊ะ ๆ” แทนที่จะเป็น “key นี้อาจจะต้อง reconcile” คือเข้าใจ pattern ผิดไปแล้ว
Rate limiting และ requeue: AddRateLimited กับ Forget
หัวข้อที่มีชื่อว่า “Rate limiting และ requeue: AddRateLimited กับ Forget”workqueue ไม่ใช่ FIFO ธรรมดา TypedRateLimitingInterface (staging/src/k8s.io/client-go/util/workqueue/rate_limiting_queue.go บรรทัด 27) เพิ่มสองเมธอดที่ queue ธรรมดาไม่มี AddRateLimited(item) ที่ requeue item หลัง backoff ที่โตขึ้นเรื่อย ๆ ตาม failure ที่เกิดซ้ำ และ Forget(item) ที่ reset backoff นั้นเมื่อ reconcile สำเร็จในที่สุด worker loop จริงของ Deployment controller แสดง pattern นี้ตรง ๆ (pkg/controller/deployment/deployment_controller.go)
// pkg/controller/deployment/deployment_controller.go, v1.37.0 (line numbers from this course's pinned source)// line 416, inside syncHandler's caller on a transient error:dc.queue.AddRateLimited(key)
// line 502/518, inside processNextWorkItem, on success:dc.queue.Forget(key)
// line 512, on error from syncHandler:dc.queue.AddRateLimited(key)reconcile ที่คืน error จะถูก requeue ด้วย exponential backoff (AddRateLimited) ส่วนตัวที่คืนสำเร็จเรียก Forget เพื่อ reset backoff กลับเป็นศูนย์ เพื่อให้ failure ชั่วคราวครั้งถัดไปเริ่ม backoff ใหม่จากศูนย์แทนที่จะโตต่อเนื่องไม่หยุด นี่คือกลไกจริงเบื้องหลัง “controller ของ Kubernetes retry อัตโนมัติ” มันไม่ใช่ supervisor ทั่วไปที่ restart ทั้ง process แต่คือ queue ตัวนี้ ต่อ key
TypeScript informer ขนาดเล็ก รันกับ cluster จริง
หัวข้อที่มีชื่อว่า “TypeScript informer ขนาดเล็ก รันกับ cluster จริง”pattern เดียวกันใน @kubernetes/client-node 2.x ใช้ object-parameter API จริง (listNamespacedPod({ namespace }) ไม่ใช่ signature แบบ positional argument เก่า) กับ makeInformer
import { KubeConfig, CoreV1Api, makeInformer } from '@kubernetes/client-node';
const kc = new KubeConfig();kc.loadFromDefault();const k8sApi = kc.makeApiClient(CoreV1Api);
const NAMESPACE = 'internals-demo';const listFn = () => k8sApi.listNamespacedPod({ namespace: NAMESPACE });
// A workqueue-shaped reconcile loop: every event just enqueues a key,// never runs reconcile inline -- this is what "level-triggered, not// edge-triggered" means in practice.const queue: string[] = [];function enqueue(key: string) { console.log(`[informer] enqueue ${key}`); if (!queue.includes(key)) queue.push(key);}
const informer = makeInformer(kc, `/api/v1/namespaces/${NAMESPACE}/pods`, listFn);
informer.on('add', (obj) => enqueue(`${NAMESPACE}/${obj.metadata?.name}`));informer.on('update', (obj) => enqueue(`${NAMESPACE}/${obj.metadata?.name}`));informer.on('delete', (obj) => enqueue(`${NAMESPACE}/${obj.metadata?.name}`));informer.on('error', (err) => { console.error('[informer] watch error, will restart after backoff:', err); setTimeout(() => informer.start(), 5000);});
async function reconcile(key: string) { // A real reconcile always re-reads current state from the Lister/cache // instead of trusting the event payload -- proven necessary below. console.log(`[reconcile] ${key}`);}
setInterval(async () => { const key = queue.shift(); if (key) await reconcile(key);}, 200);
informer.start();รันจริงกับ kind cluster ของบทนี้ (kubectl label pod probe-pod round=one ทำให้เกิด event update ระหว่าง informer ต่ออยู่)
$ npx tsx src/informer-demo.ts[informer] enqueue internals-demo/probe-pod[reconcile] internals-demo/probe-pod[informer] enqueue internals-demo/probe-pod[reconcile] internals-demo/probe-podคู่ enqueue/reconcile แรก fire ทันทีตอนเริ่ม — makeInformer ส่ง state ปัจจุบันเป็น event add เสมอระหว่าง list() เริ่มต้นของมัน ตรงกับ ListAndWatch ของ Reflector เองเป๊ะ ๆ คู่ที่สองคือ event update จริงจากการเปลี่ยน kubectl label ส่งผ่าน watch connection เดิมที่ยังเปิดอยู่ ไม่มีการ poll คั่นกลางเลย
ownerReferences และ garbage collection พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “ownerReferences และ garbage collection พิสูจน์แล้ว”Deployment ไม่ได้ลบ Pod ของตัวเองตอนคุณลบมัน — controller แยกต่างหากชื่อ GarbageCollector watch ownerReferences แล้วลบ dependent เมื่อ owner หายไป chain นี้เห็นได้ตรง ๆ บน cluster จริง ReplicaSet ทุกตัวที่ Deployment สร้างมี metadata.ownerReferences ชี้กลับไปที่ Deployment (pkg/controller/replicaset/replica_set.go อ่านกลับด้วย metav1.GetControllerOf(rs) เช่นบรรทัด 309) และ Pod ทุกตัวที่ ReplicaSet สร้างมี ownerReferences ชี้ไปที่ ReplicaSet นั้น (GetControllerOf(pod) บรรทัด 474)
apiVersion: apps/v1kind: Deploymentmetadata: name: gc-demo3 namespace: internals-demospec: replicas: 1 selector: matchLabels: { app: gc-demo3 } template: metadata: labels: { app: gc-demo3 } spec: containers: - name: c image: registry.k8s.io/pause:3.10ownerReferences จริง อ่านกลับหลัง apply manifest ข้างบน
$ kubectl -n internals-demo get rs -l app=gc-demo3 -o jsonpath='{.items[0].metadata.name} {.items[0].metadata.ownerReferences}'gc-demo3-5c7d78c84b [{"apiVersion":"apps/v1","blockOwnerDeletion":true,"controller":true,"kind":"Deployment","name":"gc-demo3","uid":"e71838d9-6050-45ff-a00e-b1d89b54d071"}]
$ kubectl -n internals-demo get pods -l app=gc-demo3 -o jsonpath='{.items[0].metadata.name} {.items[0].metadata.ownerReferences}'gc-demo3-5c7d78c84b-8jlbk [{"apiVersion":"apps/v1","blockOwnerDeletion":true,"controller":true,"kind":"ReplicaSet","name":"gc-demo3-5c7d78c84b","uid":"1ba8c52e-1ebf-4b0f-bce0-8f17f58771b3"}]ลบ Deployment ด้วย cascade default ลบทั้ง chain — ReplicaSet หายไปทันทีและ Pod กำลังจะตามไป ไม่ใช่แค่ object Deployment
$ kubectl -n internals-demo delete deployment gc-demo3deployment.apps "gc-demo3" deleted from internals-demo namespace
$ kubectl -n internals-demo get rs,pods -l app=gc-demo3NAME READY STATUS RESTARTS AGEpod/gc-demo3-5c7d78c84b-8jlbk 1/1 Terminating 0 40s--cascade=orphan ปฏิเสธพฤติกรรมนั้นตรง ๆ และพิสูจน์อะไรที่เจาะจงกว่า “children รอด” — มัน orphan แค่ dependent ชั้นถัดไปทันที ของ object ที่คุณระบุชื่อ ไม่ไล่ลงไปลึกกว่านั้น การลบ Deployment สอง replica แบบนี้
$ kubectl -n internals-demo delete deployment gc-demo4 --cascade=orphandeployment.apps "gc-demo4" deleted from internals-demo namespace
$ kubectl -n internals-demo get rs,pods -l app=gc-demo4NAME DESIRED CURRENT READY AGEreplicaset.apps/gc-demo4-54cd5f4cd9 2 2 2 1s
NAME READY STATUS RESTARTS AGEpod/gc-demo4-54cd5f4cd9-7jhlq 1/1 Running 0 1spod/gc-demo4-54cd5f4cd9-lddmh 1/1 Running 0 1sReplicaSet ยังอยู่ ไม่ใช่แค่ Pod เท่านั้น — --cascade=orphan บอก GarbageCollector ว่า “อย่าลบ dependent ของ object นี้” แค่นั้นจบ มันไม่ได้พูดอะไรเกี่ยวกับ dependent ของ ReplicaSet เองเลย เพราะ ReplicaSet เองไม่เคยถูกแตะต้องเลย นี่คือการแก้การเดาที่ฟังดูสมเหตุสมผลแต่ผิด orphan mode ไม่ได้ “ไล่ลงไปชั้นเดียวแล้วเลิกเป็นเจ้าของ” — มันหยุด cascade ที่ object ที่คุณลบจริง ๆ เท่านั้น และทุกอย่างข้างล่าง object นั้น (ซึ่งไม่เคยถูกลบเอง) ก็ไม่ถูกกระทบเลย
chain controller Deployment → ReplicaSet → Pod จาก source
หัวข้อที่มีชื่อว่า “chain controller Deployment → ReplicaSet → Pod จาก source”controller สามตัวแยกกัน แต่ละตัว reconcile คนละชั้น แต่ละตัวไม่รู้จักชั้นที่ห่างออกไปสองชั้น
- DeploymentController (
pkg/controller/deployment/deployment_controller.go) reconcile Deployment ด้วยการสร้าง/แก้/ลบ ReplicaSet ให้ตรงกับspec.replicasและ rollout strategy — มันไม่แตะ Pod โดยตรงเลย - ReplicaSetController (
pkg/controller/replicaset/replica_set.go) reconcile ReplicaSet ด้วยการสร้าง/ลบ Pod ให้ตรงกับspec.replicasของตัวเอง —manageReplicas(บรรทัด 649) คือฟังก์ชันที่เรียก API สร้าง/ลบ Pod จริง ๆ มันไม่รู้ด้วยซ้ำว่ามี Deployment อยู่ชั้นบน - GarbageCollector watch
ownerReferencesทั่วทั้ง cluster แล้วลบ object ใด ๆ ที่ owner หายไป นี่คือเหตุผลเดียวที่การลบ Deployment ดูเหมือน “cascade” — ไม่มีฟังก์ชันเดียวที่ลบ Pod ของ Deployment มีแค่ controller อิสระสามตัวที่ต่างทำงานแคบ ๆ ของตัวเอง เชื่อมกันแค่ผ่านownerReferences
Leader election: controller ที่ active ตัวเดียว มีตัวสำรองยืนรอ
หัวข้อที่มีชื่อว่า “Leader election: controller ที่ active ตัวเดียว มีตัวสำรองยืนรอ”cluster จริงรัน controller-manager process ด้วย redundancy — หลาย replica ของ kube-controller-manager มีแค่ตัวเดียวที่ควร reconcile อยู่จริง package tools/leaderelection ของ client-go (staging/src/k8s.io/client-go/tools/leaderelection/leaderelection.go) implement สิ่งนี้ด้วย object Lease ใน API server LeaseDuration, RenewDeadline และ RetryPeriod (field ของ LeaderElectionConfig บรรทัด 116 เป็นต้นไป) คุมว่า non-leader รอนานแค่ไหนก่อนจะสรุปว่า leader ปัจจุบันตายแล้วแล้วพยายาม acquire() (บรรทัด 252) lease เอง นี่คือ pattern เดียวกันเป๊ะกับ “watch API server แล้วทำตามที่เห็น” ของ informer — leader election ไม่มีกลไก coordination แยกต่างหาก มันคือ controller ที่ reconcile object Lease ตัวหนึ่ง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”controller ทุกตัวในบทนี้ — Deployment, ReplicaSet, GarbageCollector หรือตัวที่เขียนเองด้วย TypeScript — ลดรูปเหลือ pipeline สามขั้นตอนเดียวกัน Reflector เปิด watch connection ที่ยาวนานไว้ตัวเดียวแล้วแปลงเป็น Delta stream, Indexer/Lister แปลง stream นั้นเป็น cache ในเครื่องที่ทันสมัยตลอดเวลาเพื่อให้ logic reconcile ไม่ต้องขอ network round-trip ของตัวเองเพื่ออ่าน state และ workqueue แปลง “มีอะไรเกิดขึ้น” เป็น “key นี้ต้องการความสนใจ” dedupe แล้วก็ rate-limit เพื่อให้ reconcile ปลอดภัยเสมอที่จะถูกเรียกมากกว่าหนึ่งครั้งด้วยเหตุผลเดียวกัน ownerReferences คือ state ชิ้นเดียวที่ทำให้ controller อิสระ (Deployment, ReplicaSet, GarbageCollector) ต่อกันเป็นสิ่งที่ดูเหมือน delete แบบ cascade เดียว โดยไม่มีตัวไหนรู้จักตัวอื่นเลย
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
| Shared informer (watch เดียว หลาย consumer ผ่าน Lister) | watch connection เดียวต่อ resource type ต่อ process ไม่ว่า controller จะอ่านมันกี่ตัว | consumer ทั้งหมดใช้ resync period เดียวกัน consumer ที่ต้องการคนละแบบต้องมี informer ของตัวเอง |
workqueue กับ AddRateLimited | backoff อัตโนมัติเมื่อ fail ซ้ำ ไม่ต้องมี supervisor process | failure ชั่วคราวยังดีเลย์ reconcile ที่สำเร็จครั้งถัดไปของ key นั้นตาม backoff window |
ownerReferences + GarbageCollector สำหรับ cascade delete | ไม่มี controller ตัวไหนต้องรู้ dependent tree เต็มของตัวเอง | --cascade=orphan เป็น footgun ที่หยิบใช้ตามความเคยชินง่าย ทิ้งทั้ง subtree ของ dependent (ReplicaSet และ Pod) ให้รันต่อโดยไม่มี owner แบบเงียบ ๆ พิสูจน์ข้างบน |
| Leader election ผ่าน object Lease | ไม่ต้องมี coordination service แยก ใช้ API server และกลไก watch เดิมซ้ำ | พลาด RenewDeadline แบบจัง ๆ (เช่น GC pause) อาจทำให้มีช่วงสั้น ๆ ที่ไม่มี leader active เลย หรือ (น้อยครั้ง) มีสองตัว |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “controller poll API server เป็นช่วง ๆ” พิสูจน์ตรงข้ามข้างบนด้วย informer จริงที่รันอยู่ มัน list ครั้งเดียวแล้วตอบสนองต่อ watch event ทีละตัวผ่าน connection เดียวที่ยาวนาน ส่วน delta
Sync/Replacedเป็นช่วง ๆ คือ safety net ของ resync ไม่ใช่กลไกหลัก และยังเดินทางผ่าน watch connection เดิม ไม่ใช่ poll ใหม่ - “ลบ Deployment เรียกฟังก์ชันเดียวที่ลบ Pod ของมัน” พิสูจน์ข้างบนว่าไม่มีฟังก์ชันแบบนั้น controller อิสระสามตัว (Deployment, ReplicaSet, GarbageCollector) ต่างทำงานแคบ ๆ ของตัวเอง เชื่อมกันแค่ผ่าน
ownerReferences - “
--cascade=orphanแค่ปล่อย Pod ไว้ ส่วน ReplicaSet ถูกลบตามปกติ” พิสูจน์ข้างบนด้วย Deployment สอง replica จริง--cascade=orphanorphan แค่ dependent ชั้นถัดไปทันที ของ object ที่คุณลบ — ReplicaSet เองรอดสมบูรณ์ และ (เพราะมันไม่เคยถูกลบ) Pod ของมันเองก็ไม่ถูกแตะต้องเลย - “workqueue ส่งทุก event ไปให้ reconcile function” พิสูจน์ข้างบนว่า queue dedupe ด้วย key ไม่ใช่ด้วย event — update สิบครั้งติด ๆ กันของ object เดียวรวมเป็น
reconcile(key)ครั้งเดียวที่แค่อ่าน state ปัจจุบันครั้งเดียวได้ - “
AddRateLimitedกับ retry loop ธรรมดาคือสิ่งเดียวกัน” พิสูจน์ข้างบนจาก interface จริงAddRateLimitedโต backoff เมื่อ fail ซ้ำ และForgetreset มันตอนสำเร็จ — retry ธรรมดาไม่มีความจำต่อ key แบบนี้ จะยิง API server รัวๆ ตอน fail ต่อเนื่อง หรือไม่กลับมาเร็วเลยหลัง fail ชั่วคราว
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลา review “controller” ที่ generate มา (Go หรือ TypeScript) เช็คสิ่งเหล่านี้
- เรียก
List()ใน loop (setInterval,for {}กับsleep) แทนที่จะใช้ shared informer — พิสูจน์ข้างบนด้วย informer จริงที่รันอยู่ วิธีนี้พลาดประเด็นทั้งหมดของ Delta stream และเปลืองภาระ API server ตามขนาด cluster ทุก tick - event handler ที่เรียก reconcile logic ตรง ๆ โดยไม่มี queue คั่น — เสีย dedupe และ rate limiting ไปทั้งหมด update สองครั้งเร็ว ๆ ของ object เดียวรัน reconcile function (ที่อาจแพง) สองครั้งติดกันแทนที่จะเป็นครั้งเดียว
- reconcile function ที่อ่านจาก payload ของ event เอง แทนที่จะอ่าน state ปัจจุบันใหม่จาก Lister/cache — payload อาจ stale ไปแล้วตอน reconcile รันจริง (มันรออยู่ใน queue) และ delta
Sync/Replacedไม่มี payload “อะไรเปลี่ยน” ให้อ่านตั้งแต่แรก - ใช้
queue.Add(key)ทุกที่ ไม่มีการแยกAddRateLimited/Forget— controller ที่ไม่เคยใช้ rate-limited requeue ตอน error จะยอมแพ้หลัง fail ครั้งเดียว หรือ hot-loop error ถาวรที่ความเร็วเต็มที่ - script ที่สมมติว่าลบ parent object ลบ children เสมอ — พิสูจน์ข้างบนว่า
--cascade=orphanมีจริง รองรับจริง และเปลี่ยนสมมติฐานนี้แบบเงียบ ๆ
🐛 หา bug ในโค้ดนี้
// A "controller" that reacts to Pod eventsinformer.on('update', async (pod) => {if (pod.status.phase === 'Running') { await markReady(pod.metadata.name, pod.status);}});เฉลย
โค้ดนี้ reconcile ตรงจาก payload pod.status ของ watch event เอง ตรง ๆ ไม่มี workqueue คั่น พิสูจน์ข้างบนจาก semantics จริงของ DeltaFIFO resync เป็นช่วง ๆ ส่ง object เดิมเป๊ะ เป็น delta Sync โดยไม่มีอะไรเปลี่ยนจริง และ relist หลัง watch หลุดส่งมันซ้ำเป็น Replaced — ทั้งสองแบบ handler นี้ประมวลผลซ้ำราวกับว่า status.phase เพิ่งเปลี่ยน ทำงานซ้ำซากตลอดไปกับ Pod ที่แข็งแรงและไม่เปลี่ยนแปลง แย่กว่านั้น การเรียก markReady ตรงข้างใน event handler หมายความว่า event update สองครั้งที่มาใกล้กัน (เรื่องปกติจริงเมื่อหลาย field เปลี่ยนในการเขียนครั้งเดียว) รัน markReady สองครั้งพร้อมกันโดยไม่มี dedupe — วิธีแก้ที่พิสูจน์ตลอดบทนี้คือ enqueue key แล้วให้ worker แยกต่างหากเรียก reconcile(key) ที่อ่าน state ปัจจุบันใหม่ครั้งเดียวจาก Lister แทนที่จะเชื่อ payload ของ event ตัวไหนก็ตามที่มาถึง