1. 从 CRD 到 Operator:把“声明”变成“现实”
写完自定义资源(CRD)之后,很多人会有一种错觉:好像已经大功告成了。CRD 定义好了,YAML 也能提交了,kubectl get也能看到了,但这个资源真的能“工作”吗?
答案是否定的。CRD 本质只是一套“数据结构声明”,它告诉 Kubernetes“世界上存在这样一种资源,长这样,字段有这些”。但它不会做任何事,不会创建 Pod,不会拉起服务,不会清理旧数据。它就像一个贴在墙上的流程图,画得再漂亮,也不会自己跑起来。
真正让 CRD“活过来”的,是 Operator。这也是本系列从 CRD 走到 Operator 的必然逻辑:前面几篇解决的问题是“怎么定义一个资源”,这一篇解决的问题是“怎么让这个资源产生实际作用”。
如果你之前一直在跟 CRD 打交道,或者正在设计一套基于 Kubernetes 的业务系统,这篇内容基本就是你要跨过去的那道坎。我会用实际写过的控制器代码来讲,尽量让你读完之后能动手搭一个自己的 Operator,而不只是看热闹。
2. Operator 是什么,以及为什么非它不可
2.1 从“脚本编排”到“控制器模式”
在 Operator 还没流行之前,大家是怎么在 Kubernetes 上做自动化部署的?最常见的是 CI/CD 流水线:代码编译成镜像,Jenkins 或同类工具执行kubectl apply,完事。
这套流程在简单场景下够用,但一旦要处理有状态应用、要按业务规则编排资源、要做故障自愈,就会发现脚本方向完全走偏了。脚本是“一次性”的,执行完就结束,它不具备“持续观察、持续修正”的能力。
Operator 的核心思想完全不同:它不是一个“跑一次就结束”的脚本,而是一个“永远在线”的控制循环。它不断问自己三个问题:当前状态是什么?期望状态是什么?两者之间差了什么?差的部分,我来补。
这就是 Kubernetes 本身的设计哲学。Kubelet 是这样管节点的,Deployment 控制器是这样管副本数的,而 Operator 不过是把这套哲学延伸到自定义资源上。
2.2 场景举例:从实际需求反推 Operator 的价值
我拿一个实际做过的需求来讲。某公司的业务团队需要在 Kubernetes 里跑一批定时备份任务,每个备份任务要求:每天凌晨两点执行、备份数据保留 7 天、失败自动重试三次、状态要在自定义资源上能查看到。
如果用脚本做,能实现,但你得自己维护定时器、自己写重试逻辑、自己处理集群节点宕机导致任务消失的问题。这些脚本跑在 Pod 里,Pod 挂了任务就废了。
用 Operator + CRD 做,效果完全不同。定义一种BackupJob资源,用户提交一个 YAML,就声明“我要一个几点执行、保留几天、失败重试几次的备份任务”。Operator 观察这个资源,自动创建 CronJob 或直接创建 Pod,执行完成后把结果写回资源状态。用户全程只需要kubectl apply和kubectl get backupjob,其余全部自动化。
这个例子涵盖了 Operator 最典型的价值:把复杂编排逻辑封装成“资源”,用户声明意图,机器保证执行。这也是为什么现在几乎所有主流中间件(数据库、消息队列、缓存)都有官方 Operator——它们把人类运维专家的经验直接固化成代码。
2.3 Operator 和 Helm、Kustomize 的区别
这一节要澄清一个常见的混淆点:Helm 和 Kustomize 也是做部署的,它们跟 Operator 有什么区别?
Helm/Kustomize 解决的是“模板化与参数化部署”问题,本质是 YAML 的生成与渲染工具。它们把一堆资源打包成一整套,一条命令部署完。但部署完之后,它们就不管了,不会监控运行状态,不会根据业务变化自动调整。
Operator 的重心不在“部署那一刻”,而在“部署之后的整个生命周期”。它甚至可以自己调用 Helm 或 Kustomize 去渲染初始 YAML,但更核心的是后续的调谐(reconcile)逻辑。简单说,Helm 是“生孩子”,Operator 是“养孩子”,既要生也要养。
3. Operator 核心工作流程拆解
3.1 Reconcile 循环:Operator 的心脏
写 Operator 绕不开一个概念:Reconcile 循环,也叫调谐循环。它不是一个具体的函数名,而是一种思维模式。在 controller-runtime 框架里,每个控制器都有一个Reconcile方法,这个方法的逻辑就是整个调谐过程的体现。
基本流程是这样的:
- 拿到一个资源对象的 key(命名空间 + 名字)。
- 用这个 key 从集群里查当前资源对象。
- 根据资源的期望状态(spec 字段),比对集群里实际存在的子资源。
- 如果发现缺失或配置不对,就创建、更新或删除这些子资源。
- 最后把结果写回资源的 status 字段,方便用户查看。
这个流程最关键的一点是“不要假设一次执行就成功”。Reconcile 可能失败,可能重复执行,可能在上一次执行到一半时资源被删掉。所以这个方法的逻辑,以及伴随它的事件监听机制,要按“随时可能被中断、随时可能重新执行”的思路来写。
3.2 事件监听与“全量打散”的配合
Reconcile 方法本身只是被触发的回调,那什么来触发它?答案是控制器注册的 EventHandler。控制器可以监听多种事件:资源本身的增删改、关联 Pod 的变化、关联 Service 的变化。
这里有一个非常容易踩坑的设计细节。假设我们注册监听了 BackupJob 关联的 Pod,Pod 发生任何变化都要触发对应 BackupJob 的 Reconcile。但如果这个集群里同时有几百个 BackupJob,每个 BackupJob 下又有几个 Pod,事件就会频繁触发,Reconcile 会被大量调用。
controller-runtime 有个默认机制叫“全量打散”(full requeue),意思是一个 Pod 变化事件不仅会通知这个 Pod 对应的 BackupJob,还会把所有 BackupJob 全部重新入队。这样保证了正确性,但代价是效率低。如果资源量少无所谓,量大了建议在 EventHandler 里做过滤,只把事件映射到真正相关的资源 key 上。
3.3 工作队列与去重机制
这里要明白一个重要的背景:Reconcile 并不是“并发执行一个操作”,而是“从队列里取一个事件,处理完再取下一个”。controller-runtime 底层用工作队列(workqueue)来管理这些待处理的资源 key。
工作队列最大的特点有两个:一是“按 key 去重”,同一个 key 如果短时间内被触发了 10 次,队列里只保留一个;二是“限速重试”,处理失败的 key 不会无限快速重试,而是按指数退避策略,从 5 毫秒开始,逐渐增长到上限(默认 1000 秒)。这两个机制保证了大批量事件涌入时控制器不会被打爆,也避免了失败任务无限空转浪费资源。
理解这一点很有用,因为很多人调 Reconcile 逻辑时发现“好像没执行”“好像延迟了”,其实不是没执行,而是去重后多个事件合并成了一个,加上重试限速,看起来“反应慢”。这反而是保护机制在起作用。
3.4 声明式状态与“Desired vs Current”思维
编写 Reconcile 逻辑时,要时刻提醒自己:Kubernetes 是声明式的,不是命令式的。你不需要写“先执行 A,再执行 B,再执行 C”,而是要写“我期望最终状态是 X,请把当前状态往 X 方向调整”。
比如前面提到的备份任务,期望状态是“有个 CronJob,调度规则是每天凌晨两点”。Reconcile 逻辑要做的是:查一下当前有没有这个 CronJob,如果没有就创建,有但配置不一致就更新,有且配置一致就什么都不做。不要写“每次执行都创建一个 CronJob”——那不是调谐,那是生产事故。
这种思维是写 Operator 和写普通后台任务的本质区别。普通后台任务关心“我要做什么”,Operator 关心“我要确保什么被保持”。
4. 从零搭建一个 Operator:定时备份控制器实战
这一章我们抛开空谈,实际走一遍搭建流程。我用的技术栈是 Go + controller-runtime,这是目前生态最成熟的选择。如果你想看更完整的代码,网上仓库很多,我这里讲的是核心思路和关键点。
4.1 项目结构与代码骨架
用脚手架工具(比如 operator-sdk)初始化一个项目后,目录结构大致是这样:
. ├── api │ └── v1 │ ├── backupjob_types.go │ ├── groupversion_info.go │ └── zz_generated.deepcopy.go ├── controllers │ ├── backupjob_controller.go │ └── suite_test.go ├── config │ ├── crd │ ├── manager │ └── rbac ├── cmd │ └── main.go └── go.modapi/v1下面放的是自定义资源的类型定义,controllers下面放 Reconcile 逻辑,config下面放部署清单,cmd/main.go是程序入口。
4.2 API 类型定义:spec 与 status 的分工
定义BackupJob资源时,spec 和 status 的职能要分清楚。
spec 是用户填的“期望值”,比如:
type BackupJobSpec struct { // 备份的数据库连接地址 DatabaseHost string `json:"databaseHost"` // 备份执行时间,cron 格式 Schedule string `json:"schedule"` // 备份保留天数 RetentionDays int `json:"retentionDays"` // 失败重试次数 MaxRetries int `json:"maxRetries"` }status 是控制器写的“实际结果”,比如:
type BackupJobStatus struct { // 最近一次执行时间 LastBackupTime *metav1.Time `json:"lastBackupTime,omitempty"` // 最近一次执行结果 LastResult string `json:"lastResult,omitempty"` // 下一次计划执行时间 NextScheduleTime *metav1.Time `json:"nextScheduleTime,omitempty"` // 当前状态 Phase string `json:"phase,omitempty"` }边界原则:spec 只允许用户改,status 只允许控制器改。如果用户想改 schedule,可以改 spec,控制器收到变化后会调整 CronJob;status 是控制器反馈给用户的“观察窗口”,用户不应该直接编辑。
4.3 Controller 的核心方法:把意图翻译成 Kubernetes 资源
Reconcile 方法的大致流程如下:
func (r *BackupJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { // 1. 获取 BackupJob 资源 var backupJob backupv1.BackupJob if err := r.Get(ctx, req.NamespacedName, &backupJob); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } // 2. 如果资源正在被删除,处理清理逻辑 if !backupJob.DeletionTimestamp.IsZero() { return r.handleDeletion(ctx, &backupJob) } // 3. 确保关联的 CronJob 存在且配置正确 if err := r.ensureCronJob(ctx, &backupJob); err != nil { return ctrl.Result{}, err } // 4. 更新 status if err := r.updateStatus(ctx, &backupJob); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil }每一步都有细节。比如第 1 步,Get失败时要判断是否 NotFound——资源被删除时 Reconcile 也会被触发,NotFound 不算错误,直接返回即可;第 2 步,DeletionTimestamp非零说明正在删除,要在 finalizer 里处理关联资源清理,否则会出现“资源删了但 CronJob 还留在集群里”的孤儿问题。
4.4 关键实现细节:finalizer 的用法与陷阱
finalizer 是 Kubernetes 的“删除前钩子”。当资源设置了 finalizer 时,删除操作不会立即完成,而是把这个资源的状态标记为 Terminating,直到 finalizer 被移除。
在 Operator 里,finalizer 最常见的用途是“确保关联资源被正确清理”。操作模式是固定的:
- 给资源加上一个自定义 finalizer(比如
backupjob.example.com/finalizer)。 - 删除资源时,控制器在 Reconcile 里检测到
DeletionTimestamp非零。 - 执行清理逻辑:删除关联的 CronJob、清理遗留数据。
- 清理完成后,从资源里移除这个 finalizer。
这里有个非常常见的坑:如果清理逻辑本身依赖的外部资源不可用,比如数据库连不上,那 finalizer 永远移除不掉,资源就会一直卡在 Terminating 状态。所以清理逻辑要有超时机制、容错分支,至少要有一个“清理不了也允许删除”的逃生通道。
4.5 入队机制与关联资源监听
前面提到,我们要监听 Pod 变化,然后触发 BackupJob 的 Reconcile。controller-runtime 里可以给控制器添加 Watches:
ctrl.NewControllerManagedBy(mgr). For(&backupv1.BackupJob{}). Owns(&batchv1.CronJob{}). Complete(r)For是监听主资源本身,Owns是监听控制器创建的子资源。这里Owns的含义是:CronJob 归属于某个 BackupJob,CronJob 的任何变化都会找到它的 owner 并触发 Reconcile。
这个机制能用的前提是:创建 CronJob 时,必须设置 OwnerReference 指向对应的 BackupJob。controller-runtime 在创建时通过controllerutil.SetControllerReference来设置。如果不设置,子资源变化不会自动触发父资源的 Reconcile,你会遇到“手动改了 CronJob 但 BackupJob 状态一直不更新”的诡异问题。
4.6 参数计算:调度时间与重试策略
备份任务的 Schedule 直接透传成 CronJob 的 cron 表达式,但需要注意一处细节:Kubernetes CronJob 的时区默认是 UTC,而业务方通常期望本地时区。如果直接透传,凌晨两点会在上午十点执行(假设 UTC+8)。
解决方案有两种:一是要求用户在 spec 里直接写 UTC 时间的 cron 表达式,不友好;二是在控制器里配置 CronJob 的timeZone字段(Kubernetes 1.24+ 开始支持),明确指定时区。
重试次数也需要转换成 CronJob 的backoffLimit字段,以及单个 Job 的activeDeadlineSeconds。注意backoffLimit与 Reconcile 逻辑里的失败重试是两层概念:前者是 Kubernetes 对 Job 的重试,后者是控制器自己对整个备份流程的补偿,不能混淆。
4.7 控制器启动参数与 Leader Election
生产环境部署 Operator,建议打开 leader election 特性。打开后,同一个 Operator 即使启动了多个副本,也只有一个副本会真正执行 Reconcile,另一个处于待命状态。这样主副本挂了可以快速切换,不会出现两个副本同时对同一个资源做操作导致冲突。
controller-runtime 的 leader election 配置非常简答,在cmd/main.go里设置LeaderElection: true和LeaderElectionID即可。ID 要求全局唯一,通常用域名式的名字,比如backupjob-operator.example.com。
这块在生产环境是必须的。我见过团队不敢开副本数大于 1,就是因为没开 leader election,怕两个实例抢同一个 CronJob。开了之后,多副本部署就不再是问题。
5. 常见问题排查与避坑实录
5.1 事件风暴:Reconcile 被无限触发
症状:一个资源刚创建,控制器日志里出现大量 Reconcile 请求,甚至资源状态在Progressing和Ready之间反复横跳。
原因:status 更新本身会触发资源更新事件,而如果控制器监听的是“所有更新事件”,状态更新就会再次进入 Reconcile 队列,形成自触发循环。
解决:在 Reconcile 里写状态更新时判断一下“值是否真的变了”,没变化就不 update;或者用 generation 来判断,spec 没变就直接跳过状态更新逻辑。
这个坑会出现在很多新手项目里。如果发现控制器 CPU 占用异常高,先检查一下是不是自己在不停地写自己的 status。
5.2 资源删除后关联资源成为孤儿
症状:删掉 BackupJob 后,CronJob 还在,还在按计划执行任务。
原因:没设置 finalizer + OwnerReference,或者设置了 OwnerReference 但SetControllerReference调用失败没被注意到。
解决:删除逻辑统一走 finalizer 通道;创建子资源时检查 owner reference 是否真的设置成功。还有一个经验是,不要依赖 Kubernetes 的“级联删除”,有些场景下关联资源的正确清理顺序需要你手动编排。
5.3 RBAC 权限不足导致的“看不见”
症状:Reconcile 反复报错,日志显示is forbidden: User "system:serviceaccount:xx" cannot get resource "backupjobs"。
原因:控制器的 ServiceAccount 没有对应的 RBAC 权限。controller-runtime 默认会自动生成 ClusterRole,但如果你改了资源名或者自定义了命名空间,容易漏配。
解决:仔细检查 config/rbac 下的 ClusterRole 规则,至少要覆盖:自定义主资源的增删改查、关联子资源的增删改查、status 子资源的更新权限。
这里有个隐藏权限常被人遗漏:更新 status 需要单独的权限。backupjobs/status这个子资源要特别授权,否则 Get 正常但 update status 一直失败。
5.4 本地调试与远程集群联调
本地跑 Operator 时,最方便的方式是kubectl --kubeconfig指向测试集群,然后go run cmd/main.go直接启动。这样日志直接出现在终端,改完代码立刻生效,不用每次打镜像推到仓库再拉取部署。
但要注意,本地运行时代码需要能连接到集群,如果用的是 kind 或者 minikube,确认 kubeconfig 配置正确。生产环境我会先打镜像,在测试集群用 Helm 部署一遍,确认手册里的部署步骤真实可走通,再上生产。
还有一个建议:多打日志。Reconcile 里的每一步都打一行结构化日志,包含资源 key 和当前阶段。虽然看起来冗余,但排查问题时效价比特别高,尤其是状态不一致、资源重复创建这类问题上。
6. 从 Operator 到平台化:下一步还能做什么
6.1 批量资源调和与性能优化
单控制器如果管理几百个资源,Reconcile 是串行处理的,性能可能成为瓶颈。controller-runtime 默认单工作队列并发数是 1,可以通过MaxConcurrentReconciles提高并发数。但要注意,提高并发后要保证逻辑是并发安全的,尤其是访问共享缓存和外部 API 时。
还可以考虑给控制器加RateLimiter自定义限速策略。默认的指数退避在场景特殊时不一定合适,比如某些资源失败后希望 30 秒重试而不是 5 秒。
6.2 Admission Webhook 与输入校验
Operator + Webhook 是 CRD 系列的完整形态。前面我们说 spec 不能乱填,但默认情况下 Kubernetes 只校验类型,不校验业务逻辑。比如retentionDays填 -1,语法合法,业务上却是错的。
这种情况要靠 ValidatingWebhook 做业务校验,在提交 YAML 时就拦截非法参数。配合 Webhook 的还有 DefaultingWebhook,可以给没填的字段填默认值。这个系列前面应该讲过,这里再提一次是为了说明它在 Operator 架构里的位置:Operator 管“运行逻辑”,Webhook 管“输入合法”。
6.3 多版本管理与升级策略
如果一个 CRD 已经上了生产,后续又需要改字段,就涉及 CRD 多版本管理和转换。老版本资源继续运行,新版本资源用新字段,转换逻辑写在转换 Webhook 里。这个话题在 CRD 系列早期应该详细说过,和 Operator 的关系在于:升级 Operator 版本时,要小心旧资源还在被旧逻辑管理,新逻辑上线后能否兼容处理。
原则是“先兼容,后清理”。新版本 Operator 部署时先兼容旧资源,确认无异常后再把旧版本的 CRD 删掉。
6.4 基于 Operator 的业务平台设想
把多个 Operator 组合起来,就能形成一个完整的平台层。比如,备份 Operator 负责备份,恢复 Operator 负责恢复,迁移 Operator 负责跨集群迁移,再配一个总控的 Operator 去协调它们。
这种做法的好处是模块清晰:每个 Operator 管理一种资源,资源之间有明确的 OwnerReference 关系和数据流。业务方不需要关心背后的实现细节,只需要声明“我要什么”。
我自己在实践中的体会是,Operator 的价值不在于“能用代码操作 Kubernetes”,而在于“把业务逻辑变成集群的底层能力”。当你在一个 System 里定义清楚了“资源长什么样、期望状态是什么、怎么收敛差异”,你就拥有了一套自我驱动的运维体系。开发、测试、交付、自愈全都沉淀在代码里,而不是散落在一堆没有人敢动的脚本中。
最后再分享一个小技巧:写 Operator 时,把 status 的每个枚举值都定死,不要用自由字符串。Phase字段如果写成“running”、“正在跑”、“not ready”三种风格,后面做监控和告警会抓狂。枚举值统一、文档写清,这比任何代码优化都更能节省后期维护时间。