Kubernetes Pod 反复重启:区分 CrashLoopBackOff、探针失败与 OOM

95 次浏览5 条回复

适用于 Kubernetes 1.28+、kubectl 1.28+。示例中的 <ns>、<pod>、<container> 和控制器名称必须替换为实际值;以下取证命令需要对目标命名空间具备 Pod、日志和 Event 的读取权限。CrashLoopBackOff 只是 kubelet 对重复失败施加退避后的等待状态,不是根因。先保留现场,不要一开始就删除 Pod;删除后,上一容器实例的日志通常无法再从原 Pod 获取。

1. 固定对象、时间线与失败容器

date -u
kubectl version --client
kubectl -n <ns> get pod <pod> -o wide
kubectl -n <ns> describe pod <pod>
kubectl -n <ns> get pod <pod> -o jsonpath='{range .status.initContainerStatuses[*]}init {.name}{"\trestarts="}{.restartCount}{"\twaiting="}{.state.waiting.reason}{"\tlast="}{.lastState.terminated.reason}{"\texit="}{.lastState.terminated.exitCode}{"\n"}{end}{range .status.containerStatuses[*]}app {.name}{"\trestarts="}{.restartCount}{"\twaiting="}{.state.waiting.reason}{"\tlast="}{.lastState.terminated.reason}{"\texit="}{.lastState.terminated.exitCode}{"\n"}{end}'

先确认失败的是 init container、主容器还是 sidecar。restartCount 是当前 Pod 内该容器的累计值;控制器替换 Pod 后会重新计数,因此必须同时记录 Pod 名称、UID 和创建时间。describe 底部的 Event 有助于定位拉取镜像、挂载、探针和调度问题,但 Event 有保留期限,不能作为唯一证据。

2. 同时读取当前与上一实例日志

kubectl -n <ns> logs <pod> -c <container> --timestamps --tail=200
kubectl -n <ns> logs <pod> -c <container> --previous --timestamps --tail=200
kubectl -n <ns> get events \
  --field-selector involvedObject.kind=Pod,involvedObject.name=<pod> \
  --sort-by=.metadata.creationTimestamp

--previous 只有在同一 Pod 中存在上一容器实例时才有内容。若应用启动后立刻退出,上一实例日志通常比当前日志更关键。先按 UTC 时间对齐应用日志、容器终止原因和 Event;不要只凭最后一行异常判断根因。

3. 区分应用退出、探针重启与 OOM

  • lastState.terminated.reason=Error:结合退出码和应用文档检查参数、配置、依赖与权限。退出码 1 只表示程序报告失败,不能进一步推断原因。
  • Event 先出现 liveness probe failed,随后出现 Killing:重点检查探针的路径、端口、协议、超时和执行成本。readiness 探针失败只会把 Pod 移出就绪端点,本身不会重启容器;startup 探针成功前,liveness 与 readiness 不会开始执行。
  • reason=OOMKilled:核对内存限制、应用峰值和节点侧记录。退出码 137 表示进程收到 SIGKILL,并不单独证明是 OOM,仍需与 OOMKilled、Event 或节点日志相互印证。

可继续做只读检查:

kubectl -n <ns> get pod <pod> -o jsonpath='{range .spec.containers[*]}{.name}{"\trequests="}{.resources.requests}{"\tlimits="}{.resources.limits}{"\n"}{end}'
kubectl -n <ns> top pod <pod> --containers

kubectl top 依赖 Metrics Server,只显示近期采样,无法还原已经结束的内存峰值;命令无数据时不能据此排除 OOM。不要仅通过放宽探针或内存限制来掩盖持续增长的资源使用。

4. 核对控制器与最近变更

kubectl -n <ns> get pod <pod> -o jsonpath='{.metadata.uid}{"\n"}{range .metadata.ownerReferences[*]}{.kind}{"/"}{.name}{"\n"}{end}{range .spec.containers[*]}{.name}{"\timage="}{.image}{"\n"}{end}'
kubectl -n <ns> rollout history deployment/<deployment>

Pod 的直接所有者可能是 ReplicaSet、StatefulSet、DaemonSet 或 Job,不要假定一定存在 Deployment。将首次失败时间与镜像、配置、Secret、探针及资源限制的变更时间对应;环境变量形式的配置不会因源对象更新而自动刷新到既有容器。检查清单时注意输出中可能包含敏感值,避免把完整 Pod YAML 或 Secret 内容粘贴到公开记录。

5. 修复后的闭环

在允许的变更窗口按实际控制器发布修复,不要把手工修改单个 Pod 当作持久修复。随后至少验证:

kubectl -n <ns> get pod <pod>
kubectl -n <ns> describe pod <pod>
kubectl -n <ns> logs <pod> -c <container> --timestamps --tail=100
kubectl -n <ns> rollout status deployment/<deployment> --timeout=5m

rollout status 仅适用于实际由 Deployment 管理的工作负载。验证窗口应超过原来的典型崩溃周期及探针启动时间:Pod 持续 Ready,容器 restartCount 不再增长,Event 与日志无新增失败,并且从真实服务入口完成一次无副作用的健康检查。只有这些条件同时满足,才能认为重启循环已经闭环。

