很多 Go 开发者第一次写 Kubernetes Controller 时,会自然地把 r.Get() 和 r.List() 理解成一次次对 kube-apiserver 的查询。这个理解一旦带入生产环境,就很容易误判控制器的性能、数据一致性和内存开销。
在典型的 controller-runtime Controller 中,Reconcile 的读取通常来自进程内的本地缓存,而不是 API Server。缓存由 list + watch 填充和更新;写操作则直接发送给 API Server。这个模型解释了为什么控制器可以高频读取对象,却也解释了为什么它可能占用数 GB 内存、出现短暂的旧数据,或者因为一次不经意的 List 变成隐藏的 O(n) 扫描。
一张图理解读取链路
controller-runtime 在底层复用了 client-go 的经典组件:
kube-apiserver
|
| list + watch
v
Reflector
v
DeltaFIFO
v
Indexer
|
+--> controller event handlers
+--> r.Get() / r.List()
启动时,Reflector 对目标 GVK 执行一次全量 list,把对象放入本地 Store。随后,它使用 List 返回的 resourceVersion 建立 watch,从这个版本之后持续接收新增、更新和删除事件。
如果 watch 连接断开,Reflector 会使用最近的 resourceVersion 重新连接。如果 API Server 返回 410 Gone,说明此前的版本已经不在历史事件窗口中,Reflector 才会重新 list,再次构建缓存。这种 relist 不是定时刷新,而是恢复机制。
DeltaFIFO 负责按对象 key 聚合和有序传递事件,Indexer 负责保存对象和维护索引,SharedIndexInformer 把这些组件组合起来并通知订阅者。控制器收到事件后,通常只会把 namespace/name 放入 workqueue。
这里有两个容易混淆的去重层次:
DeltaFIFO保存同一对象的事件顺序,不会把连续的 Updated 事件全部压成一个最终状态。- workqueue 只保存对象 key。同一个 key 在队列中已经存在时,后续事件会被合并。
因此,一个 Pod 在短时间内经历调度、状态变化和 Ready 更新时,事件处理器可能被调用多次,但 workqueue 通常只保留一个 key。等 Reconcile 真正开始执行时,本地缓存往往已经包含该对象的较新状态。
r.Get() 为什么很便宜
管理器启动后,controller-runtime 会等待其 informer 完成同步,再启动控制器 worker。因此,正常的 Reconcile 不会看到“informer 已经注册但缓存还没准备好”的初始状态。
类似下面的代码:
var deploy appsv1.Deployment
if err := r.Get(ctx, req.NamespacedName, &deploy); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
对缓存客户端而言,内部逻辑接近:
item, exists, err := indexer.GetByKey("default/my-deploy")
if err != nil {
return err
}
if !exists {
return apierrors.NewNotFound(schema.GroupResource{}, "my-deploy")
}
// controller-runtime 会把缓存对象 DeepCopy 到 deploy 中
这里没有 HTTP、TLS、protobuf 序列化或 etcd 访问,主要成本是加锁后的 map 查找和对象复制。因此,Reconcile 中大量 Get 通常不会直接增加 API Server 的请求压力。
但“读取便宜”并不等于“缓存免费”。缓存保存的是对象的完整 Go 结构体,还可能维护多个索引。Pod、Secret、ConfigMap、Event 和 Node 等类型规模较大时,启动阶段的全量 list 可能就会造成明显的网络流量和内存占用。
读缓存,写 API Server
controller-runtime 的 client.Client 是一个组合客户端:
| 操作 | 默认路径 |
|---|---|
Get、List |
本地缓存 |
Create、Update |
API Server |
Patch、Delete、DeleteAllOf |
API Server |
这个设计让高频读取保持低成本,同时让写操作接受 API Server 的校验和并发控制。
当你从缓存读取对象时,对象携带的是 Reflector 最近观察到的 resourceVersion。如果另一个客户端已经先更新了对象,你再用旧版本调用 Update,API Server 会返回 409 Conflict。这是 Kubernetes 的乐观并发控制,不是 controller-runtime 的异常。
obj := deploy.DeepCopy()
obj.Spec.Replicas = ptr.To(int32(5))
if err := r.Update(ctx, obj); err != nil {
if apierrors.IsConflict(err) {
// 重新读取最新对象,再决定是否重试
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
更常见的做法是让 Reconcile 返回错误,由 controller-runtime 根据 rate limiter 稍后重试;或者使用适合场景的 Patch、Server-Side Apply,减少整对象 Update 带来的冲突。
不要假设写入后立即读到新值
写入路径是:
r.Update()
|
v
API Server
|
v
watch event
|
v
本地 cache
因此,Update 成功和 watch 事件抵达本地缓存之间存在一个短暂窗口。在这个窗口中,紧接着执行的 r.Get() 可能仍然返回旧对象。
错误模式通常类似这样:
obj.Spec.Replicas = ptr.To(int32(5))
if err := r.Update(ctx, &obj); err != nil {
return ctrl.Result{}, err
}
var fresh appsv1.Deployment
if err := r.Get(ctx, req.NamespacedName, &fresh); err != nil {
return ctrl.Result{}, err
}
// fresh.Spec.Replicas 仍可能是旧值
正确的控制器应该是幂等的:根据当前观察到的状态计算目标状态。如果缓存中的状态还没有追上,下一次事件或重试会再次执行相同逻辑,而不会造成错误结果。不要通过 time.Sleep(100 * time.Millisecond) 人为等待,这既不保证一致性,也会占用 worker。
如果业务真的要求读取 API Server 的最新状态,可以使用:
apiReader := mgr.GetAPIReader()
var deploy appsv1.Deployment
if err := apiReader.Get(ctx, req.NamespacedName, &deploy); err != nil {
return err
}
APIReader 适合验证 Webhook、初始化阶段的读取、没有维护 informer 的一次性读取,以及需要使用 API Server 分页参数的场景。它的代价是真实的网络请求,不应该被当作普通 Reconcile 读取的默认替代品。
事件对象不是你的对象
从 r.Get() 和 r.List() 得到的对象通常会被 DeepCopy,可以在 Reconcile 中安全修改后再提交。但 Predicate 和 EventHandler 收到的对象来自 informer 的共享 Store,可能被同一个进程中的多个控制器同时观察。
下面这种写法存在风险:
UpdateFunc: func(e event.UpdateEvent) bool {
pod := e.ObjectNew.(*corev1.Pod)
pod.Labels["processed"] = "true" // 不要直接修改共享对象
return true
}
如果确实需要修改,先复制:
UpdateFunc: func(e event.UpdateEvent) bool {
pod := e.ObjectNew.(*corev1.Pod).DeepCopy()
if pod.Labels == nil {
pod.Labels = map[string]string{}
}
pod.Labels["processed"] = "true"
return true
}
更好的原则是:Predicate 和 Handler 只负责判断事件、提取字段或生成请求,不要在其中修改对象。共享 Store 被意外污染后,其他控制器可能读取到一个从未真正写入 API Server 的状态。
List 可能是隐藏的 O(n)
缓存读取很快,但无条件列出大量对象并在 Go 中过滤,仍然可能拖垮 Reconcile:
var pods corev1.PodList
if err := r.List(ctx, &pods); err != nil {
return ctrl.Result{}, err
}
for i := range pods.Items {
if pods.Items[i].Spec.NodeName == "node-1" {
// 处理 Pod
}
}
当缓存中有数万条 Pod,并且 Reconcile 高频触发时,这段逻辑会不断遍历整个列表。更合适的方式是启动时注册字段索引:
func (r *PodReconciler) SetupWithManager(mgr ctrl.Manager) error {
if err := mgr.GetFieldIndexer().IndexField(
context.Background(),
&corev1.Pod{},
"spec.nodeName",
func(obj client.Object) []string {
pod := obj.(*corev1.Pod)
if pod.Spec.NodeName == "" {
return nil
}
return []string{pod.Spec.NodeName}
},
); err != nil {
return err
}
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.Pod{}).
Complete(r)
}
查询时使用完全相同的索引名称:
var pods corev1.PodList
if err := r.List(
ctx,
&pods,
client.MatchingFields{"spec.nodeName": "node-1"},
); err != nil {
return ctrl.Result{}, err
}
这里的 "spec.nodeName" 本质上只是索引注册表中的字符串 key。controller-runtime 不会把它当成 JSONPath,也不会自动检查字段是否存在。名称可以叫 "by-node",但注册和查询必须一致。
索引本质上是反向字典:
node-1 -> default/pod-a, kube-system/pod-b
node-2 -> default/pod-c
对象事件到达时,Indexer 更新这张字典;查询时直接根据 key 找到对象,而不是扫描所有对象。代价是额外内存,因此不要为所有可能的字段盲目建立索引。
MatchingFields 只支持等值查询。它不能直接完成范围、排序、聚合或 LIKE 查询。还要注意,MatchingLabels 默认不是独立索引,它通常仍会扫描缓存对象。如果某个标签查询是高频热点,可以显式为该值注册 IndexField。
控制缓存规模,而不是等内存报警
默认情况下,一个 informer 会缓存其目标类型在所有命名空间中的对象。对于 Secret、ConfigMap、Event、Pod 和 Node,这个默认范围往往过大。
可以通过命名空间、标签选择器和 Transform 缩小缓存:
mgr, err := ctrl.NewManager(cfg, ctrl.Options{
Cache: cache.Options{
ByObject: map[client.Object]cache.ByObject{
&corev1.Secret{}: {
Namespaces: map[string]cache.Config{
"controller-system": {},
},
Label: labels.SelectorFromSet(labels.Set{
"app.kubernetes.io/managed-by": "my-controller",
}),
},
&corev1.Pod{}: {
Transform: func(obj interface{}) (interface{}, error) {
pod := obj.(*corev1.Pod)
pod.ManagedFields = nil
return pod, nil
},
},
},
},
})
if err != nil {
return err
}
Namespaces 限制缓存范围,Label 和 Field 会成为 watch 的过滤条件,Transform 则允许对象进入 Store 前删除不需要的字段。managedFields、大型 annotation 和 ConfigMap 的二进制数据,常常是值得审查的内存来源。
必须注意,这些选项是 Manager 级别的。一个进程内的多个 Controller 共享对应 GVK 的 informer。若把 Secret 缓存限制到单个命名空间,另一个需要读取全量 Secret 的 Controller 也会受到影响。
选择器限制的是“控制器能看到的世界”,而不是集群中实际存在的对象。一个 Secret 如果不匹配标签,在缓存客户端看来就像不存在。
只需要元数据时使用 PartialObjectMetadata
如果控制器只需要名称、标签、annotation、ownerReferences 或 finalizer,而不需要 spec、status 和 data,可以使用 PartialObjectMetadata:
var secrets metav1.PartialObjectMetadataList
secrets.SetGroupVersionKind(schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "SecretList",
})
if err := r.List(
ctx,
&secrets,
client.InNamespace("controller-system"),
); err != nil {
return err
}
这种方式能显著减少 Secret 等大对象的内存占用。但它不能用于按 spec 字段过滤,因为本地对象根本没有这些字段。
如果某个类型既不需要持续监听,也不值得常驻内存,可以彻底关闭该类型的缓存:
mgr, err := ctrl.NewManager(cfg, ctrl.Options{
Client: client.Options{
Cache: &client.CacheOptions{
DisableFor: []client.Object{
&corev1.Secret{},
},
},
},
})
这样对 Secret 的 Get 和 List 会直接访问 API Server,也不会为它启动 informer。适合体积大、访问频率低且不需要事件驱动的类型,但它会把读取成本重新变成网络请求,应该根据访问模式选择。
RequeueAfter 是调度,不是睡眠
外部系统尚未完成时,不要在 Reconcile 中 time.Sleep,也不要自行启动 goroutine。使用工作队列提供的延迟重新入队:
return ctrl.Result{
RequeueAfter: 30 * time.Second,
}, nil
这样不会占用当前 worker;如果这 30 秒内对象收到真实事件,队列可以立即触发 Reconcile。相同 key 的去重也能避免定时器和事件制造重复工作。
发布前检查清单
- 为 Secret、ConfigMap、Event、Pod 和 Node 审查缓存范围。
- 只缓存控制器真正需要的命名空间和标签集合。
- 对不需要的
managedFields、大 annotation 和数据字段使用 Transform。 - 每一个
MatchingFields查询都应有对应的IndexField。 - 不要在 Predicate 或 EventHandler 中直接修改共享对象。
- 把 Reconcile 写成幂等逻辑,允许同一对象重复执行。
- 不要假设
Update后立即能从缓存读到新值。 - 需要强一致读取时使用
APIReader,不要构造“缓存失败再访问 API”的混合逻辑。 - 不要在
mgr.Start()之前使用缓存客户端读取对象。 - 需要延迟动作时使用
RequeueAfter,不要阻塞 worker。
结语
controller-runtime Cache 不是一个可有可无的性能优化,而是 Controller 的基本运行模型:Reflector + DeltaFIFO + Indexer 通过一次 list 和持续 watch 维护本地对象副本。r.Get() 和 r.List() 通常读内存,写操作直接进入 API Server,变化再通过 watch 返回缓存。
掌握这个模型后,很多生产问题都有了清晰答案:读取为什么便宜,内存为什么会增长,写入后为什么短暂读到旧值,为什么某个 List 会突然变慢,以及什么时候应该使用索引、选择性缓存、元数据对象或 APIReader。真正可靠的 Controller,不是强行追求每一步都立即一致,而是明确缓存边界、控制查询复杂度,并让 Reconcile 能够在重复和延迟中保持正确。