1. 从一次Pod“失联”说起:为什么需要Controller
刚接触K8s那会儿,我干过一件现在想起来都脸红的事。手动创建了一个Pod跑业务,跑得好好的,结果某天节点资源紧张,Pod被驱逐了,业务直接挂掉。我当时的第一反应是“谁把我的Pod删了”,排查半天才发现,根本没人删它,是它自己“死”了之后没人管。这件事让我彻底明白了一个道理:在K8s里,Pod不是宠物,是牲畜。你不能指望手动创建的Pod能长久稳定地活着,它随时可能因为各种原因消失。而真正让K8s具备自愈能力的,就是今天要聊的主角——Controller(控制器)。
Controller这个词听起来挺唬人,但它的核心逻辑其实特别朴素:声明式API + 控制循环。你告诉K8s“我想要3个副本一直跑着”,Controller就会像个不知疲倦的监工,每隔一段时间检查一下实际状态,发现只有2个,就赶紧补1个;发现多出来1个,就干掉1个。它不关心过程,只关心结果。这种“面向终态”的设计,是K8s区别于传统运维工具的根本所在。
这篇文章我会把Controller的家族成员一个个拆开讲清楚,包括Deployment、ReplicaSet、StatefulSet、DaemonSet、Job、CronJob这些你天天打交道的对象。不只是告诉你它们是什么,更会讲清楚它们底层怎么工作、什么场景该用哪个、以及我在实际使用中踩过的那些坑。不管你是刚入门K8s的新手,还是已经用了一段时间但总觉得“知其然不知其所以然”的老手,应该都能从中找到一些有用的东西。
2. Controller的底层逻辑:控制循环到底在循环什么
2.1 声明式API与控制循环的配合机制
要理解Controller,得先理解K8s的声明式API。这玩意儿跟传统的命令式操作有本质区别。命令式是你告诉系统“做什么”,比如“启动一个Nginx容器”;声明式是你告诉系统“我要什么”,比如“我要一个Nginx容器一直运行着”。前者你发完指令就完事了,容器死没死跟你没关系;后者系统会持续保证这个状态,容器死了它自动给你拉起来。
Controller就是实现这个“持续保证”的组件。它的工作模式是一个无限循环,业内叫Reconcile Loop(调和循环)。每一轮循环做三件事:第一,读取期望状态(从API Server里的Spec字段读);第二,读取实际状态(从API Server里的Status字段读,或者直接查底层资源);第三,比较两者差异,执行操作让实际状态向期望状态靠拢。
这个循环不是靠事件驱动的,而是定时触发 + 事件触发结合。定时触发就是每隔一段时间(比如几秒到几分钟不等)强制同步一次,防止状态漂移;事件触发是当被监控的资源发生变化时(比如Pod被删了),立即触发一次调和。两者配合,既保证了实时性,又保证了最终一致性。
这里有个容易混淆的点:Controller本身不直接操作容器运行时。它只跟API Server打交道,修改的是etcd里的资源对象。真正去创建容器的是kubelet,Controller只是“下单”,kubelet才是“干活”的。理解这个分层,后面排查问题的时候思路会清晰很多。
2.2 期望状态与实际状态的差异从哪来
实际状态和期望状态不一致的原因五花八门,我大致归了几类。第一类是资源不足,比如节点CPU不够了,Pod调度不上去,或者被驱逐了。第二类是人为干预,比如有人手动kubectl delete了一个Pod,或者改了某个资源的标签导致它不再被Controller管理。第三类是组件故障,比如kubelet挂了,节点失联了,Pod状态变成Unknown。第四类是配置错误,比如镜像拉不下来,或者健康检查一直失败导致Pod反复重启。
Controller处理这些差异的方式也不一样。对于资源不足,它会不断重试调度,直到有资源为止;对于人为删除,它会立即创建新的Pod补上;对于组件故障,它会等待节点恢复或者把Pod重新调度到其他节点;对于配置错误,它会按照重启策略反复重启容器,直到你修复配置。理解这些差异来源,你就能预判Controller的行为,而不是被它的“反复重试”搞得一头雾水。
2.3 控制器管理器:多个Controller如何协同工作
K8s里不是只有一个Controller,而是一堆Controller同时运行,它们都住在kube-controller-manager这个组件里。这个组件本质上是一个进程,里面启动了多个独立的控制循环,每个循环负责一种资源类型。比如Deployment Controller管Deployment,ReplicaSet Controller管ReplicaSet,Node Controller管节点状态,Endpoint Controller管Service和Pod的映射关系。
这些Controller之间通过API Server间接通信,不直接互相调用。比如你创建一个Deployment,Deployment Controller会创建一个ReplicaSet,ReplicaSet Controller看到这个ReplicaSet后,会创建对应数量的Pod。整个过程是链式的,每一层只关心自己那一层的事情。这种设计的好处是解耦,每个Controller可以独立升级、独立扩展,坏了一个不影响其他的。坏处是排查问题的时候链路比较长,你得一层一层往下查。
3. 无状态应用的守护者:Deployment与ReplicaSet
3.1 ReplicaSet:保证副本数量的最简控制器
ReplicaSet是Controller家族里最基础的一个,它的职责单一到令人发指:保证指定数量的Pod副本一直运行。你告诉它“我要3个”,它就盯着,少了补,多了删。它通过标签选择器(Label Selector)来识别哪些Pod归它管,所以你在创建ReplicaSet的时候必须定义selector,而且这个selector必须能匹配到它创建的Pod模板里的标签。
ReplicaSet的YAML结构很直白,核心就三个字段:replicas(副本数)、selector(标签选择器)、template(Pod模板)。我刚开始用的时候犯过一个低级错误:selector写的是app: nginx,但template里的labels写的是app: nginx-web,结果ReplicaSet创建出来的Pod它自己不认识,疯狂创建新Pod,最后把集群资源耗光了。这个坑的本质是selector和template.labels必须匹配,不匹配的话ReplicaSet会认为“我创建的Pod都不存在”,然后无限循环创建。
实际使用中,你几乎不会直接创建ReplicaSet,而是通过Deployment来管理。ReplicaSet更像是Deployment的一个“执行单元”,Deployment负责版本管理和滚动更新,ReplicaSet负责维持副本数量。这种分工让版本回滚变得特别简单,后面讲Deployment的时候会详细说。
3.2 Deployment:滚动更新与版本回滚的实现原理
Deployment是日常用得最多的Controller,没有之一。它在ReplicaSet之上加了一层版本管理的能力。你创建一个Deployment,它会创建一个ReplicaSet;你更新镜像版本,它会创建一个新的ReplicaSet,然后逐步把流量从旧ReplicaSet切到新ReplicaSet,这个过程叫滚动更新。
滚动更新的核心参数有两个:maxSurge和maxUnavailable。maxSurge控制更新过程中最多可以超出期望副本数多少个Pod,默认是25%;maxUnavailable控制最多可以有多少个Pod不可用,默认也是25%。举个例子,你有4个副本,maxSurge=1,maxUnavailable=0,更新过程是这样的:先创建1个新Pod(总数变成5),等它Ready后,删掉1个旧Pod(总数回到4),再创建1个新Pod,再删1个旧Pod,如此循环直到全部替换。整个过程始终保证至少有4个Pod可用。
版本回滚是Deployment的另一个杀手锏。每次更新,Deployment都会保留旧的ReplicaSet(通过revisionHistoryLimit控制保留多少个,默认10个)。当你发现新版本有问题,一条kubectl rollout undo命令就能回滚到上一个版本。回滚的本质是把旧ReplicaSet的副本数调上去,新ReplicaSet的副本数调下来,跟滚动更新的逻辑一模一样。
3.3 滚动更新卡住了?常见原因与排查路径
滚动更新卡住是我遇到过最多的问题之一。表现是kubectl rollout status一直显示“Waiting for deployment rollout to finish”,或者kubectl get pods看到新Pod一直处于Pending、CrashLoopBackOff、ImagePullBackOff状态。排查思路我总结了一个顺序:
第一步,看新Pod的状态。如果是Pending,大概率是资源不够或者调度约束不满足,用kubectl describe pod看Events,通常会告诉你“Insufficient cpu”或者“node(s) didn't match node selector”。如果是ImagePullBackOff,检查镜像地址和拉取凭证。如果是CrashLoopBackOff,看容器日志,通常是应用启动失败。
第二步,看Deployment的Conditions。kubectl describe deployment输出里的Conditions字段会告诉你“Progressing”还是“Available”,以及具体的原因。比如“ReplicaSetUpdated”表示新ReplicaSet正在创建,“NewReplicaSetAvailable”表示新ReplicaSet已经可用。
第三步,检查readinessProbe。这是最容易被忽略的坑。如果你的应用启动需要30秒,但readinessProbe的initialDelaySeconds只设了10秒,那Pod会在启动过程中被判定为不健康,滚动更新就会一直卡着。我一般会把initialDelaySeconds设得比应用实际启动时间多10-20秒,留足余量。
还有一个隐蔽的坑:maxUnavailable设成了0,同时maxSurge也设成了0。这两个参数不能同时为0,否则滚动更新根本无法进行,因为既不能多创建Pod,也不能少Pod。K8s会直接报错拒绝这个配置。
4. 有状态应用的专属管家:StatefulSet
4.1 有状态与无状态的本质区别
无状态应用好处理,因为每个Pod都一样,随便删随便建,用户请求打到哪个Pod上都行。但有状态应用不行,比如数据库、消息队列、分布式存储,每个实例都有自己独特的数据和身份。你删掉一个MySQL Pod再建一个新的,数据没了,主从关系乱了,整个集群可能就崩了。
StatefulSet就是为这种场景设计的。它跟Deployment最大的区别有三点:稳定的网络标识、稳定的持久化存储、有序的部署和扩缩容。这三点缺一不可,共同构成了有状态应用在K8s里运行的基础。
4.2 稳定的网络标识:Headless Service与Pod DNS
StatefulSet里的每个Pod都有一个稳定的DNS名称,格式是<pod-name>.<service-name>.<namespace>.svc.cluster.local。这个DNS名称在Pod重建后依然不变,因为Pod的名字是固定的,不是随机生成的。比如一个3副本的StatefulSet叫mysql,那Pod名字就是mysql-0、mysql-1、mysql-2,删掉mysql-1重建,新Pod还是叫mysql-1,DNS记录自动更新到新Pod的IP。
这个能力依赖于Headless Service。普通的Service会分配一个ClusterIP做负载均衡,但Headless Service的clusterIP字段设成None,它不分配IP,而是直接返回所有Pod的IP列表。StatefulSet必须配合Headless Service使用,否则Pod之间无法通过稳定的DNS名称互相发现。
这里有个细节:Headless Service的selector必须匹配StatefulSet的Pod标签,否则DNS解析会返回空。我见过有人创建了Headless Service但selector写错了,结果Pod之间互相解析不到,集群一直起不来。
4.3 持久化存储:volumeClaimTemplates的妙用
StatefulSet通过volumeClaimTemplates为每个Pod自动创建独立的PVC。你定义一个模板,StatefulSet在创建每个Pod的时候,会基于这个模板生成一个名字带Pod序号的PVC,比如>tolerations: - key: node-role.kubernetes.io/master operator: Exists effect: NoSchedule
这个配置的意思是“我能容忍Master节点的NoSchedule污点”。不加这个,DaemonSet在Master节点上的Pod会一直Pending。另一个常用的配置是nodeSelector或者nodeAffinity,用来限定DaemonSet只在特定节点上运行。比如你只想在带SSD的节点上跑存储插件,就可以用nodeSelector指定disktype: ssd。
有个容易踩的坑:DaemonSet的Pod模板里如果写了resource requests,而某个节点的资源不够,那个节点上的Pod就会Pending。DaemonSet不会因为一个节点资源不够就跳过它,它会一直重试。所以给DaemonSet设置资源请求的时候要特别保守,宁可设小一点,也不要设得太大导致部分节点跑不起来。
6. 一次性与定时任务:Job与CronJob
6.1 Job:批处理任务的正确打开方式
Job用来跑一次性任务,比如数据迁移、批量计算、备份脚本。它跟Deployment的区别是:Deployment保证Pod一直运行,Job保证Pod成功退出。Job的Pod跑完就结束了,不会重启(除非失败)。Job有几个关键参数需要理解:
completions:需要成功完成的Pod数量,默认1。parallelism:同时运行的Pod数量,默认1。backoffLimit:失败重试次数,默认6。activeDeadlineSeconds:整个Job的超时时间,超过这个时间Job会被标记为失败。
举个例子,你要处理100个文件,可以设completions=100,parallelism=10,这样Job会同时跑10个Pod,每个处理一个文件,总共处理100个。如果某个Pod失败了,Job会重试,直到成功或者达到backoffLimit。这个模式特别适合工作队列场景,每个Pod从队列里取一个任务处理,处理完退出。
6.2 CronJob:定时任务的坑与避坑指南
CronJob就是Job的定时版本,用Cron表达式控制执行时间。格式跟Linux的crontab一样:分 时 日 月 周。比如0 2 * * *表示每天凌晨2点执行。CronJob有几个特有的坑,我挨个说。
第一个坑是时区。K8s的CronJob默认使用UTC时间,不是本地时间。你写0 2 * * *,实际执行时间是UTC 2点,如果你在东八区,那就是北京时间上午10点。解决办法是在CronJob的spec里加timeZone: Asia/Shanghai(需要K8s 1.27+),或者自己换算成UTC时间。
第二个坑是并发策略。CronJob的concurrencyPolicy有三个值:Allow(允许并发)、Forbid(禁止并发)、Replace(替换)。默认是Allow,意思是如果上一次任务还没跑完,下一次触发时间到了,会再启动一个Job。如果你的任务跑得比较久,可能会同时跑好几个,把资源耗光。我一般会设成Forbid,保证同一时间只有一个Job在跑。
第三个坑是历史记录清理。CronJob会保留成功和失败的Job记录,由successfulJobsHistoryLimit和failedJobsHistoryLimit控制,默认分别是3和1。如果不清理,时间长了会积累大量已完成的Job对象,把etcd撑大。生产环境我一般设成successfulJobsHistoryLimit=3,failedJobsHistoryLimit=1,够排查问题就行。
6.3 Job失败后的排查思路
Job失败最常见的原因是容器退出码非0。排查第一步是kubectl logs <pod-name>看容器日志,大部分问题日志里都有线索。如果日志没输出,可能是容器启动就挂了,用kubectl describe pod看Events,通常是镜像问题或者命令写错了。
第二个常见原因是backoffLimit耗尽。Job重试了6次都失败,就会被标记为Failed。这时候你得先修复问题,然后删掉旧的Job重新创建。注意,Job失败后不会自动重试,你得手动处理。
第三个原因是activeDeadlineSeconds超时。任务跑太久超过了设定时间,Job会被强制终止。这个参数要设得合理,太短了任务跑不完,太长了失败任务会占用资源太久。我一般会根据历史执行时间设一个1.5倍左右的余量。
7. 控制器选型对照与生产环境经验
7.1 一张表看清各控制器的适用边界
| 控制器 | 适用场景 | Pod身份 | 存储 | 启动顺序 | 典型用例 |
|---|---|---|---|---|---|
| Deployment | 无状态应用 | 随机 | 无状态 | 无序 | Web服务、API网关 |
| ReplicaSet | 副本保障(被Deployment管理) | 随机 | 无状态 | 无序 | 一般不直接使用 |
| StatefulSet | 有状态应用 | 固定序号 | 独立PVC | 有序 | 数据库、消息队列 |
| DaemonSet | 节点级守护进程 | 每节点一个 | 通常无状态 | 无序 | 日志收集、监控Agent |
| Job | 一次性批处理 | 随机 | 通常无状态 | 无序 | 数据迁移、备份 |
| CronJob | 定时任务 | 随机 | 通常无状态 | 无序 | 定时报表、清理任务 |
这张表我建议刚入门的时候贴在显示器旁边,选型的时候对照着看,能避免很多“用错控制器”的问题。比如有人用Deployment跑MySQL,结果Pod重启后数据丢了,这就是典型的选型错误。
7.2 生产环境中的参数调优经验
副本数设置:Deployment的replicas不是越多越好。副本多了,滚动更新慢,资源占用高;副本少了,可用性没保障。我一般根据QPS和单Pod处理能力来算,留30%左右的余量。比如单Pod能扛100 QPS,业务峰值300 QPS,那就设4个副本。
资源请求与限制:requests影响调度,limits影响运行时。requests设得太小,节点会超卖,Pod之间抢资源;设得太大,资源利用率低。我的经验是requests按实际用量的70%设,limits按实际用量的150%设。这样既保证调度合理,又留了突发余量。
健康检查:livenessProbe和readinessProbe必须配,但参数要调好。livenessProbe的failureThreshold不要设太小,否则应用偶尔卡一下就被重启了。我一般设failureThreshold=3,periodSeconds=10,也就是连续30秒不健康才重启。readinessProbe可以设得敏感一点,failureThreshold=2,periodSeconds=5,快速摘除不健康的Pod。
更新策略:maxSurge和maxUnavailable要根据业务容忍度来。对可用性要求极高的业务,maxUnavailable=0,maxSurge=1,保证更新过程中可用Pod数不减少。对资源紧张的环境,maxSurge=0,maxUnavailable=1,避免更新时资源翻倍。
7.3 那些年我踩过的控制器相关的坑
坑一:标签选择器写错导致Pod无限创建。前面提过,ReplicaSet的selector和template.labels必须匹配。我见过有人selector写app: nginx,template.labels写app: nginx-v2,结果ReplicaSet疯狂创建Pod,几分钟就把集群资源耗光了。排查的时候看Events,会有大量“Created pod”的记录,但Pod列表里又看不到,因为创建出来的Pod不匹配selector,ReplicaSet认为它们不存在。
坑二:StatefulSet的PVC没清理导致存储泄漏。删了StatefulSet但没删PVC,PVC一直占着PV,新的StatefulSet创建时如果用了同样的volumeClaimTemplates,会报“PVC already exists”错误。解决办法是删StatefulSet之前先确认PVC是否需要保留,不需要的话手动删掉。
坑三:CronJob时区问题导致任务在错误时间执行。这个坑特别隐蔽,因为CronJob的YAML里写的是0 2 * * *,你以为凌晨2点执行,实际是UTC 2点。我建议所有CronJob都显式设置timeZone字段,避免歧义。
坑四:DaemonSet在Master节点上Pending。忘了加tolerations,DaemonSet的Pod在Master节点上一直Pending。这个坑排查起来很简单,kubectl describe pod看Events会提示“node(s) had taint”,加上对应的tolerations就好了。
坑五:Job的backoffLimit设得太大导致资源浪费。有个任务因为配置错误一直失败,backoffLimit设了100,结果Job重试了100次,每次创建Pod、拉镜像、启动容器、失败退出,浪费了大量时间和资源。后来我把backoffLimit设成3,失败3次就停,人工介入排查。
7.4 控制器层面的可观测性建设
光会配Controller还不够,生产环境得能观测到Controller的行为。我一般从三个层面做:
事件层面:kubectl get events能看到Controller的所有操作记录,比如“Scaled up replica set nginx-abc to 3”、“Created pod: nginx-abc-xyz”。这些事件默认保留1小时,排查短期问题够用。长期保留需要额外部署事件收集组件。
指标层面:kube-controller-manager暴露了大量Prometheus指标,比如workqueue_depth(工作队列深度)、workqueue_adds_total(入队总数)、reconcile_errors_total(调和错误数)。这些指标能反映Controller的健康状况。如果workqueue_depth持续增长,说明Controller处理不过来,可能需要扩容或者排查为什么调和这么慢。
日志层面:kube-controller-manager的日志级别可以调,-v=2能看到每次调和的详细信息,-v=4能看到API请求的细节。生产环境一般用-v=2,排查问题的时候临时调到-v=4。日志量比较大,建议配合日志收集系统使用。
我个人的经验是,Controller层面的问题,80%都能通过
kubectl describe和kubectl get events定位。剩下20%需要看kube-controller-manager的日志。所以遇到Controller行为异常,先别急着翻源码,把describe和events看仔细,大部分问题都有线索。
8. 从Controller看K8s的设计哲学
用了这么久K8s,我越来越觉得Controller机制是它最精妙的设计之一。它把“运维逻辑”从“基础设施”里抽离出来,变成了可编程、可扩展的API对象。你想要什么行为,就定义什么样的Controller,K8s负责帮你跑起来。这种可扩展性是K8s能成为事实标准的关键。
另一个让我佩服的点是最终一致性的设计。Controller不追求瞬间达成目标,它接受中间状态,允许失败重试,只要最终能收敛到期望状态就行。这种“慢就是快”的哲学,让K8s在面对各种异常时表现得特别稳健。节点挂了、网络抖了、组件重启了,Controller都会自动恢复,不需要人工干预。
当然,这套机制也不是银弹。Controller的调和循环有延迟,不是实时的;多个Controller之间的协作链路长,排查问题复杂;声明式API的学习曲线陡,新手容易懵。但瑕不掩瑜,理解了Controller的工作原理,你就掌握了K8s运维的核心能力。后面再遇到Pod异常、更新卡住、扩缩容不生效这些问题,你就能从Controller的角度去思考,而不是盲目地重启、删除、重建。
最后分享一个我个人的习惯:每次创建一个新的Controller资源之前,先问自己三个问题——这个应用是有状态的还是无状态的?需要稳定网络标识吗?需要独立存储吗?三个问题的答案基本就能确定该用哪个Controller。这个思考过程花不了几秒钟,但能避免很多选型错误。