做 Kubernetes 排障做久了你会发现一个规律真正复杂的问题往往不是容器怎么部署、镜像怎么拉取而是“集群里那些看不见的进程在干什么”。这些进程就是 k8s 控制器。控制器是声明式 API 的幕后执行者是 K8s 具备“自愈能力”的核心也是你在排查 Pod 为何不创建、资源为何不删除时绕不开的根因层。这篇文章我想把 k8s 控制器从头到尾梳理一遍包括它的设计思路、核心运转机制、常用内置控制器的行为差异、以及如何基于这套机制写一个自定义控制器。不管你是刚上手 K8s 的开发者还是已经在生产环境里被诡异现象折磨过的运维这篇都应该能给你一些值得带走的经验。1. 控制器的核心设计思路为什么 K8s 能“自我修复”理解控制器必须先理解 Kubernetes 的一个核心哲学我们不告诉系统“怎么做”而是告诉系统“最终长什么样”剩下的全部交给控制器。比如你想跑 3 个 Nginx 实例你做的事情不是去逐台创建 3 个容器而是写一个 Deployment声明replicas: 3。至于这 3 个 Pod 现在有没有运行、跑在哪个节点、挂掉之后怎么补都是控制器的工作。这种设计从根本上改变了运维模式故障恢复从“人盯着去修”变成了“系统自愈”只要期望状态还在控制器就会想尽办法收敛到那个状态。1.1 从 Deployment 的目标状态说起很多刚接触 K8s 的人会混淆 Deployment 和 ReplicaSet 的关系。Deployment 是面向用户的资源对象它负责管理版本和 rollout 策略ReplicaSet 才是真正负责“维持副本数”的执行者。Deployment 控制器每次发布新版本时会创建新的 ReplicaSet然后逐步缩掉旧的 ReplicaSet这个过程就是我们熟悉的滚动更新。这里有个容易忽略的点Deployment 的replicas字段会同步给 ReplicaSet而 ReplicaSet 控制器只关心“当前有多少个 Pod 由我管辖规格是否匹配”。你在 Deployment 里改了镜像版本Deployment 控制器会创建新的 RS你在 RS 层手动改了副本数Deployment 控制器会把它拉回到期望值。所以一个高效的心智模型是控制器层层向下“声明期望”越往下越贴近具体执行越往上越关注策略和版本。1.2 调谐循环与“终态收敛”背后的哲学控制器内部其实就是一个无限循环逻辑上用伪代码表达就是while true: 期望状态 : 从 API Server 读取资源 Spec 实际状态 : 从 API Server 读取关联资源当前状态 if 实际状态 ! 期望状态: 执行一系列操作 等待下一次触发这个循环在 K8s 内部叫 reconcile调谐也常被翻译成“协调循环”。它最重要的特征不是“按顺序执行命令”而是“每次比较差异后向终态靠拢”。就算系统在中间发生故障、消息丢失、Pod 创建失败只要循环还在跑最终状态仍然会收敛到期望值。实际实现里没有任何控制器会“每秒轮询全部资源”否则大规模集群早就被自己的控制平面拖垮了。真正的做法是事件驱动API Server 推送资源变化事件给控制器控制器收到事件后再把对应对象放进本地工作队列逐个处理。这种“事件触发 本地队列 差异比对”的组合是 K8s 控制平面能在大集群里保持稳定的根本原因。2. 控制器内部工作机制informer、workqueue 与调谐函数如果只停留在“调谐循环”这个概念层面你还是没法理解生产环境里各种疑难杂症。真正的控制器代码核心组件有三个informer、workqueue、以及调谐函数。这三个组件配合起来才构成一个完整的控制回路。2.1 事件驱动的资源监听机制informer 为什么重要informer 是 client-go 提供的一个封装它做了三件关键事情通过 List 接口拉取全量对象放进本地内存缓存Indexer。通过 Watch 接口监听 API Server 后续变化增量更新本地缓存。把 add / update / delete 事件分发给注册的处理器event handler。为什么 informer 重要因为控制器如果每次都直接读 API Server集群里上百个控制器同时读写API Server 和 etcd 的压力会非常巨大。informer 把“读操作”收敛到本地缓存控制器的大多数读取都不需要经过 API Server大幅降低了控制平面的负载。同时 informer 的缓存天然具备最终一致性本地缓存可能短暂滞后于 API Server但最终一定收敛到一样的数据。这一点非常关键——控制器处理事件时读到的期望状态可能是“几分钟前”的状态但经过几轮调谐后仍然会逼近正确结果。这就是为什么 K8s 面对消息乱序、延迟、重放等分布式环境下常见的问题依然能保持正确性。2.2 工作队列的幂等设计与速率限制informer 收到事件后并不会直接调用调谐函数而是先把对象的 key通常是namespace/name字符串放进 workqueue。这是整个机制里最容易被新手忽略的环节。workqueue 有几个非常重要的特性幂等合并同一个 key 在队列里只会存在一份。即使同一时间有 10 个事件都指向同一个 Deployment队列里只有一个 key处理完再收到新事件才重新入队。延迟重试处理失败时可以调用AddRateLimited让 key 在指数退避后重新入队。优雅关停控制器退出时可以通知队列停止消费正在处理的任务也不会突然中断。这种设计的直接好处是极大减少了重复计算。想想看一次 Deployment 滚动更新RS 和 Pod 的事件可能触发几十上百次调谐如果每次调谐都全量处理控制器的 CPU 开销会非常难看。有了 workqueue 合并且配合 resync 定期兜底控制器即使偶尔漏掉事件也能通过周期性的全量比对来纠偏。2.3 resync 周期为什么控制器要定期“全量盘点”resync周期回拨是 informer 里另一个常被误解的参数。它做的不是重新从 API Server 拉全量而是把本地缓存中的所有对象重新触发一遍 update 事件相当于“假更新”。这样做的目的只有一个防止丢失事件导致状态永久不一致。比如某次 watch 因为网络原因断开重连过程中发生的事件可能已经无法完整回放虽然 etcd 有资源版本号可以回放一定范围但总有边界和盲区。resync 就像一个定时巡检把所有对象重新过一遍有差异就调谐没差异就跳过。生产环境中我一般建议把 resync 周期设置成 10 到 15 分钟太短会增加无谓的 CPU 消耗太长则事件丢失后恢复速度偏慢。这个参数在原生控制器里普遍存在自定义控制器时也一定要做好设置这是很多线上事件丢失问题的根源。3. 常用内置控制器与配置参数解析Kubernetes 控制平面里内置了很多控制器它们都基于上面这套机制工作但行为差异很大。理解每一个控制器的关注点和它管理的边界对排查问题非常有帮助。3.1 Deployment 与 ReplicaSet最常见的控制器组合ReplicaSet 控制器的逻辑非常纯粹用一个 Pod 模板创建 Pod然后用 label selector 统计自己管辖的 Pod 数量不匹配就调整。它调整 Pod 数量的操作是调用 API Server 创建或删除 PodPod 真正运行在哪台节点上由调度器决定编排控制器不负责。这里有一个必须记住的点ReplicaSet 只认“标签选择器”和“Pod 模板哈希”。如果你的 Pod 标签写错了ReplicaSet 会立刻创建新的 Pod而旧 Pod 一直挂在集群里没人管直到有人发现账单异常。所以生产环境建议给 Pod 模板的 label 和 service selector 做好一致性校验最好通过 CI 检查手段强制约束。Deployment 控制器则在 ReplicaSet 之上封装了新版本发布。它有几个关键参数直接把控着发布节奏maxSurge滚动更新期间最多允许多出的 Pod 数量。maxUnavailable最高允许不可用的 Pod 数量。minReadySecondsPod 就绪后至少保持多少秒后才认为“发布成功”。revisionHistoryLimit保留的历史 ReplicaSet 数量影响回滚能力。我在实际发布中遇到最多的坑是minReadySeconds设成 0 导致新版本 Pod 刚 Ready 就被判定为发布完成而镜像实际是坏的应用日志一上线就疯狂报错。把这个参数设成 10~20 秒能拦截大部分隐藏问题。3.2 StatefulSet 与 DaemonSet两类特殊控制器的行为差异StatefulSet 控制器和 Deployment 最大的不同在于它管理的 Pod 是有身份的。它给每个 Pod 固定序号从 0 开始序号决定创建和删除的顺序并且每个 Pod 拥有独立的 stable network identity 和 stable storage。这个设计让有状态应用比如数据库、消息队列可以在 K8s 上以分布式的方式运行。StatefulSet 的伸缩过程有几个现象值得注意缩容时最高序号的 Pod 先被删除而扩容时最小序号先创建而且默认是逐个进行podManagementPolicy为OrderedReady。如果你的中间件要求多节点同时启动那就得改成Parallel策略。DaemonSet 控制器则完全不同它保证集群中符合条件的节点上各运行一个且仅运行一个 Pod。它最常见的使用场景是日志采集、监控 agent、网络插件这类基础组件。DaemonSet 滚动更新也用maxSurge和maxUnavailable控制并发程度但因为是逐节点更新比较建议maxUnavailable: 1确保同一时刻最多只有一个节点的采集能力中断。3.3 Job、CronJob 与更细分的资源控制器Job 控制器负责运行一次性任务核心逻辑是“完成指定数量的 Pod 成功退出”。它的关键参数是backoffLimit和activeDeadlineSeconds。前者控制失败重试次数后者控制整个 Job 的最长运行时间两者配合能有效防止坏任务无限重试。CronJob 是在 Job 之上加了时间维度它做的事其实非常朴素到点创建一个 Job 资源。但要注意concurrencyPolicy默认是Allow也就是说前一个 Job 还没跑完新的 Job 也可能会被创建出来。如果你的定时任务对并发很敏感记得改成Forbid或Replace。除此之外还有一大批控制器在幕后工作比如负责节点状态管理的 NodeLifecycleController处理节点 NotReady 和驱逐 Pod、负责资源配额清理的 ResourceQuotaController、负责服务账号自动创建的 ServiceAccountController。它们绝大多数时候是无感知的但只要集群规模上了 300 节点这些所谓“次要控制器”的日志量会非常庞大排查问题时经常能从它们的错误日志里定位出根因。4. 自定义控制器与 CRD 开发全流程了解内置控制器后很多人会遇到一个现实问题我自定义了一个 API 对象可它没有任何“反应”。这时候你就需要在 CRD自定义资源定义基础上写一个自己的控制器。4.1 什么情况下必须写自定义控制器不是所有自定义资源都需要控制器。有一种资源叫“声明式配置”它只用来存放用户提交的配置API Server 原样保存不需要任何自动化逻辑。但大部分实际业务需求都希望“当我创建某个 CR 时系统自动帮我做一组事情”比如创建一个自定义的负载均衡实例自动编排一批底层 Pod 和 Service封装一套应用发布流程自动管理版本命名空间和流量比例把 CR 的字段映射成外部云厂商资源自动调用云 API 完成调度。这些场景统统需要控制器。控制器的职责就是把 CR 的 Spec 翻译成一组“基础资源的期望状态”再协调这些基础资源的状态最终把结果回写到 CR 的 Status 中。4.2 用 controller-runtime 搭一个最小控制器现在写自定义控制器业界事实标准是controller-runtime它把 informer、workqueue、leader election、指标暴露等底层细节都包装好了。你主要需要关注的核心入口是一个函数func (r *YourReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error)req里面包含被调谐对象的namespace/name。拿到这个 key 以后你通过 client 把 CR 对象读取出来读取它的 Spec然后去检查底层资源是否存在做差异比对最后更新 CR 的状态。ctrl.Result可以控制下次调谐的时间比如RequeueAfter: 30 * time.Second。下面是一个最小骨架示例Go 语言package controllers import ( context fmt appsv1 k8s.io/api/apps/v1 corev1 k8s.io/api/core/v1 k8s.io/apimachinery/pkg/api/errors metav1 k8s.io/apimachinery/pkg/apis/meta/v1 ctrl sigs.k8s.io/controller-runtime sigs.k8s.io/controller-runtime/pkg/client sigs.k8s.io/controller-runtime/pkg/log examplev1 example.com/m/v2/api/v1 ) type DemoReconciler struct { client.Client Scheme *runtime.Scheme } func (r *DemoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger : log.FromContext(ctx) var cr examplev1.Demo if err : r.Get(ctx, req.NamespacedName, cr); err ! nil { if errors.IsNotFound(err) { return ctrl.Result{}, nil } logger.Error(err, unable to fetch Demo) return ctrl.Result{}, err } // 检查 Deployment 是否存在 dep : appsv1.Deployment{} depKey : client.ObjectKey{Namespace: cr.Namespace, Name: cr.Name -demo} if err : r.Get(ctx, depKey, dep); err ! nil { if errors.IsNotFound(err) { // 不存在则创建 expected : buildDeployment(cr) if err : r.Create(ctx, expected); err ! nil { logger.Error(err, failed to create Deployment) return ctrl.Result{}, err } return ctrl.Result{Requeue: true}, nil } logger.Error(err, failed to get Deployment) return ctrl.Result{}, err } // 数量不匹配就更新 if *dep.Spec.Replicas ! cr.Spec.Replicas { dep.Spec.Replicas cr.Spec.Replicas if err : r.Update(ctx, dep); err ! nil { return ctrl.Result{}, err } } // 更新 CR 状态 cr.Status.ReadyReplicas dep.Status.ReadyReplicas if err : r.Status().Update(ctx, cr); err ! nil { return ctrl.Result{}, err } return ctrl.Result{}, nil }这段代码体现了控制器的核心动作Get 当前状态对比 Spec创建或更新基础资源最后回写 Status。这里面有一个容易踩的坑是在调谐函数里直接操作cr.Spec这是错误示范。控制器不应该修改 SpecSpec 是用户的意图控制器只能修改 Status 或者创建、更新下属资源。如果你改了自己的 SpecAPI Server 不会阻止你但整个调谐逻辑就会被你搞得混乱二次调谐时永远无法收敛。4.3 finalizer 与幂等自定义控制器必须补齐的短板严格来说一个能进生产环境的自定义控制器还需要额外处理两件事finalizer 和删除重试。finalizer 的作用是当用户删除 CR 时API Server 不会立刻把对象销毁而是等 finalizer 从 CR 上移除后才真正删除。这给了控制器一个机会去做清理工作比如删除关联的外部资源、通知下游系统、执行归档等。使用方式是在 CR 的metadata.finalizers里加入一个字符串标识然后在调谐函数里通过Get检查对象是否存在如果不存在且 finalizer 还在就执行清理逻辑并移除 finalizer。如果你没实现 finalizer删除 CR 时关联的 Deployment 可能被残留下来形成了典型的“资源泄漏”。幂等则是每个控制器函数必须天生具备的属性。同一条调谐逻辑执行一次和一百次最后的状态都应该一样。比如创建 Deployment 前要先判断是否已经存在更新镜像前先比较当前镜像和期望镜像是否一致。很多初学者写的控制器第一次调谐创建成功了第二次调谐因为状态没更新就又重复创建最终 API Server 返回 AlreadyExists 错误控制器陷入错误循环。5. 排查经验与常见问题速查最后这部分我整理了一些生产环境里真正高频遇到的控制器问题。这些坑如果不提前知道排查起来很容易绕远路。5.1 控制器不更新 Status 的可能原因最常见的情况是CR 的 Status 字段一直为空调谐函数看起来执行了但没有写回结果。大部分原因在于 CRD 没有启用 status 子资源。需要确保 CRD 定义里有这么一段subresources: status: {}没有这段Status().Update()会失败而通过r.Update()去更新整个对象又常常因为 RVResourceVersion冲突被拒绝。生产上我建议所有自定义资源都显式开启 status 子资源把用户意图Spec和系统状态Status彻底分开这样权限控制和审计也会清晰得多。5.2 watch 事件丢失与缓存不一致的隐身坑控制器使用的本地缓存来自 informer正常情况下一定收敛但如果你在代码里使用cache.NewInformer并且自己管理缓存或者在一个进程里创建了多个 informer 但 watch 的 GroupVersion 不一致就可能导致资源事件串台。常见现象是Pod 创建了控制器没反应等 resync 周期到了才突然补处理。排查手段其实很简单看控制器的指标workqueue_depth和informer_sync_total。如果你发现 informer 长时间没有 sync 计数大概率 watch 连接有问题查看 API Server audit 日志里是否有大量 resourceVersion 过期请求。网络抖动引起的 watch 重连是正常现象但如果重连频率很高就要检查服务端负载和 etcd 延迟。5.3 排障速查表现象可能原因排查路径控制器没有任何日志没有注册到 manager / RBAC 权限不足查看 manager 启动日志检查 ServiceAccount 权限Pod 创建成功但一直 ContainerCreating不是控制器问题是 kubelet 或 CNI 问题先看 kubelet 日志和容器运行时日志删除 Deployment 后 Pod 不消失有 finalizer 卡住删除检查 Pod metadata.finalizers 和 ownerReferencesCR 创建后没有反应informer 没有注册该 GVK或 RBAC 无 watch 权限检查 kubectl get crd查看控制器 debug 日志ReplicaSet 报 “pods ... already exists”控制器与 informer 缓存不一致重启控制器 Pod确认是否还有旧实例释放了 leader 锁这里还要提一个生产环境教训多副本部署控制器时一定要开启 leader election。没有 leader election 的两个控制器实例同时跑调谐轻则重复创建资源重则因为同时修改同一对象引发 RV 冲突风暴整个控制器的队列会被错误日志淹没。controller-runtime 里开启 leader election 只是一两行配置但对生产稳定性影响极大。最后分享一点经验我维护过一个自定义控制器上线初期一切正常直到一次故障演练时把控制器的 Pod 杀了一半才发现大问题因为没开 leader election多个副本同时跑调谐瞬间把 API Server 打挂了。后来我养成了一个习惯任何控制器上线前都会对照检查清单过一遍是否有 leader election、是否有 status 子资源、是否对每类事件幂等、resync 是否合理、删除逻辑是否清理干净。控制器这东西写出来不难难的是让它安静稳定地跑上几个月不用管。从“能用”到“好用”你要补的东西就是这些细节。