A pod in CrashLoopBackOff tells you almost nothing. It is the waiting state, not the failure: the container exited, and the kubelet is sitting out a back-off before trying again. The failure happened one restart ago and is recorded somewhere else.
Two commands narrow it to one of five causes before you open the application logs. kubectl describe pod gives you the last termination reason and exit code, and kubectl logs --previous gives you what the dead instance printed. Everything below is a way of reading those two outputs.
the two commands, in order
Start with describe, because the exit code does most of the classification work:
kubectl describe pod api-7f4b9c6d8f-9m2tqThe part worth reading is not the top. It is the Last State block and the events at the bottom:
State: Waiting
Reason: CrashLoopBackOff
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: Tue, 12 Aug 2026 09:14:22 +0530
Finished: Tue, 12 Aug 2026 09:14:51 +0530
Restart Count: 6Reason and Exit Code are the diagnosis. Started and Finished are the second most useful pair in the block, because the gap between them separates "died instantly" from "ran for a while and then died", and those are different bugs.
Then read what the dead instance said:
kubectl logs api-7f4b9c6d8f-9m2tq --previousWithout --previous you get the current instance, which in a crash loop has usually just started and printed nothing. This is the single most common reason people conclude a crashing pod has no logs.
exit code 1 or 2: the application decided to quit
An exit code of 1 with output in the previous log is the easy case. The process started, hit something it did not like, and exited on purpose. Missing environment variable, a config file it could not parse, a database it could not reach on startup.
$ kubectl logs api-7f4b9c6d8f-9m2tq --previous
2026-08-12T09:14:22Z FATAL config: DATABASE_URL is requiredThe reason this one is worth naming separately is that it is the only cause where the application log is the answer. For the other four, the log is either empty or misleading, and you need the pod's own state instead.
If the variable is supposed to come from a Secret or ConfigMap, check that it actually arrived rather than trusting the manifest:
kubectl get pod api-7f4b9c6d8f-9m2tq -o jsonpath='{.spec.containers[0].env}' | jqThere is more on pulling exactly the field you want out of an object in kubectl JSONPath: extract exactly what you need.
exit code 137 with OOMKilled: the memory limit
137 is 128 + 9, so the process took a SIGKILL. If Reason says OOMKilled, the kernel's OOM killer did it because the container exceeded its memory limit.
The tell that separates a genuine limit problem from a memory leak is the gap between Started and Finished. Killed within seconds every time means the limit is below what the process needs at startup, usually a JVM heap or a model load. Killed after ten minutes, then twenty, then five, means it is leaking or the workload is spiky.
kubectl get pod api-7f4b9c6d8f-9m2tq -o jsonpath='{.spec.containers[0].resources}'{"limits":{"memory":"256Mi"},"requests":{"cpu":"100m","memory":"128Mi"}}Raising the limit is the fix for the first case and a delay for the second. What matters more than the number is that requests and limits are both set: a container with a limit and no request gets scheduled onto a node that cannot actually give it that memory, and you meet the OOM killer under load rather than at startup.
Key Insight: Exit code 137 does not always mean out of memory. It means SIGKILL. The kubelet sends the same signal when a liveness probe fails. Read the Reason field next to the exit code, not the exit code alone.
exit code 137 with no OOMKilled: the probe shot it
Same signal, different killer. If the last state reason is Error rather than OOMKilled, and there is a probe failure in the events just before the kill, then the liveness probe ended a container that was working.
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Unhealthy 2m (x9 over 8m) kubelet Liveness probe failed: Get "http://10.0.3.14:8080/health": dial tcp 10.0.3.14:8080: connect: connection refused
Normal Killing 2m (x3 over 8m) kubelet Container api failed liveness probe, will be restartedconnection refused during startup means the probe arrived before the process was listening. The container gets killed, restarts, and never survives long enough to answer, so the loop is self-sustaining. The fix is a startupProbe, which suspends liveness and readiness entirely until it passes:
startupProbe:
httpGet: { path: /health, port: 8080 }
periodSeconds: 5
failureThreshold: 30 # 30 x 5s = 150s of startup budget
livenessProbe:
httpGet: { path: /health, port: 8080 }
periodSeconds: 10
failureThreshold: 3 # after startup, a real hang is caught in 30sRaising initialDelaySeconds on the liveness probe instead looks like the same fix and is not. It blinds you to genuine deadlocks for the whole delay, on every restart, forever. The startup probe buys the same time and then hands liveness back its tight interval. Your model isn't crashing, your probe is walks through the version of this that eats an afternoon, where the process is a model server and the startup budget is ten minutes rather than two.
exit code 127, 126, or a StartError: the process never ran
An empty --previous log with a non-zero exit is a different class of problem. Nothing ran, so nothing logged.
127is command not found. The entrypoint or thecommand:in the manifest points at a path that is not in the image.126is found but not executable. Usually a script without the execute bit, or a shell script with a CRLF line ending so the kernel looks for an interpreter named/bin/sh\r.StartErrororCreateContainerErrorinReasonmeans the kubelet could not launch the process at all, and the events say why.
The events are more specific than the exit code here, so read them:
Warning Failed 30s (x4 over 90s) kubelet Error: failed to create containerd task: failed to create shim task: OCI runtime create failed: exec: "/app/server": stat /app/server: no such file or directoryThat is a build problem wearing a runtime costume. The image does not contain the binary the manifest asks for. Checking the image directly beats re-reading the Dockerfile:
docker run --rm --entrypoint ls ghcr.io/example/api:2.4.1 -la /appA related trap is a volume mounted over the directory holding the binary, which produces the same error from a perfectly good image. Docker volume debugging: finding where your data actually lives covers pinning down what is actually at a mount point.
exit code 0: it finished, and Kubernetes disagreed
The strange one. Exit code 0 means the process completed successfully, and the pod is still crash-looping, because a Deployment's pods carry restartPolicy: Always and Kubernetes restarts a successful exit exactly as eagerly as a failed one.
This is nearly always a workload in the wrong object. A migration script, a backfill, a report generator: something that is supposed to run once and stop. It belongs in a Job, where restartPolicy: OnFailure or Never is available and completion is a terminal state rather than an invitation:
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate
spec:
backoffLimit: 3
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: ghcr.io/example/api:2.4.1
command: ["/app/migrate"]The other version of exit code 0 is a long-running server whose main process backgrounds itself and lets PID 1 return. The container is doing exactly what you told it to. It is just that what you told it was "start the thing and exit".
the sixty-second triage
Everything above collapses into one pass over the pod's state. Get the reason and exit code for every container in one shot rather than reading a screen of describe output:
kubectl get pod api-7f4b9c6d8f-9m2tq \
-o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.lastState.terminated.reason}{"\t"}{.lastState.terminated.exitCode}{"\n"}{end}'api OOMKilled 137Then branch on what comes back:
- Exit code 1 or 2, log has output. Read the log. It is an application error.
- Exit code 137, reason
OOMKilled. Memory limit. Check the gap betweenStartedandFinishedto tell a too-small limit from a leak. - Exit code 137, reason
Error, probe failure in the events. The liveness probe killed a healthy container. Add astartupProbe. - Exit code 126, 127, or a
StartError. The process never launched. Read the events, then inspect the image. - Exit code 0. Wrong workload type. This wants to be a Job.
The reason to run this before opening the application logs is that three of the five causes leave no application logs at all, and one of them leaves logs that look fine right up to the moment something external kills the process. The pod's own state is the more honest witness.
If none of the five fit, the next thing to check is whether the container is being killed before it is scheduled at all, which is a different failure that presents as Pending rather than CrashLoopBackOff. That one, along with the networking failures that dress up as application bugs, is in five Kubernetes debugging tricks that saved my production. The rest of the Kubernetes writing here is collected under the Kubernetes tag, and the wider set of symptom-versus-cause posts under debugging.
The habit worth keeping is smaller than any of this. Before reading a single line of application output, run describe and write down two things: the exit code and the reason. Most crash loops stop being mysterious right there.