写 Operator 最容易犯的错,是把 Reconcile 当成一次性脚本——创建外部资源、删除对象、更新状态全塞进一个函数,重试几次就产生重复资源或状态漂移。controller-runtime 把这类坑收敛成一套模式:期望状态由 CR 描述,Reconcile 负责把实际状态拉回期望状态,且必须可被任意次重入。本文覆盖 CRD 设计、Manager 架构、Reconcile 幂等性、事件过滤、OwnerReference、Finalizer、Status 条件与 envtest 测试。
目录
- 1. CRD 设计与版本演进
- 2. controller-runtime 架构与启动流程
- 3. Reconcile 循环与幂等性设计
- 4. 事件过滤与 Watches 映射
- 5. OwnerReference 与级联回收
- 6. Finalizer 与外部资源清理
- 7. Status 子资源与条件类型
- 8. 测试:envtest 与单元测试
- 9. 生产最佳实践
1. CRD 设计与版本演进
1.1 从需求反推 CRD 结构
CRD 的 spec 是用户意图,status 是控制器观测到的现实。设计时先问三个问题:哪些字段用户可写、哪些由控制器推导、哪些字段变更需要触发重建。前两者决定字段归属,第三点决定是否要加 CEL 校验。
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databases.example.com
spec:
group: example.com
scope: Namespaced
names:
kind: Database
plural: databases
shortNames: ["db"]
versions:
- name: v1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Engine
type: string
jsonPath: .spec.engine
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["engine", "sizeGB"]
properties:
engine:
type: string
enum: ["postgres", "mysql"]
sizeGB:
type: integer
minimum: 1
replicas:
type: integer
default: 1
1.2 版本演进与转换
多版本共存时只有一个版本标记 storage: true,其余版本通过转换 Webhook 做双向转换。转换必须无损,新增字段要么给默认值,要么用注解保留旧值。
| 场景 | 做法 |
|---|---|
| 新增可选字段 | 给 default,无需转换逻辑 |
| 字段重命名或结构重构 | 转换 Webhook 双向无损映射 |
1.3 校验的三种层次
第一层:OpenAPI schema(类型、枚举、必填、范围)—— 零成本,优先用
第二层:CEL 校验规则(x-kubernetes-validations)—— 支持跨字段表达式
第三层:ValidatingAdmissionWebhook —— 需要外部数据时才用
x-kubernetes-validations:
- rule: "self.replicas <= 5 || self.engine == 'postgres'"
message: "仅 postgres 支持超过 5 副本"
2. controller-runtime 架构与启动流程
2.1 Manager 与 Controller
Manager 是进程级容器,负责共享的 Client、Cache、Scheme、Leader Election 与生命周期;Controller 是某个 Kind 的协调循环。一个进程可跑多个 Controller,共享同一个 Cache 能显著降低 API Server 压力。
Manager
├── Cache (informer 共享) ← 所有 Controller 复用
├── Client (读写入口,读走 Cache)
├── Scheme (GVK 与 Go 类型互转)
├── Leader Election (多副本只有一个 Active)
└── Controllers (DatabaseReconciler / BackupReconciler)
2.2 启动骨架
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
LeaderElection: true,
LeaderElectionID: "database-operator.example.com",
})
if err != nil {
os.Exit(1)
}
if err = (&DatabaseReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).
SetupWithManager(mgr); err != nil {
os.Exit(1)
}
mgr.AddHealthzCheck("healthz", healthz.Ping)
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
os.Exit(1)
}
2.3 Leader Election 的意义
多副本部署时只有 Leader 执行 Reconcile,避免同一对象被并发处理。它只保证同一时刻一个 Active,切换期间可能有两个副本短暂同时工作,因此 Reconcile 仍必须幂等。
3. Reconcile 循环与幂等性设计
3.1 基本骨架
func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var db examplev1.Database
if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err) // 已删除属正常
}
// 1. 观测现实并写 status 2. 对比期望与实际
// 3. 采取行动 4. 更新 status.conditions
return ctrl.Result{}, nil
}
3.2 幂等性的三条铁律
| 铁律 | 说明 |
|---|---|
| 先读后写 | 每次行动前先 Get,存在则跳过或更新,不盲目 Create |
| 不依赖内存状态 | 所有判断依据来自集群中的对象,而非上次执行的变量 |
| 可中断可重入 | 任意步骤失败后重跑,结果一致 |
3.3 更新与冲突处理
直接 Update 会因 ResourceVersion 冲突而失败,更推荐 Patch:
patch := client.MergeFrom(db.DeepCopy())
db.Spec.Replicas = desired
if err := r.Patch(ctx, &db, patch); err != nil {
return ctrl.Result{}, err
}
3.4 返回值语义
return ctrl.Result{}, nil 成功,等待下次事件
return ctrl.Result{}, err 失败,指数退避重试
return ctrl.Result{RequeueAfter: 30*time.Second}, nil 延迟重入
永远不要用 Requeue: true 做轮询——它会打满队列。周期性检查用 RequeueAfter,并在对象进入终态后停止重入。
复杂对象建议在 Reconcile 内显式建模状态机,每步只做一件事,并把阶段写入 status.phase(Pending → Provisioning → Ready → Updating → Deleting)。每步开头判断当前 phase,若已越过则跳过,天然获得幂等性。
4. 事件过滤与 Watches 映射
4.1 SetupWithManager
return ctrl.NewControllerManagedBy(mgr).
For(&examplev1.Database{}).
Owns(&appsv1.StatefulSet{}).
Owns(&corev1.Service{}).
WithEventFilter(predicate.GenerationChangedPredicate{}).
Complete(r)
For 声明主对象,Owns 声明从属资源——从属资源变化时,controller-runtime 会通过 OwnerReference 反查并触发主对象的 Reconcile。
4.2 谓词过滤
WithEventFilter(predicate.Or(
predicate.GenerationChangedPredicate{}, // spec 变更
predicate.LabelChangedPredicate{}, // 标签变更
predicate.AnnotationChangedPredicate{}, // 注解变更
))
GenerationChangedPredicate 会过滤掉 status 变更事件,这是好事:否则自己更新 status 会触发自己,形成无限循环。这正是「status 更新必须走子资源」的原因之一。
4.3 自定义映射
当需要根据非拥有资源触发 Reconcile(如 ConfigMap 变更触发所有引用它的对象),用 Watches 加映射函数:Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.mapConfigToDatabases))。
4.4 常见事件源对比
| 方式 | 触发条件 | 适用场景 |
|---|---|---|
| For | 主对象变更 | 必然使用 |
| Owns | 从属资源变更 | 拥有关系明确 |
| Watches | 任意资源变更 | 跨命名空间引用关系 |
5. OwnerReference 与级联回收
5.1 设置 OwnerReference
if err := ctrl.SetControllerReference(db, sts, r.Scheme); err != nil {
return ctrl.Result{}, err
}
SetControllerReference 会写入 ownerReferences 并设置 controller: true。一个对象只能有一个 controller owner,但可以有多个非 controller owner。
5.2 级联删除策略
Foreground:先删子对象,父对象保持 finalizer 直到子对象全删完
Background:立即删父对象,GC 异步清理子对象(默认)
Orphan:只删父对象,子对象保留
kubectl delete database my-db --cascade=foreground
5.3 跨命名空间引用
OwnerReference 不能跨命名空间:CR 在 ns-a 而外部资源在 ns-b 时无法级联,必须靠 Finalizer 手动清理。
5.4 常见问题
| 坑 | 现象 | 对策 |
|---|---|---|
| owner 未设 controller | 两个控制器争抢 | 用 SetControllerReference |
| 跨命名空间引用 | 子对象删不掉 | 改用 Finalizer |
6. Finalizer 与外部资源清理
6.1 何时需要 Finalizer
只要对象删除后还有集群外的东西需要清理(云上数据库实例、DNS 记录、对象存储桶、负载均衡器),就必须用 Finalizer。否则 CR 一删,外部资源就变成孤儿。
6.2 标准写法
const dbFinalizer = "database.example.com/finalizer"
if db.DeletionTimestamp.IsZero() {
if !controllerutil.ContainsFinalizer(&db, dbFinalizer) {
controllerutil.AddFinalizer(&db, dbFinalizer)
return ctrl.Result{}, r.Update(ctx, &db) // 先落库,再做别的
}
} else {
if controllerutil.ContainsFinalizer(&db, dbFinalizer) {
if err := r.deleteExternalResources(ctx, &db); err != nil {
return ctrl.Result{}, err // 清理失败保留 finalizer,稍后重试
}
controllerutil.RemoveFinalizer(&db, dbFinalizer)
return ctrl.Result{}, r.Update(ctx, &db)
}
return ctrl.Result{}, nil
}
6.3 顺序要点
□ 首次 Reconcile 先加 finalizer,再创建任何外部资源
□ 删除路径先清外部资源,全部成功后再移除 finalizer
□ 清理逻辑必须幂等(资源不存在视为成功),并设置超时
7. Status 子资源与条件类型
7.1 为什么用子资源
启用 subresources.status 后,对 status 的写入走独立端点,不会递增 metadata.generation,因此不会触发 GenerationChangedPredicate 造成自触发循环。
if !reflect.DeepEqual(db.Status, newStatus) {
db.Status = newStatus
if err := r.Status().Update(ctx, &db); err != nil {
return ctrl.Result{}, err
}
}
7.2 条件规范
status:
conditions:
- type: Ready
status: "True"
reason: ProvisionSucceeded
message: "数据库实例已就绪"
observedGeneration: 3
lastTransitionTime: "2026-10-02T02:00:00Z"
| 字段 | 语义 |
|---|---|
| type | 条件名,如 Ready / Progressing / Degraded |
| status | True / False / Unknown |
| observedGeneration | 本条件对应的 spec 代数 |
observedGeneration 是判断 status 是否跟得上 spec 的关键:若 metadata.generation > observedGeneration,说明控制器还没处理完最新 spec,此时不应把 Ready 置为 True。
7.3 条件更新工具
meta.SetStatusCondition(&db.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionTrue,
Reason: "ProvisionSucceeded",
ObservedGeneration: db.Generation,
})
SetStatusCondition 会自动维护 lastTransitionTime,且仅在 status 真正变化时才更新时间戳。
8. 测试:envtest 与单元测试
8.1 envtest 简介
envtest 会在本地拉起真实的 etcd 与 kube-apiserver(不含 kubelet、调度器),是测试 CRD 加控制器最贴近真实的方式。用 CRDDirectoryPaths 指向生成的 CRD 目录,testEnv.Start() 启动、testEnv.Stop() 收尾即可。
8.2 测试关注点
| 关注点 | 测法 |
|---|---|
| Reconcile 幂等 | 连续调用两次,断言无重复资源 |
| Finalizer 流程 | 删除 CR,断言外部资源被清理 |
| 条件正确性 | 断言 observedGeneration 与 status |
8.3 常用命令
make test # 跑单元测试(含 envtest)
make manifests # 重新生成 CRD YAML 与 RBAC
make run # 本地对真实集群跑控制器
9. 生产最佳实践
9.1 落地 Checklist
□ CRD 启用 status 子资源与 additionalPrinterColumns
□ 所有外部资源创建前先加 Finalizer
□ Reconcile 全程幂等,可被任意次重入
□ 用 Patch 而非 Update 降低冲突
□ 谓词过滤掉 status 变更,避免自触发
□ 条件带 observedGeneration,便于判断新旧
□ 控制器多副本 + Leader Election + PDB
□ RBAC 最小权限,用 kubebuilder 注解生成
9.2 常见坑与对策
| 坑 | 现象 | 对策 |
|---|---|---|
| 忘记加 Finalizer | 删除 CR 后云资源成孤儿 | 首次 Reconcile 即加 Finalizer |
| status 更新触发自循环 | CPU 打满、事件风暴 | 用 status 子资源 + GenerationChangedPredicate |
| 用 Requeue 轮询 | 队列打满、延迟升高 | 改用 RequeueAfter |
| 直接 Update 冲突 | 频繁 requeue 报错 | 改用 MergeFrom Patch |
| 外部清理不幂等 | 删除卡在 Terminating | 资源不存在视为成功 |
| OwnerReference 跨命名空间 | 子对象无法级联删除 | 改用 Finalizer 显式清理 |
小结
CRD 控制器的本质是一个可被任意次重入的收敛循环:它读 spec 作为期望、读实际资源作为现实,然后把差异抹平并写回 status。掌握三件事就能避开绝大多数坑——Finalizer 保证外部资源不泄漏,Status 子资源与谓词保证不会自触发,幂等与 Patch 保证重试安全。落地时建议从 envtest 起步,把幂等、删除、冲突三条路径写成测试,再用 Leader Election 与最小 RBAC 把它推进生产。记住:Reconcile 不是脚本,而是收敛函数。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。