1. 先搞清楚:Pod为什么非要套一层“壳”,不能直接跑容器
网上教程讲到Kubernetes(K8s)时,几乎都会给你配一张图:最小的调度单位是Pod,不是容器。我第一次学的时候心里是犯嘀咕的——既然Docker容器已经能把应用打包跑起来了,为什么K8s还要在外层再包一层Pod?多包一层不就多一层复杂度吗?
这个疑问一直到我第一次在真实环境里同时跑“主业务容器 + 日志采集容器”才彻底解开。容器本身是隔离的,每个容器有自己的网络命名空间、文件系统、进程视图,这种隔离对单容器应用来说是好事,但对“一个应用 + 辅助组件”的协作模型来说就是灾难。你总不能要求业务容器把日志写到共享卷里,再由另一个容器定时去读,然后两者的生命周期还得分开管理。Pod解决的问题,就是把这些强绑定的容器“捆”在一起,共享网络栈、共享存储卷、共享生命周期,像一个合租屋里的室友:门牌号是同一个(IP),冰箱是共用的(Volume),开party一起开,散伙一起走。
1.1 共享网络和存储意味着什么
当你走进一个Pod,会发现里面所有容器共享同一个IP和同一个端口空间。这意味着两个容器完全可以只用localhost互相访问,不用关心什么Service、DNS。最常见的场景就是日志采集器(比如filebeat、fluent-bit)作为sidecar容器,跟在业务容器旁边,直接读本地文件或者本地socket,把日志转发到ES或Kafka。业务容器不需要知道日志怎么采集、怎么转发,它只要把日志写进共享目录就行。
共享Volume也是同样的道理。Pod里的每一个容器都可以挂载同一个PVC,生产环境里最常见的组合是“主容器写日志到emptyDir,sidecar容器把日志压缩上传到对象存储”。一旦Pod被重新调度,两个容器会一起被杀死、一起被重建,谁也不会留下孤儿进程。
另外需要注意:Pod里的容器是同时启动的吗?答案不是。K8s会先启动initContainers(初始化容器),等它们全部成功退出后再启动业务容器。这个机制常被用来做“前置依赖检查”或“预置数据迁移”。如果你有一个应用需要等数据库表结构就绪才能启动,把它放在initContainer里跑迁移脚本,比在业务容器里sleep硬等要优雅得多。
1.2 从Pod生命周期理解“临时性”
Pod从创建到销毁会经历几个阶段:Pending(等待调度)、Running(已运行)、Succeeded(正常退出)、Failed(异常退出)、Unknown(状态不可知)。很多人一开始记不住这些状态,我建议你用“它是不是一次性的”这个维度去理解:
- 如果是常驻服务,Pod退出后必须有人把它重新拉起来;
- 如果是任务型Pod,正常跑完就Succeeded,不需要再拉起来;
- 如果是定时任务,每次跑完都会新建一个Pod,旧的Pod保留一段时间方便看日志。
Pod本身是临时且可替换的。节点宕机、资源不足被驱逐、手动删除,都会导致Pod消失。如果你只学会了kubectl run手动创建Pod,那你就得手动处理无数种“Pod突然没了”的情况。这显然不是规模化运维的方式。所以说,控制器的出现不是锦上添花,而是K8s能够自我修复的根基。
2. 控制器的底层逻辑:从PID反馈回路到“期望状态调和”
“控制器”是个特别容易让人误会的词。搞过工控的人一听到“控制器”,脑子里出现的可能是PID控制器、电机控制器、主令控制器这些实体设备;搞WiFi网络的人会觉得是AC控制器;而做物流SAP的人看到POD,想到的是交货单上的送达确认标识。它们虽然“同名”,但干的事完全不一样。
K8s的控制器,本质上可以类比成传统控制论里的闭环控制系统:你先给定一个期望状态,传感器不断测量当前状态,控制器比对两者差异,然后输出调节指令,让系统向期望状态收敛。K8s里这个“调节”动作被叫做调谐循环(reconcile loop),它不是一次性执行,而是“不断比对、不断纠正”的过程。
2.1 一个Deployment的调谐过程
拿一个最简单的Deployment举例:你要跑3个Nginx副本。你告诉API Server“我要3个”,这是一种期望状态。kube-controller-manager里的deployment controller看到期望是3,当前实际只有0,它就去创建ReplicaSet;ReplicaSet controller看到期望是3,当前只有0,就去创建3个Pod副本;调度器把这些Pod调度到满足条件的节点上;kubelet收到Pod绑定信息后拉起容器。整个过程像一条流水线,每一环都在做“查状态、算差异、改状态”。
等3个Pod都Run起来后,期望值和当前值一致,控制器就进入“待命”状态。但只要任何一个Pod挂了,控制循环马上会再次跑起来:当前值变成2,期望值还是3,于是重新创建一个Pod,直到实际数量回到3。这就是K8s“自愈”能力的核心原理。
这里有一个常见的理解盲区:控制器只负责让“副本数量”符合预期,它不负责保证“业务健康”。Pod即使处于Running状态,也可能因为业务问题正在返回500错误。要让它更聪明,你需要配合后面会提到的存活探针(livenessProbe)和就绪探针(readinessProbe)。
2.2 控制器之间是有层级的
K8s里的控制器不是一个大类,而是一个严格的层级结构。以Deployment为例:Deployment管理ReplicaSet,ReplicaSet管理Pod。为什么中间要多一个ReplicaSet?因为滚动更新时需要同时存在新旧两批Pod,如果Deployment直接管理Pod,每次更新都得先销毁全部再重建全部,做不到平滑过渡。ReplicaSet给“版本”加了一个维度:每次你想更新,Deployment会创建一个新的ReplicaSet,把Pod副本数从旧的慢慢迁移到新的,旧的RS保留一定数量以便回滚。
这个层级还体现在kubectl get命令上。你执行kubectl get deploy看到的是Deployment,kubectl get rs看到的才是更细粒度的副本集,kubectl get pod看到的是最终被托管的Pod。Debug的时候,经典三步走就是:describe deployment → describe rs → describe pod。层级错了,你会漏掉大量线索。
3. 五大控制器逐个拆解:什么场景选谁
K8s提供的控制器不少,但这个主题下最核心的就五类:Deployment、StatefulSet、DaemonSet、Job、CronJob。我在实战中见过的99%的使用场景都落在它们身上。
3.1 Deployment:无状态应用的默认选择
先说Deployment。我个人的经验是:但凡你的应用是无状态的(谁都能替代谁、不需要固定身份、不需要固定存储),一律用Deployment。它支持的滚动更新、一键回滚、快速扩缩容,正好满足无状态服务最核心的运维诉求。
一个最精简的Deployment yaml长这样:
apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deploy labels: app: nginx spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.27-alpine ports: - containerPort: 80replicas是期望副本数;selector.matchLabels是控制器用来“认领”Pod的标签,必须与template里的标签一致,这也是最容易写错的地方。
如果要把“滚动更新参数”也纳入考虑,最简单的方式是用kubectl set image或kubectl edit修改镜像版本。但你更应该了解两个关键参数:maxSurge(滚动更新期间最多允许多出的Pod数)和maxUnavailable(滚动更新期间最多允许多少Pod不可用)。默认值是25%,比如副本数是4,更新时最多可以多1个Pod,也最多允许1个Pod不可用。生产环境如果想更保守,可以把maxUnavailable设为0,确保每批更新前先拉起新Pod再杀掉旧Pod:
spec: strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0我曾经吃过一个亏:默认参数下滚动更新,一次性把两个容器实例同时销毁,下游服务瞬间出现连接超时。后来改成maxUnavailable: 0,问题立刻消失。这类“看不见的参数”在教程里常被一笔带过,但线上稳定性往往就卡在这种细节上。
滚动更新期间,新旧Pod会短暂共存。kubectl rollout status deployment/nginx-deploy可以看到更新是否完成;kubectl rollout history deployment/nginx-deploy可以查看历史版本;如果新版本有问题,一条kubectl rollout undo deployment/nginx-deploy就能回到上一个稳定版本。回滚操作本身也是滚动式的,不会一刀切。
3.2 StatefulSet:数据库、中间件的“身份识别系统”
Deployment解决不了一类问题:如果Pod挂了,新Pod的IP变了、主机名变了、PVC也变了,对有状态应用来说就是“换了一台新机器”,数据可能全丢了。StatefulSet就是为了解决“状态与身份一致性”出现的。
StatefulSet里每个Pod都有一个稳定的标识:序号从0开始,比如mysql-0、mysql-1、mysql-2。这个名称是固定的,重新调度后不会变;对应的PVC也不会变,因为Pod和PVC通过volumeClaimTemplates绑定。网络标识方面,它需要配合headless Service(ClusterIP为None的Service)使用,每个Pod会得到一个稳定的DNS记录,形如mysql-0.mysql-service.namespace.svc.cluster.local。
一个典型的StatefulSet配置:
apiVersion: apps/v1 kind: StatefulSet metadata: name: mysql spec: serviceName: mysql-headless replicas: 3 selector: matchLabels: app: mysql template: metadata: labels: app: mysql spec: containers: - name: mysql image: mysql:8.0 volumeMounts: - name: data mountPath: /var/lib/mysql volumeClaimTemplates: - metadata: name: data spec: accessModes: [ "ReadWriteOnce" ] resources: requests: storage: 20GiStatefulSet的“有序性”常被误解为“有状态应用就自动高可用”。其实不然:它保证的是启动和终止顺序(0号先启动,下一个才启动),以及每个Pod对应独立存储。真正的主从同步、数据分片逻辑,依然需要应用层来实现。比如部署MySQL集群,你仍然要自己配置主从复制关系,K8s只负责让“mysql-0、mysql-1、mysql-2”三个Pod稳定地存在且各自绑定独立磁盘。
在扩容/缩容上要格外谨慎:对StatefulSet缩容,默认是从序号最大的开始删除,这个顺序是安全的;但缩容不会自动清理PVC(这是设计如此,防止误删数据)。我在一次测试环境里删掉StatefulSet后,PVC依然挂在集群里,记录一下:手动删Pod不是问题,删掉StatefulSet后需要自己评估数据盘去留。
3.3 DaemonSet:每个节点跑一个,谁来了都不少
DaemonSet的语义和Deployment完全是两个方向:Deployment是“数量驱动”,DaemonSet是“节点驱动”。你不需要指定副本数,只需要在想覆盖的节点范围内让它跑,它就会保证每个符合条件的节点上都且仅有一个Pod。节点加入集群,它自动补跑;节点被移除,Pod随之清理。
实际中使用DaemonSet最多的场景也就那几类:
- 日志采集:filebeat、fluentd、vector;
- 监控采集:node-exporter、prometheus-node-exporter;
- 网络插件:Calico的calico-node、Flannel的flanneld;
- 存储插件:某些CSI Node组件。
长成一个yaml是这样的:
apiVersion: apps/v1 kind: DaemonSet metadata: name: node-exporter spec: selector: matchLabels: app: node-exporter template: metadata: labels: app: node-exporter spec: hostNetwork: true tolerations: - operator: Exists containers: - name: node-exporter image: prom/node-exporter:v1.8.1 ports: - containerPort: 9100 hostPort: 9100注意那个tolerations,如果漏掉“容忍所有污点”,你会发现DaemonSet在Master节点或者被打了污点的节点上怎么都不起来。我第一次部署node-exporter,明明Pod被创建了,却一直Pending,describe之后才发现是污点没容忍。要加tolerations这个字段,比Deployment多了一个“要能主动去脏节点上跑”的能力。
3.4 Job和CronJob:一次性任务与定时任务
还有一类任务不需要常驻运行:审批定时跑脚本、数据清洗、批量发邮件。Job和CronJob就是干这个的。
Job的核心参数是completions(期望完成的Pod数量)和parallelism(同时并发几个Pod)。一个Pod退出了,Job会创建一个新Pod继续干,直到成功完成的Pod数量达到completions。如果一直失败,backoffLimit控制了最大重试次数,超过就不再重试,任务标记为失败。
apiVersion: batch/v1 kind: Job metadata: name: batch-job spec: completions: 3 parallelism: 2 backoffLimit: 4 template: spec: restartPolicy: Never containers: - name: worker image: busybox command: ["sh", "-c", "echo running job && sleep 5"]注意restartPolicy必须设为Never或OnFailure,Job里的Pod不能设Always,否则Pod失败了还会不断重启,永远不会进入终态,Job也永远完不成。
CronJob就是“带cron表达式的Job”,在spec.schedule字段写*/5 * * * *这类表达式。最容易被忽略的一个字段是concurrencyPolicy:默认是Allow(允许并发执行),如果上一轮任务还没跑完,下一轮又触发了,可能会同时跑两个相同任务。做定时数据迁移时,我一般会把它设成Forbid,避免并发重复处理数据。想要省资源的话还可以配startingDeadlineSeconds,避免Cron控制器挂了一段时间后,积压的调度任务一次性全部触发。
4. 控制器如何“认领”Pod:标签选择器与OwnerReference的配合
理解了各种控制器是什么,下一步搞懂它们怎么管理Pod,否则你会在排查问题时严重迷失方向。
控制器的核心查找机制是标签。Deployment通过标签选择器找到自己管的ReplicaSet,ReplicaSet也通过标签选择器找到自己管的Pod。你在template里打什么标签,selector就必须匹配上;反过来,你给Pod多加一个标签,只要匹配关系不变,它依然会被控制器托管。
4.1 手动改Pod标签会发生什么
这是很多新手喜欢玩但很容易翻车的操作。如果你把一个被Deployment托管的Pod标签改掉,控制器会立刻发现“我的匹配范围内少了一个Pod”,然后新建一个Pod把数量补回来。而被你改掉标签的那个Pod,会变成“没有任何控制器认领的孤儿Pod”。它不会被删除,但也不再受保护。这等于亲手把一个受管理的Pod从控制器的保护伞下面踢了出去。
反过来,如果你手动创建一个带同样标签的Pod,控制器可能直接“收养”它——把它纳入副本计数。所以永远不要手动创建和控制器标签匹配的Pod,这是基本纪律。
4.2 OwnerReference:为什么删了Deployment,Pod也被带走了
光是标签匹配还不够。每个被控制器创建的Pod都会带上一个ownerReferences字段,指向创建它的控制器对象。这个字段是级联删除的关键:你删Deployment的时候,Deployment的ownerReferences指向ReplicaSet(反过来的说法是ReplicaSet的ownerReferences指向Deployment),ReplicaSet又拥有Pod,于是整个链条向下级联删除。用kubectl get pod -o yaml随便看一个Pod,你会看到类似这样的字段:
ownerReferences: - apiVersion: apps/v1 kind: ReplicaSet name: nginx-deploy-7b5dfbc9b7 uid: xxxxxx如果你不想要级联删除,可以在删Deployment时加--cascade=orphan,那Pod会全部留下来,但失去控制器管理,变成“没爹没娘”的孤儿。从此以后没有任何人保证它的存活,节点重启它就起不来了。临时排查问题时可以这么干,但别当作常规操作。
4.3 滚动更新时的“双RS并存”状态
滚动更新期间,新旧两个ReplicaSet会同时存在,而且各自拥有不同的Pod模板哈希。这就是为什么你有时在kubectl get pod里看到新旧两批Pod同时Running。用kubectl get rs --show-labels能直接看到哈希标签差异,这也是排查滚动更新卡住时最有用的一行命令。
如果新版本的Pod一直无法Ready,滚动更新就会处在一个“旧的不肯退、新的又起不来”的尴尬阶段。这时候不要慌,按下面这个顺序排查基本能定位:
kubectl rollout status deployment/xxx:看当前滚动状态,是否超时;kubectl describe deployment/xxx:看Event,里面一般有镜像拉取失败、探针失败、调度失败等关键信息;kubectl get rs:确认新旧RS当前副本数;kubectl describe pod <新pod名称>:看Pod级别的告警和探针情况;kubectl logs <新pod名称>:看应用日志。
一旦确认是新版本有问题,kubectl rollout undo deployment/xxx火速回滚,然后再慢慢排查新版本。
5. 生产环境里控制器使用最容易踩的坑
理论知识到位了,但实战中还有几个坑值得单独拿出来讲。这些坑我在自己集群里真实遇到过,踩一次就心疼一次。
5.1 探针配置不当引发的滚动更新失败
Deployment的滚动更新能不能顺利完成,和探针强相关。readinessProbe(就绪探针)告诉控制器“这个Pod能不能接流量”,livenessProbe(存活探针)告诉控制器“这个容器要不要重启”。如果这两个探针设置得太激进,比如超时时间设成1秒,恰好碰上应用初始化慢,K8s就会反复杀掉又重建Pod,滚动更新就一直无法完成。
我见过最极端的情况是:应用启动需要30秒,但启动探针的超时时间只有5秒,结果Pod永远在CrashLoopBackOff。解决办法是合理配置initialDelaySeconds(让容器起来先歇一会儿再做探针)和periodSeconds,并且尽量把livenessProbe的阈值设得比readinessProbe宽松,让“活是活着,但先别接流量”成为常态。
5.2 StatefulSet的优雅停机与数据风险
StatefulSet虽然有稳定存储,但不代表它不会丢数据。默认情况下,节点出现问题时,Pod可能需要很长时间才能被重新调度(这就涉及PodDisruptionBudget和节点状态判定)。更要命的是,如果节点被强制kubectl delete node,Pod还没完成优雅退出就被终止,应用层来不及刷盘,可能出现数据不一致。
针对这个问题,我的经验是两个动作:一是给应用配置preStop钩子,在Pod真正收到SIGTERM前多留几秒做数据刷新和反注册;二是把terminationGracePeriodSeconds从默认的30秒调到更长(比如60秒),给数据库足够时间完成checkpoint。
spec: containers: - name: mysql image: mysql:8.0 lifecycle: preStop: exec: command: ["sh", "-c", "mysqladmin -uroot -p$MYSQL_ROOT_PASSWORD shutdown"]5.3 手动扩缩容和HPA同时操作的“打架”问题
自己用kubectl scale deployment xxx --replicas=10,和HorizontalPodAutoscaler(HPA)自动调副本数,是两套逻辑在改同一个字段。如果你一边手动scale,一边HPA也在跑,两个“大脑”抢方向盘,副本数会被来回拉锯,最后谁赢取决于最后一次写入的时机。
生产环境里,我建议明确分工:被HPA管理的Deployment,不要手动改replicas;HPA的minReplicas和maxReplicas应该覆盖业务的日常峰值和低谷。临时应对大流量,优先考虑先改HPA的maxReplicas,再观察Pod数量变化,而不是直接kubectl scale。
5.4 PodDisruptionBudget:节点维护时保底的最后一道防线
节点要做内核升级、硬件维修,通常会用kubectl drain把节点上的Pod赶走。如果没有配PodDisruptionBudget(PDB),控制器会非常“豪爽”地把所有副本从该节点全部重建到其他节点,瞬间多副本同时短暂不可用。更麻烦的是,如果集群资源不足,重建的Pod可能一直Pending,影响面直接扩大。
PDB的作用是给这个驱逐过程加一道闸:无论什么原因导致Pod主动驱逐,PID都要求“不可用副本数不能超过N”。我一般这样配:
apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: nginx-pdb spec: minAvailable: 2 selector: matchLabels: app: nginx这个配置表示:任意时刻至少要有2个app=nginx的Pod可用。drain节点时,控制器会一个个排,不会一次全放。没有PDB的集群维护,本质上就是在裸奔。
5.5 把控制器当“永久服务管理器”用的边界
最后补充一个容易忽略的边界:控制器不是万能的。Deployment能拉起Pod,但Pod起来了不等于业务OK;StatefulSet能保证IP和名字稳定,但不负责数据分片和主从选举;Job能保证任务跑完,但不负责把结果通知到人。K8s控制器管理的是“编排状态”,真正的业务逻辑还是要靠应用自身实现。
比如做数据库集群高可用,你依然需要operator或者应用层的选主机制;做消息队列扩容,你依然要在应用层重新做分区再平衡。K8s只是把基础设施层的“扩缩容、自愈、发布”标准化了,应用层的“状态协同”还得自己设计。
按照我的学习路线,等你把Pod和这些控制器的关系彻底理顺,再回头去看Service、Ingress、Helm、Operator,会感觉一通百通。后续如果你们想看,我可以继续往下写一写Service的流量转发原理和排错经验,那又是一个能写一整篇的大话题。