Skip to content

控制器错过了一次更新,为什么还能把副本改对? ​

你提交一个副本,Deployment 已经创建,Service 还没创建,控制器就重启了。这时用户又把副本数改为二。如果程序靠重放“创建一个副本”恢复,它会忠实地执行一个已经过时的动作。ModelDock 只要求它记住一个问题:现在这个名字对应的 ModelService,要求集群保持什么状态?

通知传递的是地址,不是操作说明 ​

先看注册关系。For 把主对象变化映射为 namespace/name;Owns 在 Deployment、Service 变化时沿 controller owner reference 找回父对象。否则用户不改 ModelService,只手动删除 Service,控制器就少了一个及时修复的触发源。

完整源码:modeldock/operator/main.go · 第 165–177 行

go
func main() {
	mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{Cache: cache.Options{DefaultNamespaces: map[string]cache.Config{"modeldock-lab": {}}}, LeaderElection: os.Getenv("LEADER_ELECTION") == "true", LeaderElectionID: "modeldock.lab", LeaderElectionNamespace: "modeldock-lab"})
	if err != nil {
		panic(err)
	}
	err = ctrl.NewControllerManagedBy(mgr).Named("modelservice").For(resource()).Owns(&app.Deployment{}).Owns(&core.Service{}).Complete(Controller{mgr.GetClient()})
	if err != nil {
		panic(err)
	}
	if err = mgr.Start(ctrl.SetupSignalHandler()); err != nil {
		panic(err)
	}
}
sequenceDiagram
participant API as API Server
participant C as Informer cache
participant Q as WorkQueue
participant R as Reconcile
API-->>C: List 当前对象并 Watch 后续变化
C->>Q: 入队父对象键
Q->>R: namespace/name
R->>C: 读取当前声明和子对象
R->>API: 只提交差异
API-->>C: 写入也产生新事件

在本项目的 controller-runtime 客户端中,常规读取来自 Manager 的缓存,创建和更新则直接调用 API Server。服务端 Watch cache 与本机 Indexer 是不同的存储:前者帮助 API 提供观察流,后者让同一个 Manager 内多个读取者共享对象视图。经典 Reflector 从 List 的 resourceVersion 接 Watch;连接中断时尝试续接,版本过旧则重新取得快照。能恢复的是当前状态,不是消失过的每个中间状态。

同一键在排队时可合并;正在处理时又被标记为变化,处理结束后还可能再跑一次。再加上自己写 status 产生的事件,执行次数不等于用户修改次数。这解释了为什么 CreateOrUpdate 的修改函数要保留服务端默认字段,为什么 condition 没有转换时不能每次换一个时间戳。

在最坏的两个时刻停下来 ​

第一处是写完 Deployment、尚未写 Service。下轮检查 Deployment 的归属和模板,发现已满足就继续补 Service;不是先删除两者再来一遍。第二处是读完声明、准备更新时,用户写入了新版本。resourceVersion 相当于“我的更新以哪份对象为基础”,不是业务版本号。服务端拒绝旧写入时,应返回冲突、退避并重读;在原对象上循环 Update 只会反复提交同一个错误前提。

删除也有中间状态。带 finalizer 的对象先得到 deletionTimestamp,仍可读到 UID。控制器据此只清理自己的子资源,等它们消失才移除 finalizer。如果进程停在清理一半,重新观察到的依然是删除中的对象;如果同名子对象已被替换,不能把新 UID 当旧对象删除。这里 finalizer 保护的是收尾顺序,ownerReference 表达的是归属,两者不能互换。

用真实 API Server 区分三种“看起来成功” ​

从实验包根目录运行。envtest 启动独立 API Server 和 etcd,不访问你的工作集群:

sh
mkdir -p .bin
GOBIN="$PWD/.bin" go install sigs.k8s.io/controller-runtime/tools/setup-envtest@release-0.22
(cd modeldock/operator && KUBEBUILDER_ASSETS="$(../../.bin/setup-envtest use 1.34.x --bin-dir ../../.bin/envtest -p path)" go test -count=1 -v -run TestAPIServerConvergence .)

第一组断言在没有变化时再次执行,要求子对象的 resourceVersion 不变;只检查“仍只有一个 Deployment”会漏掉无意义的覆写。第二组检查 status 为 Progressing:没有 kubelet,不能期待 Ready。第三组特意保留一份旧对象,将副本改为二后尝试用旧对象写注解,再给一个新控制器实例同一个键:

完整源码:modeldock/operator/controller_test.go · 第 77–97 行

go
	// Simulate a second writer changing the declaration after our read.
	stale := u.DeepCopy()
	if err := unstructured.SetNestedField(u.Object, int64(2), "spec", "replicas"); err != nil {
		t.Fatal(err)
	}
	if err := c.Update(ctx, u); err != nil {
		t.Fatal(err)
	}
	stale.SetAnnotations(map[string]string{"study": "stale"})
	if err := c.Update(ctx, stale); !apierrors.IsConflict(err) {
		t.Fatalf("expected conflict, got %v", err)
	}
	// A fresh reconciler gets only a key, not the event that changed replicas.
	r = Controller{c}
	tick()
	if err := c.Get(ctx, req.NamespacedName, d); err != nil {
		t.Fatal(err)
	}
	if *d.Spec.Replicas != 2 {
		t.Fatalf("stale desired replicas: %d", *d.Spec.Replicas)
	}

本轮结果为 PASS。这个测试手动调用 Reconcile,证明的是服务端冲突与重新计算;它没有启动 Manager,也没有模拟真实进程被杀。要验证缓存监听、RBAC 和 Pod Ready,请继续做从空目录生成 Kubebuilder 工程。面试里如果被问到“通知至少送达一次是否足够”,应先反问保证的对象是什么:让副本数收敛可以重算;向外部账本扣费不能仅靠重跑,需要持久的请求身份。