☰
Kubernetes Pod异常排查:从状态码与Events定位根因
2026/10/27 3:06:51 网站建设 项目流程

Pod 异常问题排查这件事,我在不同规模的集群里反复做过很多次,慢慢发现一个挺反直觉的结论:大多数人排查卡住,不是因为命令掌握得不够多,而是因为第一眼就盯错了地方。看到容器起不来,下意识就去翻业务日志,结果日志里空空如也;看到 Pending,就先怀疑网络不通,实际上调度器压根还没给这个 Pod 分配节点。Pod 的状态字段本身已经把答案写在脸上了,只是很多人跳过了这一步。

这篇内容我打算把 Pod 异常排查拆成一条可复用的判断链路:从状态码读到什么、怎么用事件定位到具体环节、不同异常各自的根因分布、以及哪些"故障"其实是配置写错造成的假象。适合已经能在集群里跑起工作负载、但遇到问题还习惯性重启试运气的同学,也适合想把排查流程沉淀成团队规范的人。下面这些结论基本上都是我在真实环境里踩出来的,不是文档的复述。

1. Pod 状态是第一手线索:先把"异常"翻译成可验证的假设

Pod 异常排查最容易犯的错,是把所有"起不来"当成同一类问题。实际上 Kubernetes 用一个很有限的状态集合,已经把故障切分到了不同的责任方:调度器、kubelet、容器运行时、镜像仓库、应用进程,各自对应不同的状态表现。读懂这张映射表,排查范围能立刻缩小一大半。

1.1 七种高频状态分别指向谁

我习惯在动手之前先做一次"责任方判定",用下面的对照关系快速缩小范围:

状态大概率责任方第一手要看的字段
Pending调度器 / 存储绑定Events 里的 FailedScheduling
ContainerCreatingkubelet / 运行时 / 存储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 我自己用的三段式排查顺序

长期下来,我的顺序基本固定成三段,中间不做跳跃:

  1. 状态与事件:get pod -o wide看状态和节点,describe看 Events 和 Conditions,确定责任方。
  2. 进程现场:能进容器就进,进不去就用--previous日志、kubectl debug临时容器、或者到节点上看运行时日志。
  3. 横向印证:同节点其他 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,先搞清楚内存到底花在哪。我的顺序是:

  1. 看kubectl top pod,确认内存曲线是缓慢爬升还是瞬间冲到顶。缓慢爬升通常是内存泄漏或者缓存无界增长;瞬间冲顶多半是启动时一次性加载了大量数据。
  2. 对比requests和limits的差值。差太大意味着这个 Pod 是 Burstable 类型,在节点内存紧张时优先级低,先调 request 让它更接近真实用量。
  3. 检查运行时层面对内存的处理。以 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. 我自己的排查清单与几个值得记住的教训

下面这份清单是我现在开新集群或者带新人时都会给的一份,按顺序走基本能覆盖八成问题:

  1. get pod -o wide确认状态和所在节点
  2. describe看 Events、Conditions、Last State
  3. 对比同节点、同 Deployment 其他副本是否正常
  4. logs -p看上一次退出前的输出
  5. 确认配置引用(ConfigMap/Secret/PVC)是否存在且内容正确
  6. 确认探针参数与启动耗时是否匹配
  7. 从 Pod 内部验证 DNS 与端口连通性
  8. 检查最近是否有发布、配置变更、节点事件

关于教训,有三个印象最深。

第一个是退出码 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 存下来再动手。改完之后如果没解决,你至少还有一个对照基准;改完之后解决了,你也得知道是哪一处改动起的作用。我见过太多人一口气改了五处配置,问题好了但根本不知道原因,下次照样抓瞎。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询