补充一个 OOM 分支:还要区分容器触及内存限制、节点级 OOM 与 kubelet 因内存压力驱逐;三者都可能表现为工作负载中断,但修复方向不同。Kubernetes 1.28+ 可先记录 Pod 所在节点、QoS、终止信息与节点条件:

kubectl -n <ns> get pod <pod> -o jsonpath='{.spec.nodeName}{"\tqos="}{.status.qosClass}{"\tphase="}{.status.phase}{"\treason="}{.status.reason}{"\tmessage="}{.status.message}{"\n"}'
kubectl -n <ns> get pod <pod> -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\tlastReason="}{.lastState.terminated.reason}{"\texit="}{.lastState.terminated.exitCode}{"\tfinished="}{.lastState.terminated.finishedAt}{"\n"}{end}'
kubectl get node <node> -o jsonpath='{range .status.conditions[*]}{.type}{"="}{.status}{"\treason="}{.reason}{"\tlastTransition="}{.lastTransitionTime}{"\n"}{end}'

Pod 的 status.reason=Evicted 或 Event 中的 eviction 信息更偏向 kubelet 驱逐;容器 lastReason=OOMKilled 说明终止与 OOM 相关,但仅凭这一字段仍不能判断是容器内存上限还是节点全局内存不足。若具备节点日志读取权限,且节点由 systemd 托管,可在对应 UTC 时间窗核对内核与 kubelet 日志:

sudo journalctl -k --since '<UTC-start>' --until '<UTC-end>' --no-pager | grep -Ei 'out of memory|oom-kill|killed process'
sudo journalctl -u kubelet --since '<UTC-start>' --until '<UTC-end>' --no-pager | grep -Ei 'evict|memorypressure|oom'

修复后除了观察 restartCount,还应确认节点 MemoryPressure 未持续为 True、没有新增驱逐事件,并在至少一个原故障周期内观察内存使用趋势。若问题来自节点压力,单纯提高容器 limit 反而可能扩大影响范围。

再补一个 Pod 已被控制器替换、--previous 也取不到时的取证兜底。--previous 只覆盖同一 Pod 内的上一容器实例;Deployment 换出旧 Pod,或 StatefulSet 以同名新 UID 重建后,不能再依赖它。Kubernetes 1.28+ 可先把当前 UID 与终止摘要一起记录:

kubectl -n <ns> get pod <pod> -o jsonpath='{.metadata.uid}{"\n"}{range .status.initContainerStatuses[*]}init/{.name}{"\t"}{.lastState.terminated.finishedAt}{"\t"}{.lastState.terminated.reason}{"\t"}{.lastState.terminated.message}{"\n"}{end}{range .status.containerStatuses[*]}app/{.name}{"\t"}{.lastState.terminated.finishedAt}{"\t"}{.lastState.terminated.reason}{"\t"}{.lastState.terminated.message}{"\n"}{end}'

对于后续发布,可在工作负载模板中显式保留终止消息:

containers:
  - name: app
    terminationMessagePath: /dev/termination-log
    terminationMessagePolicy: FallbackToLogsOnError

应用若能捕获致命错误,应只向 /dev/termination-log 写入简短、脱敏且可定位的摘要;不要写凭据、请求正文或完整环境变量。FallbackToLogsOnError 仅在终止消息文件为空且容器异常退出时回退到末尾日志,容量也有限,不能替代完整日志。

跨 Pod 保留证据仍应依靠集中日志或节点侧采集,并至少索引 cluster、namespace、pod UID、container 与 UTC 时间。验证时应在非生产 canary 中让进程写入唯一标记后异常退出,确认同一 Pod 的 lastState.terminated.message 可读;再让控制器替换 Pod,确认日志后端仍能按旧 Pod UID 查到该标记。这样能分别验证“终止摘要”和“跨 Pod 日志留存”两条链路。

还可以单列一个“正常退出但仍循环”的分支。若 lastState.terminated.reason=Completed 且 exitCode=0,不代表工作负载健康:对 Deployment 而言,Pod 模板的 restartPolicy 必须为 Always,长驻进程一旦正常结束,kubelet 仍会重启它并逐步进入退避。先核对实际启动入口:

kubectl -n <ns> get pod <pod> -o jsonpath='{"restartPolicy="}{.spec.restartPolicy}{"\n"}{range .spec.containers[*]}{.name}{"\tcommand="}{.command}{"\targs="}{.args}{"\n"}{end}'
kubectl -n <ns> logs <pod> -c <container> --previous --timestamps --tail=200
kubectl -n <ns> get pod <pod> -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\treason="}{.lastState.terminated.reason}{"\texit="}{.lastState.terminated.exitCode}{"\tstarted="}{.lastState.terminated.startedAt}{"\tfinished="}{.lastState.terminated.finishedAt}{"\n"}{end}'

Kubernetes 的 command 会覆盖镜像 ENTRYPOINT,args 会覆盖镜像 CMD。常见问题包括覆盖后漏掉服务启动参数、shell 脚本把主进程放到后台后自行退出,以及应用执行一次初始化任务后按设计返回 0。应同时检查镜像默认入口和工作负载模板,不能只看退出码。检查命令输出时注意参数可能包含敏感信息,不要原样贴到公开记录。

