Skip to content

Informer Pattern: List-Watch, Local Cache, Resync Intervals

Vì Sao Quan Trọng Ở Production

Watch mechanism là giao thức tầng network. Nhưng không controller nào (kể cả các controller lõi của Kubernetes như kube-controller-manager) gọi thẳng watch API và tự quản lý state — tất cả đều đi qua một lớp trừu tượng trong client-go gọi là informer. Nếu bạn viết một Operator mà không hiểu bên trong informer hoạt động thế nào, bạn sẽ mắc phải những lỗi kinh điển: gọi API server trực tiếp trong reconcile loop (gây quá tải không cần thiết), viết event handler tưởng rằng nó chạy đơn luồng an toàn nhưng thực chất có race condition, hoặc hiểu sai vì sao "OnUpdate" bắn ra dù không ai sửa object.

Phần này mổ xẻ bốn thành phần lõi bên trong informer — Reflector, DeltaFIFO, Indexer/ThreadSafeStore, và lớp điều phối SharedInformer/SharedInformerFactory — theo đúng data flow thực tế, không phải mô tả hộp đen "informer cache lại object".

Bài Toán Informer Giải Quyết

Nếu mỗi thành phần trong một controller process (ví dụ vừa cần list Pod để tính toán, vừa cần watch Pod để phản ứng thay đổi) tự mở watch-connection và tự giữ cache riêng, một process có 10 loại logic cùng quan tâm tới Pod sẽ mở 10 watch connection riêng biệt tới cùng resource — lãng phí cả network lẫn CPU serialize phía API server. Informer giải quyết bằng cách tách biệt rõ một nguồn watch duy nhất khỏi nhiều consumer tiêu thụ dữ liệu từ nguồn đó, theo đúng mô hình producer-consumer.

Kiến Trúc Tổng Thể: Bốn Lớp

┌─────────────────────────────────────────────────────────────────┐
│                         API Server                              │
└───────────────────────────┬───────────────────────────────────-─┘
                             │  List + Watch (HTTP stream)

                    ┌─────────────────┐
                    │    Reflector     │  ← Producer duy nhất
                    │  (ListAndWatch)  │
                    └────────┬─────────┘
                             │  Add/Update/Delete/Sync/Replace

                    ┌─────────────────┐
                    │    DeltaFIFO     │  ← Hàng đợi delta theo object
                    └────────┬─────────┘
                             │  Pop() — xử lý tuần tự, đơn luồng

                ┌────────────────────────────┐
                │   controller (informer's    │
                │   internal processLoop)     │
                └──────┬───────────────┬──────┘
                       │               │
                       ▼               ▼
              ┌─────────────────┐  ┌──────────────────┐
              │ Indexer /        │  │ ResourceEventHandler│
              │ ThreadSafeStore  │  │ (OnAdd/OnUpdate/    │
              │ (local cache)    │  │  OnDelete)           │
              └─────────────────┘  └──────────────────┘

                       │  Get/List (không gọi API server)
              ┌─────────────────┐
              │     Lister       │  ← Consumer đọc cache
              └─────────────────┘

Reflector: Producer Duy Nhất, Nguồn Sự Thật Về Watch

Reflector chính là thành phần thực thi giao thức list-watch mô tả ở phần trước. Nó chạy vòng lặp ListAndWatch:

  1. List toàn bộ object hiện có (thường với resourceVersion=0 để tận dụng watch cache phía server, xem phần Watch Mechanism), lấy resourceVersion baseline.
  2. Đẩy toàn bộ object từ List vào DeltaFIFO dưới dạng delta Replaced (thay thế toàn bộ nội dung hiện tại của FIFO bằng snapshot mới — dùng khi khởi tạo lần đầu hoặc sau khi relist).
  3. Mở Watch từ resourceVersion baseline đó, và với mỗi sự kiện nhận được, đẩy delta tương ứng (Added, Updated, Deleted) vào DeltaFIFO.
  4. Nếu watch bị đóng hoặc nhận 410 Gone, quay lại bước 1 (relist), với exponential backoff.
  5. Nếu resyncPeriod > 0, định kỳ gọi DeltaFIFO.Resync() — chi tiết ở phần Resync bên dưới.

Điểm mấu chốt về mặt kiến trúc: Reflector là điểm duy nhất trong toàn bộ informer nói chuyện với API server để đọc dữ liệu. Mọi thành phần phía sau nó (DeltaFIFO, Indexer, các consumer) hoàn toàn không biết tới sự tồn tại của network — chúng chỉ xử lý dữ liệu đã có sẵn trong bộ nhớ. Đây chính là ranh giới tách biệt "I/O với cluster" khỏi "business logic xử lý sự kiện", cho phép nhiều consumer dùng chung một Reflector mà không ai phải lo về watch reconnect, backoff, hay relist storm — tất cả được xử lý một lần duy nhất, tập trung.

DeltaFIFO: Hàng Đợi Theo Object, Không Phải Theo Sự Kiện Đơn Lẻ

