Watch Mechanism: Lan Truyền State Hiệu Quả Không Cần Polling
Vì Sao Quan Trọng Ở Production
Toàn bộ mô hình reconciliation của Kubernetes — mọi controller, mọi Operator, kubectl get -w, Dashboard — đứng trên một giả định duy nhất: watch là kênh truyền tải state đáng tin cậy, có thứ tự, và có thể resume. Nếu giả định này sai lệch dù chỉ một chút (mất một event, nhận event sai thứ tự, hay không biết watch đã "quá cũ"), toàn bộ logic reconciliation phía trên nó sẽ suy luận sai về trạng thái thực tế của cluster — và những lỗi loại này thường im lặng cho tới khi gây ra outage.
Hiểu sai phổ biến nhất là coi watch giống long-polling đơn giản. Thực tế nó là một giao thức có state (resourceVersion), có cơ chế phục hồi lỗi rõ ràng (410 Gone → relist), và có cơ chế heartbeat riêng (bookmark) để phân biệt "không có gì thay đổi" với "kết nối đã chết". Phần này xây dựng mental model đúng về watch — không chỉ "nó hoạt động thế nào" mà còn "nó sẽ vỡ như thế nào, và tại sao thiết kế theo cách đó là hợp lý".
Vì Sao Không Dùng Polling
Trước khi đi vào cơ chế, cần hiểu bài toán mà watch giải quyết. Nếu mọi controller phải poll API server định kỳ để phát hiện thay đổi, chi phí tăng tuyến tính theo (số client) × (số resource) × (tần suất poll) — và để đạt độ trễ phát hiện thấp, tần suất poll phải cao, kéo theo tải khổng lồ lên etcd dù phần lớn lần poll không có gì thay đổi. Watch đảo ngược mô hình: client đăng ký một lần, server chủ động đẩy sự kiện khi có thay đổi. Chi phí trở thành tỷ lệ với số lượng thay đổi thực tế, không phải số lần kiểm tra — đây là lý do một cluster hàng nghìn node với hàng chục nghìn object vẫn có thể có độ trễ phát hiện thay đổi dưới một giây mà không làm sập etcd.
Giao Thức List-Watch: Hai Nửa Không Thể Tách Rời
Một client watch đúng chuẩn không bao giờ chỉ watch — nó luôn thực hiện theo trình tự List rồi Watch, gọi chung là giao thức list-watch:
Bước 1 — LIST: GET /api/v1/pods
→ trả về toàn bộ object hiện có + resourceVersion snapshot (gọi là RV_0)
Bước 2 — WATCH: GET /api/v1/pods?watch=true&resourceVersion=RV_0
→ stream các WatchEvent xảy ra SAU RV_0, vô thời hạn cho tới khi
kết nối bị đóng (timeout, lỗi mạng, API server restart, ...)Lý do bắt buộc phải làm cả hai bước: watch chỉ trả về sự kiện xảy ra sau một điểm mốc — nó không bao giờ tự "backfill" toàn bộ state hiện tại. Nếu chỉ mở watch mà không list trước, client sẽ không bao giờ biết những object đã tồn tại từ trước khi watch bắt đầu. resourceVersion trả về từ LIST chính là điểm nối giữa hai nửa — nó đảm bảo không có khoảng trống giữa "ảnh chụp state tại RV_0" và "các thay đổi từ RV_0 trở đi": không sự kiện nào bị bỏ sót, không sự kiện nào bị nhân đôi.
Watch Event Types
Mỗi sự kiện trong stream có dạng:
{"type": "ADDED", "object": {...}}
{"type": "MODIFIED","object": {...}}
{"type": "DELETED", "object": {...}}
{"type": "BOOKMARK","object": {"metadata": {"resourceVersion": "..."}}}ADDED/MODIFIED/DELETED phản ánh trực tiếp thay đổi trên object. BOOKMARK là loại sự kiện đặc biệt được trình bày ở phần sau — nó không mang thay đổi thực sự nào.
Ngữ Nghĩa resourceVersion Trong Từng Loại Request
resourceVersion là giá trị opaque (client không được diễn giải nội dung của nó, chỉ được truyền lại nguyên vẹn), phản ánh vị trí trong dòng thay đổi của etcd. Ý nghĩa của tham số này khác nhau tùy loại operation:
| Operation | resourceVersion không đặt | resourceVersion=0 | resourceVersion=<giá trị cụ thể> |
|---|---|---|---|
| GET | Đọc nhất quán trực tiếp từ etcd (linearizable) | Tương đương không đặt — đọc mới nhất | Đọc snapshot tại đúng version đó (có thể stale nếu phục vụ từ watch cache) |
| LIST | Đọc nhất quán, snapshot toàn bộ collection tại thời điểm hiện tại | Cho phép API server phục vụ từ watch cache trong bộ nhớ thay vì hit etcd — nhanh hơn nhưng có thể hơi cũ | Trả về state tại đúng revision đó |
| WATCH | Bắt đầu stream sự kiện từ thời điểm hiện tại trở đi | Hành vi tương tự — bắt đầu từ hiện tại (triển khai cũ có thể coi 0 như "list rồi watch" ẩn) | Resume watch từ đúng điểm đó — dùng để tiếp tục sau khi kết nối bị ngắt |
Điểm cần khắc sâu: resourceVersion=0 là một cơ chế tối ưu hiệu năng có chủ đích, không phải giá trị mặc định vô nghĩa. Nó cho phép API server trả lời trực tiếp từ watch cache trong bộ nhớ (xem phần etcd consistency) thay vì phải round-trip tới etcd — đánh đổi một chút khả năng "hơi cũ" (vài trăm mili-giây) để lấy độ trễ thấp hơn nhiều và giảm tải etcd đáng kể. client-go tận dụng triệt để điều này: informer luôn LIST với resourceVersion=0 ở lần khởi tạo, vì nó chấp nhận đánh đổi độ mới lấy hiệu năng — bản thân informer sẽ tự sửa sai qua watch stream ngay sau đó.
Watch Bookmark: Heartbeat Không Mang Dữ Liệu
Nếu một resource hiếm khi thay đổi (ví dụ một CRD ít được cập nhật), watch stream có thể im lặng hàng giờ. Vấn đề nảy sinh: client làm sao phân biệt được "không có gì thay đổi" với "kết nối đã chết nhưng chưa kịp phát hiện"? Và nếu kết nối bị ngắt, client biết resume từ resourceVersion nào nếu event cuối cùng nhận được đã từ rất lâu?
Watch bookmark giải quyết cả hai vấn đề bằng cách bật allowWatchBookmarks=true:
GET /api/v1/pods?watch=true&resourceVersion=RV_0&allowWatchBookmarks=trueAPI server định kỳ gửi một sự kiện BOOKMARK chứa resourceVersion mới nhất dù không có thay đổi thực tế nào trên object đó. Về bản chất, bookmark tách hai khái niệm vốn bị gộp làm một trong mô hình watch ngây thơ: "sự kiện dữ liệu" và "tiến trình theo thời gian". Nhờ đó, client có thể:
- Phát hiện watch connection còn sống mà không cần một cơ chế heartbeat riêng ở tầng ứng dụng.
- Sau khi reconnect, resume từ
resourceVersiontrong bookmark gần nhất thay vì phải LIST lại toàn bộ collection — quan trọng với collection lớn, nơi một lần relist đầy đủ có thể tốn hàng giây và tạo áp lực CPU/network đột biến.
Chunking: Phân Trang List Cho Collection Lớn
Với một namespace có hàng chục nghìn Pod, việc LIST toàn bộ trong một response duy nhất tạo áp lực bộ nhớ lớn cho cả client lẫn server. API server hỗ trợ chunking qua limit và continue:
GET /api/v1/pods?limit=500
→ { "items": [...500 object...],
"metadata": { "resourceVersion": "RV_0", "continue": "<opaque-token>" } }
GET /api/v1/pods?limit=500&continue=<opaque-token>
→ trang tiếp theo, cùng resourceVersion snapshot RV_0Điểm quan trọng về mặt cơ chế: tất cả các trang trong cùng một chuỗi phân trang phản ánh cùng một snapshot resourceVersion, dù việc lấy dữ liệu trải dài qua nhiều request có thể mất vài giây và trong lúc đó có thể có ghi mới vào etcd. continue token mã hóa đủ thông tin (revision, offset) để server tiếp tục đọc đúng snapshot cũ đó — client nhận được một view nhất quán tại một thời điểm, không phải hỗn hợp dữ liệu từ nhiều thời điểm khác nhau. Đây là lý do informer luôn nhận được đúng một resourceVersion baseline duy nhất sau khi list xong toàn bộ, dù list có thể trải qua nhiều round-trip.
Lỗi 410 Gone: Khi Resume Không Còn Khả Thi
Watch cache của API server (và bản thân etcd) không giữ lịch sử vô hạn. Chỉ một cửa sổ revision gần đây được giữ lại trước khi bị compact/evict (xem chi tiết cơ chế compaction ở phần etcd). Nếu một client resume watch với resourceVersion đã nằm ngoài cửa sổ này — thường vì kết nối bị gián đoạn quá lâu, hoặc client đứng im không đọc event khiến server-side buffer bị đầy và đóng kết nối — API server trả về:
HTTP/1.1 410 Gone
{
"status": "Failure",
"message": "too old resource version: 100 (12000)",
"reason": "Gone",
"code": 410
}Relist: Cách Duy Nhất Phục Hồi Đúng
Không có cách nào "vá" một watch đã nhận 410 Gone — điểm mốc mà client đang giữ đã không còn tồn tại trong lịch sử server có thể phục vụ. Quy trình phục hồi bắt buộc là:
- Vứt bỏ toàn bộ local cache hiện tại (vì không còn cách nào biết chính xác nó lệch với server ở đâu).
- Thực hiện LIST mới hoàn toàn (không có
resourceVersion, hoặcresourceVersion=0) để lấy snapshot mới cùngresourceVersionmới. - Mở lại WATCH từ
resourceVersionmới đó.
Đây gọi là relist, và nó không hề rẻ: với một resource có số lượng object lớn (ví dụ toàn bộ Pod trong cluster 5.000 node), một lần relist đồng loạt của nhiều controller có thể tạo ra relist storm — hàng loạt LIST request lớn dội vào API server gần như cùng lúc, mỗi request tốn CPU để serialize hàng chục nghìn object. Nếu nguyên nhân gốc của 410 Gone là API server đang quá tải, thì chính relist storm lại càng làm tình hình tệ hơn — một vòng lặp khuếch đại lỗi cổ điển ở hệ thống phân tán.
client-go xử lý việc này qua Reflector, và mặc định áp dụng exponential backoff với jitter giữa các lần relist thất bại để tránh nhiều client đồng bộ hóa lẫn nhau (thundering herd). Đây là lý do khi thiết kế bất kỳ client watch tự viết nào (không qua client-go), bắt buộc phải tự cài đặt backoff tương tự — bỏ qua bước này là nguyên nhân phổ biến của các sự cố "API server sập vì một Operator tự chế".
Watch Trong Bối Cảnh GKE Managed Control Plane
Trên GKE, API server có thể trải qua rolling restart trong quá trình auto-upgrade hoặc scale control plane theo tải (đặc biệt ở Autopilot, nơi control plane tự động resize theo workload). Mỗi lần API server instance bị thay thế, mọi watch connection đang mở tới instance đó bị đóng — client bắt buộc phải reconnect. Nếu watch cache của instance mới không giữ đủ lịch sử tại đúng resourceVersion mà client đang có (do vừa khởi động lại, watch cache rỗng và cần thời gian để warm up từ etcd), client có thể nhận 410 Gone ngay cả khi khoảng cách thời gian không lớn. Đây là lý do mọi controller chạy trên GKE phải xử lý 410 Gone như một trường hợp bình thường, không phải lỗi hiếm gặp — nó xảy ra thường xuyên hơn trực giác gợi ý, đặc biệt quanh thời điểm bảo trì control plane.
Constraints Và Failure Mode Ở Scale
- Watch cache có kích thước hữu hạn theo revision, không theo thời gian. Một cluster có tốc độ ghi cao (nhiều Pod tạo/xóa liên tục) sẽ làm cửa sổ lịch sử "trôi qua" nhanh hơn tính theo thời gian thực — nghĩa là cùng một khoảng "5 phút ngắt kết nối" có thể an toàn ở cluster ít thay đổi nhưng gây
410 Gonechắc chắn ở cluster có write rate cao. - Số lượng watch connection đồng thời là tài nguyên hữu hạn trên mỗi API server instance — mỗi watch giữ một goroutine và buffer riêng. Có quá nhiều controller custom mỗi cái tự mở watch riêng (thay vì dùng chung một
SharedInformerFactory, xem phần Informer Pattern) là nguyên nhân phổ biến khiến API server cạn tài nguyên watch dưới tải, dẫn tới việc APF bắt đầu queue hoặc reject watch request mới. - Bookmark không mặc định bật cho mọi client — client tự viết (không qua client-go) mà quên set
allowWatchBookmarks=truesẽ mất khả năng phát hiện sớm kết nối "âm thầm chết" trên resource ít thay đổi, dẫn tới độ trễ phát hiện sự kiện tăng vọt một cách khó chẩn đoán. - Relist là thao tác O(số lượng object), không phải O(số lượng thay đổi). Thiết kế controller watch trên resource có số lượng object cực lớn (ví dụ toàn bộ Event trong cluster) mà không dùng field selector để thu hẹp phạm vi sẽ khiến mỗi lần relist trở thành một cú sốc tải đáng kể — đây là lý do các built-in controller của Kubernetes gần như luôn watch với field selector hoặc label selector thu hẹp, không bao giờ watch "tất cả mọi thứ" một cách ngây thơ.