Split View: Kubernetes CrashLoopBackOff 원인별 진단과 해결 — 로그가 비어 있을 때 무엇을 봐야 하나
Kubernetes CrashLoopBackOff 원인별 진단과 해결 — 로그가 비어 있을 때 무엇을 봐야 하나
들어가며 — RESTARTS 숫자만 올라가고 로그는 비어 있습니다
배포 직후 파드 목록을 보면 이런 모습입니다.
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초부터 시작
이 규칙에서 두 가지 실무적 결론이 나옵니다.
첫째, 파드가 오래 방치되어 있으면 재시작 간격이 5분까지 벌어져 있습니다. 수정한 이미지를 올리고 "왜 아직도 안 뜨지"라고 기다릴 필요가 없습니다. 파드를 지우면 새 파드는 백오프 이력 없이 즉시 시작합니다.
kubectl delete pod checkout-7d9f6c5b4d-2xk9p -n payments
둘째, RESTARTS 카운트가 조금씩 늘어나는 파드는 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까지 비어 있다면 그때야 비로소 프로세스가 로그 한 줄도 못 쓰고 죽었다는 뜻이고, 그 경우 원인은 거의 항상 이미지 진입점 또는 볼륨 마운트입니다.
컨테이너가 여러 개인 파드라면 어떤 컨테이너가 죽는지부터 특정해야 합니다.
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에는 Error가 아니라 Completed가 찍힙니다. 원인은 대개 프로세스를 포그라운드로 실행하지 않은 것입니다. nginx를 데몬 모드로 실행하거나, 셸 스크립트가 마지막 명령을 백그라운드로 던지고 끝나는 경우가 전형적입니다.
- 1 — 일반적인 애플리케이션 오류. 프레임워크가 잡지 못한 예외, 설정 검증 실패, 필수 환경변수 부재가 여기에 몰립니다. 로그에 원인이 반드시 있습니다.
- 2 — 셸 사용법 오류. 잘못된 인자를 넘겼을 때 셸이 반환합니다.
- 126 — 파일은 있는데 실행 권한이 없음. 엔트리포인트 스크립트에 실행 비트를 안 준 경우입니다.
- 127 — 명령을 찾을 수 없음. command에 오타가 있거나, distroless 또는 scratch 이미지에 없는 셸을 호출한 경우입니다.
- 137 — 128 더하기 9, 즉 SIGKILL. 커널 OOM 킬러 또는 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 | 로그의 스택트레이스를 그대로 수정 |
| 필수 설정·시크릿 누락 | 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 수정, 이미지에 셸 존재 여부 확인 |
| 볼륨 마운트 실패 | 컨테이너가 시작조차 못 함, Events에 FailedMount | kubectl describe pod의 Events | PVC 바인딩과 시크릿 존재 확인 |
| init 컨테이너 실패 | STATUS가 Init:CrashLoopBackOff | kubectl logs POD -c INIT_NAME --previous | init 로직 수정, 의존 서비스 기동 순서 확인 |
설정과 시크릿 누락
가장 흔합니다. 파드 스펙이 참조하는 키가 실제로 존재하는지부터 확인합니다.
kubectl get secret payment-keys -n payments -o jsonpath='{.data}' | tr ',' '\n'
{"PAYMENT_API_URL":"aHR0cHM6...","PAYMENT_WEBHOOK_SECRET":"czNjcjN0"}
스펙은 PAYMENT_SIGNING_KEY를 요구하는데 시크릿에는 그 키가 없습니다. 키 이름 오타가 원인입니다.
여기서 중요한 설계 포인트가 있습니다. env 아래 secretKeyRef로 참조하면서 optional: true를 주면 키가 없어도 파드는 뜨고 애플리케이션이 런타임에 죽습니다. 반대로 optional을 명시하지 않으면 kubelet이 컨테이너를 시작조차 하지 않고 CreateContainerConfigError로 멈춥니다. 후자가 훨씬 진단하기 쉽습니다.
env:
- name: PAYMENT_SIGNING_KEY
valueFrom:
secretKeyRef:
name: payment-keys
key: PAYMENT_SIGNING_KEY
# optional을 생략하면 기본값 false — 키가 없으면 컨테이너를 시작하지 않는다
이때 파드 상태는 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입니다. 자세한 계산은 프로브 3종 설계 편에 정리했습니다.
잘못된 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", ...]를 쓰면 셸이 없어서 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
둘째, 임시 컨테이너입니다. 원본 이미지에 셸이 없는 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
임시 컨테이너는 파드 스펙을 변경하지 않으므로 재시작을 유발하지 않습니다. 다만 볼륨은 기본적으로 공유되지 않으니, 마운트된 파일을 봐야 한다면 --target으로 지정한 컨테이너의 파일시스템을 /proc/1/root 경로로 접근합니다.
/ # 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은 새 파드가 Ready가 되기 전까지 기존 파드를 죽이지 않게 합니다. 크래시하는 새 버전을 배포해도 서비스는 유지됩니다.
셋째, 재시작 증가를 알림으로 잡습니다. 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와 직전 컨테이너의 로그에 적혀 있습니다.
Diagnosing Kubernetes CrashLoopBackOff by Cause — What to Look At When the Logs Are Empty
Introduction — the RESTARTS number keeps climbing and the logs are empty
Right after a deploy, the Pod list looks like this.
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
Try to read the logs and nothing comes back.
kubectl logs checkout-7d9f6c5b4d-2xk9p -n payments
Error from server (BadRequest): container "checkout" in pod "checkout-7d9f6c5b4d-2xk9p" is waiting to start: CrashLoopBackOff
At this point many people conclude the app does not log anything and start digging through the logging configuration. That is the wrong direction. The logs are there. The container you are trying to query is simply the next-generation container that has not started yet.
CrashLoopBackOff is not a cause, it is a restart delay
Read the name carefully. CrashLoopBackOff tells you nothing about why the container died. It only means the kubelet has declared that this container keeps dying, so it will hold off on restarting it for a while.
The kubelet restart delay follows a fixed rule.
- Wait 10 seconds after the first failure
- The wait doubles with every failure — 10s, 20s, 40s, 80s, 160s
- The ceiling is 300 seconds, that is 5 minutes
- If the container runs healthily for long enough (10 minutes by default), the delay timer resets and starts over at 10 seconds
Two practical conclusions follow from that rule.
First, a Pod that has been left alone for a while has a restart interval stretched out to 5 minutes. There is no need to push a fixed image and then sit there wondering why it still has not come up. Delete the Pod and the new one starts immediately with no back-off history.
kubectl delete pod checkout-7d9f6c5b4d-2xk9p -n payments
Second, a Pod whose RESTARTS count creeps up slowly may never show up as CrashLoopBackOff. An app that survives more than 10 minutes before dying resets the back-off every time, so the status column reads Running while RESTARTS quietly increases. That pattern is actually more dangerous. Sweep for it periodically with the command below.
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
Where diagnosis starts — Last State, Exit Code, and the previous logs
The order is always the same. Confirm the reason for death with describe, then read the logs of the dead container.
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
There are three values to read.
- The Reason under Last State — is it Error, OOMKilled, or Completed
- Exit Code — cross-reference it against the exit code dictionary below
- The gap between Started and Finished — a 1-second gap means it died during startup, 30 seconds or more means it came up and died later
In the example above, Started and Finished are 1 second apart. The process died the moment it came up, so suspect the startup-time configuration rather than the runtime logic.
Now read the logs of the dead container. The key is the --previous flag. Because the kubelet creates a new container each time it restarts, kubectl logs without the flag points at a container that has not started yet or was just born. The logs of the dead container live in the previous generation.
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
A common wrong answer: concluding that the application does not log because the logs are empty. In the overwhelming majority of cases you simply did not type --previous. If --previous is empty too, then and only then does it mean the process died without writing a single line, and in that case the cause is almost always the image entrypoint or a volume mount.
For a Pod with multiple containers, first pin down which container is dying.
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>
The exit code dictionary — 0, 1, 127, 137, 139, 143
Exit codes cut the diagnosis in half. Any value above 128 is 128 plus a signal number, so you can work backward to the signal that killed it.
- 0 — clean exit. The process finished its work and ended. A Deployment restartPolicy is always Always, so even a clean exit gets restarted by the kubelet and eventually becomes CrashLoopBackOff. Reason shows Completed, not Error. The cause is usually that the process was not run in the foreground. Running nginx in daemon mode, or a shell script that throws its last command into the background and exits, are the classic cases.
- 1 — a generic application error. Exceptions the framework did not catch, configuration validation failures, and missing required environment variables all land here. The cause is definitely in the logs.
- 2 — shell usage error. The shell returns this when it is passed bad arguments.
- 126 — the file exists but is not executable. The entrypoint script never got its execute bit.
- 127 — command not found. Either the command has a typo, or you invoked a shell that does not exist in a distroless or scratch image.
- 137 — 128 plus 9, that is SIGKILL. The kernel OOM killer or the kubelet force-killed it. If Reason is OOMKilled the memory limit was exceeded; if Reason is Error, it failed to exit within the grace period after SIGTERM and was force-killed.
- 139 — 128 plus 11, that is SIGSEGV. A segmentation fault in native code. It shows up with native extension modules, JNI, CGO, or an image built for the wrong architecture.
- 143 — 128 plus 15, that is SIGTERM. Someone asked for a graceful shutdown and the process complied. That is normal during a rolling update or a node drain, but if 143 repeats for no reason, a liveness probe is very likely killing the container.
A common wrong answer: raising the memory limit the moment you see 137. All 137 tells you is SIGKILL. Whether memory was the cause has to be judged by whether the Reason field says OOMKilled. Giving more memory to a container that was force-killed for refusing to exit after a liveness failure changes nothing. If it is confirmed as a memory issue, it is faster to move on to the OOMKilled 137 diagnosis article.
Cause branches and diagnostic commands
There are roughly seven paths that converge on CrashLoopBackOff. Rule them out one at a time.
| Cause | Signature | Diagnostic command | Fix |
|---|---|---|---|
| Immediate exit from an application exception | Exit Code 1, Reason Error, under 1 second of runtime | kubectl logs POD --previous | Fix exactly what the stack trace in the logs says |
| Missing required config or secret | Exit Code 1, key name printed in the logs | kubectl get secret, kubectl describe pod | Check ConfigMap and Secret key names and namespace |
| Repeated restarts after a clean exit | Exit Code 0, Reason Completed | kubectl logs POD --previous | Run the process in the foreground |
| OOMKilled | Exit Code 137, Reason OOMKilled | Last State in describe | Raise the limit or tune the runtime heap settings |
| Forced termination by a liveness probe | Exit Code 143 or 137, Unhealthy and Killing in Events | kubectl get events --field-selector reason=Unhealthy | Add a startupProbe, relax the thresholds |
| Wrong command or entrypoint | Exit Code 126 or 127, executable file not found in Message | Message in kubectl describe pod | Fix command and args, check whether the image has a shell |
| Volume mount failure | The container never even starts, FailedMount in Events | Events in kubectl describe pod | Check PVC binding and secret existence |
| Init container failure | STATUS is Init:CrashLoopBackOff | kubectl logs POD -c INIT_NAME --previous | Fix the init logic, check the startup order of dependent services |
Missing config and secrets
This is the most common one. Start by checking whether the key the Pod spec references actually exists.
kubectl get secret payment-keys -n payments -o jsonpath='{.data}' | tr ',' '\n'
{"PAYMENT_API_URL":"aHR0cHM6...","PAYMENT_WEBHOOK_SECRET":"czNjcjN0"}
The spec requires PAYMENT_SIGNING_KEY but the secret does not have that key. A typo in the key name is the cause.
There is an important design point here. If you reference it under env with secretKeyRef and set optional: true, the Pod comes up even when the key is missing and the application dies at runtime. Conversely, if you do not specify optional, the kubelet does not even start the container and stops at CreateContainerConfigError. The latter is far easier to diagnose.
env:
- name: PAYMENT_SIGNING_KEY
valueFrom:
secretKeyRef:
name: payment-keys
key: PAYMENT_SIGNING_KEY
# omitting optional defaults to false — if the key is missing, the container is not started
In that case the Pod status shows CreateContainerConfigError rather than CrashLoopBackOff, and describe spells out the exact reason.
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
When a liveness probe is doing the killing
If Unhealthy and Killing appear together in Events, the probe is the culprit.
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
Attach a liveness probe with initialDelaySeconds 10 to an app that takes 40 seconds to start and it will never come up. Raising initialDelaySeconds here is a stopgap; the right answer is a startupProbe. The detailed arithmetic is laid out in the three-probe design article.
Wrong command and entrypoint
The Message spells out, word for word, that the executable could not be found.
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
A permission problem gives 126; a missing file gives 127. Using command: ["sh", "-c", ...] in a distroless image yields 127 because there is no shell.
Volume mount failures and init containers
If the container never even started, look at Events rather than the logs.
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
When an init container fails, the STATUS column gets an Init prefix and the main container never runs at all. You have to name the container explicitly when reading logs.
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
Getting inside the container while keeping it alive
When the logs alone do not crack it, you have to look inside the dying container yourself. There are two ways, and they serve different purposes.
First, override the command so the container sleeps instead of running the process. You can inspect the filesystem, environment variables and DNS under exactly the same conditions as the original.
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
To get the same effect without touching the existing Deployment, make a copy.
kubectl debug checkout-7d9f6c5b4d-2xk9p -n payments \
--copy-to=checkout-debug \
--container=checkout \
-- sleep infinity
kubectl exec -it checkout-debug -n payments -- sh
Second, ephemeral containers. They are especially useful in distroless environments where the original image has no shell. Because they share a process namespace with the target container, you can observe even a dying process.
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
An ephemeral container does not modify the Pod spec, so it does not trigger a restart. Volumes are not shared by default, though, so if you need to see a mounted file, reach the filesystem of the container named by --target through the /proc/1/root path.
/ # ls /proc/1/root/etc/checkout
application.yaml logging.yaml
Preventing recurrence — make crashes surface in the deploy pipeline
To avoid repeating the same incident, install guardrails in three places.
First, make the application validate all of its configuration at startup and die with a clear message. Code that only reads a required key on the first runtime request surfaces as a 500 error instead of CrashLoopBackOff, and gets found far later.
Second, make the deploy command wait for failure before it returns. kubectl apply submits the resource and returns success immediately, which creates the state where CI is green while production is dead.
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
Specify progressDeadlineSeconds on the Deployment and this verdict is attached automatically.
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 keeps the existing Pods alive until the new ones are Ready. Deploy a crashing new version and the service stays up.
Third, catch rising restart counts with an alert. CrashLoopBackOff is conspicuous, but the quiet restart every 10 minutes mentioned earlier will sit ignored for weeks unless you are watching a dashboard.
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
Closing — when the logs are empty, type previous first
Diagnosing CrashLoopBackOff is not talent, it is order. Check the Reason and Exit Code under Last State with describe, hear the dead container out with kubectl logs --previous, and if that still gives you nothing, override the command to keep the container alive and go inside. Those three steps end most incidents within 10 minutes.
The one sentence to remember is this. CrashLoopBackOff is not a diagnosis, it is the name of the waiting room, and the real diagnosis is always written in the Exit Code and the logs of the previous container.