DeltaFIFO không phải một hàng đợi sự kiện đơn giản kiểu FIFO thông thường — nó là một hàng đợi các object, mỗi object mang theo danh sách các delta đã tích lũy (Deltas []Delta, mỗi Delta gồm TypeObject). Các loại delta gồm Added, Updated, Deleted, Replaced (dùng khi relist), và Sync (dùng khi resync định kỳ, xem bên dưới).

Vì Sao Tích Lũy Theo Object Thay Vì Xử Lý Từng Sự Kiện Ngay Lập Tức

Giả sử một object bị Update hai lần liên tiếp rất nhanh trước khi consumer kịp xử lý lần đầu. Nếu DeltaFIFO là hàng đợi sự kiện thô, consumer sẽ phải xử lý cả hai Update tuần tự — tốn công xử lý một trạng thái trung gian mà thực tế không còn tồn tại (object đã tiến sang trạng thái thứ ba). DeltaFIFO thay vào đó dedupe theo key của object: nếu object đã có delta đang chờ xử lý trong hàng đợi, delta mới được append vào cùng danh sách deltas của đúng object đó thay vì tạo entry hàng đợi riêng. Khi consumer gọi Pop(), nó nhận được toàn bộ chuỗi deltas tích lũy cho object đó tính tới thời điểm pop, xử lý atomically, rồi mới chuyển sang object tiếp theo trong hàng đợi.

Hệ quả quan trọng cho người viết controller: OnUpdate(oldObj, newObj) không đảm bảo oldObj là trạng thái ngay trước newObj theo từng lần ghi vào etcd — nếu có nhiều update dồn lại trước khi consumer kịp xử lý, oldObj có thể là trạng thái từ vài update trước đó, không phải update liền kề. Controller không bao giờ được viết logic dựa trên giả định "diff giữa old và new phản ánh đúng một lần ghi etcd" — luôn phải coi newObj là nguồn sự thật duy nhất và tính toán lại toàn bộ trạng thái mong muốn từ nó (đây chính là nguyên lý level-triggered reconciliation, xem phần Controller-Runtime).

Indexer / ThreadSafeStore: Local Cache Mà Mọi Read Path Phải Dùng

Sau khi một delta được Pop khỏi DeltaFIFO và xử lý, kết quả cuối cùng được ghi vào Indexer — một cache trong bộ nhớ, thread-safe, hỗ trợ index theo field tùy ý (mặc định có sẵn index theo namespace qua MetaNamespaceIndexFunc). Đây chính là nơi state hiện tại của informer được lưu trữ, tách biệt hoàn toàn khỏi hàng đợi delta phía trên (vốn chỉ chứa các thay đổi đang chờ xử lý, không phải state).

Nguyên tắc thiết kế quan trọng nhất ở đây: mọi Lister (công cụ đọc dữ liệu do informer sinh ra) đều đọc trực tiếp từ Indexer trong bộ nhớ tiến trình, không bao giờ gọi API server. Đây là lý do vì sao gọi podLister.List() trong reconcile loop gần như miễn phí về mặt network — nó là một lần tra cứu map trong bộ nhớ cục bộ, có độ trễ micro giây, so với một API call thực sự tốn hàng chục mili-giây và tạo tải lên API server. Toàn bộ giá trị hiệu năng của informer pattern nằm ở đúng điểm này: N consumer đọc cache local với chi phí gần như bằng không, đổi lấy một Reflector duy nhất gánh chi phí network.

Hệ quả ngược lại cũng quan trọng: Indexer có thể stale — nó phản ánh state tại thời điểm delta cuối cùng được xử lý, không phải state hiện tại tuyệt đối trong etcd. Với phần lớn logic reconciliation (vốn là level-triggered và tự sửa sai qua các lần reconcile tiếp theo), độ trễ này chấp nhận được. Nhưng với logic cần tính đúng đắn tức thời (ví dụ kiểm tra tồn tại trước khi tạo để tránh trùng lặp), đọc từ Indexer là không đủ — phải đọc trực tiếp qua client tới API server.

SharedInformer Và SharedInformerFactory: Chia Sẻ Một Reflector Cho Nhiều Consumer

SharedInformer là lớp đóng gói toàn bộ pipeline Reflector → DeltaFIFO → Indexer thành một đơn vị có thể chia sẻ giữa nhiều consumer trong cùng process. SharedInformerFactory quản lý một map các SharedIndexInformer theo GVK (Group-Version-Kind), đảm bảo dù có 10 controller trong cùng binary cùng cần theo dõi Pod, chỉ một Reflector, một DeltaFIFO, một Indexer được tạo ra cho Pod — mỗi controller chỉ đăng ký thêm một ResourceEventHandler vào cùng informer đó.

go
factory := informers.NewSharedInformerFactory(clientset, 30*time.Second)
podInformer := factory.Core().V1().Pods().Informer()

podInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
    AddFunc:    func(obj interface{}) { /* controller A logic */ },
    UpdateFunc: func(old, new interface{}) { /* controller A logic */ },
})
// Một controller B khác trong cùng process:
podInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
    AddFunc: func(obj interface{}) { /* controller B logic — dùng chung informer */ },
})

factory.Start(stopCh)
factory.WaitForCacheSync(stopCh)

