尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

K8s 排障手册:CrashLoopBackOff 深度解析与排查思路

K8s 排障手册:CrashLoopBackOff 深度解析与排查思路 Kubernetes 排障里CrashLoopBackOff可能是最让人头疼的状态之一。你没改任何代码也没动过节点但 Pod 就是这个死循环启动、崩溃、退避、再启动、再崩溃。如果你在集群里盯着kubectl get pod输出看到 NAME 下面一串CrashLoopBackOff第一反应多半不是看代码而是先反复拉日志却发现日志什么都看不出来——这种情况我经历过太多次了。CrashLoopBackOff 的排障难点不在崩溃本身而在它的伪装性同一个现象背后可能是应用代码异常、资源不足、探针误杀、镜像入口命令错误、甚至节点级的系统问题。本文我会从状态机的本质、Exit Code 分流、日志获取姿势、真实案例复盘再到几个长得像 CrashLoop 但不是 CrashLoop的相邻场景把我在生产环境里踩过和帮别人排查过的经验完整讲一遍。适合刚接触 Kubernetes 的运维和开发也适合已经在排障但想提升效率的同学。1. CrashLoopBackOff 的本质状态机、指数退避与越等越久的时间规律1.1 一个 Pod 从启动到崩溃状态是怎么一步步变成 CrashLoopBackOff 的很多新人容易把 CrashLoopBackOff 理解成进程有问题所以崩溃了。实际上它是 kubelet 对容器反复启动又反复失败这一现象做的状态标记是一种保护性退避机制在起作用而不是一个单纯的错误码。要理解它先看 Pod 的完整生命周期。正常情况下一个 Pod 提交到 API Server 后调度器把它调度到某个节点kubelet 开始创建沙箱、拉镜像、启动容器状态依次可能是Pending→ContainerCreating→Running。但如果容器里的主进程启动后立刻退出或者启动后因为某种原因被 killkubelet 会按容器重启策略重启它。默认的restartPolicy是Always所以只要不是Neverkubelet 就会尝试拉起重启。关键在于重启一次失败还不足以标记为 CrashLoopBackOff。kubelet 内部维护了一个状态记录当同一个容器在短时间内连续多次失败重启后就会转入CrashLoopBackOff。这时候你再kubectl describe pod会在 Events 里看到类似这样的记录Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 2m50s default-scheduler Successfully assigned ... Normal Pulled 2m50s kubelet Container image ... already present on machine Normal Created 2m50s kubelet Created container ... Normal Started 2m50s kubelet Started container ... Warning BackOff 2m48s kubelet Back-off restarting failed container注意Back-off restarting failed container这条事件。它的语义是我已经尝试重启了但依然失败为了不把节点资源耗光我决定冷却一段时间再重启。 这个冷却时间不是固定的见下面的计算逻辑。1.2 指数退避机制的隐藏价值从重启间隔反推问题影响范围kubelet 对容器重启的退避时间遵循指数增长策略。K8s 源码里默认的backoff逻辑大致是首次退避 10 秒然后是 20 秒、40 秒、80 秒、160 秒达到上限 300 秒5 分钟后保持。失败重启次数等待时间大致重启频率110s每 10 秒一次220s每 20 秒一次340s每 40 秒一次480s每 80 秒一次5160s每 160 秒一次6300s每 5 分钟一次这个表有个很实用的排障价值你的 Pod 现在处于每 5 分钟重启一次还是每 10 秒重启一次能间接反映问题的严重程度和类型。如果重启间隔一直在 10 秒到 20 秒之间说明容器可能在启动后非常短的时间内就崩溃了比如命令写错、依赖缺库、启动即panic这类问题一般看应用日志就能抓到。如果重启间隔已经到 5 分钟说明容器能运行一段时间才挂掉很可能是运行期不稳定——资源超过 limit 被 OOM、探针超时被杀、或者处理某个请求时触发 fatal error。间隔越短越偏启动本身有问题间隔越长越偏运行过程中被外部条件杀死。这个反向推演是我排障时用的第一个判断工具。拿到一个 CrashLoopBackOff 的 Pod我先不慌着看日志而是先看kubectl describe里最近的重启时间和Restart Count把重启间隔搞清楚再看下一步。很多时候重启间隔能告诉你该去查哪一层。2. 动手前先分流Exit Code 能告诉你崩溃发生在哪一层2.1 常见 Exit Code 速查表及对应排查路径容器崩溃退出时kubelet 会记录它的退出码Exit Code。这个数字不是随便给的它直接指明了进程是被谁终止的、怎么终止的。拿到退出码之后再去翻日志会比盲目找日志高效得多。我把生产环境里最常见的几个退出码整理成了速查表每次排障先对号入座Exit Code含义通常对应的排查方向0进程主动退出正常结束任务型容器跑完命令退出PID 1 进程意外 return启动脚本执行完没阻塞1应用内部错误看应用日志大概率是代码异常、依赖不可用、配置错误2程序运行时错误/命令行参数问题检查启动命令、环境变量、入口脚本126命令存在但不可执行检查二进制文件权限、挂载目录的执行权限127命令不存在checker 启动命令、镜像内二进制路径是否正确130进程收到 SIGINT通常是 CtrlC或被外部发送中断信号137进程被 SIGKILL 杀死大部分是 OOM Kill查资源 limit 和节点内存也可能是kill -9139段错误 SIGSEGV原生代码崩溃、JVM 崩溃、C/C 库问题143进程被 SIGTERM 后杀死优雅终止超时被强杀一般发生在滚动更新或节点驱逐时这个表不是绝对的但足以帮助你划分排查方向。比如退出码 127你不用去翻代码逻辑先检查启动命令里写的路径是否存在image 里的 shell 是否有问题退出码 139就得往 JVM 崩溃日志、hs_err_pid、native 库方向走。2.2 Exit Code 137/143不是崩溃是被系统干掉的实际排障中最容易被误读的是 137 和 143。很多人看到退出码 137 就觉得代码出 bug 了其实 137 对应的信号是 SIGKILL一个进程如果没写自杀逻辑一般不会主动给自己发 SIGKILL它大概率是被外部杀掉的。137 的常规解释是 OOMKilled容器进程触发了 Linux 内核的 OOM Killer。K8s 层面你可以通过kubectl describe pod看到Last State: Terminated的Reason: OOMKilled同时Exit Code: 137。但这里要特别注意不是只有容器自身超过 limit 才会被 OOM Kill节点整体内存不足时内核也可能杀你的容器来保护节点。所以看到 137第一件事是看容器用了多少内存第二件事是看节点内存水位。143 对应 SIGTERM 可能超时后强杀经常发生在滚动更新、节点排空或者存活探针失败触发容器重建时。如果某个 Pod 频繁 143 退出优先看是不是探针设置了太短的failureThreshold或者 Deployment 的terminationGracePeriodSeconds太短导致优雅退出没跑完。2.3 容易被忽略的 Exit Code 0主动退出的容器还有一类诡异的 CrashLoopBackOff 是退出码 0。进程正常退出也会导致 kubelet 重启容器反复几次后同样进入 CrashLoopBackOff。这种场景在任务型应用里特别常见比如镜像的ENTRYPOINT是/bin/sh -c脚本最后一条命令执行完shell 直接退出容器也随之退出应用在后台 fork 之后主进程立即 return比如 Java 启动脚本里末尾写了但没有用wait阻塞command配置里写的是[echo, hello]容器 echo 完就退出。这类问题有一个很明显的特征应用日志里没有报错退出得很干净。看到 Exit Code 0 的 CrashLoopBackOff优先检查两点command是不是一个常驻前台进程以及镜像入口是否用了nohup或导致主进程已经脱离前台。容器和虚拟机不同它要求 PID 1 进程一直挂在前台一旦 PID 1 退出整个容器就终止了。3. 日志获取的正确打开方式不止 kubectl logs 一条路3.1 kubectl logs 与 --previous以及背后的日志轮转陷阱拿到退出码以后紧接着需要日志佐证。最常用的命令是kubectl logs pod-name -n namespace但这里有个新手最常踩的坑容器崩溃重启后当前容器的日志不一定包含崩溃前的内容。因为 kubelet 创建了新容器而kubectl logs默认只看当前容器的输出。想拿上一次生命周期的日志就得加-p或--previouskubectl logs pod-name -n namespace --previous--previous拿到的才是崩溃之前那个容器实例的输出。很多 CrashLoopBackOff 场景里当前容器日志只有重新启动后刷出来的几行真正的错误都在上一次实例的日志里。还有两个参数值得养成习惯--tailN只看最后 N 行。容器反复重启时日志可能很长全量拉下来刷屏反而找不到重点。--timestampstrue给每行日志加时间戳。排障时需要把应用日志和 K8s 事件做时序比对没有时间戳会非常被动。另外要注意的是如果应用的 stdout/stderr 没有接入日志系统而是写到了文件里那么kubectl logs只能看到部分内容——这取决于你镜像里的启动脚本是否做了重定向。遇到应用日志文件写在 /app/logs 下的情况你得先kubectl exec进去看文件或者通过kubectl debug复制一份容器内的路径来查看。这层信息往往被忽略但对排障非常关键。3.2 kubectl describe 的 Events 字段里藏着更重要的事实很多人在 CrashLoopBackOff 排障时只看容器日志忘了kubectl describe pod这个命令其实是最快的入口。它不会给你应用内部的业务日志但它把 kubelet 视角下的事实完整列出来了我排障时永远先跑这一条kubectl describe pod pod-name -n namespace重点看三块State / Last State / Reason当前容器的状态、上一次退出时的退出码、终止原因。如果 Reason 是OOMKilled、Error还是Completed直接决定了下一步走哪条路。ConditionsInitialized、Ready、ContainersReady、PodScheduled四列。其中ContainersReady经常为 False说明容器起不来。Events按时间顺序列出调度、镜像拉取、容器创建、启动、Back-off 重启的完整事件链。这里能看到类似Failed to create pod sandbox: ...、Back-off restarting failed container、Liveness probe failed这些 kubelet 层面的关键判断。举一个非常典型的例子如果你在 Events 里看到Liveness probe failed: HTTP probe failed with statuscode: 500说明容器本身可能在 Running但因为探针失败被 kubelet 杀掉重启。这时候你去翻应用日志大概率只看到正常启动的日志找不到任何崩溃痕迹。这就是为什么只看 kubectl logs会走入死胡同——崩容器的是探针不是业务代码。3.3 容器运行时层面兜底从 containerd 日志与节点 journald 里捞信息有些情况下kubelet 层面的日志和 Events 都不够得下沉到容器运行时和节点操作系统层面。如果集群使用的是 containerd现在绝大多数 K8s 集群默认都是它可以用crictl直接和容器运行时打交道# 列出所有容器包括已经退出的 crictl ps -a # 查看指定容器的详细信息包括退出码、资源使用、注解 crictl inspect container-id # 直接拉容器的 stdout 日志 crictl logs container-idcrictl ps -a的输出里有一列STATUS如果看到Exited (137)内容比kubectl更底层。而且当kubectl logs因为某种原因取不到日志时比如容器在限流、节点负载过高crictl logs往往是可用的备用通道。再往下一层是节点系统日志。kubelet 自身的日志和操作记录通常在 systemd journal 里# 查看 kubelet 日志按时间过滤最近 30 分钟 journalctl -u kubelet --since 30 minutes ago # 持续跟踪 kubelet 日志 journalctl -u kubelet -f如果问题是节点级的比如磁盘空间不足、镜像层损坏、cgroup 配置问题kubelet 日志里会给出非常明确的报错。但我个人经验是新手看到journalctl -u kubelet里密密麻麻的噪音容易懵建议先加过滤关键词比如grep -i error\|failed\|backoff\|oom。还有一层是内核日志。如果遇到 OOM Kill可以在节点上执行dmesg | grep -i -E oom|killed process journalctl -k | grep -i oom这些日志能精确告诉你是哪个进程被 OOM Killer 干掉的、当时系统内存压力有多大。这层信息在应用日志里是永远找不到的。4. 一次 Java 服务反复 CrashLoopBackOff 的完整定位过程4.1 第一印象Exit Code 1 且应用日志很正常问题反而不好查下面这个案例来自我实际经历过的一次排障把过程复盘出来你会发现 CrashLoopBackOff 的根因可能藏在好几层之外。业务方报障一个 Java Spring Boot 服务在测试环境反复 CrashLoopBackOff重启后偶尔能对外提供几个请求然后又挂。我先执行了kubectl get pod -o wide kubectl describe pod pod-name -n namespacedescribe 里显示Restart Count: 18Last State: TerminatedExit Code: 1Reason 是Error。退避间隔已经到 300 秒说明容器能存活一段时间。看到 Exit Code 1第一反应是业务代码异常于是拉日志kubectl logs pod-name -n namespace --previous --tail200 --timestampstrue结果让我愣了一下应用日志只显示 Spring Boot 正在启动加载数据源、连接配置中心、初始化缓存然后突然中断没有任何 Exception。正常来说一个进程如果因为业务异常退出日志里总会留下 StackTrace但这里没有说明进程是被掐断的而不是自己抛异常退出。4.2 深挖链路从容器日志到节点系统日志再到宿主机内核消息于是我把排查方向从应用层转向系统层。第一步进 describe 看 Events。发现里面除了常规的Back-off restarting failed container之外还夹杂着Warning Unhealthy 48s kubelet Liveness probe failed: HTTP probe failed with statuscode: 503Liveness probe failed出现了好几条。到这里我基本确认不是应用主动退出而是存活探针失败导致 kubelet 杀容器。但问题来了探针为什么会失败应用日志里明明没有报错。第二步看节点资源。用以下命令确认节点是否内存压力过大kubectl top nodes kubectl top pod pod-name -n namespacePod 显示内存使用接近 limitCPU 使用率很高。继续看节点系统日志journalctl -k --since 10 minutes ago | grep -i oom这次内核日志给出了关键信息某个进程触发了 OOM Killer但被杀的不是我们这个 Java 容器而是节点上的其他进程。不过节点内存已经处于高压状态这解释了为什么 Java 应用启动非常慢——因为 CPU limit 卡得很死JVM 启动时的类加载和初始化要花远超平时的时间。4.3 真正的凶手存活探针门槛过高 启动期资源被限制到这里根因已经开始清晰。我再把这两个因素拼在一起看YAML 里配置了livenessProbeinitialDelaySeconds只有 5periodSeconds是 10。理想情况下Spring Boot 应用在 5 秒内根本起不来更别说健康的 HTTP 端口还没监听完成。同时resources.limits.cpu设了 0.5 核但 JVM 在启动阶段非常需要 CPU在这个限制下启动时间被大幅拉长。结果就是探针在容器真正 Ready 之前去请求/actuator/health返回 503kubelet 判定不健康直接杀掉容器然后重启。重启后又是同样的流程无限循环。修复方案非常直接加一个startupProbe先探测启动阶段让 liveness 等到应用真正 Ready 再接管同时把initialDelaySeconds调大并把 CPU limit 提高一些。startupProbe: httpGet: path: /actuator/health port: 8080 failureThreshold: 30 periodSeconds: 10 livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 10 periodSeconds: 10改动之后 Pod 稳定运行不再重启。这个案例里如果我只盯着 Exit Code 1 和应用日志可能看一整天都找不到答案。真正的线索链是Exit Code 1 → 日志无异常 → Events 出现Liveness probe failed→ 节点资源分析 → 发现探针与资源限制的组合问题。5. 三个容易误判为 CrashLoopBackOff 的相邻场景5.1 livenessProbe 失配导致的伪崩溃第四章案例其实已经涉及了其中一个相邻场景但这里我要单独展开因为它太常见了。livenessProbe存活探针的职责是检测容器是否还活着如果认为不健康就杀掉重启。问题在于如果这个探针的判定条件本身就过于苛刻就会出现容器明明没有崩溃但 kubelet 认为你应该崩的情况。常见的坑有initialDelaySeconds设得太短应用还没监听端口探针就来了httpGet请求路径写错比如应用实际暴露的是/healthz探针写的是/healthperiodSeconds太密集应用高峰期响应变慢单个请求超过timeoutSeconds探针失败探针返回码只接受 2xx但应用在启动阶段返回 503。遇到这类伪崩溃你会看到 Pod 的Restart Count持续增长但kubectl logs里完全没有异常堆栈。判断信号很明确describe 的 Events 里反复出现Liveness probe failed而且容器日志显示业务进程还在正常打印。解决方式是先确认应用的真实健康检查路径和启动耗时再合理配置探针参数。记住一个原则livenessProbe尽量放宽别让它在容器刚启动或者瞬时高负载的时候凑热闹。5.2 依赖外部服务未就绪时应用自己退出第二种相邻场景是应用依赖的中间件不可用比如 MySQL、Redis、配置中心、另一个内部服务。一种常见的错误写法是应用启动时用同步方式连接这些依赖一旦连接失败直接抛异常退出。表现就是 Exit Code 1容器反复重启等你以为修复了依赖服务之后才恢复。这种场景最常见于老式 Java 应用和部分 Python 服务它们的启动流程里没有重试和优雅降级。区分它和真正代码 bug 的方法是看崩溃时机如果每次重启后的存活时间都很短、重启间隔在 10~20 秒并且应用日志中能看到类似Connection refused、Unable to connect、Caused by: java.net.ConnectException基本就是依赖未就绪。我的修复建议分两步短期内调整启动顺序确保依赖服务先启动长期看在应用启动逻辑里加入重试或健康状态判断别让进程直接退出。如果代码不方便改也可以用 K8s 侧的方案在 Deployment 里给业务容器加一个 initContainer专门等依赖就绪。initContainer 成功了主容器才开始启动从编排层面规避依赖未就绪就崩溃的问题。5.3 镜像里 PID 1 处理不当孤儿进程与退出码丢失第三个场景比较隐蔽往往出现在自研镜像或非主流基础镜像里。Linux 下 PID 1 进程有特殊职责它必须负责回收孤儿进程、正确转发信号。如果镜像里 PID 1 是一个 shell 脚本而脚本里用java -jar app.jar 这种方式把 Java 进程放到后台shell 可能不会正确转发 SIGTERM导致容器在滚动更新时收到 SIGTERM 后没有优雅退出最终被 SIGKILL 强杀。表现出来就是更新 Deployment 时容器反复 CrashLoopBackOff退出码可能是 137 或 143应用日志里有时还会出现sh: cant access tty; job control turned off之类的警告。最干净的处理方式是在 Dockerfile 里用exec启动进程让目标进程直接成为 PID 1ENTRYPOINT [/app/start.sh]并在 start.sh 里用exec启动#!/bin/sh exec java -jar /app/app.jar这样 Java 进程会替代 shell 成为 PID 1信号能正确传递给 JVMjavac 进程也能正确响应优雅退出。还有一种方式是直接使用tini或dumb-init这类 init 进程作为入口对很多基础镜像不完善的项目会有奇效。6. 这是我自己的排查顺序和踩坑总结6.1 一套可复用的五步排查法排了一年多的 K8s 问题我把 CrashLoopBackOff 的排查流程收敛成五个固定步骤基本能覆盖 80% 以上的场景第一步先看kubectl describe pod。把 State、Last State、Reason、Exit Code、Conditions、Events 一次性看完。这一条命令能过滤掉大量低效工作。第二步根据 Exit Code 分流。137 查资源和 OOM143 查探针和优雅退出127 查命令路径1 查应用日志0 查启动脚本是否阻塞了前台进程。第三步拉日志。当前日志优先但要立即用--previous补一份崩溃前日志。注意日志若无异常不要纠结马上转向系统层。第四步查资源与节点。kubectl top node、kubectl top pod确认节点内存水位和 Pod 资源使用是否接近 limit。这步能打通 OOM 类问题。第五步下沉到运行时和节点系统。crictl ps -a看容器运行时状态journalctl -u kubelet看 kubelet 决策dmesg | grep -i oom看内核 OOM 证据。这套顺序不是死板的但它的核心逻辑是从最外层的事实开始逐步往下钻避免一上来就在应用日志里浪费大量时间。很多 CrashLoopBackOff 看起来是应用问题实际根因都在系统或者编排配置层面。6.2 几个值得长期注意的小细节最后分享几个我踩过坑之后养成的习惯说得直白一点给生产环境配日志采集。别等到 CrashLoopBackOff 才想起来去看日志。写入文件的应用日志要提前接入 stdout 或日志系统不然容器重建后文件可能随 Pod 一起消失。K8s 里的重启不等于崩溃。探针失败、节点驱逐、滚动更新都会导致容器重启Exit Code 和 Events 要一起看不要单看一个维度。imagePullPolicy和镜像版本要盯住。有些 CrashLoopBackOff 其实是拉到了错误 tag 的镜像镜像里的启动命令本身就是坏的。检查describe里Image字段是否与预期一致。合理利用kubectl debug。当容器反复崩溃很难 exec 进入时kubectl debug -it pod --imagebusybox --targetcontainer可以复制一个带 shell 的调试容器去查看原容器共享的文件系统或网络命名空间这在很多场景下是救命稻草。CrashLoopBackOff 排障的本质是用事实缩小范围用日志验证假设。它能让你烦躁是因为你把所有可能性都当成了线索。而实际上只要按 Exit Code 分流、正确拿日志、把系统层和应用层的证据拼起来大多数 CrashLoopBackOff 都能在半小时内定位到根因。希望这篇经验能让你下次遇到它的时候少走我走过的弯路。
返回列表