先别急着 kubectl delete pod
上周三晚上,朋友发来一张截图,kubectl get pods 显示一个 Pod 的 RESTARTS 是 37,状态 CrashLoopBackOff。他问我是不是内存不够,因为他之前见过 OOMKilled。我让他先跑 kubectl describe pod <pod-name> -n <namespace>,看 Events 和 Last State。结果 Last State 里写的是 Terminated,Reason: Error,Exit Code: 137。注意,这里 Reason 不是 OOMKilled,所以不是内核 OOM。很多人一看到 137 就以为是内存超了,其实 137 = 128 + 9,表示进程收到 SIGKILL。可能是容器内进程自己调用了 kill -9,也可能是被 livenessProbe 杀掉的。我让他加 --previous 看日志:kubectl logs <pod-name> --previous -n <namespace>。这个参数很关键,因为 CrashLoopBackOff 时当前容器可能还没输出日志就挂了,上一次容器的日志才是真相。他之前一直用 kubectl logs 没加 --previous,所以啥也看不到,还以为日志被清了。其实 kubelet 会保留上一次的日志,至少在 containerd 下是这样。
那个 no such file or directory 坑了我两小时
--previous 日志里只有一行:standard_init_linux.go:228: exec user process caused: no such file or directory。这通常不是文件真的不存在,而是动态链接器找不到。他的 Dockerfile 是 FROM alpine:3.18,编译的 Go 程序用了 CGO,默认 CGO_ENABLED=1,链接了 glibc,但 Alpine 用的是 musl libc,所以跑不起来。我让他改成 CGO_ENABLED=0 go build -o app main.go,重新打镜像,问题解决。但这里有个对比:如果他用 FROM scratch,那必须静态编译,而且不能有 shell 调试;如果用 FROM debian:bullseye-slim,可以保留 glibc,镜像大概 80MB,比 Alpine 的 7MB 大不少。我一般推荐静态编译 + scratch,除非你需要调试工具。另外,如果你不确定是不是动态链接问题,可以在容器里跑 ldd /app,但 Alpine 没有 ldd,得装 libc6-compat 或者用 readelf -d /app 看 NEEDED 字段。这些细节网上很多文章不提,但实际排查时特别有用。
探针配置太激进,Java 应用根本起不来
还有一个常见原因:livenessProbe 的 initialDelaySeconds 太短。我去年帮一个团队看他们的 Spring Boot 应用,启动要 45 秒,但 initialDelaySeconds 只给了 15 秒,periodSeconds 10 秒,failureThreshold 3 次。结果应用还在初始化,就被探针杀了,反复循环。他们一开始以为是内存不够,把 limits 从 512Mi 提到 2Gi,没用。后来我让他们把 initialDelaySeconds 改成 60,timeoutSeconds 改成 5,failureThreshold 改成 5,重启后 Pod 就稳定了。这里有个参数对比:对于 Java 应用,initialDelaySeconds 至少给 60,如果启动慢,给 120 也不过分;对于 Go 或 Node,可以短一些,10-20 秒。另外,readinessProbe 和 livenessProbe 要分开,不要共用。readinessProbe 失败只是不接流量,livenessProbe 失败会重启容器。很多人把两个探针配成一样的,结果应用暂时处理慢一点就被重启,反而更糟。具体配置:readinessProbe 的 failureThreshold 可以设 3,periodSeconds 5;livenessProbe 的 failureThreshold 设 3,periodSeconds 10,但 initialDelaySeconds 要足够大。
资源限制和 QoS:requests 和 limits 别乱设
资源限制也是重灾区。如果 limits.memory 设成 128Mi,但应用启动就要 200Mi,那容器会被 OOMKilled,Exit Code 也是 137,但 describe 里会明确写 Reason: OOMKilled。怎么确认?看 kubectl describe pod 的 Last State,如果是 OOMKilled,那就是内存超了。这时候要调大 limits.memory,但别忘了 requests.memory 也要相应调整。K8s 的 QoS 类别:如果 requests 和 limits 都不设,是 BestEffort,最容易被驱逐;如果 requests < limits,是 Burstable;如果 requests = limits,是 Guaranteed,最稳定。我一般建议生产环境至少设 requests,并且 limits 不要设得太紧。具体数字:一个普通的 Go 服务,requests.memory 可以设 64Mi,limits.memory 设 256Mi;Java 服务,requests.memory 设 512Mi,limits.memory 设 1Gi。另外,kubectl top pod 可以看实际使用量,但需要 metrics-server。如果没有,可以进容器看 cgroup:cgroup v1 是 /sys/fs/cgroup/memory/memory.usage_in_bytes 和 memory.limit_in_bytes;cgroup v2 是 /sys/fs/cgroup/memory.current 和 memory.max。这个细节很多人不知道,因为不同 K8s 版本和 OS 用的 cgroup 版本不一样。
我的通用排查清单(附命令)
说实话,CrashLoopBackOff 的原因就那么几类:启动命令错、依赖缺失、配置错误、探针太严、资源不够、挂载卷权限不对。我现在的排查顺序是:1)kubectl get pods -n <ns> -o wide 看重启次数和节点;2)kubectl describe pod <pod> -n <ns> 看 Events、Last State、Exit Code;3)kubectl logs <pod> --previous -n <ns> --tail=200 看上一次日志;4)如果是 Exit Code 137 且 Reason 是 OOMKilled,调大内存;如果是 Error,看日志里的具体报错;5)检查 livenessProbe 的 initialDelaySeconds 是否小于应用启动时间;6)检查 ConfigMap/Secret 挂载路径和权限,比如 defaultMode: 0644 可能导致脚本无法执行;7)如果还不行,用 kubectl debug -it <pod> --image=busybox --target=<container> 进入容器命名空间手动跑命令。注意,kubectl debug 在 v1.18+ 才有,而且需要开启 EphemeralContainers 特性门控(v1.25 默认开启)。我用的 v1.24,需要加 --feature-gates=EphemeralContainers=true。这些版本差异很容易让人踩坑,所以排查时一定要先 kubectl version 看客户端和服务端版本。
最后说个独立观点:很多人一遇到 CrashLoopBackOff 就重启 Pod 或者删了重建,其实没用,因为根因没解决。还有人只看 kubectl logs 不加 --previous,然后说“没日志”,这等于白查。我自己的经验是,80% 的 CrashLoopBackOff 都能从 --previous 日志里找到线索,剩下 20% 需要看 Events 和资源限制。另外,别迷信 kubectl describe 里的 Events,有时候 Events 会延迟或者被覆盖,直接看 /var/log/pods/<namespace>_<pod>_<uid>/<container>/0.log 更可靠。这个路径在 containerd 下是固定的,但 Docker 运行时可能不一样。好了,希望这些能帮你少踩几个坑。