理解 CrashLoopBackOff:退避机制与真实含义
在 Kubernetes 上部署应用,遇到 CrashLoopBackOff 几乎是每个团队都要经历的事。它看起来像一个明确的错误,但其实是 kubelet 对“容器反复启动失败”这个现象的汇总描述。很多时候,当你盯着 kubectl get pods 的输出,试图从一堆状态里找到规律时,真正的问题可能并不在业务日志里,而在你还没看过的上一份容器日志中。

先看 Kubernetes 源码里的定义:CrashLoopBackOff 表示容器的启动-退出循环被卡住,kubelet 进入退避等待。退避时间不是固定的,会从 10 秒开始,每次翻倍,最大到 300 秒。也就是说,一个反复崩溃的容器,并不会像我们想象中那样疯狂消耗资源,而是被 kubelet 有意限制了重启频率。
这里有一个容易被忽略的点:进入 CrashLoopBackOff 并不代表你的容器“正在崩溃”,当前容器可能正处于退避等待的时间窗口,状态是 Waiting。如果你在这个窗口执行 kubectl logs,可能会得到“ContainerCreating”一类误导信息。所以第一步不是马上看日志,而是看 Pod 的 Status 和 ContainerStatuses 里的 State。
此外要留意 restartPolicy。只有 restartPolicy 设置为 Always 或 OnFailure,并且容器反复失败时,才会进入 CrashLoopBackOff。对于 restartPolicy: Never 的 Job Pod,失败的容器会直接进入 Failed 状态。如果发现一个 Pod 处于 CrashLoopBackOff 但你不确定它受什么控制器管理,先查 Pod 的 ownerReferences。
OOMKilled 为什么总与 CrashLoopBackOff 一起出现
OOMKilled 是一个容器退出原因,它描述的是容器内存使用超过 cgroup 限制后,被内核强制终止。在 Kubernetes 中,容器被 OOM 杀死后,退出码通常是 137,kubelet 在 describe 输出的 Last State 里会写出 Reason: OOMKilled。
因为它是一种突然死亡式的崩溃,容器进程没有机会优雅退出,也不会留下应用级别的错误日志,所以很多人排障时觉得困惑:应用日志看起来一切正常,进程却不断消失。
造成 OOMKilled 的因素不只有业务代码的暴力内存分配,还包括 Kubernetes 对内存 limit 的管理方式、运行时自身的开销、JVM 等语言运行时对系统内存的感知偏差。这里不需要深入内核,但需要知道一个关键原理:内核 OOM killer 是作用于 cgroup 的,而 cgroup 的 memory limit 就是 kubelet 根据 Pod 的 limits.memory 写出的。容器用的页缓存、匿名页、内核堆中的任何一部分,都会把 cgroup 内存推向 limit。
用 kubectl 完成一次真实的故障定位
现在进入具体排查。假设你在控制台上看到两个处于 CrashLoopBackOff 的 Pod,应用是 Java 服务,刚上线的新版本。你手头没有监控,只有 kubectl。按照下面的命令链走,通常能很快锁定方向。
# 查看 Pod 当前状态
kubectl get pods -n production -o wide
# 查看事件和容器退出原因
kubectl describe pod <pod-name> -n production
# 查看上一次容器的日志,这是关键
kubectl logs <pod-name> -n production --previous -c app
describe 会输出大量信息,但只需要关注几个字段:Status.ContainerStatuses 下的 State、Last State、Exit Code,以及 Events 的最后几行。比如 Events 里出现 “Killing container with OOMKilled”,那基本可以判断是内存 limit 的问题。如果 Last State 显示 Error,Exit Code 是 1,那更可能是应用启动逻辑失败。
为什么要用 –previous?因为 CrashLoopBackOff 状态下当前容器可能还在启动等待,kubectl logs 默认取当前容器,拿不到崩溃前的日志。–previous 取的是上一次容器实例的日志,那才是崩溃现场。
假设一个后端服务本地跑得好好的,部署到集群后就 CrashLoopBackOff。describe 看到 Container app 的 Last State 是 Terminated,Reason 是 OOMKilled,Exit Code 是 137。但监控显示平均内存只有 800Mi,limit 设置了 1Gi。这看起来不合理。再翻 previous log 发现,服务启动时会加载一份离线数据到内存,瞬时峰值超过 2.6Gi。平均值掩盖了启动尖峰,所以 OOM 其实发生在启动阶段。
再看一种情况:一个 Pod 里有两个容器,主服务和日志采集 sidecar。主服务起来了,但 sidecar 因为配置文件格式错误不断退出,kubelet 判断 Pod 处于崩溃循环。describe 里明明写着主容器 Running,但整体状态仍是 CrashLoopBackOff。只看主容器日志,永远找不到问题。所以排查多容器 Pod 时,一定要用 -c 指定容器,逐个检查。
退出码:快速缩小问题范围
退出码是第二个关键信息。不要只记住 137,很多情况下退出码能直接帮你区分是不是 OOM。
137 等于 128 加 9,即进程收到 SIGKILL。主要由两类触发:cgroup OOM 和节点驱逐。需要结合 events 判断具体是哪一类。143 等于 128 加 15,SIGTERM,通常是正常终止流程,比如滚动更新、Pod 删除,或者应用内部调用 os.Exit。如果容器出现 143 后进入 CrashLoopBackOff,需要看是不是被别的控制器终止了。其他 1 到 255 的非 0 退出码,一般需要看应用自身的 previous log。
还有一个容易忽略的变体:Pod 里带 initContainer 时,如果 initContainer 失败,Pod 状态会显示 Init:CrashLoopBackOff,而不是普通 CrashLoopBackOff。这类问题的线索不在主容器的日志里,而在 initContainer 的 logs 里。
CrashLoopBackOff 和 OOMKilled 的多种表现
严格来说,OOMKilled 是导致 CrashLoopBackOff 的原因之一,而不是两个孤立状态。CrashLoopBackOff 是 kubelet 对 Pod 状态的描述,OOMKilled 是容器退出原因。下面这个表整理了排查时最常看到的几个组合。
| 现象 | 退出码 | 指向的根因 | 第一排查命令 |
|---|---|---|---|
| CrashLoopBackOff,Last State 为 OOMKilled | 137 | 容器超过 memory limit,被 cgroup OOM killer 杀死 | kubectl describe pod 看 events 和 limits |
| CrashLoopBackOff,Last State 为 Error | 1/2/3 | 应用启动失败、配置错误、依赖不可用 | kubectl logs –previous -c container |
| CrashLoopBackOff,探针失败 | 通常为 0 | Liveness 探针导致的杀容器重启 | describe 看 Liveness 探测日志 |
| Pod 状态为 Evicted | 137 | 节点级资源压力驱逐,非单容器 OOM | kubectl describe node 查看压力 |
常见误区:别在调试之前就下结论
- 看到 CrashLoopBackOff 就当作严重错误:退避机制本身是保护。如果应用启动依赖外部组件,网络抖动导致失败,退避后可能自己恢复。先观察退避时间,再决定干预方式。
- 只看当前日志,忽略 –previous:这是最常见的问题。崩溃现场在上一个容器实例,而不是当前这个刚启动的实例。
- 遇到 OOMKilled 就立刻调大 memory limit:如果只是内存泄漏,调大 limit 只是推迟爆炸。而且过大的 limit 会加剧节点超卖,影响其他 Pod。
- 只关心容器状态,不关心节点状态:Pod 可能因为节点级驱逐进入 Evicted,状态看上去可能仍然是 Failed 或 CrashLoopBackOff。检查节点 MemoryPressure 和 DiskPressure 能排除这部分干扰。
这些误区不是孤立的,它们背后有一个共性:把“容器崩了”当成根因,而不是当成待解释的现象。
当 OOMKilled 真的是根因,怎么调整才合理?
首先确认应用运行时的内存模型。Java 服务要关注 -Xmx、Metaspace、线程栈和直接内存。Go 服务主要看 runtime 和业务缓存。Node.js 要区分 heap 和 external memory。没有万能公式,但可以遵守一个原则:limits.memory 必须大于应用正常运行峰值和启动峰值中的较大者,并为重启、GC 和运行时开销留出余量。
拿 Java 服务举例,假设启动时消耗 1.2Gi,稳定后约 900Mi,-Xmx 设置为 1Gi,那么容器 limits.memory 设置 1536Mi 相对合理。requests.memory 应接近稳定运行值,至少不能让调度器把 Pod 放到一个没有余量的节点上。
# 一个初步可行的 Java 服务资源配置
resources:
requests:
memory: 1Gi
limits:
memory: 1536Mi
但需要注意,这份配置只适合示例场景。真正落地时还要考虑节点规格、同节点其他 Pod 的资源分配、以及应用是否存在内存泄漏。
内存泄漏的典型特征是:容器内存曲线持续上升,并且重启后重新走高。这时候即使调大 limit,也只是把问题延后。正确做法是通过 pprof、JFR 等工具定位到具体分配点,修复后再回归验证 limit 设置。
把排查流程沉淀成团队实践
单靠临时想命令很容易漏掉某个环节。团队里维护一份轻量级故障排查清单,能显著缩短平均恢复时间。下面这份是我比较推荐的基础版本:
- 记录 Pod 所处状态和退避间隔,判断是否需要立刻干预;
- 查看上次容器退出码和退出 Reason;
- 拉取上一次容器日志,多容器用 -c 指定;
- 检查应用启动依赖、健康检查和探针配置;
- 对比内存监控曲线与当前 limit,判断是否存在 OOM 趋势;
- 查看节点水位,排除超卖和驱逐。
告警规则也应该有区分度。例如退出码 137 的 Pod 单独告警,标记为 OOMKilled;Liveness 探针失败单独告警,标记为健康检查问题。这样收到告警的人能带着大致方向去排障,而不是看到一堆 CrashLoopBackOff 不知道先看哪一条。
最后,回到问题的起点
CrashLoopBackOff 和 OOMKilled 不是一个需要背下来的面试题,而是一套 Kubernetes 对故障的编码方式。状态、事件、退出码,已经记录了容器每次死亡的线索。排障时最怕的是只盯着状态名,然后拿已有的经验猜原因。
下次再遇到这类问题,先跑一遍 describe,读一遍事件,再看 previous 日志;如果确认是 OOMKilled,先看内存曲线,再动手调参数。你会发现很多所谓的疑难杂症,其实只是线索一直放在那里,没有串起来。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/720/