聊到Kubernetes(下面统一叫K8s),绕不开的就是Pod控制器。很多人刚上手时,对Deployment、StatefulSet这些概念是似懂非懂的:网上模板复制一份能用,一旦副本数不对、滚动更新卡住、Pod老是在重启,就开始抓瞎。这篇不是入门科普,也不是照着官方文档念经,我尽量从“控制器到底在管什么”这个底层问题讲起,结合我自己在集群里踩过的坑,把Deployment、StatefulSet、DaemonSet、Job、CronJob这几类Pod控制器的原理、使用场景和实操要点一次说透。适合正在维护集群、或者准备把自己的工作负载迁进K8s的同学。
1. 先把“控制器”这个词说透
1.1 控制循环:期望状态和实际状态死磕
K8s核心的自愈能力,不来自某个玄乎的“智能调度”,而是来自一个很朴素的机制:控制循环。你可以把它理解成一台空调:你设定26度,这是期望状态;室内温度传感器读出来30度,这是实际状态;空调持续制冷,直到误差消失。K8s里的控制器就是这台空调,它的目标永远只有一个——让实际状态无限逼近期望状态。
这些控制器跑在kube-controller-manager这个组件里。它里面不是只有一个控制器,而是一堆控制器各管一摊:Deployment控制器管Deployment资源,ReplicaSet控制器管ReplicaSet资源,Namespace控制器管命名空间的清理,等等。它们的套路是一致的:通过API Server监听资源变化,对比资源的“期望状态”和集群里的“实际状态”,然后调用API去创建、删除或者修改Pod,让状态收敛。
我见过不少同学以为Pod重启、补副本是kubelet干的。其实kubelet只负责单个节点上的Pod生命周期,真正在背后盯着“副本数够不够”的,是控制器。比如你把一个Deployment的副本数从3改成5,Deployment控制器发现期望5个、实际3个,就会通过ReplicaSet控制器把Pod数量补到5。整个过程不需要你手动干预。
1.2 为什么不让Pod“裸奔”
有人可能会问:我自己用kubectl run或者直接创建一个Pod,不也能跑吗?为什么要套一层控制器?
因为直接创建的Pod,本质上是一份“一次性”资源。节点宕机、Pod因资源被驱逐、镜像启动失败,都不会有人帮你把它拉起来。在K8s的哲学里,Pod是“牲畜”,不是“宠物”。你可以随时换一头新的,只要行为和期望一致就行。控制器存在的意义,就是把“养一堆随时可能报废的Pod”这件事变得可控。
这也解释了为什么控制器里几乎都有一个Pod模板(template)。这个模板就是控制器用来生产新Pod的“图纸”。你改的是控制器里的模板,控制器负责按模板创建新Pod,并且保证数量、编排方式都符合预期。脱离了控制器去讨论Pod的稳定性,在K8s里基本是站不住脚的。
1.3 控制器和Pod之间靠标签选择器挂钩
控制器怎么知道哪些Pod是“自己人”?不是记住Pod名字,而是靠标签选择器(label selector)。Pod创建时带着一组标签,控制器通过selector匹配这些标签来圈定管辖范围。这种设计的好处是解耦:控制器不关心Pod名字,只关心标签是否符合。
但这也是一个巨坑来源。selector一旦创建,之后基本不能改。比如Deployment的selector指定了app: nginx,你升级时如果手滑把template里的标签改成了app: new-nginx,controller manager会直接报错,拒绝更新。因为Pod模板的标签必须能匹配selector,这是设计上的一条硬约束,防止控制器失去对Pod的追踪。
提示:如果你想改selector,没有平滑修改的办法。别想着原地改名,直接新建一个Deployment,再删掉旧的,这才是安全路径。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五类Pod控制器逐一拆解
K8s里长期运行的控制器主要是这五类:ReplicaSet、Deployment、StatefulSet、DaemonSet、Job/CronJob。每一类诞生的理由都不同,选型时先想清楚“我的应用需要什么”,再决定用谁。
| 控制器 | 核心能力 | 典型场景 |
|---|---|---|
| ReplicaSet | 固定副本数,Pod自愈 | 通常不直接使用,被Deployment托管 |
| Deployment | 无状态应用滚动发布与回滚 | Web服务、API服务、微服务 |
| StatefulSet | 稳定网络标识、稳定存储、有序扩缩容 | 数据库、消息队列、分布式中间件 |
| DaemonSet | 每个节点确保一个Pod | 日志采集、节点监控、网络插件 |
| Job / CronJob | 一次性任务、定时任务 | 数据迁移、批量计算、定期清理 |
2.1 ReplicaSet:藏在Deployment背后的“保底人”
ReplicaSet可能是最容易被忽略的控制器,因为你日常基本不会直接操作它。但Deployment的一切能力,最终都要落到ReplicaSet头上。
ReplicaSet的职责非常简单:保证指定数量的Pod一直在跑。它的核心字段就三个:replicas、selector、template。你写一个Deployment时,Deployment控制器会替你去创建一个ReplicaSet,再由ReplicaSet把Pod拉起。你可以运行kubectl get rs看看到底是谁在干活。
ReplicaSet有个小知识点:修改RS里的Pod模板,不会影响已有Pod,只会影响新创建的Pod。因为RS只看模板造新Pod,旧Pod没违反副本数,它就不会动。这也是为什么我们一般不直接编辑RS,而是通过Deployment去改——Deployment每次变更会生成一个新的ReplicaSet,然后逐步把旧RS的副本数降为0。RS在这里的价值,就成了“一个版本的快照”。
2.2 Deployment:大部分无状态应用的第一选择
Deployment是使用率最高的Pod控制器。它管理的对象是ReplicaSet,不是Pod本身。每次你更新Deployment里的镜像版本,Deployment都会创建一个新的ReplicaSet,代表新版本,然后把流量从旧RS一点一点迁到新RS。这种设计带来的好处是:你可以随时回滚到任意历史版本,因为旧RS还留着,虽然副本数是0。
一个标准的Deployment大概长这样:
yaml复制apiVersion: apps/v1
kind: Deployment
metadata:
name: web-demo
spec:
replicas: 3
selector:
matchLabels:
app: web-demo
template:
metadata:
labels:
app: web-demo
spec:
containers:
- name: web
image: registry.example.com/web:v1.2
ports:
- containerPort: 8080
这里有三个点值得注意:
replicas:期望运行的Pod数量。selector:选定它管理的Pod,上面说过不可变。template:Pod模板,里面必须有labels,并且labels必须覆盖selector。
Deployment还支持滚动更新策略(strategy)配置,这就是后面要重点讲的maxSurge和maxUnavailable。还有一个常被忽略的字段revisionHistoryLimit,默认10,代表保留多少个历史ReplicaSet。如果你们集群很在意etcd资源,可以把历史版本数调小;但回滚的余地也会变小。
2.3 StatefulSet:给有状态应用发“身份证”
StatefulSet是这些人里面最“较真”的一个。有状态应用需要稳定的标识、稳定的存储、顺序的启停,这几个需求,Deployment一个都满足不了。
StatefulSet环境下,Pod的名字不是随机后缀,而是有编号的,比如db-0、db-1、db-2。而且这个编号在Pod重建后会保持不变。它通过一个Headless Service来给每个Pod提供固定的网络身份。也就是说,db-1无论被调度到哪个节点、重启多少次,它的DNS名都是db-1.headless-svc.namespace.svc。这套机制对很多需要“认名字”的分布式系统是刚需,比如某个数据库集群要识别彼此的主从身份。
存储方面,StatefulSet使用volumeClaimTemplates,每次创建Pod时自动创建对应的PVC。缩容的时候注意一点:Pod会被删除,但PVC默认保留。换句话说,数据不会被顺手清理掉,算是故意设计的保护机制。所以用StatefulSet前要想清楚,PVC和底层存储的占用是持续存在的,删除StatefulSet不等于释放数据。
它的启停顺序也有讲究:扩容时按编号从0开始依次创建,缩容时按编号从大到小依次删除。这种“有顺序”的行为,对于初始化依赖(比如主节点必须先起来)非常关键。更新策略上,StatefulSet支持RollingUpdate和OnDelete。如果你不想让控制器自动滚动更新,可以设成OnDelete,这样只会在你手动删除Pod后,用新模板重建,很适合需要人工把关的场景。
2.4 DaemonSet:每个节点一个“常驻哨兵”
DaemonSet管的东西逻辑很简单:确保集群中每个(符合选择条件的)节点上都运行一个Pod。节点加入集群,它自动补一个;节点被移走,它自动清理掉。你不需要关心副本数,因为副本数等于符合条件的节点数。
最常见的应用场景是三类:节点日志采集、节点监控指标采集、集群网络插件。这类组件必须跟节点强绑定,不能因为调度策略被随机扔到某些节点就完事。
DaemonSet也支持滚动更新,默认maxUnavailable是1,也就是一次只更新一个节点。这个字段对节点级组件来说很实用,你可以避免所有节点同时重启导致集群失联。另外,DaemonSet不是完全“雨露均沾”的,如果你设置了nodeSelector、节点亲和性或者污点容忍,它会在匹配的子集节点上运行。
提示:给节点打上自定义标签,配合DaemonSet的nodeSelector,可以精确控制日志采集的范围。我常用这种方式来跳过某些高负载的生产节点,只采集应用节点的日志。
2.5 Job与CronJob:跑完就走的任务型控制器
前三类控制器都是为了“长时间运行的服务”,Job和CronJob则是为“跑完就结束”的工作设计的。Job负责一次性任务,CronJob负责按计划周期触发Job。
Job有一个容易被新手踩的坑:Pod的restartPolicy只能设置为Never或OnFailure,不能设为Always。因为Job里的Pod本来就是去执行一次任务的,跑完就该结束,如果设成Always,Pod每次失败都会重新拉起,Job永远不会进入完成状态。如果任务执行时间较长,建议给Job设置activeDeadlineSeconds,超过时间直接标记失败,避免任务卡死。
CronJob的原理就是用cron表达式定时创建Job。它有几个关键参数:concurrencyPolicy控制并发策略(Allow允许并发、Forbid禁止并发、Replace替换正在运行的Job);startingDeadlineSeconds表示如果到了计划时间却没成功创建Job,最多能拖多久。生产环境中我见过因为上一批Job没跑完,下一批又启动了,导致数据重复处理的情况。把concurrencyPolicy设为Forbid能直接避免这种事故。
还有一个容易忽视的时区问题。不少版本的CronJob默认按kube-controller-manager所在时区解释cron表达式,如果你的调度时间写的是“每天凌晨2点”,但组件跑在UTC时区,实际执行时间可能和你本地时间差8个小时。建议要么显式设置spec.timeZone字段(较新版本支持),要么在Job内部把时间逻辑处理成绝对时间,别和调度时区纠缠。
3. 更新策略与滚动发布实操要点
3.1 maxSurge和maxUnavailable怎么配
Deployment滚动更新里两个关键参数:maxSurge和maxUnavailable。很多人知道这两个词,但配起来比较随意,其实它们决定了发布的速度和可用性边界。
先看含义:
maxSurge:更新时允许超出期望副本数的最大Pod数量。可以是绝对值,也可以是百分比。maxUnavailable:更新时允许不可用的最大Pod数量。同样支持绝对值或百分比。
举个例子。我有一个Deployment,replicas: 10,配置如下:
yaml复制strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
百分比换算规则可以参考官方语义:maxSurge向上取整,maxUnavailable向下取整。这样的话:
maxSurge = ceil(10 × 0.25) = 3,代表发布过程中最多可以多出3个Pod,合计最多13个。maxUnavailable = floor(10 × 0.25) = 2,代表发布过程中允许最多2个Pod不可用,也就是说至少有8个Pod正常服务。
发布过程中,控制器会先创建一个新RS,并逐步扩容新RS、缩容旧RS,直到新RS副本数等于10,旧RS副本数归零。整个过程始终保证可用Pod数量不低于8,总Pod数量不超过13。如果你的服务对单次发布流量很敏感,可以设置maxSurge: 1, maxUnavailable: 0,这样可以做到逐个替换,发布期间旧Pod一直是正常的,代价就是发布速度会慢一些。
注意:
maxSurge和maxUnavailable不能同时为0。否则控制器没有操作空间,发布会一直卡住不动。这个错误很隐蔽,配置完Deployment后看似没报错,实际更新永远不推进。
3.2 回滚、暂停与恢复的正确姿势
Deployment最大的好处之一就是发布可回溯。你可以用rollout命令查看历史版本和当前状态。
bash复制kubectl rollout status deployment/web-demo
kubectl rollout history deployment/web-demo
kubectl rollout undo deployment/web-demo
rollout undo默认回滚到上一个版本,如果想回滚到指定版本,用--to-revision参数:
bash复制kubectl rollout undo deployment/web-demo --to-revision=2
回滚的本质,就是把旧RS从副本数0重新拉起来,同时把当前新RS缩到0。所以不存在“回滚失败”的玄学,你只要保证旧版本镜像仍然可拉取,回滚一般都能完成。常见问题反而是镜像仓库把旧tag覆盖了,导致回滚时拉取到新镜像。
rollout pause和rollout resume是我强烈建议你掌握的发布控制手段。线上发布时可以先执行:
bash复制kubectl rollout pause deployment/web-demo
然后分批更新一部分Pod,观察监控和日志,确认没问题后再执行resume,让剩下的发布继续走。这种做法能在金丝雀发布和全量发布之间做一个很轻量的过渡,不需要额外引入复杂的发布系统。我自己的习惯是:大版本变更、数据库结构升级这类高风险发布,基本都会先pause,看个30分钟再放量。
3.3 selector与标签的“契约”问题
控制器和Pod的绑定关系基于selector,所以对selector的操作要格外谨慎。
第一,不要轻易修改selector本身。之前说过,Deployment的selector不可变。如果你创建后发现selector不合适,最省事的办法是删掉重建。有人试图用kubectl edit硬改,结果controller manager直接拒绝。这不是报错信息不清晰的问题,是设计上不允许。
第二,template里的labels必须覆盖selector的key-value。比如selector里有app: web-demo,template里就必须有这个标签。模板里可以额外加别的标签,比如version: v2,但绝对不能缺少selector中定义的标签。
第三,一个Pod不要同时被多个控制器选中。如果你手动创建了一个带app: web-demo标签的Pod,而集群里已经有一个Deployment用同样的selector,那么这个控制器会认为这个Pod也是自己该管的,可能会出现意外的副本数变化。生产环境里我习惯给selector加一个专属的前缀标签,比如app.kubernetes.io/name,避免跟手动创建的Pod互相干扰。
提示:建议把标签的设计当成API来管理,新部署的服务都遵循同一套标签规范,长期维护会省很多事。
4. 常见故障排查实录与速查表
4.1 Pod一直Pending是怎么回事
Pod一直Pending,第一步先执行kubectl describe pod <pod-name>,重点看Events区域。Pending的本质是调度器找不到合适的节点。
常见原因:
- 容器请求的资源过大。比如requests里写了CPU 4核、内存8Gi,但所有节点的可分配资源都没这么大,调度器就找不到位置。
- nodeSelector或节点亲和性没有匹配的节点。比如给Deployment配了
disktype=ssd,但没有一个节点带这个标签。 - 节点上有污点,而Pod没有对应的容忍。
- PVC无法绑定。某个StatefulSet或Deployment挂载了PVC,但PVC一直处于Pending状态,Pod自然起不来。
排查时不要光看Pod状态,要结合kubectl get nodes看节点资源分配情况,再用kubectl get pvc确认存储是否就绪。有一次我在一个测试环境里发现所有Pod都Pending,describe了半天发现是某个PV的容量比PVC申请的还要小,导致PVC绑定失败,这种问题看Events一眼就能定位。
4.2 滚动更新卡在Waiting
Deployment更新之后,kubectl rollout status一直不结束,或者显示Waiting for deployment rollout to finish。这种“卡住”大多数不是系统故障,而是新Pod一直没进入Ready状态。
通常的检查路径是:
- 看新RS的副本状态:
kubectl get rs -l app=web-demo。如果新RS期望副本是3,但可用副本是0,说明新Pod没起来或没Ready。 - 看新Pod的情况:
kubectl get pods,观察是否有CrashLoopBackOff、ImagePullBackOff。 - 看新Pod事件:
kubectl describe pod <new-pod>。
一个很常见的原因是:新版本容器的readinessProbe配置不当,探针一连不上,Pod一直不Ready。这时控制器按照maxUnavailable的规则,不会贸然删除旧Pod,于是发布就卡住了。我处理过最典型的一例,是服务升级后暴露了一个新的内置端口,但探针还在探测老的端口路径,结果新Pod永远不Ready,旧Pod也不缩容。这种问题的解决思路就是临时回滚,再检查探针路径。
4.3 CrashLoopBackOff与OOMKilled
CrashLoopBackOff是一个状态,也是一类问题的统称。它表示Pod不断重启,而且每次重启后没过多久又崩了。原因五花八门,但排查顺序有套路:
- 先看
kubectl logs <pod-name> --previous,拿上一次退出的日志。因为当前容器已经崩了,直接logs只能看到最近一次的输出,--previous能看到上一轮的日志,往往关键报错在里面。 - 再看
kubectl describe pod <pod-name>里的Exit Code。退出码很有辨识度:137通常代表被系统杀,一般是被OOMKilled;1或255则是应用自身崩溃;128+信号数可能与crash相关。 - 如果是OOM,检查Pod的limits和实际内存用量,适当调大limits,或者从代码层排查内存泄漏。有一个我踩过很多次的点:JVM类应用在容器里如果不设
-XX:MaxRAMPercentage,容易申请比容器limit还大的堆内存,进程直接被内核干掉,表现就是莫名其妙137。
4.4 排查问题速查表
下面是这些年我排查Pod控制器问题时,最常用的一张速查表,省去重复翻文档的时间。
| 现象 | 常见原因 | 排查命令 | 处理建议 |
|---|---|---|---|
| Pod一直Pending | 资源不足、PVC未绑定、节点不匹配 | kubectl describe pod |
看Events,缩需求或绑存储 |
| ImagePullBackOff | 镜像不存在、仓库认证失败、tag写错 | kubectl describe pod |
检查镜像名和拉取凭证 |
| CrashLoopBackOff | 启动命令错误、配置缺失、启动即崩 | kubectl logs --previous |
分析退出码和启动日志 |
| OOMKilled | 内存超过limits | kubectl describe pod |
调大limits或优化内存占用 |
| 更新卡住 | 探针不Ready、maxSurge/maxUnavailable配错 | kubectl rollout status |
检查新RS和新Pod |
| 副本数一直不对 | 控制器冲突、资源争抢 | kubectl get rs -A |
检查selector是否重叠 |
| Pod被频繁调度 | 缺少反亲和性、节点压力不均衡 | kubectl get pods -o wide |
配置podAntiAffinity |
还有一个小技巧:排查时不要只看Pod,养成看事件的习惯。kubectl get events --sort-by='.lastTimestamp'能按时间顺序把集群最近发生的关键事件列出来,很多时候比describe更直观。尤其当你同时操作多个控制器时,Events会把“谁在什么时候创建了什么、删除了什么、失败原因是什么”完整串起来。
控制器选型这件事,我个人体会是不要贪多求全。能用Deployment解决的,不要硬上StatefulSet,后者的PVC和有序机制会带来额外的运维成本;确实需要稳定网络标识或者每个节点一个守护进程的,再考虑StatefulSet和DaemonSet;Job类任务一定要想清楚并发策略和失败重试次数,否则定时任务一旦堆积,数据会出现重复处理。
最后再分享一个我自己的习惯:每个应用不管用哪种控制器,我都会在metadata里补一个owner的annotation,比如owner: platform-team。集群大了以后,Deployment、StatefulSet混在一起,没有owner信息,根本不知道哪个服务是哪个团队负责的,出问题找不到对接人。K8s本身提供了丰富的标签机制,把这类“人肉信息”也纳入其中,长期来看是值得的。
