ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Kubernetes控制器原理与实战:从控制循环到Operator开发

Kubernetes控制器原理与实战:从控制循环到Operator开发 如果你刚接触Kubernetes一定经历过这样的场景写了一个DeploymentPod被删掉后几秒钟又自动出现某个节点挂了Pod会自动跑到其他节点更新镜像时Pod一组一组滚动替换。这些“自动”行为背后真正的操盘手并不是用户脚本也不是kubelet而是Kubernetes里最核心的运行机制——控制器Controller。坦白说不理解控制器你对Kubernetes的理解就始终隔着一层窗户纸。这篇文章用问答的方式来拆解控制器它是什么、靠什么机制工作、内置控制器怎么选、如果要自己写一个控制器该怎么落地、出问题时又该怎么查。不管你是运维、开发还是正在备考CKA这套内容都能让你从“知道有控制器”变成“真的会用它”。1. 控制器到底是什么从“声明式API”这棵大树说起1.1 为什么不能靠“脚本定时检查”来维持系统状态很多人第一次接触控制器都会冒出一个念头这不就是一个轮询脚本吗我写个cronjob每30秒查一下Pod数量少了就补不也一样理论上也能达到类似效果但实际生产环境里这条路走不通。原因有几个。第一轮询必然有延迟30秒的空窗期内如果Pod已经挂掉一批服务早就受损了。第二轮询会带来大量无效请求API Server的压力会直线上升尤其是在几千个节点的集群里。第三也是最关键的脚本自己怎么保证不挂谁来拉起脚本这又需要一个更高层的管理者套娃套不完。控制器的思路完全不同。它不靠反复“查”而是靠“听”和“比较”。用户通过yaml文件告诉Kubernetes“我要什么”比如“我要3个nginx副本”这个期望状态会被写入etcd。保存系统当前状态的不是某个人而是API Server背后各组件的协作。控制器的工作就是盯着API Server里的对象变化一旦发现当前状态和期望状态有差异就立刻动手修正。这种“只描述目标、不描述步骤”的设计就是Kubernetes经常挂在嘴边的声明式API。它带来的最大好处是把“运维逻辑”从“业务逻辑”里剥离开Deployment里只写副本数至于Pod怎么创建、滚动更新怎么推进、失败怎么回滚那是控制器的事。1.2 控制器和API Server是怎么“打电话”的这里就要说到Kubernetes的组件分工了。你平时执行的kubectl get、kubectl apply最终都会打到API Server上。API Server是唯一和etcd直接打交道的组件也是整个集群的“信息登记处”。控制器的老巢在kube-controller-manager这个组件里。它内部不是一个控制器而是很多个控制器共享一个进程ReplicaSet Controller、Deployment Controller、DaemonSet Controller、Job Controller、Namespace Controller等等。每个控制器各管一摊通过API Server读取自己关心的资源。举个例子。ReplicaSet Controller管的是ReplicaSet和Pod。当它发现某个ReplicaSet期望副本数是3但现存Pod只有2个时就会调用API Server创建1个Pod。API Server把Pod写入etcd调度器看到新Pod后把它调度到某个节点kubelet在节点上把容器真正跑起来。所以控制器并不直接碰节点不直接操作容器。它只和API Server打交道。这对于刚入门的人可能有点反直觉但恰恰是这种“控制面和数据面分离”的设计让整个系统在一个统一的总线下运转也更容易做权限控制和审计。注意kube-controller-manager只负责Kubernetes原生资源的控制逻辑。云厂商的负载均衡、存储卷这类需要对接云API的资源则交给cloud-controller-manager处理。它的存在让Kubernetes核心代码不用写死某个云厂商的SDK。2. 控制循环的秘密期望与现实的“调谐”过程2.1 调谐循环三步走观察、比较、行动控制器内部最核心的机制叫做控制循环Control Loop。这个说法很吓人其实就是一个“观察、比较、行动”的无限循环观察从API Server拿到某个对象比如一个Deployment的当前状态以及它依赖的Pod列表。比较把当前状态和用户声明的期望状态放在一起算差异。行动根据差异创建、更新或删除资源。这个过程在Kubernetes里有个专门术语叫调和Reconcile。比如一个ReplicaSet期望3个Pod控制器观察到只有2个两者差1个于是创建1个Pod。如果实际有4个Pod控制器就随机删掉多出来的那个直到数量回到3。这个机制说起来简单但落地时有很多细节。控制器不可能每次都对整个集群所有对象做全量扫描那样API Server早就被压垮了。于是Kubernetes发明了一套非常精巧的“事件驱动本地缓存工作队列”的架构这才是控制器真正值钱的地方。2.2 informer机制控制器感知变化的“消息管道”控制器不是靠“每隔几秒去API Server拉一遍”来感知状态变化的而是通过informer。informer在控制器启动时会先做一次List把关心的对象全量拉下来然后和API Server建立Watch长连接之后任何对象的增删改事件都会通过这条连接推给控制器。听到这里你可能会问如果我一直抱着这条长连接不处理事件堆积怎么办所以informer的核心设计是双通道一边监听事件一边维护一个本地缓存Indexer。事件到达后先进DeltaFIFO去重合并再由controller framework把变化的对象变成一条条“任务”丢进工作队列Workqueue。实际执行调谐逻辑的是独立的worker协程它们从队列里取任务、调用Reconcile函数处理完再取下一条。这样做的好处很多。本地缓存让控制器不需要频繁访问API Server事件驱动让反应速度非常快工作队列让多个对象可以并行处理同时又能对单个对象保持顺序。我习惯把informer理解成“消息队列”把Reconcile理解成“消费者函数”。这样再看自定义控制器代码思路会顺很多。2.3 重试、限流和重新入队控制器为什么不会“疯掉”控制器也是程序也会在处理任务时出错。如果一个对象处理失败控制器会把它重新丢回队列稍后重试。但这里绝对不能“无条件死磕”否则一个坏对象能把整个控制器线程打满。所以工作队列默认带速率限制同一对象首次失败后重试间隔很短然后指数退避最大重试间隔通常在几分钟到十几分钟量级。还有个容易被忽略的细节当某个对象重试次数达到上限默认5次之后控制器就不会再自动处理它了。这时候你在事件日志里会看到一条“dropping ... out of the queue”的记录。这个对象只能靠下一次来自API Server的新事件比如你手动改一改它的label或annotations才能被重新入队。很多人遇到过“改了yaml没用”的诡异问题其实就是因为对象已经掉出队列最后touch一下annotation才恢复。这个坑希望大家别踩。2.4 resync兜底的定期校准事件驱动虽然快但有一个天生的隐患如果某个事件由于网络闪断或其他原因丢了控制器可能一直感知不到状态变化。为了兜底informer还有一个周期性机制叫做resync。默认情况下每过一段时间默认10小时informer会把本地缓存里的所有对象重新入队一次触发一次全量调谐。注意resync不是去API Server重新List而是把本地已有的对象再“过一遍脑子”。这样做不会产生大量API请求但能让控制器把之前漏掉的任务补上。控制器框架里有个字段叫ResyncPeriod自定义控制器时可以根据业务压力调整。调得太短所有对象频繁重新调谐CPU和队列压力会变大调得太长漏事件的恢复时间就会变长。一般默认值在大多数场景下都够用。3. 内置控制器逐个拆解选型与边界3.1 Deployment、ReplicaSet、StatefulSet怎么选kube-controller-manager里内置的控制器很多日常打交道最多的是工作负载类控制器。先看最经典的三个Deployment、ReplicaSet、StatefulSet。很多人会把Deployment和ReplicaSet搞混。一句话说清楚Deployment不直接管Pod它管的是ReplicaSetReplicaSet管的才是Pod。Deployment每发布一个新版本就会创建一个新的ReplicaSet然后滚动调整新旧两个ReplicaSet的副本数。所以你在集群里看ReplicaSet的历史版本其实就是在看Deployment的发布历史。StatefulSet和Deployment最大的区别在于身份。Deployment创建的Pod是完全对等的名字带随机后缀随便删哪个都行。StatefulSet则给每个Pod一个稳定的序号和稳定的网络标识加上稳定持久化存储。数据库、消息队列这类有状态应用要求每个Pod在重建后还能用旧名字、认旧盘、按顺序启动就必须用StatefulSet。我见过不少人在选型上犯迷糊明明是无状态API服务非要用StatefulSet就为了拿一个固定Pod名结果反而背上了有序部署的包袱。反过来数据库的从节点又有人图省事用Deployment结果节点漂移后连不上原来存储。选型其实没有高深标准Pod身份和存储能不能丢能丢就用Deployment不能丢就StatefulSet。3.2 DaemonSet、Job、CronJob各自该什么时候用另外三个控制器也很有用但适用范围更聚焦。DaemonSet保证集群里每个满足条件的节点上都有一个Pod新节点加入时自动补上节点删除时Pod也回收。典型的例子是网络插件Calico、Cilium的agent、日志采集Fluentd、Filebeat、节点监控Node Exporter。这类Pod适合和节点绑定而不是按副本数随机调度。Job负责跑一次性任务比如数据迁移、批量渲染。它有几个关键参数需要注意backoffLimit控制失败重试次数activeDeadlineSeconds控制任务整体超时时间parallelism控制并发数。如果任务跑完Pod一直不退出除了检查业务代码也要看一眼是否忘了设置restartPolicy为Never或OnFailure。CronJob就是带时间表的Job。Kubernetes在较早版本里使用UTC时间不少同学因为没区分时区定在凌晨2点执行的任务实际上跑在了上午10点。从v1.26开始CronJob已经支持在spec.schedule里带时区信息。如果你还在维护老集群一定要先确认版本再跟使用者确认时区语义。3.3 多个控制器会“打架”吗ownerReferences如何规定归属集群里有这么多控制器会不会有多个控制器同时盯上同一个Pod理论上可能所以Kubernetes定义了所有权Owner机制。每个被Controller创建的资源元数据里都会带上ownerReferences字段指向它的直接创建者。比如ReplicaSet创建的PodownerReferences指向ReplicaSetDeployment创建的ReplicaSetownerReferences指向Deployment。这个字段有两条重要作用。第一它决定了级联删除删除Deployment时Deployment Controller会去删ReplicaSetReplicaSet Controller会删除背后的Pod一层层传下去。第二它规定了“管事的爹”只有一个如果一个Pod没有ownerReferences但label恰好匹配某个Deployment的selectorReplicaSet Controller也会把它“收养”给它补上ownerReferences。如果这个Pod同时也匹配了另一个控制器的selector两个控制器就会争抢最终结果可能很混乱。所以我的建议是不要手动创建和控制器selector匹配的Pod也不要在多个工作负载之间复用同一套pod selector。这一条看似简单实际坑过很多人。顺便提一点所谓“孤儿资源”就是owner已经被删除但子资源还在的情况。正常情况下级联删除会清理干净但如果你用kubectl delete --cascadeorphan或者遇到finalizer卡住就会留下孤儿这种时候要人工介入清理而不是简单重复删除父资源。4. 自定义控制器从零实现一个“专属管家”需要准备什么4.1 什么时候值得写自定义控制器内置控制器覆盖了很多场景但业务里总有它管不到的地方。我举几个真实例子你需要根据客户自定义的“备份计划”资源定期创建备份任务你需要监听某个CRD自定义资源定义当它被创建时自动去外部系统创建云资源你需要实现一套灰度发布逻辑比Deployment的RollingUpdate更复杂。这种情况下你需要的不是改控制器框架而是基于Kubernetes的扩展机制写一个自定义控制器。最常见的落地形态就是“Operator”一个CRD声明业务期望状态一个自定义控制器负责调和。Kubernetes官方有大量文档和样例建议先把sample-controller跑明白再动手。4.2 选型controller-runtime还是纯client-go写自定义控制器目前最成熟的两个路径直接用client-go手写或者用sigs.k8s.io/controller-runtime框架。如果只是几十行内部工具纯client-go完全够但一旦涉及多副本并发、leader election、指标暴露手写样板代码会越来越多。controller-runtime把上面说的informer、workqueue、限流、leader election都封装好了你只需要实现一个Reconcile方法。用kubebuilder或Operator SDK脚手架可以快速生成项目骨架还能自动生成CRD定义和RBAC规则。我个人在生产项目里基本都是走这条路线维护成本明显更低。4.3 一个精简的Reconcile函数长什么样Reconcile函数的核心逻辑和内置控制器的调谐逻辑一个套路读期望对象、对比现状、做动作、返回结果和处理错误。下面是个伪代码风格的示例用go语言写只保留主干func (r *FooReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { var foo myappv1.Foo // 1. 读期望对象 if err : r.Get(ctx, req.NamespacedName, foo); err ! nil { // 对象被删了做清理逻辑 return ctrl.Result{}, client.IgnoreNotFound(err) } // 2. 查看子资源是否存在 var pod corev1.Pod err : r.Get(ctx, types.NamespacedName{Name: foo.Name -worker, Namespace: foo.Namespace}, pod) // 3. 按需创建或更新 if errors.IsNotFound(err) { pod *buildPod(foo) if createErr : r.Create(ctx, pod); createErr ! nil { return ctrl.Result{}, createErr } return ctrl.Result{}, nil } if err ! nil { return ctrl.Result{}, err } // 如果pod已经存在且spec有变化做update没有变化就返回空结果 return ctrl.Result{}, r.syncPodSpec(ctx, pod, foo) }这段代码看着简单但有两个关键点。第一Reconcile必须是幂等的无论被调用一次还是一百次结果应该一样。不要在这里“先判断不存在再创建”因为并发情况下可能两个worker同时判断都不存在然后创建两个。更稳的做法是通过SetControllerReference把Pod的ownerReferences指向Foo资源让级联删除有依据。第二错误处理不是简单返回error就行你还需要决定是否需要重新入队、多久重试。controller-runtime会根据错误自动重试但如果错误来自外部系统且是临时性的你可以用ctrl.Result{RequeueAfter: 30 * time.Second}实现定期重试。4.4 本地调试和部署的实操细节自定义控制器调试起来比普通服务麻烦因为依赖集群的API Server。我常用的两种方式一是用Kind在本地起一个真正的集群把控制器编译成镜像或直接在本地跑通过kubeconfig连接Kind二是用envtest它会在本地自动拉起kube-apiserver和etcd组件适合跑单测和集成测试不需要完整集群。部署到集群时最容易被忽略的是RBAC、leader election和资源需求。控制器需要访问CRD和子资源必须在ClusterRole里把相关apiGroup、resources、verbs一一列全权限缺失时通常不会报错而是你在日志里看到一条条forbidden然后发现控制器什么也没动。leader election也很重要如果控制器部署了多个副本又没开选举多个副本会同时处理同一批对象轻则重复创建重则资源冲突。controller-runtime里只需要配置一下LeaderElection字段强烈建议写上。还有一点经验自定义控制器同样要暴露metrics尤其是workqueue指标。很多集群故障都是通过查看workqueue depth和retries指标定位的。Kubernetes内部控制器自带这些指标自定义控制器接入controller-runtime后默认也有千万别关掉。5. 控制器“抽风”时我的排查思路5.1 场景Pod一直在“创建-删除”循环我相信不少人见过这种诡异画面Pod列表里总有一两个Pod处于ContainerCreating过几秒变成Terminating同时又冒出新的Pod来。第一反应是调谐失控。但用户改过的Deployment没问题、yaml没问题、资源也够为什么会反复排查时先看Pod的ownerReferences和labels。常见原因有两种。一种是非受管Pod混了进来RS控制器看到有Pod不满足自己的期望又因为某些原因不能删除或收养它于是一边创建新Pod一边清理不匹配Pod形成循环。另一种是label selector冲突两个控制器管理同一批Pod互相抢“所有权”导致Pod不断重建。我一般这样排查执行kubectl get rs -n xxx -o yaml看selector再执行kubectl get pods -n xxx --show-labels对比每个Pod的app标签和ownerReferences。如果发现一个Pod的ownerReferences不是当前RS而是另一个十有八九是selector复用了。解决办法就一个字改selector给不同工作负载分配不同的标签键值。5.2 控制器日志看不到关键信息日志级别设置控制器不像业务应用那样每个操作都打日志。默认kube-controller-manager的日志量很少遇到问题往往只有一句“xxxx is unhappy”或者什么都不打。这时候很多人会束手无策其实只要把日志级别调高信息立刻丰富起来。在kube-controller-manager的启动参数里加-v4会输出大量调谐细节加到-v6能看到更底层的API请求。但生产环境不建议长期开高日志那会在故障期间瞬间写满磁盘。我的做法是先恢复到问题现场后临时加-v4重启kube-controller-manager静态Pod场景可以修改yaml后由kubelet拉起观察几分钟拿到日志再调回默认。如果是自建控制器则可以动态改日志级别脚本用完后恢复。另外控制器日志未必在“自己的容器”里。kube-controller-manager作为静态Pod运行在master节点上日志在对应容器的stdout里。用命令kubectl -n kube-system logs kube-controller-manager- -c kube-controller-manager可以查看个别场景还需要去看/var/log/pods目录下的文件。5.3 资源一直Terminating控制器也没报错删除Deployment后Pod一直处于Terminating状态数分钟不消失。对应的控制器日志可能没有明显错误但对象卡在那里。这类问题大部分不是控制器逻辑问题而是“收尾动作没做完”。最常见的元凶是finalizer。如果一个资源带有finalizer字段API Server不会真正删除它而是把它置于Terminating等待某个外部控制器来清除finalizer。如果持有finalizer的控制器不存在、没启动或故障了资源就会永远卡住。比如某些云存储控制器给PVC加的finalizer但控制器被误删了PVC就删除不掉。排查流程其实很固定kubectl get pod xxx -o yaml | grep -A10 finalizers看到finalizer之后搜索谁管理这个finalizer确认它是否正常运行。如果只是临时想强制删除可以把finalizers字段清空后重新apply资源就会真正消失。但我不建议遇到Terminating就直接清finalizer那会让底层资源失去清理机会云盘、SLB可能一直挂着扣费。正确姿势是先恢复对应控制器。还有一类Terminating和Pod有关Pod所在节点失联kubelet无法完成卸载卷等收尾动作kube-controller-manager必须等待节点重新上线或者由管理员删除节点对象强解。这类问题在云环境里通常要先看节点状态而不是粗暴地kubectl delete pod --force。在Kubernetes里控制器这个设计看起来不起眼实际是整个声明式体系的发动机。我最早以为它只是一个“自动补副本”的小工具后来越来越觉得对控制循环的理解程度直接决定你能不能处理集群里的疑难杂症。最后分享一个自己的小习惯每次排查控制器相关问题先看三样东西——对象的ownerReferences、控制器的workqueue指标、以及事件日志。这三样看完问题基本就露馅一半。希望这篇问答式的拆解能让你下次再看到Pod自动复活时不再觉得是玄学而是能一眼看穿背后的那双手。
返回列表