1. 控制器为什么存在:Pod的宿命与K8s的“恒温器”逻辑
我刚学K8s那会儿,一直有个困惑:Pod明明是调度的最小单位,怎么我身边的同事张口闭口都是Deployment、StatefulSet这些词?后来才慢慢明白,Pod本身是有“宿命”的——它是注定要被替换、被重建的临时资源,真正保证服务不挂的,是站在Pod背后那一层控制器。如果你也想搞清楚K8s里这些控制器到底在干什么、什么时候用哪个,这篇文章就是按我自己的理解路径来写的。
1.1 Pod的“临时性”决定了控制器必然存在
K8s里的Pod是一个逻辑概念,它代表一组容器的集合,共享网络命名空间和存储卷。但Pod本身没有任何“自愈”能力。一个Pod挂了,它就是挂了;节点宕机了,上面的Pod也就跟着没了,K8s不会自动帮你拉起一个新的。如果直接拿裸Pod跑生产,那基本等于用手捧着水——漏了只能再捧一次。
控制器的存在,本质上就是为了解决“Pod的临时性”和“服务的持久性”之间的矛盾。控制器做的事情说起来很简单:你告诉它期望的状态,它负责让集群里的实际状态无限逼近这个期望。比如说期望3个副本,当前只有2个,它就补一个;当前有4个,它就删一个。这个“期望-实际”的差值被叫做误差,控制器协调整合的这个过程,就是K8s整个控制面的核心逻辑。
我自己的理解是,Pod是“打工的”,控制器才是“老板”。老板定编制、定要求,干活的人不够就招,不称职就换,老板自己不会亲自写代码、跑业务,但老板保证业务有人干。
1.2 控制循环:K8s的那台“恒温空调”
K8s里控制器的底层机制,官方叫Reconcile Loop,也就是调谐循环。它跟家用空调的恒温器原理几乎一模一样。你设定26度,空调检测到室温28度,就启动制冷;降到26度,就停机。控制器也是一样的逻辑:你声明想要的副本数,控制器不断观察当前状态,发现偏差就执行动作,直到状态收敛。
这里有个细节值得展开说。控制循环不是“事件驱动”的,它是“周期驱动+事件加速”的。也就是说,即使集群里什么都没发生,controller-manager里的各个控制器也会定期去etcd里刷一遍状态,看看有没有偏差。这种设计的好处是:就算某次事件丢失了,或者某个通知没送达,下一轮周期扫描仍然能把状态纠正回来,可靠性比纯事件驱动高很多。
控制器内部通过Informer机制监听Pod的变化。Informer会跟kube-apiserver建立长连接,用ListAndWatch的方式先拉全量数据,再持续接收增量更新。这个设计非常巧妙:本地维护一份对象缓存,读操作完全不压apiserver,只有写操作才走API。这也是为什么一个大集群里成千上万个Pod在频繁变动,apiserver还能扛得住的原因之一。
1.3 控制器怎么知道Pod是“自己人”:标签选择器与属主引用
控制器管Pod不是靠名字,而是靠两个重要机制:标签选择器(Label Selector)和属主引用(OwnerReference)。
标签选择器好理解,就像给文件打标签,控制器通过selector筛选出它该管的Pod。但光有标签选择器是不够的——如果两个Deployment用了相同的标签,那就会出现“抢孩子”的问题。所以K8s又引入了OwnerReference,每个被控制器创建的Pod,都会记录一个ownerRef,指向它的上级控制器。这就像户口本上写了监护人,就算标签再相似,Pod也只认自己的“监护人”。
这个双保险机制实际帮了大忙。比如你手动创建一个带相同标签的Pod,它不会被Deployment纳管,因为它的ownerRef是空的。但是如果你把Deployment的副本数从3改成2,而集群里恰好有一个手动创建的Pod也匹配了selector,那这个“野Pod”可能会被缩容杀掉。这种场景我见过不止一次,后面排查部分会说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Pod控制器逐个拆解:Deployment、StatefulSet、DaemonSet与Job们的分工
控制器不是一个,是一族。不同控制器解决不同场景的问题,选错控制器,后面运维起来会特别难受。我按实际生产中的使用频率和场景,一个个过一遍。
2.1 Deployment:无状态应用的标准答案
Deployment只要服务于无状态应用,也就是随便哪个副本都能处理请求、不持有本地数据、可以随意扩缩容的服务。它在K8s里地位极高,几乎所有Web类服务、API服务、微服务,都用它来部署。
Deployment本身不管Pod,它管的是ReplicaSet。每次你更新Deployment的镜像版本,它会创建一个新的ReplicaSet,然后逐步把旧ReplicaSet的副本缩到0,把新ReplicaSet的副本扩到预期值。这个过程叫滚动更新,默认策略是RollingUpdate。
滚动更新有两个关键参数:maxSurge和maxUnavailable。这两个参数控制更新过程中Pod的数量变化速度,也是最多人踩坑的地方。默认值都是25%,意思是:更新时最多可以多出25%的Pod(maxSurge),同时最多允许少于期望值25%的Pod不可用(maxUnavailable)。
举一个实际的例子。假设你有4个副本,期望值是4。默认参数下,更新的过程大概是这样的:先额外起1个新Pod,此时总共5个Pod(4旧+1新,maxSurge上限是4*25%=1);然后缩掉1个旧Pod,变成3旧+1新;再起1个新Pod,变成3旧+2新;再缩1个旧……以此类推,直到全部替换。整个过程服务容量始终维持在接近期望值的水平,不会出现服务能力断崖式下跌。
如果你的服务对“滚动过程中新旧版本同时存在”这件事敏感,比如数据库迁移不兼容,那就需要把更新策略改成Recreate。这个策略会先把所有旧Pod删掉,再一次性创建新Pod。代价是更新期间服务短暂不可用,好处是绝不会出现新旧并存的状态。
2.2 StatefulSet:有状态应用的“身份证”
有状态应用是另一类麻烦。数据库、消息队列、缓存集群、分布式协调服务,这些都需要稳定的网络标识、稳定的存储、有序的启停。这些需求Deployment满足不了,得靠StatefulSet。
StatefulSet最核心的东西有三个:稳定的网络标识、稳定的持久化存储、有序的部署和缩容。
稳定网络标识这点特别关键。StatefulSet里每个Pod的名字是固定的,而且有规律——比如叫web-0、web-1、web-2。不管Pod怎么被重建,名字不变,对应的DNS记录也不变。这就意味着web-1这个地址,始终指向“第二个副本”,哪怕它被删掉重建过、被调度到了另一台节点上。
这一点对很多分布式系统来说是生死攸关的。比如一个三节点的etcd集群,每个节点有一个固定的身份和地址,其他节点靠这个地址互相通信。如果地址随Pod重建而变,那集群的成员关系就乱了,数据同步就断了。
稳定存储则通过PersistentVolumeClaim模板来实现。StatefulSet会给每个副本绑定一份独立的PVC,而且这个PVC跟Pod绑定。Pod删了重建,PVC还在,数据就还在,新Pod启动时会重新挂载同一个卷。
有序部署和缩容,指的就是StatefulSet的“串行”特性。创建时web-0先启动,等它进入Running且Ready之后,web-1才开始创建;缩容时反过来,先删web-2,再删web-1,最后删web-0。这种严格顺序在部署数据库集群时非常有用——因为很多数据库集群要求第一个节点先初始化好,后续节点才能加入。
StatefulSet还有一个配套的诡异特性:缩容时Pod会被删除,但PVC不会删。也就是说,如果你把5个副本缩到3个,那第4、第5个副本的PVC还在集群里占着存储资源。这个设计是为了防止误删数据,但如果你不知道这一点,很容易发现存储越用越多,账单越来越贵。
2.3 DaemonSet:每个节点一份的“守护者”
DaemonSet解决的是“每个节点都需要跑一个副本”的问题。最典型的场景是:日志采集器、监控指标采集器、网络插件(比如CNI组件)、存储插件。
它的调度逻辑很特别:默认情况下,集群里每个节点都会跑一个Pod,不需要指定副本数,因为副本数由节点数决定。而且你新加一台节点,DaemonSet自动在那台节点上创建Pod;节点下线,Pod也随之清理。
DaemonSet的Pod不需要调度器来做选择,因为它的逻辑是“我这个Pod就是要在这台节点上跑”,而不是“哪个节点有空就放哪”。如果你熟悉K8s的调度机制,就会知道DaemonSet的Pod创建时其实也走调度流程,只是因为它的nodeSelector或者节点亲和性约束了目标节点,调度结果通常是唯一的。
DaemonSet有个更新策略值得注意:OnDelete和RollingUpdate。如果选OnDelete,你更新DaemonSet的镜像后,已有的Pod不会动,只有你手动删掉某个Pod,它重建时才会用新配置。如果你希望整个集群的采集器统一升级,那就选RollingUpdate,它会逐节点滚动替换。实际生产里,我见过不少人把日志采集DaemonSet配成OnDelete,结果升级了镜像,节点上的Pod还是旧版,排查了半天才发现是更新策略的问题。
2.4 Job与CronJob:跑完就走的“一次性任务”和“定时任务”
Pod和Deployment这类控制器面向的是“长期运行”的服务,而Job和CronJob面向的是“跑完就结束”的任务。
Job做的事情是保证“一批Pod成功执行完毕”。比如你要做一次数据迁移、批量处理一批图片、跑一次离线计算,就可以用Job。它的核心逻辑是:创建指定数量的Pod,等待它们成功退出,全部成功则Job完成。
Job有几个容易让人糊涂的参数。一个是completions,期望成功的Pod数量;一个是parallelism,允许并行执行的Pod数量。如果completions是10、parallelism是3,那就表示这10个任务最多同时跑3个,跑完一个再补一个,直到全部完成。还有一个参数是backoffLimit,失败重试次数。默认是6,超过这个次数Job就标记为失败。
CronJob就是带时间表的Job,格式跟Linux的crontab一样。它有几个细节需要注意时区:K8s 1.22之前CronJob的时区只能由kube-controller-manager的启动参数控制,1.22之后可以在CronJob的spec里直接指定timeZone字段。如果你创建了一个每天凌晨2点执行的CronJob,但集群默认时区是UTC,你会发现它北京时间上午10点才跑,这个坑踩的人非常多。
2.5 ReplicaSet:Deployment背后的“执行者”
单独讲一下ReplicaSet,因为很多人搞不清它和Deployment的关系。ReplicaSet保证指定数量的Pod副本在运行,它本身就是一个控制器。但Deployment在ReplicaSet之上又做了一层封装,加入了版本管理、滚动更新、一键回滚这些高级能力。
所以你通常不直接创建ReplicaSet,而是通过Deployment来间接管理。但你用kubectl get rs的时候,会看到有一堆ReplicaSet,它们的名字后面带一串随机字符串,那其实是Deployment每次更新时生成的“版本快照”。这也是Deployment能回滚的原因——它保留了历史ReplicaSet,回滚就是把当前副本数缩到0、把目标版本ReplicaSet扩起来。
这里有一个隐藏的坑:如果你手动把Deployment的ReplicaSet删了,之后Deployment的滚动更新历史就断了,再想回滚到某个旧版本就做不到了。就像你把Git的历史提交删了,想回到某个旧commit就很麻烦。
我把几种控制器的适应场景整理了一张表,方便对照:
| 控制器类型 | 适用场景 | 典型参考模式 | 失败时默认行为 | 是否保留本地数据 |
|---|---|---|---|---|
| ReplicaSet | 无状态Pod副本数管理 | 底层基础组件 | 重建挂掉的Pod | 否 |
| Deployment | 无状态应用、滚动发布、回滚 | Web服务、API服务 | 滚动替换,自动回滚手动触发 | 否 |
| StatefulSet | 有状态应用、固定身份、固定存储 | 数据库、消息队列、etcd | 按序重建,保留PVC | 是 |
| DaemonSet | 每节点必跑一个 | 日志采集、监控Agent、CNI | 新节点自动补Pod | 否 |
| Job | 一次性任务、批量处理 | 数据迁移、离线计算 | 按backoffLimit重试 | 否 |
| CronJob | 定时任务、周期任务 | 备份、定时清理、报表生成 | 错过调度则按策略处理 | 否 |
3. 控制器背后的工作引擎:controller-manager、Informer与标签体系
搞清楚控制器家族的分工之后,还得往深一层看:这些控制器本身是怎么工作、怎么协同、怎么保证状态收敛的。不然出了诡异问题,你连排查方向都没有。
3.1 controller-manager:所有控制器的“大脑”
K8s控制面里有个组件叫kube-controller-manager,它不是一个控制器,而是很多控制器的合集。Deployment Controller、ReplicaSet Controller、StatefulSet Controller、DaemonSet Controller、Job Controller、Namespace Controller、ServiceAccount Controller等等,全都跑在这个进程里,通过不同的并发线程来工作。
它是怎么知道该干什么的?答案是通过apiserver。所有控制器都会向apiserver注册自己的监听,比如ReplicaSet Controller关注ReplicaSet和Pod的变动,Deployment Controller关注Deployment、ReplicaSet、Pod的变动。响应的动作也要通过apiserver下发,比如创建Pod、删除Pod、更新Status,都是调用API完成。
所以你可以把controller-manager理解成整个集群的“总调度大脑”,它本身是无状态的,不存数据,数据全在etcd里。也正因如此,K8s支持多个controller-manager副本同时运行,同一时间只有一个leader在工作,另一个standby待命,通过lease机制选主,这样即使主节点挂了,备节点能很快接管,不会让控制器停摆。
3.2 Informer机制:为什么大规模集群里apiserver没被压垮
前面提到过Informer,这个地方值得展开。因为一个控制器要监听一类对象,如果每次变动都全量重新拉取,高频率变动时apiserver会直接被打爆。Informer通过local cache解决了这个问题。
具体流程是这样的:Informer启动时,先向apiserver发一个List请求,拿回所有目标对象的全量数据,存在本地缓存里。然后通过Watch接口建立一个长连接,持续监听增量事件:Added、Modified、Deleted。收到事件后,先更新本地缓存,再通过回调函数通知对应的控制器逻辑。控制器读数据时,直接从本地缓存读,完全不经过网络。
这里有个重要的时延知识点:从Pod实际发生变化,到控制器从本地缓存里感知到这个变化,中间有短暂的延迟。K8s的Informer默认有个resync周期,即使没有事件,也会周期性重新同步全量数据。这个设计是为了防止长时间运行后缓存跟实际状态偏差过大,也顺带兜底了Watch断连的场景。
我自己理解Informer机制时,总拿“快递驿站”来类比:apiserver是快递总公司,每个控制器是驿站,Informer是驿站的仓库管理员。管理员不是每来一个快递就跑去总公司拿一次,而是先跟总公司约定好——你这边有什么新货,打包丢给我就行,我自己定期盘点一遍库存。
3.3 标签选择器和字段选择器:控制器眼里的“亲疏关系”
控制器选Pod靠标签选择器,这个前面说过。但实际生产中有个容易忽略的点:标签不止用于控制器选择Pod,还广泛用于节点亲和、Pod间的亲和反亲和、网络策略、存储卷匹配。
在创建Deployment时,你会在spec.selector里定义选择器,同时建议在Pod模板的labels里带上同样的标签。这两个不是一回事。selector是控制器用来认领Pod的,labels是Pod自己的“名片”。如果你改了Pod模板的labels但没有同步改selector,那控制器会怎么表现?答案是:新Pod无法被控制器认领,控制器会直接报错,提示selector与pod template labels不匹配,Deployment无法创建任何Pod。
在实际项目里,我一直建议团队养成一个习惯:标签尽量加全,除了app这个必填项之外,至少再带一个version或者tier之类的区分维度。不然你微服务多了之后,光靠kubectl get pods看那些随机后缀,根本不知道哪个是哪个。标签是K8s里的索引系统,索引建好了,后面怎么做筛选都很顺。
3.4 从Deployment到Pod的完整层叠关系
我再画一条逻辑链来说明Deployment和它底下所有资源的关系,这个链条理解了,整个K8s的部署模型也就通了一大半。
你执行kubectl apply -f deployment.yaml后,发生的事是这样的:
- Deployment对象创建到了etcd里,apiserver返回成功。
- Deployment Controller监听到新建事件,读期望状态,发现没有对应的ReplicaSet,于是创建了一个新的ReplicaSet。
- ReplicaSet Controller监听到新建事件,算一下期望副本数和当前Pod数,发现差值是3,于是创建3个Pod。
- 调度器Scheduler监听到新建Pod事件,为每个Pod分配节点。
- 每台节点上的kubelet监听到Pod被调度到自己节点上,拉取镜像、启动容器。
- Pod运行起来后,kubelet通过status上报Pod状态,陆续变为Running和Ready。
- ReplicaSet Controller收到Pod Ready的事件后,更新ReplicaSet的status;Deployment Controller再把这些信息汇总,更新Deployment的status。
这个链条是严格分层的,每一层只管自己那一层的事。也正因如此,排查问题的时候才能自顶向下拆解:Deployment没反应看ReplicaSet,ReplicaSet没动作看Pod,Pod起不来再看kubelet和容器运行时。
4. 控制器实战避坑:那些YAML写对了也会踩的坑
前面讲了原理和机制,这一节说点实际踩坑的干货。Controller的问题是“看起来没事,跑起来出事”的重灾区,我把这几年遇到过的代表性坑整理出来,每个都附带排查思路。
4.1 Deployment不更新:老ReplicaSet残余导致“假死”
有一次我改了Deployment的镜像版本,kubectl apply执行成功,但kubectl get pods一看,Pod还是旧的镜像,而且没有任何滚动更新的迹象。这就是Deployment controller“假死”的一种情况。
排查步骤是这样的:先看replicasets,如果旧ReplicaSet的副本数没有变化,新ReplicaSet也没创建,说明Deployment Controller根本没感知到这次变更。最常见的原因是一个资源对象的metadata里有个字段叫generation,每次更新都会递增。如果控制器没有正确处理newReplicaSet,往往是因为selector和template labels不匹配,或者你改了template里某些不被支持的字段。
另外有一个坑容易迷惑人:如果你改了Pod template里的annotations,但这个annotations不影响Pod的实际运行,那Deployment也会触发滚动更新,因为controller判断“是否需要新ReplicaSet”的依据是template的hash是否变化,而不是业务上有没有效变化。结果就是集群里多了一堆历史ReplicaSet,看着很乱,实际没坏。清理手段是手动删除旧ReplicaSet,但要注意:删掉了就回滚不到那个版本了。
4.2 滚动更新被卡住:maxUnavailable设成0的后果
很多人为了保证服务高可用,会把maxUnavailable设成0,意思是一个Pod都不能少。这个愿望是好的,但有个前提:maxSurge要足够大,否则新Pod根本起不来。
举一个具体场景:你有3个副本,设maxUnavailable=0、maxSurge=1。滚动更新时,控制器先扩容1个新Pod,等新PodReady之后,再缩掉1个旧Pod。这看起来没问题。但是,如果你的集群资源很紧张,节点上没有空闲内存、CPU或端口,那这1个新Pod可能会一直Pending。新Pod起不来,旧Pod也删不掉,整个滚动更新就永远卡在那。
排查这个情况,先看Pod有没有Pending,再看Describe里的Events提示什么资源不足。还有一种更隐蔽的情况:你的服务本身没有配置readinessProbe,或者readinessProbe一直不过,那K8s会认为新Pod不Ready,滚动更新照样不推进。
我给团队定的实际操作规范是:如果服务对并发敏感、同时不能降副本,那就配好PDB(PodDisruptionBudget)并且maxSurge给足(比如50%),maxUnavailable一般不用设0,设一个较小的值(10%或20%)更合理。因为maxUnavailable设0在绝大多数场景下并不必要,反而拖慢了发布的节奏。
4.3 StatefulSet缩容后PVC不删:存储成本的无底洞
这个坑在2.2节提过,但值得单独拿出来说,因为它在生产环境里的影响非常大。StatefulSet缩容时,Pod被删了,但PVC和PV都还在。如果你没有配自动清理策略,这些存储卷会一直占着物理存储空间。
我见过一个真实案例:某公司跑了一个12副本的数据库集群,后来业务量下降,缩到3副本。结果几个月后运维发现存储成本没有下降,查了半天才发现缩容后那9份PVC还在集群里,每个都占用了几百GB的云盘空间,白白扣钱。
处理方式是两个方向:一是在缩容前手动删除对应的PVC;二是用storageclass的reclaimPolicy,如果配置成Delete,那PV在PVC删除后会被回收。但StatefulSet本身不会因为缩容而自动删除PVC,这是设计如此——为了安全。所以方案只能是“人肉盯”或者写个小工具定时检查。
4.4 DaemonSet不被调度:污点和容忍度的纠缠
DaemonSet默认在所有节点上创建Pod,但这个“默认”有个前提:节点上没有影响调度的污点,或者DaemonSet的Pod模板里配了对应的容忍度。
有一次我们给新节点加了一个“专门跑数据分析”的污点,结果发现监控的DaemonSet死活不上那台节点,新节点都上线两天了,监控数据还是断的。查下来原因就是:节点上有污点,DaemonSet的Pod模板里没有对应的tolerations,调度器认为这个节点“不欢迎”这类Pod。
处理方式是给DaemonSet加tolerations。生产环境的规律是:如果你想保证“每个节点必须有监控”,建议在DaemonSet的Pod模板里同时配好通用的toleration,容忍各类节点污点,甚至在必要时指定hostNetwork,让它不依赖节点网络插件的状态。网络类的DaemonSet尤其要注意这个,因为如果网络的Pod没起来,普通Pod根本没法调度,但hostNetwork可以绕过这个问题。
4.5 Job卡在Active状态:Pod成功退出但Job不完成
Job如果卡住不完成,大多数情况是Pod虽然跑完了,但容器的退出码不是0;或者容器的主进程退出之后,Sidecar容器还在跑,导致Pod状态不是Completed而是Running。
第一种情况好排查,直接kubectl logs看报错,改代码或者改命令。第二种情况比较隐蔽,常见于你在Pod里塞了一个sidecar容器,比如一个日志采集、一个网络代理。Job的完成条件要求Pod内所有容器都退出且主容器退出码为0。如果sidecar一直在那挂着,Pod永远不会进入Succeeded状态,Job就永远显示Active。
解法一般有两种:一是给sidecar加上“跟随主容器退出”的机制,比如通过共享进程命名空间,主容器退出后kill掉sidecar;二是用K8s 1.28之后才有的Pod容器退出策略,为每个容器单独指定退出后的行为。但你如果是老集群,就得用前一种方案,或者干脆让Job里的Pod不带sidecar,把采集逻辑合并到主容器里。
4.6 排查思路的速查表
最后整理一个我自己常用的排查速查表,遇到控制器相关的问题,按这个顺序来一般不会跑偏:
| 现象 | 先看什么 | 再看什么 | 常见归因 |
|---|---|---|---|
| Deployment没创建Pod | kubectl get deployment、rs | kubectl describe deployment | selector与labels不匹配 |
| 滚动更新停滞 | kubectl get rs、pods | kubectl describe pod | maxUnavailable=0 + 资源不足;readinessProbe不过 |
| Pod一直Pending | kubectl get pods | kubectl describe pod | 节点资源不足、调度约束冲突 |
| Pod反复重启 | kubectl get pods | kubectl logs -p 前一容器日志 | 启动命令错、镜像版本错、配置缺失 |
| StatefulSet副本起动缓慢 | kubectl get pods -w | kubectl describe pod | 有序部署导致先起副本故障,阻塞后续 |
| DaemonSet漏节点 | kubectl get ds -o yaml | kubectl describe ds | 节点污点未容忍、标签选择器不匹配 |
| Job不结束 | kubectl get pods | kubectl logs | 退出码非0、sidecar未退出 |
| CronJob没触发 | 看CronJob的schedule和timeZone | kubectl get jobs | 时区问题、被上次job卡住、并发策略限制 |
我自己在实际操作中的体会是,控制器的排查最忌“东看一眼西看一眼”,一定要沿着对象的关系链,从上层控制器往下逐层看,每层都确认“期望是否等于实际”,这样问题定位就快得多。
最后再分享一个小技巧:日常维护时,建议把kubectl get events --sort-by=.lastTimestamp这个命令用起来,很多控制器状态卡住的问题,在events里都会有明确的原因提示,比翻YAML、比对status字段效率高得多。控制器的逻辑本身并不复杂,复杂的是集群环境里的各种隐性约束。把期望状态、实际状态、偏差原因这三个概念刻在脑子里,K8s控制面的问题你基本就掌握了一大半。