Mỗi ResourceEventHandler đăng ký được xử lý tuần tự trong một hàng đợi callback riêng — nhưng bản thân việc lấy delta từ DeltaFIFO và cập nhật Indexer chỉ chạy một lần duy nhất, dùng chung cho mọi handler. Điều này giải thích vì sao thêm nhiều event handler vào cùng informer gần như không tăng tải lên API server, trong khi tạo nhiều informer độc lập cho cùng resource thì có.

isInInitialList (tham số trong OnAdd) là cờ quan trọng thường bị bỏ qua: nó cho biết sự kiện Add này đến từ quá trình đồng bộ ban đầu (list lúc khởi động, hoặc sau relist) hay từ một object thực sự mới được tạo. Controller cần phân biệt hai trường hợp này nếu logic của nó có side-effect chỉ nên chạy đúng một lần khi object thực sự mới xuất hiện (ví dụ gửi thông báo) — nếu không kiểm tra cờ này, mỗi lần relist sẽ vô tình bắn lại side-effect cho toàn bộ object đã tồn tại từ trước.

Resync: Cơ Chế "Tự Sửa Sai" Định Kỳ, Không Phải Polling

resyncPeriod truyền vào lúc tạo informer không phải là tần suất gọi API server — nó là tần suất Reflector tự tạo delta Sync cho mọi object đang có trong Indexer, hoàn toàn từ dữ liệu cục bộ, không gọi network:

go
informer := cache.NewSharedIndexInformer(
    listerWatcher,
    &corev1.Pod{},
    30*time.Second,  // resyncPeriod
    cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc},
)

Mỗi resyncPeriod, toàn bộ object trong Indexer được đẩy lại qua DeltaFIFO dưới dạng delta Sync, khiến OnUpdate(obj, obj) được gọi lại dù không có gì thay đổi thực sự. Mục đích của cơ chế này là bảo hiểm cho tính đúng đắn: watch stream về lý thuyết đáng tin cậy, nhưng nếu controller có bug trong xử lý một sự kiện cụ thể (ví dụ panic giữa chừng, hoặc một nhánh logic bị bỏ sót do lỗi lập trình), resync định kỳ đảm bảo controller vẫn có cơ hội "nhìn lại" toàn bộ state và tự sửa sai — đây chính là nguyên lý level-triggered reconciliation áp dụng ở tầng informer, bổ trợ cho watch (vốn là edge-triggered).

Đặt resyncPeriod = 0 tắt hoàn toàn cơ chế này — informer khi đó thuần túy edge-triggered, chỉ phản ứng khi có thay đổi thực sự qua watch. Đây là lựa chọn hợp lý cho controller mà logic reconcile hoàn toàn idempotent và không cần bảo hiểm định kỳ, nhưng rủi ro là mọi lỗi bỏ sót sự kiện sẽ tồn tại vĩnh viễn cho tới khi có thay đổi mới trên đúng object đó.

Ràng Buộc Và Failure Mode Ở Scale

  • resyncPeriod ngắn trên resource có số lượng object lớn tạo tải CPU đột biến định kỳ, không phải tải network (vì đây là thao tác thuần cục bộ) — nhưng vẫn tốn CPU để re-serialize và gọi lại toàn bộ event handler cho từng object mỗi chu kỳ. Với một informer theo dõi hàng chục nghìn object, đặt resync 10 giây là lựa chọn tệ; giá trị phổ biến trong production là 30 giây tới vài phút, hoặc tắt hẳn (0) nếu reconcile logic không cần bảo hiểm này.
  • Event handler chạy chậm trong AddFunc/UpdateFunc block toàn bộ pipeline xử lý delta của informer đó — vì việc Pop và xử lý delta là tuần tự theo thiết kế (đảm bảo thứ tự sự kiện đúng theo từng object), một handler chậm (ví dụ gọi network đồng bộ bên trong OnAdd) làm chậm việc xử lý mọi sự kiện khác xếp sau trong FIFO, kể cả sự kiện của object hoàn toàn không liên quan. Nguyên tắc bắt buộc: event handler chỉ nên enqueue key vào một workqueue riêng rồi return ngay lập tức — không bao giờ xử lý logic nặng trực tiếp trong handler. Đây chính là lý do controller-runtime tách rời event handler khỏi Reconciler qua workqueue (xem phần tiếp theo).
  • Nhiều SharedInformerFactory độc lập trong cùng process cho cùng resource là anti-pattern phổ biến, thường xảy ra khi hai thư viện bên thứ ba trong cùng binary mỗi thư viện tự tạo factory riêng thay vì nhận factory được inject từ ngoài vào — kết quả là nhiều Reflector song song watch cùng một resource, nhân bản chi phí network và CPU serialize phía API server mà lẽ ra có thể chia sẻ.
  • WaitForCacheSync bị bỏ qua là lỗi runtime kinh điển: nếu controller bắt đầu xử lý reconcile trước khi Indexer đã đồng bộ xong lần đầu (list ban đầu chưa hoàn tất), nó sẽ ra quyết định dựa trên cache rỗng hoặc thiếu dữ liệu — ví dụ tưởng một resource chưa tồn tại và tạo trùng lặp.

References