前面几篇把 CRD 的 schema、字段校验、多版本共存这些话题聊得差不多了,但有个问题我猜不少人已经踩到了:CRD 一个接一个地定义好,apply 进集群也成功了,然后呢?没有任何东西去“消费”这些自定义资源。就像你把岗位职责写进了制度文档,但一直没有招人来干这个活。文档再规范,纸面制度也不会自己执行。CRD 和 Operator 之间的那道坎就在这里:CRD 只解决了“数据长什么样、存哪里”,Operator 才真正解决“谁来干活、怎么干活”。
这篇是整个 CRD 系列的第七篇,重点把 Operator 讲透。先聊聊为什么必须有它,再拆 controller-runtime 的核心机制,然后用 Kubebuilder 从零写一个最小可用的 Operator 示例,最后把我做 Operator 时踩过的坑和排查套路整理出来。适合已经熟悉 CRD 基本概念、正准备写第一个 Operator 的人读。文章里涉及的概念会尽量用“对账”“储物柜”这类生活化的类比,方便你在脑子里建立起画面感。
1. Operator 到底在解决什么问题
1.1 CRD 只是“储物柜”,不是“办事员”
很多人学完 CRD 之后第一个困惑是:我已经定义了 replicas、image 这些字段,apiserver 也做了校验,然后呢?然后真的没有然后了。
CRD 本质上是给 kube-apiserver 增加了一类新的资源类型,它做了三件事:定义 schema、做 OpenAPI 校验、把对象持久化到 etcd。它不负责按照你的字段去创建 Deployment、去调整副本数、去处理扩容失败后的回滚。这些都是业务逻辑,apiserver 不会也无从替你执行。
我打一个比较接地气的比方:CRD 相当于公司制度里新增了一个“项目负责人”岗位的职位描述,描述写得再细致,招聘系统只会把这份描述存进库里,不会自动安排一个人来上岗。你得专门有一套“人事流程”去处理这件事。在 Kubernetes 的世界里,这套“人事流程”就是 Controller。而携带着具体业务领域知识的 Controller,就是大家常说的 Operator。
所以如果你只定义 CRD、不写 Operator,集群对你的自定义资源唯一的反应就是:校验通过、存下来、等你去查询。你不会看到任何自动创建的 Pod、Service,也不会有人帮你做状态收敛。很多初学者在这个阶段会误以为 CRD “没有生效”,其实它生效了,只是还没有“干活的人”。
1.2 Operator = CRD + Controller + 领域知识
业内有一个经典说法:Operator = CRD + Controller + Domain Knowledge。三个部分缺一不可。
- CRD 提供数据模型:用户想要什么最终状态,写在 spec 里。
- Controller 提供执行引擎:持续监听自定义资源,保证实际集群状态向期望状态收敛。
- 领域知识提供判断逻辑:比如一个数据库 Operator 知道初始化顺序、备份时机、主从切换条件、故障恢复流程。这些知识没法内建在通用的 Deployment 和脚本里。
这篇文章里的示例我用一个模拟项目 X:一个叫 WebApp 的自定义资源,用户只需要声明 image 和 replicas,Operator 负责生成 Deployment 和 Service,并把实际就绪副本数回写到 status。麻雀虽小,但 CRD 定义、控制器逻辑、资源编排、状态回写、RBAC 配置这些环节全都能串起来。
顺带聊一下业界常说的 Operator 能力成熟度模型。最低层是“基础安装”,最高层是“自治”,比如故障自愈、自动扩容、动态调参。如果你只是想把一套固定配置包装成自定义资源,那大概停留在第 1-2 层;如果你要做的是数据库、消息队列这类有状态服务的全生命周期管理,才需要奔着第 4-5 层去。写 Operator 之前先想清楚定位,别一上来就堆功能,维护成本是长期存在的。
| 方案 | 适用场景 | 主要局限 |
|---|---|---|
| 纯 Helm chart | 静态部署、配置可变 | 没有业务判断逻辑 |
| CRD + 简单 Controller | 需要根据 spec 创建子资源 | 不适合复杂状态机 |
| 完整 Operator | 有状态服务、故障自愈、生命周期管理 | 开发与维护成本最高 |
2. 核心机制拆解:Reconcile 循环和 Watch
2.1 声明式 API 与“对账”思维
Kubernetes 底层有一个很基础、也很容易被误解的设计:事件驱动 + 持续对账。Controller 并不是“每收到一个事件就精确执行一次动作”,而是“每当可能影响某个资源状态的事件发生,就把这个资源重新拉一轮全量计算,得出当前应该怎么办”。
这个“全量计算”的函数就是 Reconcile,核心逻辑可以抽象成三步:
- 读取:拿到自定义资源的当前 spec 和 status。
- 差距分析:查出实际创建的 Deployment、Service 等资源,跟 spec 期望的状态做比对。
- 收敛动作:创建、更新或删除次要资源,更新 status,记录结果。
这个流程会周而复始。就算中间有事件丢了,或者状态被外部改坏了,下一次周期性对账也会把漂移纠正回来。我常用一句话解释:事件只是按铃,真正的活儿是按流程把账对上。这跟银行每日对账是一个道理,你不会只依赖每一笔流水的记录,而是要定期拿全量账单核对一遍,发现差异就处理掉。
这个设计的优点很直接:任何输入状态下,无论对象是新创建、被修改、被意外篡改,还是控制器重启后重新加载,Reconcile 都能收敛到同一个正确终态。这也是为什么控制器代码必须是幂等的,反复执行不能出问题。
2.2 controller-runtime 的关键概念
社区里写 Operator 的主力框架是 Kubebuilder 底层的 controller-runtime。名字有点长,但核心概念不多,弄懂这几个就能看懂所有示例工程:
- Manager:统筹控制器的生命周期、客户端缓存、事件记录、HTTP 服务。main 函数里先创建一个 Manager,再把 Controller 注册进去。
- Controller:把事件源和 Reconcile 逻辑绑定起来的执行单元。
- Builder:用链式调用把“监听哪些资源”“关联哪些资源”“用什么过滤条件”串起来。
- Predicate:事件过滤器,比如只关心创建和更新、忽略删除的中间态。
- EventHandler:决定某类资源变化后,如何映射成对某个资源实例的 Reconcile 请求。
实际开发里写最多的是 Reconcile 函数,签名长这样:
func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error)入参 req.NamespacedName 告诉你“哪个自定义资源需要被处理”,返回值 ctrl.Result 里有两个字段比较关键:Requeue 表示是否立刻重新入队,RequeueAfter 表示延迟多久后再执行一次。当前这轮没处理完,比如依赖的镜像镜像还在拉取中,就可以用 RequeueAfter 让它在几秒后再试。返回 error 也会触发重试,但要注意区分“暂时失败”和“永远不可能成功”的情况,后者别靠 error 重试,直接记日志返回 nil 更合适。
2.3 为什么必须同时 Watch 关联资源
新手最容易漏的一点:只 Watch 自己的 CRD,却不 Watch 自己创建的 Deployment。
假设你的 WebApp Operator 只监听 WebApp 资源。只要 CRD 没变化,外部有人把 Deployment 错误改成 0 副本,你的 Controller 完全不知情,集群就会一直停留在错误状态。正确的姿势是:不仅要 For 自己的 CRD,还要 Owns 自己创建的那些子资源。controller-runtime 里用一行链式调用就能搞定:
ctrl.NewControllerManagedBy(m). For(&demov1.WebApp{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). Complete(r)Owns 监听让你真正具备“持续对账”能力:子资源一变化,父资源的 Reconcile 也会被触发。这是从“事件驱动脚本”升级到“声明式控制器”的分水岭,一定不能省。另外还可以加上 Predicate 过滤掉不必要的更新,比如只关注 Deployment 的实例数和镜像变化,减少无谓的对账频率。
3. 实操:用 Kubebuilder 实现一个最小 Operator
3.1 环境准备与脚手架初始化
开始之前把工具列一下,都是社区标准的几样:Go 1.21 以上、kubectl、make,以及一个本地测试集群。我个人推荐用 kind,轻量、起步快,适合本地开发;minikube 也可以,但资源占用大一些。Kubebuilder 本身用 go install 拉最新版本即可。
先初始化工程目录:
mkdir operator-demo && cd operator-demo kubebuilder init --domain demo.example.org --repo demo.example.org/operator-demo--domain 会拼进 CRD 的 group 名,后面如果 group 是 demo,完整资源名就是 webapps.demo.example.org;--repo 指定 Go module 路径。接着生成资源与控制器骨架:
kubebuilder create api --group demo --version v1 --kind WebApp --resource --controller命令会问两个问题,我都选 yes:生成 Resource 会创建 types.go 和 CRD YAML;生成 Controller 会创建 controller.go。这一步是最省时间的,别自己手写脚手架,目录规范一旦错位,后面改起来非常痛苦。生成的骨架里已经包含 main.go、Manager 初始化、健康检查接口,这些开箱即用的部分不用动。
3.2 定义 WebApp 的 Spec 与 Status
打开 api/v1/webapp_types.go,把字段补齐。一个最简单的 WebApp 只需要三个字段:副本数、镜像、服务端口。
type WebAppSpec struct { Replicas *int32 `json:"replicas,omitempty"` Image string `json:"image"` Port int32 `json:"port,omitempty"` } type WebAppStatus struct { ReadyReplicas int32 `json:"readyReplicas"` Conditions []metav1.Condition `json:"conditions,omitempty"` }这里有两个容易踩的细节。第一,Replicas 用指针 *int32,是因为 0 也是有效值,用指针才能区分“用户没填”和“用户填了 0”,控制器里可以据此给默认值。第二,Status 里的条件字段建议用标准的 metav1.Condition 列表,后续配合 kubectl describe 和事件排查都更方便,比自造一堆布尔字段强。
记得在类型上加上 kubebuilder 的 marker 注释,这些注释会生成 CRD 的 OpenAPI schema:
//+kubebuilder:object:root=true //+kubebuilder:subresource:status+kubebuilder:subresource:status 这行特别关键,它给资源开启了 status 子资源。开启之后 spec 区和 status 区的更新是分开的,避免用户每次改 spec 时 status 被整体覆盖,也有利于并发控制。很多新手不知道这行的作用,后面 status 回写总是出各种问题。改完类型之后执行 make manifests 重新生成 CRD YAML,再用 make install 装进本地集群,验证一下 schema 是否正确:
apiVersion: demo.example.org/v1 kind: WebApp metadata: name: webapp-sample spec: image: nginx:1.27 replicas: 1 port: 803.3 编写 Controller 核心逻辑
先看 SetupWithManager,把监听关系注册好:
func (r *WebAppReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&demov1.WebApp{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). Complete(r) }接着是 Reconcile 函数,我按实际开发顺序拆成几步讲。
第一步,取 WebApp 资源。用 req.NamespacedName 去 Get,如果 NotFound 说明资源已被删除,直接返回 nil;如果是其他错误才需要返回 err。注意 NotFound 和别的错误要分开处理,很多人直接 return err,同一个对象删掉后会一直报错重试,白白刷日志。
var app demov1.WebApp if err := r.Get(ctx, req.NamespacedName, &app); err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil } return ctrl.Result{}, err }第二步,创建或更新 Deployment。这里用 controllerutil.CreateOrUpdate 能省很多事:先取资源,不存在就走 create,存在就走 update,update 的改动只写在 mutate 函数里。关键的一步是 SetControllerReference,它把 Deployment 的 ownerReference 指向 WebApp,这样 WebApp 被删除时,垃圾回收器会自动级联删除它创建的 Deployment 和 Service,不用你在 Reconcile 里写一堆清理逻辑。
deploy := &appsv1.Deployment{} _, err := controllerutil.CreateOrUpdate(ctx, r.Client, deploy, func() error { name := app.Name if deploy.ObjectMeta.Name == "" { deploy.Name = name deploy.Namespace = app.Namespace if err := controllerutil.SetControllerReference(&app, deploy, r.Scheme); err != nil { return err } } deploy.Spec.Replicas = app.Spec.Replicas deploy.Spec.Selector = &metav1.LabelSelector{ MatchLabels: map[string]string{"app": name}, } deploy.Spec.Template.ObjectMeta.Labels = map[string]string{"app": name} deploy.Spec.Template.Spec.Containers = []corev1.Container{{ Name: name, Image: app.Spec.Image, Ports: []corev1.ContainerPort{{ContainerPort: app.Spec.Port}}, }} return nil })第三步,同理会创建 Service,结构差不多,不重复贴代码。核心思路是:无论资源是否存在,CreateOrUpdate 都能保证最终结果符合期望。
第四步,更新 status。在子资源创建或更新后,把 Deployment 的实际 ReadyReplicas 读出来,回写到 WebApp.Status.ReadyReplicas。注意更新 status 要用专门的 Status().Update,因为开了 subresource 之后普通 Update 不会动 status。为了让“Deployment 变化后状态能同步刷新”,除了 Owns 监听,我会再补一个轮询兜底:
app.Status.ReadyReplicas = deploy.Status.ReadyReplicas if err := r.Status().Update(ctx, &app); err != nil { return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: 30 * time.Second}, nil这个 30 秒的 RequeueAfter 就是“持续对账”的落地形式:即使没有任何事件,控制器也会每 30 秒把整个 WebApp 的状态重新对一遍。对大多数非极端性能要求的场景,30 秒完全够用,不需要把周期调得太激进。
3.4 本地调试与部署到测试集群
脚手架生成的 main.go 已经包含 Manager 和健康检查接口,本地调试最大的好处是可以打断点,我推荐这条调试路径:
make install # 把 CRD 安装到本地集群 make run # 本地直接跑控制器,日志输出到终端然后用另一个终端把示例资源 apply 进去:
kubectl apply -f config/samples/demo_v1_webapp.yaml几秒内,控制器日志会打印出“开始对账”,再往后 kubectl get deploy 能看到它生成的 Deployment,kubectl get webapp 能看到 status 已经回写成 1。确认无误后,再往集群里部署正式版本:
make docker-build IMG=your-registry/controller:latest make docker-push IMG=your-registry/controller:latest make deploy IMG=your-registry/controller:latestmake deploy 会通过 kustomize 生成控制器 Deployment,把 CRD、RBAC、Controller 一次装好。注意检查镜像仓库地址和 imagePullPolicy,我碰到过最多的部署问题就是本地 build 成功、集群却 ImagePullBackOff,原因大多是镜像 tag 没推上去或地址写错。
4. 常见问题与排查技巧实录
4.1 RBAC 权限缺失:不报错也白干
Operator 跑在集群里时,它自身的 ServiceAccount 需要访问它管理的所有资源。Kubebuilder 用 marker 注释声明权限,再执行 make manifests 生成 RBAC YAML。最典型的问题:日志里完全没有任何错误,但你的 Reconcile 就是不执行,或者执行到一半对 Deployment 的操作被静默拒绝。
排查套路是这样的:
- 先看 controller 日志里有没有包含 forbidden 字样的错误。
- 用 kubectl auth can-i --list --as=system:serviceaccount: : 检查权限。
- 打开 config/rbac/role.yaml,确认有没有对应资源的 get/list/watch/update 权限。
对应的 marker 长这样,controller 文件顶部加上之后重新 make manifests 即可:
//+kubebuilder:rbac:groups=demo.example.org,resources=webapps,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=demo.example.org,resources=webapps/status,verbs=get;update;patch //+kubebuilder:rbac:groups=demo.example.org,resources=webapps/finalizers,verbs=update //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete4.2 乐观锁冲突:Status 更新不了
当 Reconcile 并发高、或轮询周期太短时,Status().Update 会报 resourceVersion conflict。原因是同一个对象被多个 goroutine 同时修改,或者你缓存里读到的版本已经过期。正解不是盲目加大 Retry,而是:
- 多副本部署 Controller 时务必开启 leader election,让同一时间只有一个实例真正干活。
- status 更新失败时返回 err 即可,controller-runtime 检测到 conflict 会自动重新入队。
- 如果确实需要频繁更新 status,把不同维度的状态拆分到独立字段里,减少互相覆盖的可能。
4.3 Finalizer 没移除,对象卡在 Terminating
如果给 WebApp 加了 Finalizer,通常是需要在删除前做清理工作,比如通知外部系统释放资源,结果删除后一直卡在 Terminating,十有八九是 Reconcile 的删除分支里忘了移除 Finalizer。
正确顺序是:
- 在创建或更新资源时,用 controllerutil.AddFinalizer 把 finalizer 加上。
- 判断 DeletionTimestamp 是否非空,非空说明对象正在删除中,此时执行清理逻辑。
- 清理完成后调用 controllerutil.RemoveFinalizer 并更新对象,Kubernetes 才会真正删除。
少了第三步,对象会永远卡在 Terminating,而且日志里常常没有报错,非常费神。我自己踩过一次,最后是手动 patch 把 finalizer 清掉才解围。
4.4 缓存一致性问题:刚创建的资源 Get 不到
controller-runtime 的 client 默认带一层缓存,读取走 informer 缓存,不直接打 apiserver。刚创建的资源在缓存里可能还没同步,立刻 Get 会 NotFound。这不是逻辑 bug,是缓存时序问题。
处理方式有几种:依赖顺序上尽量做幂等,CreateOrUpdate 天然容忍“暂时不存在”;如果一定需要刚写入就立刻读,可以显式关掉缓存直连,但那样会牺牲一致性,不推荐。最省心的建议是:不要把“当前刚写入的状态”当成下一步逻辑的输入,Reconcile 要设计成无论缓存新旧都能收敛到正确终态。
4.5 版本升级的兼容性坑
系列前面聊过 CRD 版本,这里只提操作层面的坑:修改 types.go 结构后,必须重新执行 make manifests 并重新 apply CRD,否则旧的 schema 还留在集群里,新字段会校验失败。还有一个经验:同一个资源要同时支持 v1alpha1 和 v1beta1 时,存储版本需要仔细选择,两个版本都需要能共存,就得配置 conversion webhook,或者至少保证字段能在不丢数据的情况下做转换。这些在 CRD 系列前面的文章里已经详细聊过,这里就不再展开了。
下面整理一份排查速查表,方便后面对照:
| 现象 | 可能原因 | 排查入口 |
|---|---|---|
| Reconcile 不执行 | RBAC 缺失 / 没有 For(...) 注册 | controller 日志、auth can-i |
| 对象卡 Terminating | Finalizer 未移除 | kubectl get xxx -o yaml 看 finalizers |
| status 更新失败 | 乐观锁冲突 | 日志中 resourceVersion conflict |
| 子资源不级联删除 | 没设 ownerReference | 检查 deployment 的 metadata.ownerReferences |
| 改了 schema 不生效 | 未重新 apply CRD | kubectl get crd 看 openAPIV3Schema |
5. 写在最后的几个个人建议
文章到这里不做什么总结,就分享几个我自己做 Operator 时觉得最有用的习惯。
第一,分清“该不该写 Operator”。如果只是部署一组定义好的资源,Helm 就够了;如果是要表达“根据业务状态动态调整资源”,再考虑写 Operator。不要为了技术时髦而写,Operator 的维护成本是长期存在的,团队如果没有持续投入,很容易变成写了没人敢动的遗产代码。
第二,从最小自动化级别起步。先只写一个能生成 Deployment 的 Controller,跑通了再加扩容、加滚动策略、加备份、加 webhook。一次迭代只走一步,否则调试时根本分不清是哪个环节出了问题。我见过太多人一开始就想做一个全自动状态机,最后卡在局部逻辑里出不来。
第三,日志和事件要舍得花时间。Kubernetes 排障最依赖的就是事件。在 Reconcile 里用 r.Recorder.Event(...) 把关键动作记录下来,比如“创建 Deployment 成功”“副本数从 2 调到了 3”。线上问题发生时,kubectl describe webapp 能看到完整时间线,比在日志里翻半天强太多。
第四,写单元测试时用 envtest 起一个本地的 apiserver 和 etcd,把 Reconcile 真正跑起来测。不要只看语法对不对,资源之间的一连串编排逻辑只有真跑才能暴露问题。
我在实际开发中最大的体会是:Operator 的难度不在写代码,而在你有没有把“对账”这件事想明白。事件会丢、状态会漂移、缓存会滞后、外部操作会打断你的假设,这些统统不可靠,唯一可靠的是让你的 Reconcile 在任何输入下都幂等、收敛,最终状态始终不出差错。这个思路想通了,写起来会顺手很多。希望这篇能帮你少踩几个我踩过的坑。