修复方向取决于职责:长驻服务应让主进程以前台 PID 1 持续运行,shell 包装脚本最后用 exec 启动它;一次性任务则更适合 Job 或 CronJob,并按语义使用 OnFailure 或 Never,不应放在 Deployment 中制造无限重启。验证时,Deployment 可执行:

kubectl -n <ns> rollout status deployment/<deployment> --timeout=5m
kubectl -n <ns> get pod <new-pod> -w

观察窗口要超过原来的退出周期,确认新 Pod 持续 Ready 且 restartCount 不再增长;若改为 Job,则用 kubectl -n <ns> wait --for=condition=complete job/<job> --timeout=5m,并确认成功完成次数符合预期。

补一个容易被忽略的分支:镜像标签没变,不等于实际运行内容没变。使用可变标签、不同节点的镜像缓存,或 imagePullPolicy 与预期不一致时,同一工作负载的副本可能落到不同 digest;这类问题常表现为只有部分 Pod 启动失败。Kubernetes 1.28+ 可先同时记录声明镜像和运行时解析出的 imageID:

kubectl -n <ns> get pod <pod> -o jsonpath='{range .status.initContainerStatuses[*]}init/{.name}{"\timage="}{.image}{"\timageID="}{.imageID}{"\n"}{end}{range .status.containerStatuses[*]}app/{.name}{"\timage="}{.image}{"\timageID="}{.imageID}{"\n"}{end}'
kubectl -n <ns> get pod <pod> -o jsonpath='{range .spec.initContainers[*]}init/{.name}{"\tpullPolicy="}{.imagePullPolicy}{"\n"}{end}{range .spec.containers[*]}app/{.name}{"\tpullPolicy="}{.imagePullPolicy}{"\n"}{end}'

imageID 通常包含内容摘要,但具体前缀由容器运行时决定。排查多个副本时,用工作负载的真实标签选择器对比,不要手写一个可能覆盖不全的选择器:

kubectl -n <ns> get pods -l '<selector>' \
  -o custom-columns='NAME:.metadata.name,NODE:.spec.nodeName,CREATED:.metadata.creationTimestamp,IMAGE:.status.containerStatuses[*].image,IMAGE_ID:.status.containerStatuses[*].imageID,RESTARTS:.status.containerStatuses[*].restartCount'

若声明的标签相同但 imageID 不同,应核对制品仓库中的 digest、各 Pod 创建时间以及工作负载模板,而不是先删除异常 Pod。imagePullPolicy 的默认值是在对象创建时确定的;后续只改镜像标签,不应假设该字段会自动随之调整。

修复时优先在模板中固定经过验证的 digest,例如 registry.example/app@sha256:<digest>,再按实际控制器发布。闭环条件是新副本的 imageID 全部匹配预期摘要、发布状态完成,并在超过原故障周期的观察窗口内 restartCount 不再增长;同时保留回滚所需的旧 digest。

再补一个探针分支的判定细节:容器因 liveness 连续失败被 kubelet 终止后,容器状态可能只留下 reason=Error 与退出码 137;这仍不能单独证明 OOM。应把同一 Pod UID 的 Unhealthy、Killing Event 与终止时间放在一条时间线上:

uid=$(kubectl -n <ns> get pod <pod> -o jsonpath='{.metadata.uid}')
kubectl -n <ns> get events \
  --field-selector involvedObject.uid="$uid" \
  --sort-by=.metadata.creationTimestamp
kubectl -n <ns> get pod <pod> -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\tlastReason="}{.lastState.terminated.reason}{"\texit="}{.lastState.terminated.exitCode}{"\tfinished="}{.lastState.terminated.finishedAt}{"\n"}{end}'

Event 有保留期限;取不到历史 Event 时,不能反向认定探针没有失败。还要核对实际 Pod 模板里的 startup、liveness 与 readiness 配置,而不是只看仓库中的 YAML。HTTP、TCP 和 gRPC 探针默认由 kubelet 访问 Pod IP,容器内执行 curl localhost 成功并不能完整复现这条路径;exec 探针则在容器内执行。

探针职责也要分开:liveness 只回答“进程是否必须重启”,若把数据库或外部依赖可用性绑进 liveness,依赖抖动可能触发整批副本重启;这类依赖通常更适合 readiness。冷启动时间不稳定时,应配置 startup probe 覆盖最慢的正常启动窗口,而不是一味放宽 liveness。

修改后按实际控制器发布,并至少验证:

kubectl -n <ns> rollout status deployment/<deployment> --timeout=5m
kubectl -n <ns> get pod <new-pod> -w
kubectl -n <ns> get events \
  --field-selector involvedObject.name=<new-pod> \
  --sort-by=.metadata.creationTimestamp

观察窗口应覆盖一次完整冷启动和原来的典型失败周期,确认 Pod 持续 Ready、restartCount 不增长,且没有新增 Unhealthy 或 Killing。若工作负载不是 Deployment,应替换为对应控制器的发布与状态检查命令。