如果你和我一样,每天要和 Kubernetes 打交道,那你大概率写过 Deployment、Service,也用过各种各样的 Operator。但有一件事,我在带团队时发现很多人其实一直没完全搞懂——Kubernetes 里这些“自动纠偏”的能力,到底是谁在背后推动?答案就是 Controller。这个单词几乎出现在每一本 K8s 书籍里,但真正能把它讲清楚、并且能动手写一个出来的人,少之又少。这篇内容,我就结合自己从“会用”到“能写” Controller 的完整过程,把这个概念拆开揉碎,从原理到实操一次讲明白。适合刚接触 K8s 的运维、想深入理解调度原理的开发,以及所有准备上手 Operator 开发的人。
1. 内容整体设计与思路拆解
1.1 Controller 到底是什么
先不给教科书定义,说一个我自己的理解:Controller 就是集群里的一群“监督员”,它们各自盯着某一类资源,一发现实际状态和期望状态不一致,就立刻动手把实际状态掰回期望状态。
这听上去很简单,但背后是一整套设计哲学。Kubernetes 从来不是一个“你让我干什么我就干什么”的系统,而是一个“你告诉我最终要什么样,我自己想办法达成”的系统。Controller 就是这套声明式模型的实际执行者。
举个例子,你提交了一个 Deployment,声明“我要 3 个副本”。这个 YAML 只是表达了愿望,真正去创建 3 个 Pod 的是 Deployment Controller;Pod 创建后需要调度到某台机器,这是 Scheduler 的工作;每个节点上的 kubelet 需要按照 Pod 声明去启动容器,这也是一种 Controller 思维。可以说,整个 K8s 就是一个由无数 Controller 组成的“纠偏生态系统”。
从代码层面看,一个 Controller 的核心逻辑其实只有三件事:
- 监听某类资源的变化;
- 将变化的事件推入工作队列;
- 取出事件,比对实际状态与期望状态,执行调和逻辑。
这三件事循环往复,就成了 K8s 里最常见也最重要的模式——调谐循环(Reconcile Loop)。
1.2 为什么 Kubernetes 非要用这套模式
很多人有一个误区,觉得 Controller 是 K8s 为了“显得高级”才引入的概念。其实不是。如果你尝试过用脚本维护集群状态,你会很快遇到几个痛点:集群里资源随时可能被意外删除、配置被改、节点宕机,脚本很难处理这些偶发事件;即便写一个守护进程定时检查,也很难处理“多个操作者在同一时间修改同一资源”的并发问题。
Controller 这套模式把这些问题全部收进了一个统一框架里:
- 声明式状态,天然支持幂等,重复执行不会搞出乱子;
- 事件驱动,比轮询实时性强得多,也省资源;
- 基于 API 服务器的统一协调,多个 Controller 之间互不踩脚,各自盯着自己的资源类型。
可以用一个生活化类比帮助理解:你家里装了空调,期望温度是 25 度,温度传感器实时反馈当前温度是 30 度,空调控制器就启动制冷;降到 24 度,又停掉压缩机。这套“感知—对比—干预”的循环,就是 Controller 的精髓。Kubernetes 就是集群的温控系统,而一个个 Controller 就是负责不同区域“温控”的独立单元。
1.3 自己写 Controller 的动机和适用场景
既然 K8s 自带了一堆 Controller,为什么还要自己写?最核心的原因只有一个:系统里存在不被原生 Controller 管理的自定义资源。
这些年业务架构越来越复杂,很多团队不满足于只使用 Deployment 和 Service。比如,你希望定义一个名叫 “DatabaseCluster” 的资源,它包含主从数量、存储大小、备份策略等字段。提交这个资源后,系统能自动创建 StatefulSet、Service、PVC,并且能感知数据库实例状态变化,自动触发主备切换。这就是一个典型 Operator 的雏形,本质就是一个针对自定义资源的 Controller。
我做过一个类似的项目:某内部系统需要一个“定时任务集群”的概念,用户在 YAML 里声明任务名、调度规则、并发策略,Controller 负责把这类资源翻译成 CronJob 和 ConfigMap,并且根据运行情况自动重试、上报状态。这种需求用原生 Kubernetes 完全做不到,自己写一个 Controller 是最干净的解法。
2. 核心机制解析:Client-go、Informer 与 Workqueue
2.1 Controller 的核心组件与运行流程
动手写代码之前,必须先掌握 Controller 的四个物理组件,它们组成了一个完整的处理流水线:
- Clientset:Kubernetes 官方提供的 Go 客户端,负责和 API Server 通信,完成对资源的增删改查;
- Informer:监控资源的“眼睛”,通过 List/Watch 机制同步资源状态,并且维护一份本地缓存;
- Workqueue:一个带去重和限速机制的任务队列,用于存放待处理的事件;
- Reconciler:真正干活的“手”,从队列取出事件后执行调谐逻辑。
把这四者串起来就是标准流程:Informer 通过 Reflector 监听 API Server 的资源变化,每当有资源新增、修改、删除,就会触发对应的事件回调;回调函数并不直接处理业务,而是把事件丢进 Workqueue;Worker 从 Workqueue 取出一个事件,通过 Clientset 获取资源最新状态,再执行 Reconcile 函数比对期望状态和实际状态,最终通过 Clientset 执行写操作。
这个流程看起来多绕了几步,但每一步都有明确的工程考量。比如“事件不直接处理,而是进队列”,是因为事件可能非常频繁地发生,直接同步处理会导致 API Server 压力剧增;通过队列做缓冲、去重、限速,可以让 Controller 平稳消化突发流量。
2.2 Informer 的本地缓存到底有什么用
Informer 是新手最容易忽略、但最需要理解的部分。它本质上是一个“缓存 + 监听器”的组合体。
Informer 启动时会先执行一次 List,把某类资源的全量数据拉下来,存入本地缓存;然后通过 Watch 建立长连接,持续接收增量事件。也因为这个机制,Controller 内读取资源并不需要每次请求 API Server,直接查本地缓存即可,速度极快。
但也正是这个缓存,坑了不少刚上手的人。我见过有同事在处理事件时直接从缓存里拿对象然后修改,结果下一次 List 时发现状态“自己变回去了”——因为缓存里的对象不能随便改,你需要通过 Clientset 去 API Server 执行真正的更新,而不是直接操作缓存里的指针。这个细节我在下文的常见问题部分还会详细展开。
2.3 Workqueue 的去重与限速机制
Workqueue 是 client-go 提供的一个核心工具库,它和普通队列最大的区别有三个:
- 去重:同一个资源在队列中只会存在一份,即便它在一秒内发生了 10 次变化,也只会触发一次调和;
- 限速:当处理失败时,重试不是立即执行的,而是按照指数退避的方式进行,比如第一次失败等 5ms,第二次 10ms,第三次 20ms,逐步拉长间隔,避免风暴;
- 周期性清理:队列内部会定期清理已经处理完成的条目,避免队列无限增长。
这三个特性形同给 Controller 装了一层“稳压器”。没有它们,任何一个小小的 API Server 抖动都可能导致 Controller 崩溃或资源耗竭。
3. 实操过程与核心环节实现:从零写一个 Controller
3.1 准备工作与环境搭建
开始动手前,先说下我使用的环境,方便你对照。
- Kubernetes 集群:建议 1.22 及以上版本,用 kind 或者 minikube 就行,没必要一开始就上生产集群;
- 开发语言:Go,建议 1.20 以上,配合 client-go 对应版本;
- 必备工具:kubectl、docker、kubebuilder 或者 code-generator(后面会讲二者的取舍)。
如果你对 Go 不太熟,也不用慌。写 Controller 所需的 Go 知识其实很集中:结构体定义、接口实现、包管理,再加一点并发编程基础,足够了。真正花时间的不是语言,而是理解 K8s 的对象模型。
环境可以直接用 kind 快速搭一个测试集群:
kind create cluster --name dev kubectl cluster-info然后新建一个 Go 模块,引入 client-go:
go mod init mycontroller go get k8s.io/client-go@latest go get k8s.io/apimachinery@latest命令行工具的选择上,我推荐直接使用 kubebuilder,它是目前搭建 Controller 项目的标准脚手架,能帮你生成 CRD、Controller、RBAC 配置等一大坨样板代码。早期很多人是手写 code-generator 的,效率低而且容易出错,现在除非有极端定制需求,否则没有理由不走脚手架。
3.2 定义自定义资源(CRD)
Controller 要管理的对象必须先定义好,这就是 CRD。CRD 的本质是告诉 API Server:除了内建的 Pod、Service 之外,我还要增加一种新的资源类型,以后你可以像处理原生资源一样处理它。
我用一个模拟项目 “MyApp” 来演示。创建一个 CRD,表示一个需要被部署的简单应用副本组。定义文件如下:
apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: myapps.example.io spec: group: example.io names: kind: MyApp listKind: MyAppList plural: myapps singular: myapp scope: Namespaced versions: - name: v1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: replicas: type: integer minimum: 1 default: 1 image: type: string这个文件定义了一个名为 MyApp 的资源,它包含两个核心字段:replicas 表示期望副本数,image 表示容器镜像。提交到集群后,kubectl 就能感知这类资源了。
kubectl apply -f crd.yaml kubectl get myapps此时不会有任何实际 Pod 创建,因为还没有 Controller 去处理它。这一步非常关键:CRD 只解决“存储和查询”的问题,业务逻辑完全靠 Controller 补齐。
如果你是用 kubebuilder 初始化项目,CRD 的定义可以直接写在 Go 结构体和注解里,通过 make manifests 自动生成 YAML,不需要我这样手写。但我仍然建议初学者至少手写一遍,因为只有手写过,你才能真正理解 CRD 的 API 版本、schema 校验、状态子资源这些概念。
3.3 搭建 Controller 主程序框架
Controller 的主程序结构,我直接说标准的 kubebuilder 生成后长什么样。整个项目常见目录如下:
. ├── api/v1/ │ └── myapp_types.go # CRD 对应的 Go 结构体 ├── cmd/main.go # 程序入口 ├── controllers/ │ └── myapp_controller.go # 核心调谐逻辑 ├── config/crd/ # 生成的 CRD 定义 ├── config/rbac/ # ServiceAccount、Role 等权限配置 └── Makefile # 常用构建命令main.go 里做的事看起来很常规:读配置、建 Clientset、启动 Manager。但有一行代码很关键,就是 Scheme 注册。Kubernetes 里所有资源类型都要先注册到 Scheme 里,Controller 才知道“MyApp 是一个合法的对象,我能理解它的数据结构”。这相当于告诉系统:我认识这种新物种,请允许我处理它的实例。
main.go 的核心片段如下:
var scheme = runtime.NewScheme() func init() { clientgoscheme.AddToScheme(scheme) examplev1.AddToScheme(scheme) } func main() { // 创建 Manager,负责协调所有 Controller 的并发生命周期 mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, MetricsBindAddress: ":8080", HealthProbeBindAddress: ":8081", LeaderElection: true, LeaderElectionID: "myapp-lock.example.io", }) if err != nil { panic(err) } // 注册我们的 Controller if err = (&controllers.MyAppReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorderFor("myapp-controller"), }).SetupWithManager(mgr); err != nil { panic(err) } mgr.Start(ctrl.SetupSignalHandler()) }注意 LeaderElection 字段,生产环境必须开启。Controller 通常不止一个副本在跑,如果所有副本同时处理事件,就会出现“两个人都往同一个资源写状态”的混乱。开启 Leader Election 后,同一时刻只有一个副本真正执行调和,其他副本处于热备状态。我见过不少团队刚开始没开这个功能,上线后偶发出现资源被重复修改的问题,排查到头才发现是丢失了选举机制。
3.4 Reconcile 核心逻辑的写法
Reconcile 是 Controller 的灵魂,每一行代码都在回答一个问题:当前状态和期望状态到底差了多少,我要不要动手纠正?
下面是我在模拟项目里实际使用过的核心逻辑简化版:
func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { // 1. 通过请求中的名称,从缓存中取出 MyApp 实例 var myApp examplev1.MyApp if err := r.Get(ctx, req.NamespacedName, &myApp); err != nil { // 资源可能已被删除,无需处理 if errors.IsNotFound(err) { return ctrl.Result{}, nil } return ctrl.Result{}, err } // 2. 计算期望的 Deployment 名字 depName := fmt.Sprintf("%s-deployment", myApp.Name) // 3. 看看这个 Deployment 是否已经存在 var dep appsv1.Deployment err := r.Get(ctx, types.NamespacedName{Namespace: myApp.Namespace, Name: depName}, &dep) if errors.IsNotFound(err) { // 不存在,创建 dep := buildDeployment(myApp) if err := r.Create(ctx, dep); err != nil { return ctrl.Result{}, err } } else if err != nil { return ctrl.Result{}, err } else { // 存在,检查镜像和副本数是否和声明一致 if dep.Spec.Replicas != nil && *dep.Spec.Replicas != myApp.Spec.Replicas { dep.Spec.Replicas = &myApp.Spec.Replicas if err := r.Update(ctx, &dep); err != nil { return ctrl.Result{}, err } } // 镜像不一致也需要更新 if len(dep.Spec.Template.Spec.Containers) > 0 && dep.Spec.Template.Spec.Containers[0].Image != myApp.Spec.Image { dep.Spec.Template.Spec.Containers[0].Image = myApp.Spec.Image if err := r.Update(ctx, &dep); err != nil { return ctrl.Result{}, err } } } // 4. 更新 MyApp 的状态 myApp.Status.Ready = true if err := r.Status().Update(ctx, &myApp); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil }这段逻辑非常典型,几乎所有 Operator 的核心都长这样:先查目标资源存在不存在,不存在就建,存在就对比关键字段,不一致就更新,最后把最新状态写回自定义资源。整个过程必须保证幂等——同一个 Reconcile 调用执行一万次,结果都应该是确定的。
这里有个非常容易忽略的坑:对比 Deployment 镜像时,不能简单拿 “nginx:latest” 和 “nginx:latest” 做字符串比较。有时候用户明明没改过镜像,但 Deployment 因为其他原因被重建,镜像字段被默认值覆盖,结果字符串对不上,Controller 就会陷入无意义的更新循环。我后来处理这类问题的经验是:在写回期望值之前,先看当前值是否真的和期望值语义一致,而不是机械地做全量比对。
3.5 调谐结果的返回策略
Reconcile 函数返回的 ctrl.Result 并不是摆设,它直接决定了这次调谐结束后,Controller 要隔多久再来看一次这个资源。
return ctrl.Result{}, nil // 不需要再次触发 return ctrl.Result{RequeueAfter: 30 * time.Second}, nil // 30 秒后再看一次 return ctrl.Result{}, err // 出错,进入限速重试这里有一个我从实践中总结的要点:如果你的自定义资源依赖外部系统状态,比如数据库是否可用、备份是否完成,那么调谐函数绝不能只依赖事件触发,必须加一个定期的 RequeueAfter。因为事件触发只能覆盖“资源本身发生变化”的场景,外部系统的状态变化是不会主动通知你的。最典型的例子就是证书类 Operator,证书到期时间在资源里写死了,但过期这件事本身没有一个“事件”能触发 Controller,只能用定期调谐来发现。
3.6 权限配置与部署到集群
Controller 要操作 Deployment、Service,就必须有对应的 RBAC 权限。kubebuilder 生成的代码中,权限是通过注解声明的,比如:
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=example.io,resources=myapps,verbs=get;list;watch;create;update;patch // +kubebuilder:rbac:groups=example.io,resources=myapps/status,verbs=get;update;patch然后在 Makefile 里执行make manifests,kubebuilder 就会自动生成对应的 Role YAML。这里有个细节值得强调:许多新手只给 Controller 配置了 myapps 的权限,忘了加 apps 组的权限,结果运行时一查 Deployment 就报 403,白白折腾半天。
部署 Controller 到集群的方式很简单,构建镜像推到镜像仓库,然后kubectl apply -f config/default。如果你只是本地调试,还可以直接跑二进制,通过本地 kubeconfig 连接集群:
make run这时候你在另一个终端提交一个 MyApp 资源:
kubectl apply -f - <<EOF apiVersion: example.io/v1 kind: MyApp metadata: name: myapp-sample spec: replicas: 2 image: nginx:1.24 EOF然后观察:
kubectl get myapp myapp-sample -o yaml kubectl get deployment myapp-sample-deployment如果一切正常,你会看到 Deployment 被自动创建出来,副本数为 2,镜像为 nginx:1.24。这一刻,你就拥有了一个“能自动干活”的自定义控制器。
4. 常见问题与排查技巧实录
4.1 缓存不一致的幽灵问题
最常见的一个坑:Controller 明明创建了一个资源,但下一步查询时却找不到。这不是因为没创建成功,而是因为 Informer 的本地缓存还没来得及同步。
Informer 是异步更新的,API Server 返回创建成功后,缓存里不一定立刻就有这个对象。如果在同一个 Reconcile 循环里,创建完立刻 Get,极可能得到“资源不存在”的错误。而 Controller 对“不存在”的响应往往是再次创建——于是你就会看到一种诡异现象:日志里不停地重复 create 同一个资源,API Server 那边却在报 AlreadyExists。
我的解决方案是在 Reconcile 的开头做一个“存在性检查”后,如果发现 AlreadyExists 错误,直接把它当作“已存在”处理,而不是当作错误返回。类似这样:
if errors.IsAlreadyExists(err) { // 已经存在,无需重复创建,继续执行后续逻辑 }经验法则:凡是“创建后立即查找”的操作,都要对 AlreadyExists 做兼容。
4.2 Workqueue 重试风暴
另一个高频问题:某一次 Reconcile 因为配置错误持续失败,比如引用了不存在的 StorageClass。正常情况下,Workqueue 会指数退避,间隔逐步拉长。但如果你在 Reconcile 里对每个错误都返回了 err,并且还额外调用了return ctrl.Result{}, err,退避机制可能会被绕过,从而导致高频重试。
我在实际项目里看到过最夸张的情况:一个失败的 Reconcile 在短时间内触发了几百次写操作,把 API Server 的 etcd 都拖慢了。排查方法很简单——看 Controller 的日志输出频率,如果明显超出预期,先检查是不是每次失败都返回了 err,还是错误被包装后构造了新的 Result。更合理的做法是对“持续性配置错误”做特殊处理:检测到这类错误时,直接记录日志并返回 nil,同时附带适当的 RequeueAfter,让系统隔一段时间再试,而不是触发指数退避的洪流。
4.3 事件丢了怎么办
有些场景下你会感觉 Controller 漏处理了某个资源。原因往往不是 Controller 本身的问题,而是 Watch 连接意外断开后没有重连。client-go 的 Reflector 本身具备重连机制,但如果你用的是非常老版本的 client-go,或者自定义了异常的 ListOptions,可能会导致事件丢失。
排查技巧很简单:看 Controller 的启动日志,是否有 “reflector lost sync” 字样。如果频繁出现,一般是网络不稳定或者 API Server 重启导致。此时可以调大 Reflector 的重试频率,或者在 Reconcile 中增加兜底逻辑。无论何时,Controller 都不能完全依赖事件推送,定期的 RequeueAfter 是全量对账的兜底手段。
4.4 状态更新冲突
多个 Controller 副本同时写同一个自定义资源的状态字段,即使开了 Leader Election,理论上不会发生。但如果你的自定义资源被用户手工修改过,或者通过 kubectl apply 更新过,Controller 写状态时就可能遇到 ResourceVersion 冲突。
Kubernetes 的乐观锁机制规定:更新前必须带上 resourceVersion,版本不一致则更新失败。处理方式有两种:一是遇到冲突就直接重新读取最新资源再更新;二是干脆把状态更新放到最后,失败就返回错误,等待下一轮 Reconcile。我多数时候选择前者,减少无谓的重试。
4.5 调试技巧:日志和事件双管齐下
Controller 的调试比普通服务难度大,因为问题现象往往出现在集群对象上,而不是应用进程里。我总结出一个调试三板斧:
- 第一板斧是日志。调谐的逻辑实际上就是一行行日志组成的叙事链,每做一个关键操作都打一条日志,带上资源名和动作,之后排查问题基本靠它;
- 第二板斧是事件。Kubernetes 事件机制可以把 Controller 的动作暴露给用户,比如 “Successfully created deployment myapp-sample-deployment”。这样用户在
kubectl describe myapp时就能看到发生了什么,而不用翻日志; - 第三板斧是状态条件。给自定义资源的状态里写清楚当前处于哪个阶段,比如 Creating、Ready、Failed,配合 status.conditions 记录最近一次失败原因,这是 Operator 设计中非常重要的用户体验。
我强烈建议你在开发 Controller 的第一天就搭好这三个观测渠道,而不是等到上线出问题时再补。到时候你会感谢当初的自己。
4.6 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| Controller 创建的资源反复被重建 | 缓存不一致导致 AlreadyExists 误判 | 对 AlreadyExists 错误单独处理,避免无限循环 |
| 日志疯狂刷错误 | 失败后立即返回 err,绕过了限速机制 | 区分临时错误和永久错误,永久错误记录日志后返回 nil |
| 修改 CRD 后 Controller 不生效 | Informer 缓存的 GroupVersionKind 未更新 | 重启 Controller,确认 CRD 版本匹配 |
| Deployment 改了但 Controller 一直没处理 | Watch 事件被 API Server 限流 | 检查 events 和日志,增加定期 RequeueAfter 兜底 |
| 更新状态时报 Conflict | ResourceVersion 冲突 | 重新读取最新对象后再次更新 |
| 权限不足报 403 | RBAC 缺资源组权限 | 检查 Role 是否包含 apps 组的 get/list/watch 等动词 |
5. 个人体会与经验补充
5.1 从内置 Controller 到自研 Operator 的思维升级
写第一版 MyApp Controller 时,我特别容易陷入一个误区:把 Deployment 创建出来就完事了,根本不管后续变化。后来我才意识到,Controller 的价值不在于“创建”,而在于“持续纠偏”。
举个例子,你自己写一个脚本去创建 Deployment,没人维护状态的话,AA 把 Deployment 改成 5 个副本,Controller 什么都不知道,集群状态就“漂移”了。而真正的 Controller 会不断通过 Reconcile 对账,发现副本数不对就改回去。也就是说,Controller 不是一次性操作,而是一个永远在运行的“守护者”。一旦想通这一点,很多设计问题就迎刃而解了。
5.2 什么场景不建议自己造 Controller
虽然 Controller 很强大,但我不建议所有问题都自己造轮子。以下几点是我真实踩过的坑:
- 如果你的需求只是简单的定时任务、简单的副本管理,用 K8s 自带的 CronJob、Deployment 就行,没必要引入自定义资源;
- 如果你的团队没人懂 Go,或者是 Java/Python 栈,要考虑维护成本。虽然 K8s 也有其他语言的 client,但生态最成熟的还是 Go;
- 如果业务逻辑里依赖大量外部系统操作,并且这些操作耗时长、失败率高,直接写在 Reconcile 里会卡住整个调谐循环,最好设计成异步任务加状态回写。
“能用原生就不用自定义,能简单就不堆逻辑”,这是我后来一直提醒团队的原则。
5.3 最后的一个小技巧
所有方法论的终点都是实践。如果你刚接触 Controller,我给你的建议是:不要一上来就写复杂的 Operator,先写一个最简单的、能响应自定义资源增删改的 Controller,比如“MyApp 创建时打个日志”。跑通之后,再逐步加入 Deployment 编排、状态更新、最终一致性校验。每加一个功能就提交一次,全程保持小步前进。
我在实际开发中还有一个习惯:所有 Reconcile 都最先处理“删除场景”,因为删除和创建是两个完全不同的路径,混在一起容易出事故。你在自己的项目里也不妨试试这个顺序——先处理资源不存在的情况,再处理存在的情况,最后处理状态更新。坚持这样的节奏,你会发现 Controller 开发遇到的坑,远比你想象的少。