从零实现Kubernetes Controller:原理、核心机制与实操指南

发布时间:2026/10/22 19:12:32
从零实现Kubernetes Controller:原理、核心机制与实操指南 如果你和我一样每天要和 Kubernetes 打交道那你大概率写过 Deployment、Service也用过各种各样的 Operator。但有一件事我在带团队时发现很多人其实一直没完全搞懂——Kubernetes 里这些“自动纠偏”的能力到底是谁在背后推动答案就是 Controller。这个单词几乎出现在每一本 K8s 书籍里但真正能把它讲清楚、并且能动手写一个出来的人少之又少。这篇内容我就结合自己从“会用”到“能写” Controller 的完整过程把这个概念拆开揉碎从原理到实操一次讲明白。适合刚接触 K8s 的运维、想深入理解调度原理的开发以及所有准备上手 Operator 开发的人。1. 内容整体设计与思路拆解1.1 Controller 到底是什么先不给教科书定义说一个我自己的理解Controller 就是集群里的一群“监督员”它们各自盯着某一类资源一发现实际状态和期望状态不一致就立刻动手把实际状态掰回期望状态。这听上去很简单但背后是一整套设计哲学。Kubernetes 从来不是一个“你让我干什么我就干什么”的系统而是一个“你告诉我最终要什么样我自己想办法达成”的系统。Controller 就是这套声明式模型的实际执行者。举个例子你提交了一个 Deployment声明“我要 3 个副本”。这个 YAML 只是表达了愿望真正去创建 3 个 Pod 的是 Deployment ControllerPod 创建后需要调度到某台机器这是 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 与 Workqueue2.1 Controller 的核心组件与运行流程动手写代码之前必须先掌握 Controller 的四个物理组件它们组成了一个完整的处理流水线ClientsetKubernetes 官方提供的 Go 客户端负责和 API Server 通信完成对资源的增删改查Informer监控资源的“眼睛”通过 List/Watch 机制同步资源状态并且维护一份本地缓存Workqueue一个带去重和限速机制的任务队列用于存放待处理的事件Reconciler真正干活的“手”从队列取出事件后执行调谐逻辑。把这四者串起来就是标准流程Informer 通过 Reflector 监听 API Server 的资源变化每当有资源新增、修改、删除就会触发对应的事件回调回调函数并不直接处理业务而是把事件丢进 WorkqueueWorker 从 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. 实操过程与核心环节实现从零写一个 Controller3.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-gogo mod init mycontroller go get k8s.io/client-golatest go get k8s.io/apimachinerylatest命令行工具的选择上我推荐直接使用 kubebuilder它是目前搭建 Controller 项目的标准脚手架能帮你生成 CRD、Controller、RBAC 配置等一大坨样板代码。早期很多人是手写 code-generator 的效率低而且容易出错现在除非有极端定制需求否则没有理由不走脚手架。3.2 定义自定义资源CRDController 要管理的对象必须先定义好这就是 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:groupsapps,resourcesdeployments,verbsget;list;watch;create;update;patch;delete // kubebuilder:rbac:groupsexample.io,resourcesmyapps,verbsget;list;watch;create;update;patch // kubebuilder:rbac:groupsexample.io,resourcesmyapps/status,verbsget;update;patch然后在 Makefile 里执行make manifestskubebuilder 就会自动生成对应的 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 兜底更新状态时报 ConflictResourceVersion 冲突重新读取最新对象后再次更新权限不足报 403RBAC 缺资源组权限检查 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 开发遇到的坑远比你想象的少。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询