1. 控制器模式:Kubernetes 自动运转的核心引擎
刚接触 Kubernetes 的时候,很多人都会有一个经典困惑:我提交一个 Deployment 对象给 API Server 之后,整个系统怎么就自己动起来了?Pod 是谁创建的?滚动更新是谁编排的?副本数出了问题又是谁拉起来的?答案都指向了一个隐藏在控制平面里的核心组件——k8s 控制器。
可以这么说,控制器就是 Kubernetes 的“自动恒温器”。恒温器的原理你肯定懂:设定一个目标温度(期望状态),传感器持续测量当前室温(实际状态),当两者不一致时,就驱动空调压缩机工作(执行变更),直到温差消除。控制器做的事情在逻辑上完全一致,只不过它控制的不是温度,而是集群里的各种资源对象。
这篇文章适合下面几类人看:刚学完 kubectl 基础操作、想搞清楚 Pod 到底是怎么被“凭空造出来”的初学者;已经在用 Helm 或直接部署业务、但遇到“对象卡在某个状态不更新”时需要定位原因的运维;以及准备在项目里做 Operator、想从零撸一个自定义控制器的开发者。我会从控制器模式本身讲起,再拆解 client-go 底层的完整工作链路,然后给出一份可以照着写的自定义控制器示例,最后把我这几年在生产环境踩过的坑一次性倒出来。
1.1 控制循环的本质:声明式 API 背后的“对账”过程
Kubernetes 从设计上就抛弃了传统的“命令式操作”——也就是那种“你告诉我怎么做,我一步步执行”的模式。取而代之的是声明式操作:“你告诉我想要什么,剩下的交给系统自己想办法”。
Deployment 对象里写着replicas: 3,这不是一条“创建三个 Pod”的命令,而是一条“系统里必须永远存在三个 Pod”的声明。至于这三个 Pod 怎么创建、创建出来叫什么名字、挂在哪个节点上,API Server 本身一概不关心。真正关心这件事的,是控制平面里一个叫 ReplicaSet 控制器的组件。
这个“对账”过程,在源码里的核心就是一个无限循环。用伪代码来表示,它就是这样的:
for { // 1. 观察:从缓存或 API Server 获取当前状态 currentState := getCurrentState() // 2. 对比:算出期望状态和当前状态的差异 desiredState := getDesiredState() diff := computeDiff(desiredState, currentState) // 3. 行动:如果有差异,就执行一系列变更操作 if len(diff) > 0 { executeChanges(diff) } // 4. 等待,然后进入下一轮 nextIteration() }就这么一个朴素循环,构成了整个 Kubernetes 控制平面的心脏。所有内置控制器——Deployment 控制器、StatefulSet 控制器、Job 控制器、Endpoint 控制器——本质上都是这个循环的不同实现,只是它们“观察”的对象不同、“对比”的指标不同、“执行”的动作不同而已。
理解了这一层,很多以前觉得玄乎的概念就通了:
- 为什么
kubectl scale能立即生效?因为 scale 命令修改了 Deployment(或 ReplicaSet)对象的spec.replicas字段,控制器观察到“期望副本数”变成 5,而“当前实际 Pod 数”还是 3,于是开始创建 Pod。 - 为什么手动删掉一个由 Deployment 托管的 Pod,它会自己回来?因为控制器发现实际数量少了 1,和期望不符,于是重新创建了一个。
- 为什么 Pod 被打上
ownerReferences之后,删除 Deployment 时它会跟着消失?这是后续我们要讲的垃圾回收控制器根据属主引用做级联删除的结果。
这个过程中最关键的思维转变是:你的工作对象不再是具体的 Pod,而是“期望状态”本身。日常排障时,一旦遇到“为什么我搞了半天,系统还是回到原样”,十有八九是因为你试图用命令式手段对抗声明式系统——你改了当前状态,但控制器认为这和期望状态不符,马上又会把它纠正回去。
1.2 控制器与 API Server 的协作方式:List-Watch 机制
容器的“控制循环”听上去很简单,但要在一个可能有几千个浮动 Pod 的集群里高效运行,肯定不能每秒钟全量扫一遍 API Server。Kubernetes 是怎么解决这个问题的?答案是 List-Watch 机制。
控制器的 informer 在启动时,会先对目标资源做一次全量的 List,拿到集群里该资源的全量对象以及当时的 resourceVersion;随后通过 Watch 连接 API Server,持续接收后续变更事件。之后所有对象的“新增”“更新”“删除”都会以事件流的方式源源不断推送过来,而不是靠控制器频繁轮询。
这个机制有几个很微妙的细节:
- Watch 连接异常断掉怎么办?Reflector 会退避重连,重新建立连接后,不会从断点续传,而是会再走一遍全量 List。这就是“全量+增量”的配合逻辑:全量 List 保证了数据不会因为丢事件而丢失,增量 Watch 保证了实时性。
- 增量事件会不会重复?会。Watch 是至少一次语义,某些情况下同一事件会推送多次,所以控制器处理事件必须是幂等的——同一对象被处理十遍和一遍,最终效果必须一致。
- 对象更新非常频繁怎么办?比如一个 Pod 的状态阶段从 Pending 变到 Running 又变到 Succeeded,会瞬间产生多个事件。控制器不可能每个事件都立即处理,所以事件最终会被汇聚进一个队列,由一组 worker 并发消费,经过合并和去重后再交给具体的业务处理函数。
这套 List-Watch 加工作队列的设计就是 client-go 框架的核心,也是所有控制器开发者绕不过去的地基。下面一节我们深入拆一拆这个链条上每一个组件的职责和配合关系。
2. 一个控制器从启动到干活:client-go 的完整链路拆解
如果你去翻 controller-runtime 或者官方 client-go 的源码,会发现一个控制器远不止“一个 for 循环”这么简单。它内部由 Reflector、DeltaFIFO、Indexer、Informer、Workqueue、Worker 这一串组件咬合而成。
初学者第一次看到这个架构图通常会头大,但我会用一个“快递中转站”的类比来理解它:
- API Server是发货地,所有资源对象的变化都会从这里发出“物流信息”。
- Reflector是收货员,它负责建立连接、接收包裹(事件),并把包裹放到传送带上。
- DeltaFIFO是传送带,它会把针对同一包裹的多次变动合并,避免后续环节重复劳动。
- Indexer是仓库货架,一份完整的最新货物快照存在这里,任何环节想查“现在系统里有什么”,直接查货架,不用去问发货地。
- Workqueue是分拣中心的任务清单,分拣员(Worker)从清单里拿一个任务,根据自己的工作手册(处理函数)去执行。
下面我会把每个组件的职责和它们之间的边界讲透,因为写自定义控制器时,90% 的疑难杂症都出在这些组件之间的配合上。
2.1 Reflector 与 DeltaFIFO:事件从哪儿来,怎么进队列
Reflector 是链路的最前端。它运行在一个独立的 goroutine 里,核心方法是ListAndWatch:
- 先调用
List()拉取全量资源对象; - 把全量对象作为
Sync类型的 Delta 放进 DeltaFIFO,这是第一次“仓库上架”; - 调用
Watch()建立长连接,监听Added、Modified、Deleted事件; - 每个事件都被封装成对应类型的 Delta,放入 DeltaFIFO;
- 如果 Watch 连接断开,记录当前的 resourceVersion,回到第 3 步重建 Watch;如果失败次数过多,就重新执行一次全量 List。
DeltaFIFO 是事件到达后的第一道去重关口。设想一个 Pod 在 1 秒内经历了 Modified -> Modified -> Deleted 三次变化,如果这些 Delta 原封不动地交给下游,控制器就得处理三次,效率很低。DeltaFIFO 会基于对象 key 做合并,同一个 key 的多个 Delta 会被归并成一个条目。此外,它还能保证对象“先入先出”,防止同一个对象的事件乱序——这一点在对象频繁变更时有现实的稳定性意义:客户端拿到的一定是每个对象的最新状态。
写到这里必须提醒一句:很多人以为 informer 里的本地缓存是“完全实时”的,这个认知需要纠正。Informer 的 Indexer 是异步更新的——它是从 DeltaFIFO 的 pop 循环里被同步更新的,中间存在一个极小的窗口期。你自己的代码在Update之后立刻调用 lister 读取,有概率拿到的是旧版本对象。这就是为什么 Kubernetes 官方建议:如果你想修改一个对象,先 lister 读取最新版本,改完用这个版本去做 update;一旦并发更新产生冲突,就重新读、重新改、重试,而不是盲目覆盖。
2.2 Informer 与 Workqueue:事件如何被消费
Informer 在这一链路里承担“双向匹配”的角色:它从 DeltaFIFO 里弹出合并后的事件,把最新对象写进 Indexer 本地缓存,同时根据你在初始化时注册的 EventHandler(AddFunc、UpdateFunc、DeleteFunc)调用对应的回调函数。
回调函数里一般不会直接处理业务逻辑,而是做一件很关键的事:计算对象 key,塞进 workqueue。为什么多这一步?因为回调函数是在一个共享的 goroutine 里被调用的,直接在里面做耗时的业务逻辑会阻塞整个 informer 的事件分发——所有其他对象的事件都会排队等着。所以成熟的控制器开发模式是:回调里只做轻量计算和入队,真正的业务处理在若干 worker goroutine 里并行执行。
Workqueue 是 client-go 提供的一个高可用队列实现,它解决了不少并发场景下的经典问题:
- 去重:同一个 key 如果已经存在于队列中,重复添加会被合并。这就避免了频繁 Edit 事件导致同一对象被多个 worker 并发处理。
- 限速重试:业务处理函数执行失败时,不会立即把 key 重新入队,而是通过 RateLimiter 决定延迟时间,指数退避式的重试既保证了任务最终会再次被执行,又不会在故障期反复冲击 API Server。
- 优雅关停:控制器退出时,可以调用
ShutDown()停止队列,worker 会处理完手头任务后安全退出,不丢任务。
默认的限速策略值得专门记下来:每件 item 的初始重试间隔是 5ms,每次失败翻倍,上限到 1000 秒;同时还有一个 10 次以内的快速重试配额和全局每秒 10 次、单次突发 100 次的令牌桶限速。在实际生产里,如果控制器因为某个对象的处理函数 panic 而陷入无限重试,你会在日志里看到大量的Failed to process item且时间间隔逐渐拉长到十几分钟——这其实是限速器在保护集群,不是程序卡死了。
2.3 Worker 循环与业务处理函数的边界
队列准备就绪,接下来就是 Worker 循环了。controller-runtime 里Reconcile函数的调用方式,在原始 client-go 里通常是这样:
func (c *Controller) processNextItem() bool { key, quit := c.workqueue.Get() if quit { return false } defer c.workqueue.Done(key) // 业务处理 err := c.syncHandler(key) if err != nil { // 失败则限速重试 c.workqueue.AddRateLimited(key) return true } // 成功则忘记该 key(清掉重试计数) c.workqueue.Forget(key) return true }业务处理函数syncHandler的职责很纯粹:拿 key(通常是 namespace/name)去找对象,算出当前状态与期望状态的差异,然后调用 API 驱动实际状态向期望状态收敛。如果这个过程中报了错,比如创建 Pod 失败,就返回 error,让上层做限速重试。
这里有一个好像不明显但很重要的设计原则:业务处理函数必须自己保证幂等。workqueue 只能做去重和限速,它不能保证一个 key 只被处理一次。实际上,进程重启、watch 重连、控制器抢占等场景都会导致同一个 key 被重复处理。如果syncHandler里有“只创建一次 Pod”的逻辑,重复调用时就需要先检查是否已经存在同名的 Pod;如果直接Create一个已经存在的对象,会拿到 AlreadyExists 的 409 错误——这也是一种幂等写法,但要自己做好错误判断。
另外需要注意 worker 数量的选择。从默认情况看,很多人直接开 10 个甚至 20 个 worker,却没有意识到这些 worker 是共享同一个 workqueue 的。并发处理对象时,它们之间没有互斥锁,所以如果你在一个 worker 里改了对象 A,另一个 worker 同时也在改对象 A,就会出现资源版本冲突。解决冲突的核心方法是:update 失败时捕获Conflict错误,重新从 lister 拿最新版本再更新一次。少开几个 worker(比如 2~4 个),并把业务处理做得轻量,很多时候比盲目增加 worker 更有效。
3. 从零写一个自定义控制器:动手实现 AppDeployment
理解了框架,我们来动手做一个实际的东西。这里我会带你实现一个简化版的“应用部署控制器”:它监听一个自定义资源AppDeployment,每当用户创建一个 AppDeployment 对象,控制器就负责创建对应的 Deployment 和 Service。这样做的好处是:把“部署应用”这类重复性操作封装成一个声明式对象,用户在集群里只需要 apply 一个 YAML,剩下的工作全部由自定义控制器完成。
为了避免从底层造轮子(实测没必要),我直接推荐用 Kubebuilder 生成脚手架,然后在这个骨架上填充业务逻辑。这套流程也是目前开发 Operator 的主流路线。
3.1 用 Kubebuilder 生成项目骨架
先确保环境里已经装好了 Kubernetes 集群(或 kind 本地集群)、kubectl 以及 Go 环境。Kubebuilder 是 controller-runtime 的配套脚手架工具,安装和初始化项目都很直接:
# 初始化项目 kubebuilder init --domain example.io --repo example.com/operator/app-operator # 创建 API 类型资源 kubebuilder create api --group app --version v1 --kind AppDeployment --resource=true --controller=true执行完这两条命令,项目里会生成一系列文件。核心文件有三个:
api/v1/appdeployment_types.go:定义 AppDeployment 的 Spec 和 Status 结构体;controllers/appdeployment_controller.go:Reconcile 主逻辑;config/crd/bases/:生成的 CRD 清单。
我们来看类型定义。spec里要配置应用名和镜像,status里记录实际 Deployment 的状态:
// api/v1/appdeployment_types.go type AppDeploymentSpec struct { // 业务系统要暴露的服务端口 ServicePort int32 `json:"servicePort"` // 想要运行的副本数量 Replicas *int32 `json:"replicas"` // 容器镜像地址 Image string `json:"image"` } type AppDeploymentStatus struct { // 最后 reconcile 的时间 LastScheduleTime *metav1.Time `json:"lastScheduleTime,omitempty"` // 实际可用副本数 ReadyReplicas int32 `json:"readyReplicas,omitempty"` // 当前 Deployment 名字 DeployedName string `json:"deployedName,omitempty"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status type AppDeployment struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec AppDeploymentSpec `json:"spec,omitempty"` Status AppDeploymentStatus `json:"status,omitempty"` }这里有两处容易忽略但实际影响很大的细节,提前标注一下:
// +kubebuilder:subresource:status这一行会让 CRD 启用 status 子资源。启用后,你只能通过status子资源更新状态,直接对主对象做全量 update 会把 Status 字段清空。这个设计和其他内置资源的行为一致,也避免了对主对象的更新冲突波及到 Status 字段。Replicas字段用指针而不是 int。内置 Deployment 的 Replicas 也是指针,目的就是区分“用户没填”(nil,控制器可以填默认值)和“用户填了 0”(合理语义)。如果用 int,这个区分就消失了,写错的时候很难察觉。
3.2 Reconcile 主逻辑:如何让期望状态落成实体资源
初始化框架生成的 Reconcile 方法是控制器的入口。controller-runtime 会为你处理前面讲过的 informer、workqueue、限速等所有细节,你只需要专注一件事:拿到请求对象,算出差异,执行变更。
下面我写一个针对 AppDeployment 的简化实现,逻辑一共四步:
// controllers/appdeployment_controller.go func (r *AppDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { // 1. 获取自定义资源 var appDeploy appv1.AppDeployment if err := r.Get(ctx, req.NamespacedName, &appDeploy); err != nil { // 如果资源已被删除,无需处理,直接忽略 return ctrl.Result{}, client.IgnoreNotFound(err) } // 2. 计算期望的 Deployment 名字 deployName := fmt.Sprintf("%s-deploy", appDeploy.Name) // 3. 看 Deployment 是否存在 var deploy appsv1.Deployment err := r.Get(ctx, types.NamespacedName{Namespace: appDeploy.Namespace, Name: deployName}, &deploy) if apierrors.IsNotFound(err) { // 4. 不存在就创建一个 deploy = buildDeployment(appDeploy) if err := r.Create(ctx, &deploy); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil } else if err != nil { return ctrl.Result{}, err } // 5. 存在但镜像/副本数和期望不一致,则更新 if deploy.Spec.Replicas != appDeploy.Spec.Replicas || deploy.Spec.Template.Spec.Containers[0].Image != appDeploy.Spec.Image { deploy.Spec.Replicas = appDeploy.Spec.Replicas deploy.Spec.Template.Spec.Containers[0].Image = appDeploy.Spec.Image if err := r.Update(ctx, &deploy); err != nil { return ctrl.Result{}, err } } // 6. 更新自定义资源的状态字段 if deploy.Status.ReadyReplicas != appDeploy.Status.ReadyReplicas { appDeploy.Status.ReadyReplicas = deploy.Status.ReadyReplicas if err := r.Status().Update(ctx, &appDeploy); err != nil { return ctrl.Result{}, err } } return ctrl.Result{}, nil }这个方法并不长,但里面嵌着很多实战中才能体会到的微操细节,逐条拆解:
- Get 请求拿不到不存在的对象时用
IgnoreNotFound包裹:如果你的资源被删除了,业务代码往往还需要做清理。这里用 Ignore 包装的意思是“找不到就算了,别爆错误”——这能避免因为删个资源导致整个控制器队列持续重试。 - 检查处用
IsNotFound而不是把其他错误混在一起:查询 Deployment 只有在“确实不存在”时才走创建分支。网络超时、RBAC 权限不足这类错误也要返回理错误,让上层限速重试。如果把这些错误也当成 NotFound 处理,就会反复创建同名 Deployment,导致冲突。 - 一切更新都基于最新版本:第 5 步更新 Deployment 时,对象是从
r.Get里读出来的,包含最新 resourceVersion,直接 Update 不会冲突。但如果这段逻辑跑得慢,中间又有其他控制器改了同一个 Deployment,就会遇到 409 Conflict,上层的重试机制再拉一次最新版本就好。 - Status 子资源更新不会触发 Reconcile 死循环有讲究:第 6 步里写回的
Status.ReadyReplicas只更新 status 子资源,这个更新会再次触发 controller-runtime 的 watch 事件,把同一个请求再放进 workqueue。如果不加任何版本判断就直接执行完整 Reconcile,它又会发现 Deployment 已存在、镜像也对、副本数也对,唯一变化的是 Status 里的数字,于是再次更新 Status,如此反复——死循环。解决办法是在第 5 步更新 Deployment 时比较当前值和期望值是否一致,只有不一致才动手更新,这天然避免了无意义的增量更新。
3.3 让控制器拿到权限:RBAC 标记与实践调试
所有通过 controller-runtime 构建的控制器,运行时都在 Pod 里以特定 ServiceAccount 身份访问 API Server。如果你的控制器要读取 Deployment、创建 Service,就需要在代码里加上对应 RBAC 权限标记,然后重新生成 role 清单。
在 controller 文件开头添加这些 kubebuilder 标记:
// +kubebuilder:rbac:groups=app.example.io,resources=appdeployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=app.example.io,resources=appdeployments/status,verbs=get;update;patch // +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;delete // +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch然后执行:
make manifests这会重新生成config/rbac/role.yaml,里面包含了上述所有规则。然后通过下面的命令把 CRD 和 RBAC 应用到集群,并本地运行控制器进行调试:
# 安装 CRD make install # 本地启动控制器(调试用) make run # 另开终端,创建一个 AppDeployment 实例 kubectl apply -f - <<EOF apiVersion: app.example.io/v1 kind: AppDeployment metadata: name: sample-app namespace: default spec: replicas: 2 image: nginx:1.25 servicePort: 80 EOF如果一切正常,你会看到控制器日志里记录“创建 Deployment”“更新 Status”等操作,然后kubectl get appdeployment sample-app能看到ReadyReplicas状态被回填。
调试阶段有几条很实用的经验:
- 用
make run本地调试速度最快,IDE 断点也方便,比打包镜像放进集群再调试快一个量级。只要你本地环境通了集群的网络,controller-runtime 的配置会自动从 kubeconfig 里读取。 - 遇到 RBAC 问题,立即看 Pod 日志里的 Forbidden 字样,然后对照 role.yaml 里的 verbs 是否齐全。最常见的坑是你自定义资源本身有权限,但你要去操作它“衍生”出来的 Deployment 和 Service 没有加权限,于是卡在
deployments is forbidden这类错误上。 - Create 一个完全一模一样的 Deployment 会报 AlreadyExists,所以创建前必须确认确实不存在。多数情况下,更好的做法是先尝试 Get,如果 IsNotFound 再走创建逻辑——上面的例子已经是这么写的了。
4. 生产环境里的严重坑与对策
代码能跑通只是第一步。把控制器放进生产集群,面对真实的流量和故障,才会真正认识到“能用”和“抗造”之间差着十万八千里。下面这些坑,我几乎都踩过,而且每次都是花了大代价才爬出来的。
4.1 多副本控制器:没有 Leader Election 会发生什么
生产上为了高可用,控制器通常会部署成两个或三个副本。这时立刻面临一个问题:每个副本都在跑 Reconcile,同一个 AppDeployment 会被三个进程同时处理。
如果业务逻辑没有做并发控制,后果通常出现在这几个方面:
- 两个副本同时对同一个 Deployment 执行 Update;
- 其中一个拿到旧版本,更新时触发 409 冲突,于是重试、继续冲突,日志里刷满了 Conflict 错误;
- 更糟的是,如果处理函数里有“创建对象”的逻辑,两个副本同时看到资源不存在,同时 Create,一个成功一个报 AlreadyExists——报错的那个进入重试,造成多余的事件风暴。
解决方案就是 Kubernetes 的 Leader Election 机制。controller-runtime 里开启配置非常简单,通常写在 main.go 的 manager 配置里:
// main.go ctrl.SetLogger(zap.New(zap.UseDevMode(true))) mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: server.Options{BindAddress: ":8080"}, LeaderElection: true, LeaderElectionID: "appdeployment-lock.example.io", LeaderElectionNamespace: "kube-system", // 按你的实际需要设置 })开启之后,只有获取到 Lease 锁的副本才会执行 Reconcile,其余副本进入待命状态,等当前 Leader 失联(通常 15 秒内 Lease 过期)后,再竞争成为新 Leader。
实际部署里还要注意一个辅助性问题:非 Leader 副本的 metrics 探针。默认情况下,未获取到 Leader 的控制器不会执行 Reconcile,但 metrics 端口照样是绑定的,所以 Prometheus 会自动发现两个目标。如果你用 Prometheus 默认采集,会看到同一组指标出现两份,而里面只有 Leader 那份是“真值”。建议健康检查探针配置成只有 Leader 才返回 Ready,这样网格流量才会只走 Leader 实例。
4.2 队列积压与重试风暴:别小看错误处理
生产环境最常见的控制器故障,既不是代码逻辑错误,也不是硬件故障,而是“一个错误导致积压成山”。
场景是这样的:你的控制器在 Reconcile 里调了一个外部的第三方 API,比如同步一份 DNS 记录。某个时刻第三方 API 开始超时,每次 Reconcile 都会执行一次这样的外部调用,而每次调用耗时最长 30 秒。此时如果有 1000 个 AppDeployment 对象同时需要同步,你的 3 个 worker 就会全部被卡满。workqueue 里的 key 越堆越多,叠加限速的重试任务,队列深度指标workqueue_depth飙到几千。就算第三方 API 很快恢复了,这批积压也要等很久才能消化完,期间任何新变更都得不到及时响应。
解决这类问题的思路,我自己实测比较有效的是:
- 外部 API 调用必须做超时控制,通常建议 500ms 到 1s,超过就算失败返回错误。这样至少不会出现一个对象把整个 worker 池拖死的情况。
- 把外部状态单独做一个缓存:对象对应的外部 DNS 记录只有在变更时才需要同步,所以 Reconcile 里先比较“当前对象声明的配置”和缓存里记录的“上次同步的配置”是否一致,一致就跳过外部调用,这样大大降低了失败基数。
- 别把外部 API 的失败当成“无限重试”的信号:区分“这个错误重试有用”和“这个错误重试也白搭”。比如第三方 API 返回 4xx 鉴权错误,重试 100 次也不会成功,不如直接丢弃错误并在事件里记录;只有 5xx 超时错误才值得入队重试。
另外有一个很隐蔽但影响巨大的 bug:Reconcile 返回RequeueAfter的合理用法。有些控制器的“检查外部状态”频率很高,习惯性写成return ctrl.Result{RequeueAfter: 10 * time.Second}, nil,意思是 10 秒后再检查一次。如果集群里有几千个对象,这么写就等于系统里同时挂着几千个定时器,每个对象每 10 秒触发一次 Reconcile,对 API Server 造成的读取压力是毁灭性的。所以在正式代码里,除非必要,否则宁可依赖 informer 的 watch 事件触发,不要在 Reconcile 里做频繁轮询。
4.3 Finalizer:对象怎么删不掉了
可能大家都遇到过这种情况:kubectl delete appdeployment一按下去,命令就卡住了,对象一直停留在Terminating状态。原因很可能是 finalizer。
Finalizer 的机制是这样:如果你给 CR 设置了metadata.finalizers,那么删除请求发到 API Server 后,对象并不会立即被移除,而是被打上deletionTimestamp,进入“待清理”状态。只有等 finalizer 列表变成空,对象才会真正被删除。这套机制的目的是让控制器有机会做“删除前清理”。
比如你的 AppDeployment 在创建 Deployment 之外,还在外部系统里注册了一些东西,那么删除 CR 时必须把这些外部资源一起清理掉,否则会留一堆孤儿数据。正确写法是:
if !appDeploy.DeletionTimestamp.IsZero() { // 对象正在被删除 if containsString(appDeploy.Finalizers, cleanupFinalizer) { // 执行外部资源清理 if err := r.cleanupExternalResources(ctx, &appDeploy); err != nil { return ctrl.Result{}, err } // 清理完成后移除 finalizer appDeploy.Finalizers = removeString(appDeploy.Finalizers, cleanupFinalizer) if err := r.Update(ctx, &appDeploy); err != nil { return ctrl.Result{}, err } } return ctrl.Result{}, nil }有几个坑必须提醒:
- Finalizer 的移除必须发生在清理全部完成之后。如果你先移除了 finalizer 再去做清理,清理失败也没人再管了,对象已经被物理删除,你后续想补清理都不知道补到哪。
- 清理逻辑本身要能重入、要幂等。外部资源如果已经不存在了,清理函数不应报错,返回 nil 即可,否则 finalizer 永远卡在那里。
- Finalizer 卡住的定位方法:用
kubectl get appdeployment NAME -o yaml | grep finalizers看列表,再对着代码排查是哪个 finalizer 对应的清理逻辑没有成功。常见原因是清理函数抛错被无限重试,或者清理函数早退导致 finalizer 一直没移除。 - 千万不要手动“强删”:
kubectl delete --force --grace-period=0虽然能把对象从 etcd 里踢出去,但所有它管理的外部资源就全变孤儿了。这类清理必须由控制器协调完成。
4.4 并发写冲突与事件风暴:Update 和 Status 更新的正确姿势
多 worker、多控制器并发处理同一个对象时,resourceVersion 冲突是家常便饭。大多数人一开始碰到Operation cannot be fulfilled on appdeployments.app.example.io ... the object has been modified就直接把错误返回给上层重试。这样能解决,但不是最优解。更高效的做法是主动重试:
// 伪代码示例 func updateStatus(ctx context.Context, r client.Client, appDeploy *appv1.AppDeployment) error { for attempt := 0; attempt < 3; attempt++ { if err := r.Status().Update(ctx, appDeploy); err != nil { if apierrors.IsConflict(err) { // 重新获取最新对象,修改后再更新 var latest appv1.AppDeployment if getErr := r.Get(ctx, client.ObjectKeyFromObject(appDeploy), &latest); getErr != nil { return getErr } latest.Status = appDeploy.Status appDeploy = &latest continue } return err } return nil } return fmt.Errorf("failed to update status after retries") }还有一个几乎人人都会踩的坑——写 Status 时把整个对象 Update 一遍。如果你没有启用 Status 子资源,可能会直接在 Reconcile 里改完 Status 字段后调用r.Update(ctx, appDeploy)。一旦此时有其他控制器同时修改了 Spec 字段,就会产生两个写者、同一个对象的冲突。共享同一把写锁,冲突概率成倍上升。最佳实践是:Spec 变更走主资源 Update;Status 变更一律走r.Status().Update;两者分开,互不阻塞。
事件风暴又是另一个常见问题。控制器每成功创建/更新一个 Deployment,通常都会写一个 Kubernetes Event 做记录。如果有点规模的对象同时变更,Event 一下子产生几百条,而这些 Event 本身又被 API Server 广播给所有 watch Event 的客户端,集群瞬时压力陡增。建议对高频成功的操作少写 Event,只对失败或者用户需要重点关注的场景记录。做 Operator 的时候,这个习惯越早养成越好。
5. 控制器排查思路与诊断速查
写到这里,你脑子里应该已经建立起来“控制器 = 监听 + 对账 + 执行”的心智模型了。最后分享一下我在实际排障时的诊断套路,按优先级排列。
5.1 排查步骤与关键指标
遇到控制器不干活,先别急着看业务逻辑,按下面的顺序来:
- 第一步,看工作负载状态:
kubectl get pod -n <namespace>看控制器 Pod 是否 Running、Ready。如果 CrashLoopBackOff,看日志、看启动参数,最常见的是 RBAC 权限不足或 Leader Election 锁冲突。 - 第二步,看控制器日志:搜
Reconciler error、Failed、Forbidden关键词,定位执行层错误。注意日志里有没有Requeuing key这种字样,那通常意味着业务处理在反复失败。 - 第三步,看待处理对象状态:
kubectl describe appdeployment <name>,观察 event 列表、ReadyReplicas等状态字段有没有被更新。如果状态完全空白,说明控制器根本没拿到对象或没执行更新。 - 第四步,看 metrics:controller-runtime 自带 Prometheus 指标,重点看这几个:
controller_runtime_reconcile_total:Reconcile 调用总数;controller_runtime_reconcile_errors_total:出错总数;workqueue_depth:队列积压深度;workqueue_adds_total:累计入队数;leader_election_master_status:当前副本是否为 Leader;rest_client_requests_total:客户端请求量。
队列深度持续增长但 workers 并没有批处理,几乎可以断定是某个对象的 Reconcile 卡死或持续报重试错误。这时把队列里的 key 抓出来看是哪一个,针对性解决,而不是盲目重启 Pod。
5.2 高频问题速查表
我把这些年排查过程中遇到的高频问题整理成了速查表,给碰到同样场景的人一个快速定位入口:
| 现象 | 可能原因 | 快速验证方法 | 处理建议 |
|---|---|---|---|
| CR 创建后一直没反应 | RBAC 权限不足 / informer 未初始化 | 看日志是否 Forbidden | 补齐 rbac verbs 清单;确认 informer 的 GVK 与 CRD group 一致 |
| Status 不更新 | 未启用 status 子资源 / 更新了主对象 | describe 看 status 字段为空 | 检查 CRD 定义,加上subresource:status标记并重新make manifests |
| 多副本重复处理同一资源 | 未开启 Leader Election | 看leader_election_master_status指标为 1 的副本数 | main.go 里开启 LeaderElection 并保证多个副本都配置了 |
| Update 报 Conflict | 有并发写者(worker 间或外部控制器) | 日志里搜 Conflict | 冲突后重新 Get 最新对象再更新;合理减少 worker 数 |
| 删除卡在 Terminating | finalizer 清理逻辑卡住 | `kubectl get -o yaml | grep finalizers` |
| 控制器重启后大量重复处理 | informer 断连重连触发全量 List | 日志里出现 List 调用 | 正常机制,Reconcile 保持幂等即可 |
| 一个资源被反复快速处理 | Reconcile 里设置 RequeueAfter 时间过短 | 看日志里 Requeue 频率 | 移除高频轮询或加长间隔;只依赖 watch 事件 |
| 服务切流不通 | 非 Leader 副本只是待命,但网络层未摘除 | 检查 Pod Ready 状态 | 给 Ready 探针加 Leader 判断;探活方式用healthz.Ping前先判断 Lease 持有状态 |
5.3 调试控制器时的三个实用技巧
最后再分享三个我实际工作中用起来最顺手的小技巧。
- 给 Reconcile 加日志和事件时,把请求对象的所有权链打出来。一开始写的控制器经常犯“只记录 key”而忘了把事件标记到 CR 上是哪个对象的毛病。标准做法是在 Reconcile 里先构造
ctrl.LoggerFrom(ctx).WithValues("appdeployment", klog.KObj(&appDeploy)),之后所有日志都会自动带上这个 CR 的 namespace/name,排障时一眼就能定位。 - 本地调试时,把 metrics 端口绑定到 localhost 方便 curl。controller-runtime 默认在
:8080暴露 metrics,直接用curl localhost:8080/metrics看队列指标。如果机器上有端口冲突,就在 config 里改成别的端口。 - 遇到“我明明改了代码,行为没变化”这类问题,先检查 CRD 是否更新。代码改了会重新编译,但如果你修改了类型定义里字段名或加了一个字段,而 CRD 用的是旧的,API Server 会拒绝非法字段或直接忽略,实际行为当然是旧逻辑。执行
make manifests && make install重新生成并安装 CRD,往往问题就消失了。
控制器开发的过程,本质上就是在不断地和“最终一致”这四个字打交道。理解了控制循环、理解了 informer 的事件流、理解了重试与幂等,你写出的控制器就能扛住生产环境的冲击。我个人的实际体会是:越往深走越发现,控制器里 80% 的代码都是在处理“异常路径”和“重复调用”,真正的“正常路径”反而很短。你把异常路径梳理得越清楚,这个控制器在真实集群里就越稳定,排查起来也就越省心。