先抛个问题:你在生产环境里见过多少个 Pod 因为"只是单容器跑一下"而被反复重建?我自己排查过的线上问题里,至少有一半和 Pod 的多容器协作、探针配置、资源配额有关,而不是容器镜像本身的问题。Kubernetes 里最容易被低估、却又最值得吃透的概念,就是 Pod。它看起来只是"一组容器的壳",实际上调度、网络、存储、健康检查、优雅终止全都围绕它展开。这篇内容我会从 Pod 的设计动机讲起,一路讲到多容器协作、生命周期、QoS 驱逐、拓扑分布和排障思路,尽量把我踩过的坑和想明白的地方都写进去,适合刚接触 Kubernetes 的开发者,也适合已经跑过几个集群、但总觉得某些行为"说不清为什么"的运维朋友。
1. 为什么最小的部署单元不是"容器"而是 Pod
1.1 单容器方案解决不了的两类真实需求
先回忆一下 Docker 时代的使用习惯:一个容器一个进程,docker run 拉起来就算完事。但在真实业务里,很多需求没法用"一个容器一个进程"的模型优雅解决。举两个最常见的例子:
第一类是日志采集。你的业务容器把日志写到本地文件,如果 Kubernetes 直接调度一个裸容器,日志文件就散落在各个节点上。最通用的做法是给业务容器配一个伴生的日志采集容器,它负责把日志文件转发到统一的存储里。这两个容器必须同时跑在同一台机器上、共享同一个文件目录,否则日志就没法收。第二类是流量代理。业务进程只监听回环地址或只暴露内部协议,需要在旁边配一个代理容器把流量转出去,这两个容器也得共享网络栈,才能通过 localhost 直接互通。
如果把每个容器都当成独立调度的单元,上面两类需求就得靠"人为保证它们落在同一台机器"来解决——这显然不可靠。Pod 的出现,本质上是把"必须同生共死、互相协作的一组容器"打包成一个调度、网络、存储的原子单元。
1.2 Pod 的本质:一组容器的"共享运行环境"
我一直喜欢用一个比喻:Pod 是公交车,容器是乘客。乘客可以随时上车下车,但一旦公交车出发,这一车乘客就在同一辆车上,共享同一段旅程。对应到 Kubernetes 里,同一个 Pod 内的容器有几个强制共享的维度:
- 网络命名空间:所有容器共享同一个 IP 和端口空间,容器间通过 localhost 就能通信。
- 存储卷:Pod 级别的存储卷可以挂载给同一个 Pod 内的多个容器,实现数据交换。
- 调度结果:整个 Pod 只会被调度到一个节点上,不会出现容器 A 在节点 1、容器 B 在节点 2 的情况。
- 生命周期:Pod 的整体状态由所有容器的状态共同决定,Pod 删除时内部容器一起回收。
这里有个容易忽略的细节:所谓共享网络命名空间,实际是 Kubernetes 在每个 Pod 里额外启动一个我们看不见的"基础设施容器"来持有网络命名空间,业务容器加入这个命名空间。这个容器最早叫 pause 容器,现在通常由运行时自行实现。你在一台节点上用 docker ps 查的时候,可能会看到很多奇怪的容器,那就是这些基础设施容器。很多新手第一次看到它以为是被注入的恶意容器,其实它是 Pod 网络得以成立的基石。
1.3 从 Docker 习惯迁移过来的常见误解
我刚接触 Kubernetes 时有一个根深蒂固的错误认知:kubectl run创建的"一个 Pod"就等于"一个容器"。实际上你执行kubectl run时,Kubernetes 会通过控制器创建一个 Pod,Pod 里第一个也是唯一一个容器才是你的业务容器,基础设施容器是隐性的。
还有一个更隐蔽的误解:以为 Pod 里的多个容器可以像 Docker 的进程一样,随便互相访问对方的进程 PID。虽然同一个 Pod 的容器共享网络和存储,但默认不共享 PID 命名空间,除非你在 Pod spec 里显式打开shareProcessNamespace。如果你真的需要"容器 A 能看到容器 B 里的进程",必须主动配置,否则两个容器的进程树是隔离的。这个点在做故障排查时特别容易误导人——在容器 A 里 ps 看不到容器 B 的进程,不代表容器 B 挂了。
2. 从零拉起一个 Pod:核心字段与调度逻辑
2.1 最小可用 Pod 的 YAML 拆解
不借助控制器,只靠 YAML 文件也能直接创建一个 Pod。下面这个例子是日常调试时最常用的最小配置:
apiVersion: v1 kind: Pod metadata: name: simple-pod namespace: default labels: app: demo spec: containers: - name: nginx image: nginx:1.25 ports: - containerPort: 80 name: http这四部分各管一件事:apiVersion和kind告诉 API Server 你创建的是什么资源;metadata里的 name 是全局标识,labels 是后续 Service、Deployment 做关联选择的依据;spec.containers是容器的核心描述,可以写多个,每个容器有独立的 name 和 image。
有两个字段新手经常不问为什么就直接抄:ports和imagePullPolicy。ports其实只是"声明性"信息,并不代表 Kubernetes 会帮你做端口映射,真正让外部访问到 Pod 的是 Service。其次是imagePullPolicy,如果省略,默认规则是:镜像标签为 latest 或没有标签时总是拉取,否则仅在本地不存在时拉取。生产环境如果镜像仓库不稳定,这个默认值可能引发不必要的拉取失败,建议显式写成IfNotPresent或明确的版本号。
2.2 调度器如何决定 Pod 落在哪个节点
Pod 的调度过程可以简化成三步:先把所有不符合条件的节点过滤掉,再给剩余节点打分,最后按分数选一个。过滤条件来自你写的各种约束,最常见的包括资源请求(requests)、节点标签选择器(nodeSelector)、亲和性(affinity)和端口冲突检查。
直接指定节点是最粗暴也最不推荐的方式——通过在 spec 里写nodeName可以绕过调度器,直接把 Pod 绑到某个节点。这个字段在测试环境偶尔有用,但生产上如果节点宕机,Pod 不会自动迁移,因为它已经被"绑定"了。更合理的做法是使用nodeSelector或亲和性规则:
spec: nodeSelector: disktype: ssd这条配置会要求调度器只在带disktype=ssd标签的节点上放置 Pod。注意 nodeSelector 是硬性条件,如果没有任何节点满足,Pod 会一直处于 Pending 状态。亲和性比 nodeSelector 更灵活,支持软性偏好(preferred)和硬性要求(required),还能基于另一个 Pod 的标签来做调度约束,比如让两个服务尽量靠近或尽量分离。
2.3 静态 Pod:不经过 API Server 的特殊存在
很多人不知道,有一种 Pod 是 kubelet 在节点上"自己决定拉起"的,根本不需要通过 API Server 创建,这就是静态 Pod。kubelet 会扫描某个目录(默认是 /etc/kubernetes/manifests)下的 YAML 文件,发现新的文件就直接在当前节点上创建对应 Pod。API Server 上能看到这个 Pod,但你删掉它之后,kubelet 会立刻基于那个文件重新拉起。
静态 Pod 最常见的用途就是承载控制面组件,比如某集群的 API Server、调度器、控制器管理器,就是用这种方式固定在主节点上的。这也解释了一个现象:你在一台主节点上执行kubectl delete pod xxx,过一会儿它又回来了,因为你删掉的只是 kubelet 上报给 API Server 的"影子",而真正的"所有者"是节点上的静态文件。理解这点后,排查控制面组件频繁重启的问题会少走很多弯路。
3. 多容器协作的三种经典形态
3.1 Init 容器:启动主流程前的"前置准备"
Init 容器是 Pod 里最先运行的容器,按声明顺序逐个执行,每个成功退出后才会启动下一个,全部成功后主容器才会被拉起。它和主容器的最大区别是:不长期驻留,跑完就退出。
什么时候需要它?我总结出三类典型场景。第一类是等待外部依赖就绪,比如数据库还没起来时,主容器启动会疯狂报错,这时可以在 Init 容器里循环探测数据库端口,通了再放行主容器。第二类是预置数据,比如从对象存储下载模型文件到共享卷,再让主容器读取,避免把下载逻辑写进业务代码。第三类是权限准备,比如修改共享目录的属主或权限,在主容器启动前把环境收拾干净。
写 Init 容器时有一个需要特别注意的资源问题:Kubernetes 在计算 Pod 的资源请求时,会把所有 Init 容器的 requests 取最大值,与所有普通容器的 requests 之和做累计。也就是说,Init 容器里面的资源占用也会影响调度决策,别因为它是"临时进程"就完全忽略 resources 配置。
3.2 边车容器:与主容器同生共死的"最佳拍档"
边车容器和 Init 容器不同,它会和主容器一起长期运行。最经典的例子是日志采集边车:主容器写文件,边车容器读文件并转发;另一个是网格代理边车,负责接管主容器的进出流量。
边车容器的核心价值在于"无侵入地增强主容器":主容器完全不知道边车的存在,依然只做自己的业务,而日志、监控、流量治理这些横切关注点被剥离出去。我在一个实际项目里见过很典型的用法:把产物上传组件做成边车,业务容器只要把结果文件放到共享目录,边车监听目录变化后自动上传到指定平台,业务代码一行没改。
实现边车通信的基本前提是共享存储卷。最常用的临时存储卷是 emptyDir,它在 Pod 创建时初始化,Pod 删除后数据一并销毁,正好符合"生命周期跟随 Pod"的预期。两个容器可以把同一个 emptyDir 挂载到不同路径:
spec: containers: - name: app image: my-app volumeMounts: - name: logs mountPath: /var/log/app - name: log-shipper image: log-shipper volumeMounts: - name: logs mountPath: /var/log/shared volumes: - name: logs emptyDir: {}这里emptyDir不指定 sizeLimit 时,默认使用节点的本地磁盘,容量不受 Kubernetes 层限制。生产环境建议配置sizeLimit,否则某个容器疯狂写文件,可能把节点磁盘打满,进而触发整个节点的驱逐风暴。另外要提醒一句:emptyDir 是节点本地的存储,Pod 被调度到新节点后数据不会跟随,不能拿它当持久化存储。
3.3 多容器共享的通信与存储细节
同一个 Pod 内,容器之间通过 localhost 访问彼此的服务端口,不需要经过 Service。比如主容器监听 8080,代理容器通过http://127.0.0.1:8080就能访问。这里有个隐含条件:两个容器不能监听相同的端口,因为共享网络命名空间意味着端口是全局唯一的。一旦你写出两个容器都监听 8080 的配置,第二个容器会直接启动失败,报端口冲突。
存储共享除了 emptyDir,还可以用 configMap 和 secret 作为卷挂载。两者的共性是"以卷形式暴露配置":修改了 configMap 后,挂载到容器的文件会在一段时间后自动更新(不是实时,存在延迟),但通过环境变量注入的配置不会热更新。所以如果你希望配置变更让进程感知,要么用卷挂载方式,要么在业务里封装一个重新加载逻辑,不要指望环境变量自动刷新。
4. Pod 生命周期:从 Pending 到 Completed 的每个阶段
4.1 状态机与阶段解读
Pod 的生命周期由phase字段表达,最常见的有五种:Pending、Running、Succeeded、Failed、Unknown。Pending 表示 Pod 已被 API Server 接受,但容器还没全部就绪,可能是镜像还在拉取,也可能是调度器还没找到合适节点;Running 表示至少有一个容器还在运行;Succeeded 表示所有容器都正常退出且不会重启;Failed 表示至少有一个容器以非零状态退出;Unknown 是 kubelet 与 API Server 失联时出现的状态。
很多人只看 phase,但 phase 太粗了,排查问题时更有用的是conditions。conditions 是一组更细的状态开关,包括 PodScheduled、Initialized、ContainersReady、Ready。这四个条件能帮你快速定位卡点:如果 PodScheduled 是 False,说明调度环节出了问题;如果 Initialized 是 False,说明 Init 容器还没跑完;ContainersReady 和 Ready 的区别尤其值得注意——前者只表示容器内进程已启动,后者还要额外满足就绪探针的判定。所以一个 Pod 完全可能 ContainersReady=True、Ready=False,那就是就绪探针在拦截流量。
4.2 三种探针各有各的用处,别混着用
探针是 Kubernetes 判断容器健康的手段,很多同学只知道 liveness 和 readiness,其实还有第三种 startup。三者的分工很明确:
-livenessProbe:决定容器需不需要被杀掉重启。它探测失败,kubelet 会根据 restartPolicy 重启容器。
- readinessProbe:决定容器是否接收 Service 流量。探测失败不会重启,只是把 Pod 从 Service 的端点列表里摘掉。
- startupProbe:解决启动慢的容器被 liveness 误杀的问题。在 startup 成功之前,liveness 不生效。
三种探针支持同样的探测方式:httpGet、tcpSocket、exec。httpGet 最直观,直接请求一个 HTTP 路径;tcpSocket 只检查端口能否建立 TCP 连接,适合不提供 HTTP 服务的应用;exec 则在容器内执行命令,以退出码判断。我给一个实际的 readinessProbe 配置:
readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 3initialDelaySeconds是容器启动后等多久才开始探测,failureThreshold是连续失败多少次才把 Pod 标记为未就绪。这里我踩过一个大坑:在服务启动需要十几秒的场景里,把initialDelaySeconds设成了 1,导致探针在应用还没监听端口时就开始探测,连续失败几次直接进 CrashLoopBackOff。后来改用 startupProbe 给足启动时间,liveness 才放心用短周期去盯运行期状态。记住一个原则:启动期用 startupProbe 把关,稳定后 liveness 负责保命,readiness 负责流量摘除,三者配合而不是相互替代。
4.3 优雅终止:SIGTERM 之后的那几秒
Pod 被删除时,不是简单地把容器 kill 掉,而是有一套固定顺序。首先 API Server 标记 Pod 为 Terminating,同时从 Service 的端点列表里摘除,新流量不再进入;随后 kubelet 向 Pod 内的主容器进程发送 SIGTERM 信号;再等待terminationGracePeriodSeconds指定的时间(默认 30 秒);超时后发送 SIGKILL 强制杀掉。
你的应用能做的,就是在收到 SIGTERM 后尽快完成收尾工作:停止接收新连接、处理完正在处理的请求、关闭数据库连接、最后退出。如果应用里完全没有 SIGTERM 的处理逻辑,那默认行为就是直接退出,很多长连接任务都会因此被硬生生切断。生产上我建议主动设置terminationGracePeriodSeconds,比如 60 秒,并确保应用在收到 SIGTERM 后执行优雅关闭,否则这个字段调再大也没用。
还有个细节:如果 Pod 里的进程无论如何不肯退出,kubelet 在等待期内还可以通过 preStop 钩子执行额外命令。比如 preStop 里 sleep 10 给下游留出缓冲时间,或者通知注册中心摘除实例。不过钩子执行时间会计入 grace period,钩子和 SIGTERM 是串行关系,别把两件事的时间预算叠加错了。
5. 资源配额、QoS 等级与驱逐:Pod 的"生存权利"是怎么被决定的
5.1 requests 和 limits 的真实含义
Pod 里每个容器都可以声明 resources.requests 和 resources.limits。requests 直接影响调度:调度器只会把 Pod 放到"已分配资源 + 新请求"不超过节点容量的节点上。limits 则影响运行期的资源上限,但 CPU 和内存的限制逻辑完全不一样。
CPU 是可压缩资源,限了之后只是给你降速,一般不会杀死进程;内存是不可压缩资源,一旦超过 limit,内核会直接触发 OOM,进程可能被杀掉。这就导致一个常见现象:你的应用明明 CPU 吃满了但还在跑,内存一旦超限就立刻被杀。所以排查 OOMKilled 时,第一件事就是看容器的 limits.memory 和实际占用。
另外要注意 requests 和 limits 的比例关系。很多资深开发者会把它们设为相等,也就是 Guaranteed QoS,这种配置下 Pod 的稳定性最高。如果把 limits 设得远大于 requests,可以提升节点装箱率,但会引入一类风险:多个 Pod 同时突发内存时,节点可能 OOM,而 OOM 时谁被牺牲,由 QoS 等级和实际使用量共同决定。这里没有银弹,只能根据业务特点权衡。
5.2 三种 QoS 等级,决定了"谁先死"
Kubernetes 会根据 requests 和 limits 的配置把 Pod 划分成三个 QoS 等级。Guaranteed 要求每个容器 requests 和 limits 都相等;Burstable 是 Pod 里至少有一个容器有 requests 或 limits 但不满足 Guaranteed;BestEffort 是所有容器都没有 requests 和 limits。
等级排序决定了节点资源紧张时的处理顺序:节点层面先驱逐 BestEffort,再驱逐 Burstable,最后才考虑 Guaranteed。OOM 场景也类似,BestEffort 的进程优先被杀。下表可以清楚地看到差异:
| QoS 等级 | 配置特征 | 内存超限时的命运 | 节点资源紧张时的命运 |
|---|---|---|---|
| Guaranteed | 所有容器 requests 等于 limits | 一般先杀其它等级 | 最晚被驱逐 |
| Burstable | 有 requests 或 limits,但不全等 | 超限可能被杀 | 中等优先驱逐 |
| BestEffort | 无任何资源配置 | 最容易被 OOM | 最早被驱逐 |
实际生产中,我见过不少团队把所有应用都配成 BestEffort,想着"反正节点内存够大"。等到某天节点被监控组件打满时,最先挂掉的全是这些没配资源的业务 Pod。配置资源不是应付检查,而是给系统一个"该牺牲谁"的明确指令。
5.3 驱逐机制与 PriorityClass 的联动
节点的资源压力过大时,kubelet 会执行驱逐。最常见的驱逐条件是 memory.available 低于阈值和 disk 使用率过高。被驱逐的 Pod 会被标记为 Failed,然后进入重新调度的流程。如果你在集群里看到大量 Pod 处于 Evicted 状态,先去看节点状态和 kubelet 日志,而不是一头扎进 Pod YAML 里找原因。
和驱逐紧密相关的是 PriorityClass。当一个高优 Pod 无法调度时,调度器可能抢占(preempt)低优 Pod,把低优 Pod 直接删掉来腾出位置。这里的逻辑是"优先级高的 Pod 拥有'抢座位'的权利"。所以如果你的关键任务 Pod 被莫名删除,别只查驱逐,也看看是否有更高优先级的 Pod 在抢它。合理做法是:核心服务设较高优先级,离线任务和测试任务设较低优先级,并明确接受低优任务可能被抢占这个事实。
6. 进阶实战:拓扑分布、原地调试与安全上下文
6.1 拓扑分布约束:别让所有 Pod 挤在同一台机器
集群里最常见的一个隐患是:虽然节点有几十台,但某个应用的副本全挤在两台上。这通常是节点亲和性配置不当或调度器装箱决策导致的。为了让副本分布更均匀,Kubernetes 提供了拓扑分布约束。下面这个配置限制同一应用的不同副本尽量分散到不同的拓扑域:
spec: topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: demomaxSkew: 1表示不同节点上的副本数之差最多为 1;topologyKey用主机名表示"同一个节点算一个拓扑域";whenUnsatisfiable: DoNotSchedule是硬约束,找不到满足条件的节点就不调度。还有软约束ScheduleAnyway,它会尽量满足,但实在满足不了就照常调度。我建议核心服务用硬约束保证跨节点高可用,普通服务用软约束兼顾可用性和调度成功率。
6.2 原地调试:没有 SSH 的临时入口
虽然 Pod 里的进程在,但生产环境很多容器镜像刻意不带 Shell。想进容器排查问题时,可以选择临时使用调试容器。kubectl debug可以在不重启 Pod 的情况下往目标 Pod 里注入一个临时容器,这个容器可以和目标容器共享进程命名空间,让你直接看到目标容器的进程。配合shareProcessNamespace: true或调试容器自身开启进程共享,排查效果堪比在一台虚拟机里操作。
调试容器有几个特点:不继承原 Pod 的探针,生命周期独立,默认不会被 Service 选中,用完之后自动清理。这套机制比"打补丁注入 SSH"安全得多,也符合"不可变基础设施"的思路。我处理过一个诡异的连接数异常问题,就是用kubectl debug注入一个带 tcpdump 的容器,在共享网络命名空间里直接抓包定位到是连接未释放,而不是盲目重启。
6.3 安全上下文:把容器权限关进笼子里
Pod 的安全配置很容易被忽略,直到安全审计或实际出问题才追悔莫及。最基础的三项:runAsNonRoot、runAsUser、readOnlyRootFilesystem。runAsNonRoot: true强制容器内进程以非 root 身份运行,镜像里如果没指定 USER,启动时就会报错,这反而是好事,逼着你把镜像改干净。readOnlyRootFilesystem: true让根文件系统只读,应用需要写临时文件时通过挂载 emptyDir 解决,这能有效防止容器被写入恶意文件。
设置 capabilities 则更精细,它可以给容器额外增加能力或去除默认能力。比如一个普通 Java 服务通常只需要 CAP_NET_BIND_SERVICE 来绑定低端口,那就把默认 capabilities 全部去掉,只保留这一项。对大多数业务来说,最小化 privileges 带来的收益远比"万一要用某个能力再放开"的折腾成本值得。
7. 排障实录:从现象到根因的完整思考链
7.1 Pod 一直 Pending,先查调度而不是看日志
遇到 Pod 长时间 Pending,第一反应不是去看应用日志(此时容器还没启动,大概率没日志可看),而是按顺序排查:先kubectl get pods看事件,再kubectl describe pod看 Events 部分。最常见的几种原因:节点资源不足、镜像拉取失败、节点亲和性/污点拦截、PVC 未绑定。
我印象最深的一次是集群里突然有几十个 Pod Pending,全指向"0/3 nodes are available",但节点内存明明有剩余。后来发现是某个节点被打了 NoSchedule 污点,同时有一批 Pod 没配 nodeSelector,按理说不会只卡住这批。继续往深处查才知道,调度失败的真实原因是 PVC 动态供给的 StorageClass 被误删,PVC 一直 Pending,Pod 自然跟着卡住。所以描述里写着"节点资源不足"时,别忘了检查 PVC 状态——事件信息有时候会给你一个表面原因,真正的链路要靠 event 里的顺序去串。
7.2 CrashLoopBackOff 的排查路径
CrashLoopBackOff 应该是曝光率最高的状态了。它的含义是容器反复启动后崩溃,kubelet 在退避重试。排查时我习惯按这条链走:先kubectl logs看最近输出,如果日志为空,就加--previous看上一次容器的输出;再用kubectl describe pod看容器 state 里的 exit code;最后结合 exit code 判断方向。
这里有一个新手容易忽略的区分点:exit code 137 经常被人解读为"进程被杀",但 137 其实是 128+9,表示收到了 SIGKILL。它可能是 OOM,也可能是手动删除 Pod 或节点驱逐。而 exit code 143 是 128+15,表示 SIGTERM,通常是优雅终止流程触发的主动退出。如果你看到 137 直接往 OOM 方向查,方向就偏了。正确的做法是同时看 events 里有没有 Kill/Graceful Shutdown 的标记,再决定是查内存还是查删除记录。
还有一种很隐蔽的 CrashLoopBackOff 原因:容器启动时依赖的持久化卷里存了损坏的元数据,应用每次启动都在读同一份坏数据后崩溃。这类问题看日志能定位,但修复往往需要把卷里的坏文件清理掉,而不是无脑调大 restartPolicy 让它自己恢复。
7.3 典型误用:把 Pod 当成虚拟机来用
最后一个想提醒的坑,是把 Pod 当成持久化的虚拟机来用。有人习惯在容器里手动装包、改配置、写临时文件,然后期望 Pod 重启后一切都还在。这是和 Kubernetes 设计理念相悖的:Pod 是"可替代的",任意时刻可能因发布、驱逐、节点故障被重建,重建后一切写入容器层的改动全部丢失。
正确的姿势是:镜像构建时把依赖装好,运行时状态写入可挂载卷或外部存储,配置文件通过 ConfigMap 注入,需要持久化的数据用 PersistentVolume 承载。如果团队里有成员经常"手动进容器改东西",建议把容器文件系统改成只读挂载,配合审计日志,让不规范的变更直接失败。这样虽然一开始会多一些抱怨,但长期看能省下大量"为什么发布后配置又变回去了"之类的排查时间。
我在实际处理生产问题时发现,最耗时的往往不是单点故障本身,而是对 Pod 的认知不够准确导致的方向性错误。花一个下午把 Pod 的调度、探针、资源、生命周期从原理上过一遍,后面替你省下的,可能是好几个不眠之夜。如果这篇梳理能帮你少走一段弯路,那我的目的就达到了。