- Published on
Kubernetes CrashLoopBackOff 원인별 진단과 해결 — 로그가 비어 있을 때 무엇을 봐야 하나
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — 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와 직전 컨테이너의 로그에 적혀 있습니다.