引言 — RESTARTS 数字一直涨,日志却是空的
发布之后查看 Pod 列表,是这个样子。
kubectl get pod -n payments
NAME READY STATUS RESTARTS AGE
checkout-7d9f6c5b4d-2xk9p 0/1 CrashLoopBackOff 6 (2m41s ago) 11m
checkout-7d9f6c5b4d-lm4vz 0/1 CrashLoopBackOff 6 (2m38s ago) 11m
checkout-7d9f6c5b4d-q8w2n 0/1 CrashLoopBackOff 6 (2m44s ago) 11m
想看日志,却什么都没有。
kubectl logs checkout-7d9f6c5b4d-2xk9p -n payments
Error from server (BadRequest): container "checkout" in pod "checkout-7d9f6c5b4d-2xk9p" is waiting to start: CrashLoopBackOff
到这一步,很多人会断定这是一个不打日志的应用,然后开始翻日志配置。这是走错了方向。日志是在的。你现在要查询的容器,只是还没有启动的下一代容器而已。
CrashLoopBackOff 不是原因,而是重启等待
要把这个名字读准确。CrashLoopBackOff 完全没有告诉你容器为什么会死。它只表示 kubelet 宣布了一件事:这个容器一直在死,所以我先把重启推迟一会儿。
kubelet 的重启延迟遵循固定规则。
- 第一次失败后等待 10 秒
- 每失败一次等待时间翻倍 — 10 秒、20 秒、40 秒、80 秒、160 秒
- 上限是 300 秒,也就是 5 分钟
- 容器正常运行足够久(默认 10 分钟)之后,延迟计时器会重置,重新从 10 秒开始
从这条规则可以得出两个实务结论。
第一,被放置很久的 Pod,重启间隔已经拉长到 5 分钟。推送修好的镜像之后,不必坐在那里等着问「怎么还没起来」。把 Pod 删掉,新的 Pod 没有退避历史,会立刻启动。
kubectl delete pod checkout-7d9f6c5b4d-2xk9p -n payments
第二,RESTARTS 计数缓慢增长的 Pod 有可能根本不会显示为 CrashLoopBackOff。撑过 10 分钟以上才死的应用,每次都会把退避重置,所以状态列显示 Running,只有 RESTARTS 在悄悄增加。这种模式反而更危险。要用下面的命令定期扫一遍。
kubectl get pods -A --sort-by='.status.containerStatuses[0].restartCount' | tail -20
NAMESPACE NAME READY STATUS RESTARTS AGE
search indexer-6c4d9f7b8-h2klp 1/1 Running 17 (43m ago) 6d
payments ledger-5f8b7c6d9-nm3xt 1/1 Running 23 (12m ago) 9d
诊断的起点 — Last State、Exit Code 以及 previous 日志
诊断顺序永远一样。先用 describe 确认死亡原因,然后读已死容器的日志。
kubectl describe pod checkout-7d9f6c5b4d-2xk9p -n payments
Containers:
checkout:
Container ID: containerd://3f2a91c4e88b7d5641a0c2f9e37b1d80
Image: registry.example.com/checkout:1.14.2
Image ID: registry.example.com/checkout@sha256:9c1f...
Port: 8080/TCP
State: Waiting
Reason: CrashLoopBackOff
Last State: Terminated
Reason: Error
Exit Code: 1
Started: Sun, 26 Jul 2026 09:41:12 +0900
Finished: Sun, 26 Jul 2026 09:41:13 +0900
Ready: False
Restart Count: 6
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Pulled 3m12s (x5 over 11m) kubelet Container image "registry.example.com/checkout:1.14.2" already present on machine
Normal Created 3m12s (x5 over 11m) kubelet Created container checkout
Normal Started 3m11s (x5 over 11m) kubelet Started container checkout
Warning BackOff 2m41s (x24 over 10m) kubelet Back-off restarting failed container checkout
需要读的值有三个。
- Last State 里的 Reason — 是 Error、OOMKilled 还是 Completed
- Exit Code — 与后文的退出码词典对照
- Started 与 Finished 的间隔 — 相差 1 秒说明启动过程中当场死亡,相差 30 秒以上说明起来了但之后才死
上面的例子里 Started 与 Finished 相差 1 秒。意思是进程刚起来就死了,所以该怀疑的是启动时刻的配置,而不是运行时逻辑。
现在读已死容器的日志。关键是 --previous 参数。kubelet 重启容器时会创建新容器,所以不带参数的 kubectl logs 指向的是还没启动、或者刚刚出生的容器。已死容器的日志在上一代里。
kubectl logs checkout-7d9f6c5b4d-2xk9p -n payments --previous
2026-07-26T00:41:12.881Z INFO starting checkout 1.14.2
2026-07-26T00:41:13.104Z ERROR config: required key PAYMENT_SIGNING_KEY is not set
2026-07-26T00:41:13.105Z FATAL exiting with status 1
常见的错误答案:因为日志是空的就判断应用不打日志。实际上绝大多数情况只是没有敲 --previous 而已。如果连 --previous 也是空的,那时才说明进程一行日志都没写就死了,这种情况下原因几乎总是镜像入口点或者卷挂载。
如果 Pod 里有多个容器,要先确定是哪个容器在死。
kubectl get pod checkout-7d9f6c5b4d-2xk9p -n payments \
-o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.restartCount}{"\t"}{.lastState.terminated.reason}{"\t"}{.lastState.terminated.exitCode}{"\n"}{end}'
checkout 6 Error 1
istio-proxy 0 <none> <none>
退出码词典 — 0、1、127、137、139、143
退出码能把诊断工作量砍掉一半。大于 128 的值是 128 加上信号编号,可以反推出是被哪个信号杀死的。
- 0 — 正常退出。意思是进程干完自己的活儿结束了。Deployment 的 restartPolicy 永远是 Always,所以即使正常退出,kubelet 也会重新拉起,最终变成 CrashLoopBackOff。Reason 里显示的是 Completed 而不是 Error。原因大多是没有把进程放在前台运行。以守护进程模式启动 nginx,或者 shell 脚本把最后一条命令扔到后台就结束,都是典型场景。
- 1 — 一般性的应用错误。框架没接住的异常、配置校验失败、必需环境变量缺失都集中在这里。原因一定在日志里。
- 2 — shell 用法错误。传了错误参数时 shell 会返回它。
- 126 — 文件存在但没有执行权限。入口点脚本没有加执行位的情况。
- 127 — 找不到命令。要么 command 里有拼写错误,要么在 distroless 或 scratch 镜像里调用了不存在的 shell。
- 137 — 128 加 9,也就是 SIGKILL。内核 OOM killer 或者 kubelet 强制杀掉了它。Reason 是 OOMKilled 就是超出内存上限,Reason 是 Error 就是收到 SIGTERM 后没能在宽限期内退出而被强制终止。
- 139 — 128 加 11,也就是 SIGSEGV。原生代码的段错误。常见于原生扩展模块、JNI、CGO,或者架构不匹配的镜像。
- 143 — 128 加 15,也就是 SIGTERM。有人请求了正常关闭,进程也照做了。在滚动更新或节点排空过程中这是正常的,但如果 143 毫无缘由地反复出现,很可能是 liveness 探针在杀容器。
常见的错误答案:一看到 137 就去调高内存上限。137 只告诉你这是 SIGKILL。是不是内存导致的,要看 Reason 字段是不是 OOMKilled 来判断。给一个因为 liveness 失败后拒绝退出而被强制杀死的容器加内存,什么都不会改变。如果确定是内存问题,直接跳到 OOMKilled 137 诊断 那一篇会更快。
原因分支与诊断命令
收敛到 CrashLoopBackOff 的路径大体上有七条。逐一排除即可。
| 原因 | 典型信号 | 诊断命令 | 解决 |
|---|---|---|---|
| 应用异常导致立即退出 | Exit Code 1、Reason Error、运行不到 1 秒 | kubectl logs POD --previous | 照着日志里的堆栈直接修 |
| 必需配置或 Secret 缺失 | Exit Code 1、日志里打出了键名 | kubectl get secret, kubectl describe pod | 核对 ConfigMap 与 Secret 的键名和命名空间 |
| 正常退出后反复重启 | Exit Code 0、Reason Completed | kubectl logs POD --previous | 把进程放到前台运行 |
| OOMKilled | Exit Code 137、Reason OOMKilled | describe 里的 Last State | 上调上限或调整运行时堆设置 |
| liveness 探针导致的强制终止 | Exit Code 143 或 137、Events 里有 Unhealthy 和 Killing | kubectl get events --field-selector reason=Unhealthy | 添加 startupProbe,放宽阈值 |
| 错误的 command 或 entrypoint | Exit Code 126 或 127、Message 里有 executable file not found | kubectl describe pod 的 Message | 修正 command 与 args,确认镜像里有没有 shell |
| 卷挂载失败 | 容器根本起不来、Events 里有 FailedMount | kubectl describe pod 的 Events | 确认 PVC 绑定与 Secret 是否存在 |
| init 容器失败 | STATUS 是 Init:CrashLoopBackOff | kubectl logs POD -c INIT_NAME --previous | 修正 init 逻辑,确认依赖服务的启动顺序 |
配置与 Secret 缺失
这是最常见的。先确认 Pod spec 引用的键实际存在不存在。
kubectl get secret payment-keys -n payments -o jsonpath='{.data}' | tr ',' '\n'
{"PAYMENT_API_URL":"aHR0cHM6...","PAYMENT_WEBHOOK_SECRET":"czNjcjN0"}
spec 要求 PAYMENT_SIGNING_KEY,而 Secret 里没有这个键。原因是键名拼错了。
这里有一个重要的设计要点。在 env 下用 secretKeyRef 引用并给出 optional: true,那么即使键不存在 Pod 也会起来,应用会在运行时死掉。反过来,不写 optional,kubelet 连容器都不会启动,直接停在 CreateContainerConfigError。后者要好诊断得多。
env:
- name: PAYMENT_SIGNING_KEY
valueFrom:
secretKeyRef:
name: payment-keys
key: PAYMENT_SIGNING_KEY
# 省略 optional 时默认值为 false — 键不存在就不启动容器
这时 Pod 状态显示的不是 CrashLoopBackOff 而是 CreateContainerConfigError,describe 里会写明确切原因。
kubectl describe pod checkout-7d9f6c5b4d-2xk9p -n payments | grep -A3 "Warning Failed"
Warning Failed 9s (x3 over 25s) kubelet Error: couldn't find key PAYMENT_SIGNING_KEY in Secret payments/payment-keys
liveness 探针正在杀容器的情况
Events 里 Unhealthy 和 Killing 一起出现,那凶手就是探针。
kubectl get events -n payments --field-selector involvedObject.name=checkout-7d9f6c5b4d-2xk9p --sort-by=.lastTimestamp
LAST SEEN TYPE REASON OBJECT MESSAGE
4m12s Warning Unhealthy pod/checkout-7d9f6c5b4d-2xk9p Liveness probe failed: Get "http://10.42.3.17:8080/healthz": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
4m12s Normal Killing pod/checkout-7d9f6c5b4d-2xk9p Container checkout failed liveness probe, will be restarted
给一个需要 40 秒才能起来的应用挂上 initialDelaySeconds 10 的 liveness,它永远起不来。这种情况下调大 initialDelaySeconds 只是权宜之计,正解是 startupProbe。详细的计算方式整理在 三种探针的设计 那一篇里。
错误的 command 与 entrypoint
Message 里会原封不动地写出找不到可执行文件这句话。
kubectl describe pod migrate-runner-0 -n payments | grep -A2 "Last State"
Last State: Terminated
Reason: StartError
Message: failed to create containerd task: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "/app/entrypoint.sh": permission denied
权限问题是 126,文件本身不存在是 127。在 distroless 镜像里写 command: ["sh", "-c", ...],因为没有 shell 就会得到 127。
卷挂载失败与 init 容器
如果容器根本没能启动,要看的就不是日志而是 Events。
kubectl describe pod ledger-0 -n payments | tail -8
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedMount 2m15s (x9 over 12m) kubelet MountVolume.SetUp failed for volume "ledger-data" : rpc error: code = Internal desc = volume attachment is being deleted
Warning FailedMount 38s kubelet Unable to attach or mount volumes: unmounted volumes=[ledger-data], unattached volumes=[ledger-data kube-api-access-x9k2m]: timed out waiting for the condition
init 容器失败时 STATUS 列会加上 Init 前缀,主容器完全不会运行。看日志时必须指定容器名。
kubectl get pod ledger-0 -n payments
NAME READY STATUS RESTARTS AGE
ledger-0 0/1 Init:CrashLoopBackOff 4 (48s ago) 3m
kubectl logs ledger-0 -n payments -c wait-for-db --previous
waiting for postgres.payments.svc.cluster.local:5432 ...
timeout after 30s
让容器保持存活并进入其中
光靠日志解不开,就得亲自看看正在死去的容器内部。方法有两种,用途不同。
第一种,覆盖 command,让容器不跑进程而只睡觉。可以在与原状态完全相同的条件下确认文件系统、环境变量和 DNS。
apiVersion: v1
kind: Pod
metadata:
name: checkout-shell
namespace: payments
spec:
restartPolicy: Never
containers:
- name: checkout
image: registry.example.com/checkout:1.14.2
command: ['sh', '-c', 'sleep infinity']
envFrom:
- secretRef:
name: payment-keys
volumeMounts:
- name: config
mountPath: /etc/checkout
volumes:
- name: config
configMap:
name: checkout-config
kubectl apply -f checkout-shell.yaml
kubectl exec -it checkout-shell -n payments -- sh
/ # env | grep PAYMENT
PAYMENT_API_URL=https://api.example.com
PAYMENT_WEBHOOK_SECRET=s3cr3t
/ # /app/checkout
ERROR config: required key PAYMENT_SIGNING_KEY is not set
想不动现有 Deployment 而达到同样效果,就做一个副本。
kubectl debug checkout-7d9f6c5b4d-2xk9p -n payments \
--copy-to=checkout-debug \
--container=checkout \
-- sleep infinity
kubectl exec -it checkout-debug -n payments -- sh
第二种,临时容器。在原镜像没有 shell 的 distroless 环境下尤其有用。它与目标容器共享进程命名空间,所以连正在死去的进程也能观察。
kubectl debug -it checkout-7d9f6c5b4d-2xk9p -n payments \
--image=busybox:1.36 \
--target=checkout \
-- sh
Defaulting debug container name to debugger-7v2mp.
/ # ls /proc
1 14 self ...
/ # cat /proc/1/cmdline
/app/checkout
/ # wget -qO- http://localhost:8080/healthz
wget: can't connect to remote host: Connection refused
临时容器不修改 Pod spec,所以不会触发重启。不过卷默认不共享,如果需要查看挂载的文件,就通过 /proc/1/root 路径访问 --target 指定的那个容器的文件系统。
/ # ls /proc/1/root/etc/checkout
application.yaml logging.yaml
防止复发 — 让崩溃在发布流水线里暴露出来
要不再重复同样的故障,就在三个地方装上机关。
第一,让应用在启动时刻校验全部配置,并带着明确的消息退出。把必需的键留到运行时第一个请求才去读的代码,表现出来的不是 CrashLoopBackOff 而是 500 错误,发现得晚得多。
第二,让发布命令等到失败之后再返回。kubectl apply 提交资源后立刻返回成功,于是就造出了 CI 是绿灯而生产环境已经死掉的状态。
kubectl apply -f deploy/checkout.yaml
kubectl rollout status deployment/checkout -n payments --timeout=180s
Waiting for deployment "checkout" rollout to finish: 0 of 3 updated replicas are available...
error: deployment "checkout" exceeded its progress deadline
在 Deployment 上写明 progressDeadlineSeconds,这个判定就会自动生效。
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout
namespace: payments
spec:
replicas: 3
progressDeadlineSeconds: 180
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
maxUnavailable: 0 保证在新 Pod 变成 Ready 之前不杀掉旧 Pod。即使发布了会崩溃的新版本,服务依然维持。
第三,用告警抓住重启次数的增长。CrashLoopBackOff 很显眼,但前面说的那种「每 10 分钟悄悄重启一次」,不盯着看板的话会被放置好几周。
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: pod-restart-alerts
namespace: monitoring
spec:
groups:
- name: pod-health
rules:
- alert: PodRestartingRepeatedly
expr: increase(kube_pod_container_status_restarts_total[1h]) > 3
for: 10m
labels:
severity: warning
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.pod }} restarted more than 3 times in an hour"
- alert: PodInCrashLoop
expr: kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"} == 1
for: 5m
labels:
severity: critical
结语 — 日志为空就先敲 previous
CrashLoopBackOff 的诊断靠的不是天赋而是顺序。用 describe 确认 Last State 的 Reason 和 Exit Code,用 kubectl logs --previous 听已死容器的最后一句话,还是没有就覆盖 command 让容器活着,然后进去看。这三步能让大多数故障在 10 分钟内收尾。
要记住的一句话是这个。CrashLoopBackOff 不是诊断名,而是候诊室的名字,真正的诊断名永远写在 Exit Code 和上一代容器的日志里。
현재 단락 (1/222)
发布之后查看 Pod 列表,是这个样子。