controller-runtime 与 CRD 控制器开发实战

从 CRD 设计与版本演进讲起,系统讲解 controller-runtime 的 Manager 架构、Reconcile 循环与幂等性、事件过滤与 Watches 映射、OwnerReference 级联回收、Finalizer 外部资源清理、Status 条件类型,以及 envtest 测试与生产化落地要点。

写 Operator 最容易犯的错,是把 Reconcile 当成一次性脚本——创建外部资源、删除对象、更新状态全塞进一个函数,重试几次就产生重复资源或状态漂移。controller-runtime 把这类坑收敛成一套模式:期望状态由 CR 描述,Reconcile 负责把实际状态拉回期望状态,且必须可被任意次重入。本文覆盖 CRD 设计、Manager 架构、Reconcile 幂等性、事件过滤、OwnerReference、Finalizer、Status 条件与 envtest 测试。


目录


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
statusTrue / 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 不是脚本,而是收敛函数。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「云原生」更多文章

  1. 调度均衡:Descheduler 与资源碎片整理
  2. Cluster API 与声明式集群生命周期管理
  3. 运行时安全:Falco/Tetragon 与 eBPF 检测实战