Controller-Runtime Framework: Reconciliation Loop Patterns
Vì Sao Quan Trọng Ở Production
Gần như mọi Operator viết cho Kubernetes ngày nay — từ Operator nội bộ của một đội platform tới các dự án lớn như cert-manager, Istio, hay chính GKE Config Connector — đều xây trên sigs.k8s.io/controller-runtime. Đây là lớp trừu tượng đóng gói informer pattern thành một mô hình lập trình đơn giản: bạn chỉ cần viết một hàm Reconcile. Nhưng chính sự đơn giản đó dễ khiến người viết Operator bỏ qua các quyết định thiết kế quan trọng đang chạy ngầm bên dưới: khi nào đọc từ cache là an toàn, tại sao Result{Requeue: true} khác về bản chất với trả về error, và tại sao hai lần gọi liên tiếp Get ngay sau Create có thể không thấy object vừa tạo.
Hiểu sai những điểm này dẫn tới class lỗi phổ biến nhất trong Operator tự viết: race condition giữa write và read do giả định sai về consistency của cache, và reconcile loop không hội tụ (không bao giờ đạt trạng thái ổn định) do xử lý sai Result/error.
Manager: Nơi Sở Hữu Toàn Bộ Shared State Của Một Binary
Manager là thực thể cấp cao nhất trong controller-runtime, khởi tạo một lần cho mỗi binary Operator và sở hữu tất cả tài nguyên dùng chung giữa các controller trong cùng process:
- Cache — một shared cache đóng vai trò
client.Reader, được điền dữ liệu bởi các informer bên dưới (chính làSharedInformerFactorymô tả ở phần trước, nhưng được controller-runtime bọc lại thành một interface thống nhất). - Client — một split-client, chi tiết ở phần sau.
- Scheme — bảng ánh xạ Go type sang GVK (Group-Version-Kind) của Kubernetes, cần thiết để client biết cách serialize/deserialize object Go thành resource Kubernetes đúng API group.
- Leader Election — cơ chế đảm bảo chỉ một replica của Operator (khi chạy nhiều bản sao để HA) thực sự xử lý reconcile tại một thời điểm.
- Webhook Server — nếu Operator đồng thời expose admission hoặc conversion webhook, Manager cũng quản lý HTTP server cho việc này.
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{})
...
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
log.Error(err, "could not start manager")
}Việc mọi controller trong cùng binary dùng chung một Manager (và do đó chung một Cache) chính là ứng dụng trực tiếp của nguyên tắc "một Reflector, nhiều consumer" đã trình bày ở phần informer — controller-runtime không phát minh lại cơ chế chia sẻ, nó chỉ đóng gói lại SharedInformerFactory dưới một API dễ dùng hơn.
Split-Client: Đọc Qua Cache, Ghi Thẳng API Server
Đây là quyết định thiết kế quan trọng nhất cần nội tâm hóa khi làm việc với controller-runtime. client.Client mà Reconciler nhận được không phải một client đồng nhất — nó tách biệt hai đường hoàn toàn khác nhau:
Get(ctx, key, obj) ─────► Đọc từ Cache (informer local, trong bộ nhớ)
List(ctx, list, ...) ─────► Đọc từ Cache
Create(ctx, obj) ─────► Gọi thẳng API server
Update(ctx, obj) ─────► Gọi thẳng API server
Delete(ctx, obj) ─────► Gọi thẳng API serverHệ quả trực tiếp và quan trọng nhất: không có đảm bảo tuần tự (sequential consistency) giữa write và read tiếp theo. Một Create gọi thẳng API server thành công không đồng nghĩa với việc Get ngay sau đó (đọc từ cache) sẽ thấy object vừa tạo — cache chỉ được cập nhật sau khi watch event tương ứng lan tới, và độ trễ này dù thường rất nhỏ (mili-giây) nhưng không phải zero và không được đảm bảo bởi bất kỳ API nào.
Đây không phải một khiếm khuyết cần "sửa" — nó là đánh đổi có chủ đích để giữ đúng lợi ích hiệu năng của informer pattern: nếu Get phải xuyên qua API server để đảm bảo read-your-write, toàn bộ lợi ích cache-based-read sẽ mất, và mọi Reconciler sẽ tạo tải trực tiếp lên API server giống hệt như không dùng cache. Hệ quả thực hành: không bao giờ viết logic Reconcile giả định Create rồi Get ngay sẽ thấy object mới — nếu cần xác nhận trạng thái sau ghi, phải dùng chính object trả về từ lệnh ghi đó (client-go trả về object đã áp dụng ở response), hoặc chấp nhận trạng thái sẽ được quan sát đúng ở lần reconcile kế tiếp (level-triggered, tự sửa sai).
Reconciler Interface: Chỉ Một Hàm, Nhưng Ngữ Nghĩa Đối Xứng Nghiêm Ngặt
type Reconciler interface {
Reconcile(ctx context.Context, req Request) (Result, error)
}
type Request struct {
NamespacedName types.NamespacedName
}
type Result struct {
Requeue bool
RequeueAfter time.Duration
}Điều đầu tiên cần nhận ra: Request chỉ chứa tên và namespace, không chứa nội dung object. Đây là quyết định thiết kế cố ý — buộc Reconciler phải tự Get lại object mới nhất từ cache ngay khi bắt đầu xử lý, thay vì nhận một snapshot có thể đã cũ ngay tại thời điểm được enqueue. Nó cũng đơn giản hóa việc dedupe: nếu cùng một object có ba sự kiện thay đổi liên tiếp trước khi Reconciler kịp xử lý, workqueue chỉ cần lưu key (NamespacedName) một lần — dedupe tự nhiên xảy ra vì workqueue loại bỏ trùng lặp theo key, không quan tâm nội dung.
Ngữ Nghĩa Result Và error — Ba Trạng Thái, Không Phải Hai
return ctrl.Result{}, nil // Thành công, không cần requeue
return ctrl.Result{}, err // Lỗi — requeue với exponential backoff
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil // Thành công nhưng cần kiểm tra lại sauBa trường hợp này có ngữ nghĩa hoàn toàn khác nhau và không được dùng thay thế cho nhau:
- Trả
errorkhiến workqueue requeue theo rate limiter với exponential backoff (mặc định tăng dần, có trần tối đa) — phù hợp cho lỗi tạm thời (API server tạm thời không phản hồi, conflict do ghi đồng thời). Dùng error cho lỗi logic nghiệp vụ (ví dụ "spec không hợp lệ vĩnh viễn") là sai — nó khiến controller retry vô ích mãi mãi cho một lỗi sẽ không bao giờ tự khỏi, tốn tài nguyên vô ích thay vì log cảnh báo và dừng. RequeueAftervớinilerror dùng cho trường hợp reconcile thành công nhưng logic nghiệp vụ cần được đánh giá lại sau một khoảng thời gian xác định (ví dụ chờ một điều kiện time-based, hoặc poll trạng thái của tài nguyên GCP bên ngoài cluster). Khác biệt cốt lõi so với trả error:RequeueAfterrequeue theo đúng thời gian chỉ định, không bị nhân với exponential backoff.Requeue: truekhông kèmRequeueAfterrequeue ngay lập tức nhưng vẫn qua rate limiter — dùng khi chắc chắn cần một vòng reconcile nữa ngay nhưng object hiện tại chưa đủ điều kiện dừng, dù bản thân lần reconcile này không lỗi.
Việc trộn lẫn ba ngữ nghĩa này (ví dụ luôn trả error để "chắc chắn được retry") phá vỡ giả định thiết kế cốt lõi: error dành cho unexpected failure, RequeueAfter dành cho expected wait. Trộn lẫn khiến log lỗi ngập tràn false positive và khiến backoff behavior sai mục đích (chờ đợi có chủ đích lại bị áp exponential delay không cần thiết).
Controller: Wrapper Giữa Reconciler Và Nguồn Sự Kiện
Một Controller (khác với Manager — Manager chứa nhiều Controller) đóng gói đúng ba thành phần: Reconciler (logic nghiệp vụ), workqueue (hàng đợi rate-limited chứa các Request chờ xử lý), và Source (nguồn sự kiện sinh ra Request để enqueue). Được dựng qua builder pattern:
err := ctrl.NewControllerManagedBy(mgr).
For(&appsv1.ReplicaSet{}). // Primary resource — resource controller sở hữu
Owns(&corev1.Pod{}). // Resource phụ thuộc, sinh ra bởi primary resource
Watches(&corev1.ConfigMap{},
handler.EnqueueRequestsFromMapFunc(mapConfigMapToReplicaSets)). // Watch tùy ý, tự map sang Request
Complete(&ReplicaSetReconciler{Client: mgr.GetClient()})For vs Owns vs Watches — Không Phải Cùng Một Cơ Chế
Forđăng ký watch trực tiếp trên resource chính — mỗi sự kiện trên resource đó sinh ra đúng mộtRequesttrỏ tới chính object đó.Ownsđăng ký watch trên resource phụ thuộc (ví dụ Pod do ReplicaSet tạo ra quaownerReferences), nhưng khi có sự kiện, nó tự động tra ngượcownerReferencesđể tìm ra owner và enqueueRequesttrỏ tới owner, không phải object phụ thuộc đó. Đây là cơ chế cho phép Reconciler của ReplicaSet tự động được gọi lại khi một Pod con của nó bị xóa ngoài ý muốn — dùRequestkhông hề chứa thông tin gì về Pod, chỉ chứa tên ReplicaSet.Watcheslà cơ chế tổng quát nhất — cho phép watch bất kỳ resource nào, kể cả không liên quan trực tiếp qua ownership, và tự định nghĩa hàm map (EnqueueRequestsFromMapFunc) để quyết định sự kiện đó nên trigger reconcile cho object nào. Đây là công cụ cần dùng khi logic reconcile của một resource phụ thuộc gián tiếp vào resource khác (ví dụ một Deployment cần reconcile lại khi một ConfigMap nó tham chiếu tới thay đổi, dù không có ownerReference nào giữa chúng).
Predicate: Lọc Sự Kiện Trước Khi Tới Handler
type Predicate interface {
Create(event.CreateEvent) bool
Update(event.UpdateEvent) bool
Delete(event.DeleteEvent) bool
Generic(event.GenericEvent) bool
}Predicate chạy trước khi một sự kiện được chuyển thành Request và đẩy vào workqueue — trả về false nghĩa là bỏ qua sự kiện đó hoàn toàn, không bao giờ tới Reconciler. Đây là công cụ tối ưu hiệu năng quan trọng: ví dụ predicate.GenerationChangedPredicate chỉ cho phép sự kiện đi qua khi metadata.generation thay đổi (tức là spec thực sự đổi), lọc bỏ hoàn toàn các sự kiện Update chỉ do status subresource được ghi (một nguồn nhiễu sự kiện cực lớn trong cluster có nhiều controller cùng cập nhật status liên tục). Không dùng predicate phù hợp là nguyên nhân phổ biến khiến Reconciler bị gọi hàng trăm lần nhiều hơn mức cần thiết, dù logic bên trong luôn no-op ở phần lớn các lần gọi đó.
Rate-Limited Workqueue: Lớp Đệm Giữa Sự Kiện Và Xử Lý
Không giống informer thô (nơi event handler chạy trực tiếp trong luồng xử lý delta, như đã cảnh báo ở phần trước), controller-runtime luôn đẩy Request vào một workqueue trước khi một worker goroutine riêng lấy ra và gọi Reconcile. Thiết kế này giải quyết chính xác vấn đề đã nêu: event handler (nhận sự kiện từ informer) chỉ làm một việc cực nhẹ — tính Request rồi queue.Add() — nên không bao giờ block pipeline xử lý delta của informer.
Workqueue có ba đặc tính quan trọng:
- Deduplication theo key: nếu cùng một
Requestđược add nhiều lần trước khi worker kịp xử lý, nó chỉ nằm trong queue một lần. Đây là lý do một object bị update dồn dập 10 lần trong một giây thường chỉ kích hoạt một hoặc vài lầnReconcile, không phải 10 lần. - Rate limiting với exponential backoff riêng cho từng item: khi một
RequestđượcAddRateLimited(xảy ra khi Reconciler trả vềerror), thời gian chờ trước khi item đó xuất hiện lại tăng theo cấp số nhân, có trần tối đa (mặc định thường là vài phút). Backoff này theo dõi riêng từng key — một object liên tục lỗi không ảnh hưởng backoff của các object khác. Forget()được gọi khi Reconcile thành công, reset lại bộ đếm backoff của key đó về trạng thái ban đầu — đảm bảo một object từng lỗi nhiều lần nhưng sau đó reconcile thành công sẽ không bị "phạt" backoff dài ở lần lỗi tiếp theo (không liên quan) trong tương lai.
Leader Election: Một Reconciler Hoạt Động Tại Một Thời Điểm
Khi Operator chạy nhiều replica để đảm bảo HA, chỉ một replica được phép thực sự chạy Reconcile — nếu không, nhiều replica có thể ghi đè lẫn nhau hoặc thực hiện side-effect trùng lặp (ví dụ tạo tài nguyên GCP hai lần). Leader election dùng cơ chế Lease object trong Kubernetes (tương tự cơ chế bầu leader của kube-controller-manager chính nó):
leaseDuration := 15 * time.Second // mặc định
renewDeadline := 10 * time.Second // mặc định
retryPeriod := 2 * time.Second // mặc địnhleaseDuration là thời gian một leader giữ quyền trước khi Lease được coi là hết hạn nếu không gia hạn; renewDeadline là thời hạn leader hiện tại phải gia hạn thành công trước khi tự coi mình mất leadership và dừng reconcile; retryPeriod là tần suất replica đang chờ (standby) thử giành leadership. Bộ ba tham số này đánh đổi trực tiếp giữa tốc độ failover (giá trị nhỏ hơn → phát hiện leader chết nhanh hơn) và rủi ro split-brain tạm thời dưới network partition (giá trị quá nhỏ dễ khiến leader hợp lệ bị coi nhầm là chết chỉ vì độ trễ mạng tạm thời, dẫn tới hai replica cùng nghĩ mình là leader trong một khoảng ngắn).
Ràng Buộc Và Failure Mode Ở Scale
- Giả định read-your-write sai là lỗi phổ biến nhất khi mới chuyển từ tư duy "gọi API trực tiếp" sang controller-runtime — đặc biệt khi port logic cũ (vốn quen với client thuần, mọi thao tác đều trực tiếp tới API server) sang Reconciler mà không nhận ra
Getgiờ đọc cache. - Dùng error để trigger retry cho lỗi vĩnh viễn khiến workqueue bơm liên tục request retry cho một object không bao giờ có thể reconcile thành công, tốn CPU và log noise vô ích, đồng thời làm loãng tín hiệu cảnh báo thực sự cần chú ý.
- Thiếu Predicate lọc sự kiện status-only trên resource có nhiều controller cùng ghi status (ví dụ Pod, nơi kubelet ghi status liên tục) khiến Reconciler bị gọi với tần suất cao hơn nhiều bậc so với tần suất thay đổi spec thực sự — ở quy mô hàng nghìn object, đây là nguồn tải CPU dễ bị bỏ sót khi profiling Operator.
leaseDuration/renewDeadlineđặt quá ngắn trên cluster có network jitter đáng kể (phổ biến hơn ở multi-zone hoặc multi-region GKE) gây ra leadership flapping — nhiều lần chuyển leader không cần thiết, mỗi lần chuyển kéo theo một khoảng thời gian không có Reconciler nào hoạt động (gap giữa lúc leader cũ dừng và leader mới bắt đầu).