Pod 异常问题排查这件事,我在不同规模的集群里反复做过很多次,慢慢发现一个挺反直觉的结论:大多数人排查卡住,不是因为命令掌握得不够多,而是因为第一眼就盯错了地方。看到容器起不来,下意识就去翻业务日志,结果日志里空空如也;看到 Pending,就先怀疑网络不通,实际上调度器压根还没给这个 Pod 分配节点。Pod 的状态字段本身已经把答案写在脸上了,只是很多人跳过了这一步。
这篇内容我打算把 Pod 异常排查拆成一条可复用的判断链路:从状态码读到什么、怎么用事件定位到具体环节、不同异常各自的根因分布、以及哪些"故障"其实是配置写错造成的假象。适合已经能在集群里跑起工作负载、但遇到问题还习惯性重启试运气的同学,也适合想把排查流程沉淀成团队规范的人。下面这些结论基本上都是我在真实环境里踩出来的,不是文档的复述。
1. Pod 状态是第一手线索:先把"异常"翻译成可验证的假设
Pod 异常排查最容易犯的错,是把所有"起不来"当成同一类问题。实际上 Kubernetes 用一个很有限的状态集合,已经把故障切分到了不同的责任方:调度器、kubelet、容器运行时、镜像仓库、应用进程,各自对应不同的状态表现。读懂这张映射表,排查范围能立刻缩小一大半。
1.1 七种高频状态分别指向谁
我习惯在动手之前先做一次"责任方判定",用下面的对照关系快速缩小范围:
| 状态 | 大概率责任方 | 第一手要看的字段 |
|---|---|---|
| Pending | 调度器 / 存储绑定 | Events 里的 FailedScheduling |
| ContainerCreating | kubelet / 运行时 / 存储 | Events 里的挂载与拉取记录 |
| ImagePullBackOff | 镜像仓库 / 凭据 / 镜像名 | Events 里的拉取失败原因 |
| CreateContainerConfigError | 配置注入 | 引用的 ConfigMap/Secret 是否存在 |
| CrashLoopBackOff | 应用进程本身 | 上一次退出码与--previous日志 |
| Running 但不 Ready | 探针 / 启动时序 / 依赖 | describe 里的探针失败事件 |
| Evicted | 节点资源压力 | Pod 的 status.reason 与节点状况 |
这张表的价值在于,它把"哪里出问题"和"谁该负责"直接绑定了。Pending 你去翻应用日志,翻一整天也翻不出东西,因为容器根本没被创建,不存在日志;反过来,CrashLoopBackOff 你去查节点资源,方向也偏了,退出码通常会直接告诉你答案。
1.2 为什么 Events 应该排在日志前面
kubectl describe pod输出末尾那段 Events,是我每次排查的第一站。原因很朴素:Events 记录的是控制面和 kubelet 对这个 Pod 做过的所有尝试,包含时间戳和失败原因,而容器日志只覆盖"进程已经跑起来之后"的那一小段。容器根本没启动的情况下,日志是空的,但 Events 通常已经写满了重试记录。
我一般会这样取事件,按时间排序看整条时间线:
kubectl get events -n <namespace> \ --field-selector involvedObject.name=<pod-name> \ --sort-by=.lastTimestamp默认事件只保留一小时,如果问题发生在一小时之前,get events可能已经查不到了。这时候要去看集群的事件持久化方案,或者退一步用describe里残留的那部分。这个时间窗口是我踩过坑之后才记住的——有一次排查一个隔夜的问题,get events返回空,我一度以为集群事件机制坏了,其实是早就过期了。
1.3 我自己用的三段式排查顺序
长期下来,我的顺序基本固定成三段,中间不做跳跃:
- 状态与事件:
get pod -o wide看状态和节点,describe看 Events 和 Conditions,确定责任方。 - 进程现场:能进容器就进,进不去就用
--previous日志、kubectl debug临时容器、或者到节点上看运行时日志。 - 横向印证:同节点其他 Pod 是否正常、同 Deployment 其他副本是否正常、最近有没有发布或配置变更。
第三步经常被忽略,但它的性价比极高。如果同一个节点上所有新起的 Pod 都卡在 ContainerCreating,那问题大概率在节点或运行时层面,而不是你的镜像;如果只有你这一个副本异常,其他副本健康,那基本可以锁定在配置差异或者这个 Pod 被调度到的那个节点上。这个"横向对照"的动作,能省掉大量单点深挖的时间。
2. Pending 卡住不动:调度器到底在犹豫什么
Pending 是最容易被误判的状态。很多人的第一反应是网络问题,实际上调度器在这个阶段只做一件事:判断有没有节点满足这个 Pod 的全部约束。它不做网络连通性检查,也不拉镜像。所以 Pending 的本质是"没有节点能容纳我",或者"我依赖的东西还没准备好"。
2.1 资源碎片与 requests 的整块占位效应
调度是按requests而不是实际用量来算的。这件事听起来是常识,但真正理解它的人不多,因为它意味着资源利用率高不高和能不能调度上去,是两件独立的事。
举个具体的例子:一个节点有 8 核,上面跑了三个 Pod,各 request 2 核,实际使用率只有 15%。现在你要起一个 request 3 核的 Pod,调度器一算,8 - 6 = 2,不够,直接 Pending。你以为节点很闲,调度器眼里它已经快满了。这就是资源碎片——空闲的 2 核零散在物理上,凑不出连续的 3 核配额。
排查时直接看事件里的数字,它写得很清楚:
0/5 nodes are available: 5 Insufficient cpu.遇到这种情况,我有三条处理路径,按风险从低到高:
- 把新 Pod 的
requests调到符合实际用量的水平(前提是原来的值定高了); - 给节点池扩容,或者调整节点规格;
- 用优先级和抢占,让低优先级的 Pod 先让位。
第三条要慎用,抢占会驱逐别人,在有状态服务混布的集群里容易引发连锁反应。
2.2 亲和性、污点与节点选择器的隐形门槛
除了资源,调度器还要过一遍各种约束条件。这几类约束在 YAML 里写的时候不觉得,出问题的时候报错信息又特别长,容易看漏。
- nodeSelector / nodeAffinity:标签写错一个字符就匹配不上。常见的是节点打标签用了
disktype=ssd,Pod 里写成disk-type=ssd。 - 污点与容忍:节点上有
NoSchedule污点而 Pod 没写对应容忍,就会被直接排除。很多托管节点池会给新节点打上临时污点,等初始化完成才去掉,这段时间新建 Pod 会短暂 Pending,属于正常现象。 - Pod 反亲和:设了
requiredDuringSchedulingIgnoredDuringExecution的反亲和,而副本数超过了可用的拓扑域数量,多出来的副本就会永远 Pending。这类配置我建议一律用preferred,除非你有非常明确的硬性隔离需求。
2.3 PVC 未绑定导致的"假调度失败"
有一种 Pending 特别有迷惑性:事件里显示调度成功,但 Pod 就是起不来,或者报pod has unbound immediate PersistentVolumeClaims。
这背后的逻辑是:Pod 调度分为两个阶段,先做节点筛选,再走 volume binding。如果 PVC 的storageClassName指定了WaitForFirstConsumer的绑定模式,那么 PVC 会等到 Pod 被调度时才真正绑定底层存储。这时候如果存储侧创建失败——配额不足、可用区不匹配、存储类不存在——Pod 就会卡在这两个阶段的中间。
排查这类问题要分两路看:kubectl describe pvc看 PVC 的事件,以及存储侧的实际创建状态。我在一个跨可用区的集群里遇到过:Pod 通过亲和性被调度到了 B 区节点,但 PVC 的存储类只支持 A 区,于是绑定一直失败。事件里只有一句模糊的绑定超时,真正的答案在 PVC 那一侧。
2.4 从调度器事件读出具体失败的插件
如果事件里的FailedScheduling信息不够明确,可以用下面的命令把整条调度失败原因完整打出来:
kubectl get pod <pod-name> -o jsonpath='{.status.conditions[?(@.type=="PodScheduled")].message}'这条路径拿到的消息往往比describe里截断显示的更完整,尤其是多重约束同时不满足的时候,它会逐条列出每个节点的排除理由。我一般会把这段输出直接贴到排查记录里,方便后续对比——同一个集群反复出现同一条排除理由,说明资源规划本身出了问题,而不是某次偶然。
3. CrashLoopBackOff 与退出码:容器为什么反复自己死掉
CrashLoopBackOff 是存在感最强的异常状态,因为业务方往往第一时间就能感知到服务不可用。但要注意,这个状态名里其实包含两层信息:容器确实反复退出了(Crash),以及 kubelet 在按指数退避策略拉长重启间隔(BackOff)。后者是保护机制,不是故障本身。
3.1 退出码是最直接的判据
容器退出码遵循 Unix 惯例,128 以上表示被信号终止,具体是 128 加信号编号。我整理了一张常用对照:
| 退出码 | 含义 | 典型场景 |
|---|---|---|
| 0 | 正常退出 | 一次性任务跑完;主进程是脚本但末尾没阻塞 |
| 1 | 应用自身报错 | 配置解析失败、依赖连接不上 |
| 126 | 命令不可执行 | 权限位缺失、脚本没有 shebang |
| 127 | 命令找不到 | 入口命令在镜像里不存在、PATH 不对 |
| 137 | 被 SIGKILL 强制终止 | OOMKilled,或 liveness 探针失败被杀 |
| 139 | 段错误 | 底层库不兼容、原生扩展崩溃 |
| 143 | 被 SIGTERM 优雅终止 | 滚动更新、缩容、手动删除 |
查看方式很简单,describe里Last State那一栏就有,或者:
kubectl get pod <pod-name> -o jsonpath='{.status.containerStatuses[0].lastState.terminated}'这条命令会返回退出码、信号、起止时间和原因,信息量比describe里压缩显示的更全。
3.2 OOMKilled 的判断链路与 limit 估算
137 不代表一定是 OOM,它也可能来自 liveness 探针失败后的强制重启。区分方法是看reason字段:OOMKilled才是内存超限,其他情况通常是探针触发的。
确认是 OOM 之后,别急着调大 limit,先搞清楚内存到底花在哪。我的顺序是:
- 看
kubectl top pod,确认内存曲线是缓慢爬升还是瞬间冲到顶。缓慢爬升通常是内存泄漏或者缓存无界增长;瞬间冲顶多半是启动时一次性加载了大量数据。 - 对比
requests和limits的差值。差太大意味着这个 Pod 是 Burstable 类型,在节点内存紧张时优先级低,先调 request 让它更接近真实用量。 - 检查运行时层面对内存的处理。以 JVM 为例,堆大小如果用手写的固定值,容器 limit 又小于"堆 + 元空间 + 线程栈 + 直接内存",那就必然在压力下被杀。这类应用我建议开启容器感知,让运行时按 cgroup 限制自动算堆。
有个细节值得单独提:容器的内存指标口径和业务方在应用里看到的口径经常对不上。应用统计的是堆内内存,cgroup 统计的是整个进程组包括页缓存。出现过不少次"应用说只用了 60%,容器却被判 OOM"的情况,差值基本都在堆外和缓存上。
3.3 启动就退出的六种典型原因
如果退出码是 1,而且日志里只有几行就结束了,我通常按这六类往下排:
- 入口命令写错:
command和镜像的ENTRYPOINT叠加使用,导致最终执行的命令不是你预期的那个。command会覆盖镜像的 ENTRYPOINT,args会覆盖 CMD,两者混用很容易出岔子。 - 配置文件缺失或格式错误:配置文件通过 ConfigMap 挂进来,但 key 名字对不上,挂载点是个空目录,进程读到空文件直接退出。
- 依赖服务不可达:启动时要连数据库或配置中心,连不上就
os.Exit(1)。这类问题在滚动发布时特别集中。 - 端口被占用:用了 hostPort 或者 hostNetwork,同一节点上端口冲突。
- 权限问题:以非 root 用户运行,但要写日志目录或读挂载卷,权限不足直接退出。
- 资源限制过紧:limit 设了很小的 CPU,启动阶段被限流到几乎跑不动,超过启动窗口被杀。
3.4 用上一次的日志和临时容器抓现场
容器正在重启的间隙里,kubectl logs经常是空的。这时候必须加-p:
kubectl logs <pod-name> -p --tail=200 kubectl logs <pod-name> -c <container-name> -p如果容器镜像里连 shell 都没有,或者你想看的是文件系统里留下的现场(比如写了一半的日志文件、生成的堆转储),就要用临时容器:
kubectl debug -it <pod-name> --image=busybox:1.36 \ --target=<container-name> -- sh--target参数很关键,它让临时容器共享目标容器的进程命名空间,这样ps能看到应用进程,能确认进程到底在不在、是什么状态。不加这个参数,你看到的只是一个干净的空环境。这个能力在我排查"容器还活着但服务没响应"的问题时特别有用。
4. 镜像拉取与配置注入:那些"看起来像网络问题"的失败
有一类问题特别容易被误诊成网络故障,因为报错信息里经常出现连接超时、拒绝连接之类的字眼。但只要往下看一层,往往跟网络本身没关系。
4.1 ImagePullBackOff 的三层排查
这个状态名的意思是镜像拉不下来,kubelet 正在退避重试。真实原因通常落在三层里:
第一层,镜像引用本身有问题。仓库地址拼错、tag 不存在、用了latest而仓库里只有具体版本。事件里会明确写manifest unknown或者not found。
第二层,凭据问题。私有仓库需要在命名空间里有对应的imagePullSecrets。这里有个高频坑:Secret 建在了 A 命名空间,Pod 跑在 B 命名空间,照着文档抄配置的时候漏了这点,报错是unauthorized或者no basic auth credentials。
第三层,节点到仓库的连通性。这一层才真正涉及网络。但即便如此,也要先区分是"全部节点拉不下来"还是"个别节点拉不下来"。前者多半是仓库侧的策略或配额,后者通常是那个节点的网络配置或者本地磁盘满了。
describe里的失败原因写得挺直白,我一般先扫一眼这几个关键词:not found对应镜像名,unauthorized对应凭据,timeout对应连通性,no space left on device对应节点磁盘。
4.2 ConfigMap 和 Secret 缺失的两种表现
配置引用错误会呈现两种完全不同的症状,取决于引用方式:
- 以环境变量方式引用且标了
optional: false(默认):Pod 直接进CreateContainerConfigError,容器根本不会创建。这种其实算幸运的,失败得干脆。 - 以卷挂载方式引用:如果 ConfigMap 不存在,挂载会失败,Pod 卡在 ContainerCreating;如果 ConfigMap 存在但 key 缺失,挂载点里就是空的,进程读到空配置,然后在启动时报错退出。
第二种更麻烦,因为它伪装成了应用问题。我的经验是,看到应用报"配置项为空",先别改代码,先去确认挂载点里到底有什么:
kubectl exec <pod-name> -- ls -la /etc/app-config/ kubectl exec <pod-name> -- cat /etc/app-config/app.yaml还有一个时间差问题需要留意:以卷方式挂载的 ConfigMap,更新后同步到容器里有延迟(通常几十秒到一分钟量级),而且已经以环境变量方式注入的值永远不会更新,必须重建 Pod 才生效。我见过有团队改了 ConfigMap 却怎么都不生效,最后发现是环境变量注入,白折腾了半天。
4.3 挂载失败的报错特征
卷挂载相关的失败,事件里通常长这样:
MountVolume.SetUp failed for volume "config" : configmap "app-config" not found Unable to attach or mount volumes: unmounted volumes=[data], unattached volumes=[data]: timed out waiting for the condition第二种是我遇到最多也最难缠的——云环境的块存储挂载超时。通常的原因是:节点和存储卷不在同一个可用区、卷还被上一个 Pod 占用未释放、或者节点上的挂载点数达到上限。这类问题在 Pod 被频繁调度或节点异常下线后特别容易出现,表现为新 Pod 一直卡在 ContainerCreating,事件里反复刷挂载超时。
处理这类问题,我一般会先确认卷的当前挂载状态,如果确认是残留占用,清理掉旧 Pod 的挂载记录后就能恢复。这件事最好沉淀成运维手册,因为它有一定的操作风险,不适合临时发挥。
5. Running 却不可用:探针、启动时序与流量接入
这一类最消耗排查精力,因为从状态上看一切正常:Pod 是 Running,容器没重启,日志也没报错,但请求就是打不进来,或者进来就 502。问题往往出在"就绪"的判断逻辑上。
5.1 readiness 和 liveness 的职责边界
这两个探针的语义差异,我见过太多团队搞混:
- readinessProbe 失败:Pod 被从 Service 的 Endpoints 里摘掉,不再接收流量,但容器不重启。它表达的是"我现在不能干活,先别给我派活"。
- livenessProbe 失败:kubelet 直接杀掉容器并重启。它表达的是"我卡死了,救我一下"。
把两者用反的后果很严重。我曾经接手过一个服务,把依赖检查(数据库连通性)写进了 liveness。结果数据库做了一次短时维护,所有副本同时探针失败、同时重启,重启后依赖还没恢复,又失败又重启,滚成了一片雪崩。依赖检查应该放在 readiness,让流量绕开就行了,不该触发重启。
我的原则很简单:liveness 只检查进程自身是否还能响应,绝不检查外部依赖。
5.2 启动慢的服务为什么必须用 startupProbe
没有 startupProbe 的时候,liveness 从容器一启动就开始计时。对于一个要跑 90 秒初始化的服务,如果 liveness 的容忍窗口只有 30 秒,那它会在初始化途中被反复杀掉,永远起不来。这时候你会看到一个非常迷惑的现象:日志每次都是打印到同一个位置就断了。
startupProbe 的作用就是给启动阶段单独开一个窗口。在它成功之前,liveness 和 readiness 都不生效。等它成功了,才开始正常的健康检查节奏。
5.3 探针参数怎么算
容忍窗口 =periodSeconds × failureThreshold,再加上initialDelaySeconds。这个计算经常被忽略,我拿一个实际的配置举例:
startupProbe: httpGet: path: /healthz port: 8080 periodSeconds: 10 failureThreshold: 12 timeoutSeconds: 3 livenessProbe: httpGet: path: /healthz port: 8080 periodSeconds: 20 failureThreshold: 3 timeoutSeconds: 3 readinessProbe: httpGet: path: /readyz port: 8080 periodSeconds: 5 failureThreshold: 2这里的含义是:启动阶段最多容忍 120 秒(10 乘 12);启动成功后,liveness 最多容忍 60 秒;readiness 最多容忍 10 秒。
有个细节必须提醒:timeoutSeconds要小于periodSeconds,否则探针会在上一轮还没结束时就开始下一轮,行为变得不可预期。另外探针的请求本身也会消耗资源,在高负载时把periodSeconds设得太小,会给服务增加不必要的压力,甚至反过来导致探针超时。
还有一个隐蔽的坑:探针走的是 kubelet 所在的网络路径,如果你的服务端口只监听了127.0.0.1而不是0.0.0.0,探针会一直失败,但你在容器里curl localhost又是通的。这种问题排查起来极其耗时,因为现象和配置看起来都对。
6. 网络与 DNS:Pod 内部视角的连通性验证
网络问题排查的关键是站对位置。在集群外部 ping、在节点上 curl,得到的结论都不能直接代表 Pod 内部的视图。要判断 Pod 的网络通不通,就从 Pod 内部出发。
6.1 从 Pod 内部出发的四条命令
如果镜像里有工具,我一般按这个顺序走:
# 1. 确认本机 IP 和路由 ip addr show && ip route # 2. 确认 DNS 配置 cat /etc/resolv.conf # 3. 解析目标服务名 nslookup <service-name>.<namespace>.svc.cluster.local # 4. 确认目标端口可达 nc -zv <target-ip> <port>如果是精简镜像没有这些工具,就用kubectl debug起一个带工具的临时容器,用--target共享网络命名空间,这样排查的就是同一个网络视图,结论才可信。这一点很重要,我见过不少人在一个独立的临时 Pod 里测网络,然后把结论套到出问题的 Pod 上,两者根本不在同一个命名空间、也不在同一批网络策略的覆盖范围内。
6.2 DNS 解析慢和解析失败是两回事
这两种情况的处理方式完全不同。
解析失败(报NXDOMAIN或SERVFAIL):先确认服务名和命名空间拼写,再确认目标 Service 是否真的存在且 Endpoints 非空。注意,Service 存在但没有任何 Ready 的 Pod,DNS 会返回记录但连接必然失败,这是两码事。
解析慢:这是最常见也最容易被归错因的问题。/etc/resolv.conf默认的ndots是 5,意味着任何名字只要点数小于 5,都会先被当作相对域名去逐个 search 域后缀尝试。一次对外部域名的解析,可能要发出多轮查询才命中。在 DNS 压力大的集群里,这会明显拖慢首次连接。
优化方式有两种,我一般优先用第一种:
spec: dnsConfig: options: - name: ndots value: "2"或者直接用 FQDN 访问,以点号结尾,跳过 search 域展开。改ndots需要注意副作用:如果你的应用在同一命名空间内大量使用短服务名访问,ndots设得太小反而会增加解析失败的概率,因为短名字不会被自动补全成集群域名了。这个参数没有通用最优值,得看你的访问模式。
6.3 网络策略与端口映射
如果连接被拒绝而不是超时,通常说明网络路径是通的,但没有进程在监听。先确认容器内的监听地址(0.0.0.0还是127.0.0.1),再确认containerPort和实际端口一致。containerPort本身只是声明性的,写错了不会阻止启动,但会让依赖它的配置变得不可靠。
如果是超时,优先怀疑网络策略。现代集群里网络策略默认拒绝的场景很常见,新加的服务如果没有对应的 ingress 规则,表现就是连接一直挂着。判断方法很直接:临时创建一个允许全部的策略验证一下,如果通了,就说明是策略问题,然后按最小权限逐步收紧,而不是把全放开当成长期方案。
7. 我自己的排查清单与几个值得记住的教训
下面这份清单是我现在开新集群或者带新人时都会给的一份,按顺序走基本能覆盖八成问题:
get pod -o wide确认状态和所在节点describe看 Events、Conditions、Last State- 对比同节点、同 Deployment 其他副本是否正常
logs -p看上一次退出前的输出- 确认配置引用(ConfigMap/Secret/PVC)是否存在且内容正确
- 确认探针参数与启动耗时是否匹配
- 从 Pod 内部验证 DNS 与端口连通性
- 检查最近是否有发布、配置变更、节点事件
关于教训,有三个印象最深。
第一个是退出码 137 的误判。有一次看到 137,直接判定 OOM,把 limit 从 2G 调到 4G,结果还是 137。后来才发现是 liveness 探针配的timeoutSeconds=1,而服务在高峰期 GC 一次就要 1.5 秒,探针超时被杀。调大 limit 完全无效,改探针超时立刻解决。从那以后我看 137 一定先确认reason字段。
第二个是"镜像更新了但没生效"。用固定 tag 加imagePullPolicy: IfNotPresent,推了同名 tag,节点上已有缓存,于是永远不会去拉新的。这个组合看起来毫无问题,实际是个稳定的坑。要么用不可变 tag(如 commit hash),要么明确设成Always。
第三个是事件窗口过期。前面提过,默认事件只保留一小时。我现在会在排查模板里固定加上一步:如果是隔夜问题,先去事件持久化系统里捞,别在get events上浪费时间怀疑人生。
还有一个习惯我想单独说:每次排查完,把根因和判断依据写进记录里。不是为了交差,是因为 Pod 异常的根因分布是高度长尾的——同一个团队一年下来,真正高频的就那么七八种,剩下的都是偶发。把偶发的那部分记下来,下次遇到类似症状能省掉大半天。我自己的记录已经攒了上百条,其中至少十几次是直接照着旧记录复现出答案的。
最后分享一个小技巧:如果你在排查一个不确定原因的问题,别急着改配置。先把当前的 Pod 完整 describe 输出和 YAML 存下来再动手。改完之后如果没解决,你至少还有一个对照基准;改完之后解决了,你也得知道是哪一处改动起的作用。我见过太多人一口气改了五处配置,问题好了但根本不知道原因,下次照样抓瞎。