CrashLoopBackOff 완벽 해결: Pod 무한 재시작 7가지 원인 진단법
이 글이 맞는 상태: kubectl get pod의 STATUS가 CrashLoopBackOff이고, kubectl describe pod의 Events에 Back-off restarting failed container가 찍힙니다. 컨테이너는 떴다가 죽고 있으니, 종료 코드와 직전 로그로 원인을 고릅니다.
| 원인 | 확인 명령 | 해결 |
|---|---|---|
| 앱 예외·설정 오류 (Exit 1) | kubectl logs <pod> --previous | 로그가 가리키는 코드·환경변수 수정 |
메모리 한도 초과 (OOMKilled, Exit 137) | kubectl describe pod <pod>의 Last State | memory limit 상향 또는 사용량 축소 |
| liveness probe 실패 | Events의 Liveness probe failed | startupProbe 추가, liveness 임계치 완화 |
의존 서비스 미준비 (connection refused) | kubectl logs <pod> --previous | 앱 재시도 로직, 필요하면 initContainer |
명령·경로 오류 (no such file or directory) | kubectl describe pod <pod>의 Command·Mounts | 이미지 ENTRYPOINT 확인, 경로·subPath 수정 |
참조한 Secret·ConfigMap이 아예 없으면 STATUS는 CrashLoopBackOff가 아니라 CreateContainerConfigError입니다(원인 3 참고).
배포는 됐는데 Pod가 무한 재시작된다면
kubectl get pod를 쳤더니 STATUS 칸에 CrashLoopBackOff가 떠 있고, RESTARTS 카운트가 계속 올라가고 있다면 이 글이 정확히 필요한 순간입니다.
가장 먼저 알아둘 것은 **CrashLoopBackOff는 "상태"가 아니라 "증상"**이라는 점입니다. 컨테이너가 시작 → 크래시 → 쿠버네티스가 재시작 → 또 크래시를 반복하니까, 백오프(지연 재시도) 간격을 두며 다시 시작한다는 뜻일 뿐입니다. 진짜 원인은 컨테이너 안에서 죽은 이유에 있습니다.
이 시리즈의 1편에서 다룬 Pending은 스케줄링 단계의 문제(자원 부족·노드 셀렉터)였고, 2편의 ImagePullBackOff는 이미지를 아예 못 받아오는 문제였습니다. 반면 CrashLoopBackOff는 이미지도 받았고 컨테이너도 떴는데, 실행되자마자 죽는 단계입니다. 즉 한 단계 더 안쪽으로 들어온 셈이죠. 그래서 디버깅 포인트는 "스케줄러"가 아니라 "컨테이너 프로세스의 종료 코드와 로그"입니다.
1분 진단 루틴: describe → logs --previous → Exit Code
원인이 뭐든 시작은 항상 이 3단계입니다. 순서대로 치면 범위가 빠르게 좁혀집니다.
1단계: describe로 Events 확인
kubectl describe pod my-app-7d9f8-abcde맨 아래 Events 섹션이 핵심입니다.
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Pulled 2m kubelet Successfully pulled image
Normal Created 2m (x4 over 3m) kubelet Created container app
Normal Started 2m (x4 over 3m) kubelet Started container app
Warning BackOff 30s (x8 over 3m) kubelet Back-off restarting failed containerBack-off restarting failed container는 CrashLoopBackOff의 공통 신호입니다. 이게 보이면 "컨테이너가 시작은 했는데 죽었다"가 확정입니다. 이제 왜 죽었는지를 봐야 합니다.
2단계: logs --previous로 죽은 컨테이너 로그 확보
지금 떠 있는 컨테이너는 또 죽기 직전이라 로그가 비어 있을 수 있습니다. 방금 죽은(이전) 컨테이너의 로그를 봐야 합니다.
kubectl logs my-app-7d9f8-abcde --previous
# 컨테이너가 여러 개면
kubectl logs my-app-7d9f8-abcde -c app --previous--previous(축약 -p)를 빼먹어서 "로그가 안 나온다"며 헤매는 경우가 정말 많습니다. 크래시 디버깅에서 가장 먼저 챙길 옵션입니다.
3단계: Exit Code 확인
describe 출력의 Last State 블록에서 종료 코드를 읽습니다.
Last State: Terminated
Reason: OOMKilled
Exit Code: 137kubectl get pod의 RESTARTS 카운트도 같이 보세요. 빠르게 치솟으면 즉시 크래시, 한참 떠 있다가 죽으면 메모리 누수나 probe 문제일 가능성이 큽니다.
에러 메시지 → 원인 매핑 표
| 로그/Events 메시지 | 유력 원인 | 바로 갈 섹션 |
|---|---|---|
Back-off restarting failed container | 공통 신호 (컨테이너 크래시) | 전부 |
OOMKilled / Exit 137 | 메모리 한도 초과 | 원인 2 |
missing env / env XXX is undefined (앱 로그) | 환경변수·시크릿 키 누락 | 원인 3 |
Error: secret "xxx" not found (STATUS CreateContainerConfigError) | 참조한 Secret 자체가 없음(CrashLoopBackOff 아님) | 원인 3 |
Liveness probe failed | 프로브 설정 오류 | 원인 4 |
connection refused / dial tcp ... | 의존 서비스(DB) 미준비 | 원인 5 |
no such file or directory | 마운트 경로/command 오류 | 원인 6·7 |
| Exit 1 + 스택트레이스 | 앱 일반 예외 | 원인 1 |
원인별 진단·해결 7선
원인 1. 애플리케이션 예외 / 엔트리포인트 오류 (Exit 1)
가장 흔한 케이스. 앱 코드가 시작하자마자 예외를 던지고 죽습니다.
kubectl logs <pod> --previous # 스택트레이스 확인로그에 NullPointerException, Cannot find module, panic: 같은 메시지가 그대로 찍힙니다. 이건 인프라 문제가 아니라 앱 버그이므로, 로그가 가리키는 코드/설정을 고치는 게 정답입니다. 인프라 담당자라면 로그를 캡처해 개발팀에 그대로 넘기면 됩니다.
원인 2. OOMKilled (Exit 137)
kubectl describe pod <pod> | grep -A2 "Last State" # Reason: OOMKilled 확인
kubectl top pod <pod> # 실사용 메모리 확인(metrics-server 필요)137 = 128 + 9, 즉 SIGKILL입니다. 메모리 limit을 초과해 커널 OOM killer가 강제 종료한 것이고, describe에 Reason: OOMKilled, Exit Code: 137로 남습니다(리소스 관리 문서).
Before (한도가 너무 빡빡)
resources:
limits:
memory: "128Mi"
requests:
memory: "128Mi"After (실사용 기반 상향)
resources:
requests:
memory: "256Mi" # 평상시 사용량
limits:
memory: "512Mi" # 피크 여유분근본 해결책은 kubectl top pod(metrics-server 필요)로 실제 사용량을 측정한 뒤 limit을 맞추는 것입니다. OOM이 잦은 워크로드라면 VPA(Vertical Pod Autoscaler)로 requests/limits를 자동 튜닝하는 것도 최근 많이 쓰는 방법입니다.
원인 3. 환경변수 · 시크릿 누락
kubectl logs <pod> --previous # "secret not found" / "env XXX is undefined"
kubectl get secret db-credentials # 시크릿 존재 여부
kubectl describe secret db-credentials # 키 이름·크기만 확인(값은 출력되지 않음)먼저 구분할 점이 있습니다. 참조한 Secret이 아예 없으면 컨테이너가 시작되지 않으므로 STATUS는 CrashLoopBackOff가 아니라 CreateContainerConfigError이고, Events에 Error: secret "db-credential" not found가 찍힙니다. kubelet은 Secret이 생길 때까지 주기적으로 다시 시도합니다(Secret 문서). CrashLoopBackOff가 되는 건 Secret은 있지만 키 이름이 달라 envFrom으로 기대한 환경변수가 비어 있고, 앱이 기동 중 설정 검증에 실패해 죽는 경우입니다. -o yaml은 Secret 값(base64)까지 터미널에 출력하므로 키 확인에는 describe가 안전합니다.
Before (Secret 이름 오타 → CreateContainerConfigError)
envFrom:
- secretRef:
name: db-credential # 's' 누락After
envFrom:
- secretRef:
name: db-credentials근본 해결책: 시크릿/ConfigMap을 Pod보다 먼저 배포하고, 이름과 키를 kubectl get secret으로 대조하세요. 같은 네임스페이스에 있는지도 꼭 확인합니다.
원인 4. Liveness Probe 오류
컨테이너 자체는 멀쩡한데, 앱이 다 뜨기도 전에 probe가 실패해서 kubelet이 죽이는 경우입니다.
Warning Unhealthy kubelet Liveness probe failed: HTTP probe failed with statuscode: 500
Normal Killing kubelet Container failed liveness probe, will be restartedBefore (기동 시간을 안 줌)
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 1
failureThreshold: 1After (기동 여유 + 임계치 완화)
startupProbe: # 기동 전용 프로브(1.20 GA): 통과할 때까지 liveness 검사 보류
httpGet:
path: /healthz
port: 8080
failureThreshold: 30
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
failureThreshold: 3
periodSeconds: 10근본 해결책: 기동이 느린 앱은 startupProbe로 분리하고, liveness는 "진짜로 죽었을 때만" 발동하도록 보수적으로 설정합니다. startupProbe는 1.20에서 GA가 됐고(기능 게이트 목록), 성공할 때까지 liveness·readiness 검사를 미룹니다(프로브 설정 문서). 위 예시는 최대 30×5초=150초까지 기동을 기다립니다.
프로브 필드 기본값
| 필드 | 기본값 | 조정할 때 볼 것 |
|---|---|---|
initialDelaySeconds | 0 | startupProbe를 쓰면 liveness 쪽은 0으로 둬도 됩니다 |
periodSeconds | 10 (최소 1) | 너무 짧으면 일시적인 지연에도 실패가 빨리 누적됩니다 |
timeoutSeconds | 1 (최소 1) | 헬스 엔드포인트가 DB·외부 API를 조회하면 1초를 넘기기 쉽습니다 |
failureThreshold | 3 (최소 1) | startupProbe는 failureThreshold × periodSeconds가 최악의 기동 시간보다 길어야 합니다 |
successThreshold | 1 | liveness·startup 프로브는 1이어야 합니다 |
기본값은 Probe API 레퍼런스 기준입니다. 표의 숫자를 정답으로 쓰기보다, 실제 기동 시간과 헬스 엔드포인트 응답 시간을 재서 맞추세요.
원인 5. 의존 서비스(DB) 연결 실패
kubectl logs <pod> --previous # "connection refused" / "dial tcp 10.x:5432"앱이 DB·Redis가 준비되기 전에 떠서 연결 실패로 죽는 케이스입니다.
해결: initContainer로 의존성 대기
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ['sh', '-c',
'until nc -z postgres 5432; do echo waiting; sleep 2; done']근본적으로는 앱에 재시도(backoff retry) 로직을 넣는 것이 가장 견고합니다. initContainer는 기동 순서를 보장하지만, 운영 중 DB가 잠깐 끊겼을 때의 복원력은 앱 레벨 재시도가 책임집니다.
원인 6. ConfigMap 마운트 경로 오류
kubectl logs <pod> --previous # "no such file or directory: /config/app.yaml"
kubectl describe pod <pod> # Volumes / Mounts 확인Before (subPath 없이 디렉터리째 덮어 기존 파일이 사라짐)
volumeMounts:
- name: config
mountPath: /app/config/app.yaml # 파일을 디렉터리로 마운트After (subPath로 단일 파일만 주입)
volumeMounts:
- name: config
mountPath: /app/config/app.yaml
subPath: app.yaml
volumes:
- name: config
configMap:
name: app-config근본 해결책: 단일 파일을 주입할 땐 subPath를 쓰고, mountPath가 앱이 실제로 읽는 경로와 일치하는지 확인합니다. 단, subPath로 마운트한 파일은 ConfigMap을 수정해도 갱신되지 않으므로 변경 후 Pod를 재시작해야 합니다(ConfigMap 문서).
원인 7. 잘못된 command / args
이미지 엔트리포인트를 잘못 덮어쓰면 exec: "xxx": executable file not found가 뜹니다.
Before
command: ["python3"]
args: ["app.py"] # 작업 디렉터리에 app.py가 없음 → 즉시 종료After
command: ["python3"]
args: ["/app/main.py"]근본 해결책: 이미지의 기본 ENTRYPOINT/CMD를 확인하고, 꼭 필요할 때만 override 하세요.
Exit Code 해석 표
| Exit Code | 시그널 | 의미 | 먼저 볼 곳 |
|---|---|---|---|
| 0 | - | 정상 종료인데 재시작 | restartPolicy·엔트리포인트가 데몬으로 안 떠 있음 |
| 1 | - | 앱 일반 예외 | logs --previous 스택트레이스 |
| 2 | - | 셸 내장 명령 오용·인자 오류(셸 관례) | 엔트리포인트 스크립트 문법, 옵션 오타 |
| 126 | - | 명령은 찾았지만 실행할 수 없음 | 실행 권한(chmod +x), 파일 형식 |
| 127 | - | 명령을 찾을 수 없음 | command/args 경로, PATH, 이미지에 바이너리가 있는지 |
| 137 | SIGKILL | OOM 또는 강제 종료 | memory limit, kubectl top |
| 139 | SIGSEGV | 세그멘테이션 폴트 | 네이티브 라이브러리/아키텍처(arm vs amd) |
| 143 | SIGTERM | 정상 종료 신호 | graceful shutdown 처리, 외부 종료 요인 |
Exit 0인데 재시작한다면 대개 "한 번 실행하고 끝나는 스크립트"를 Deployment로 띄운 경우입니다. 일회성 작업이면 Job으로 바꾸세요.
126·127은 Bash 종료 상태 관례(명령을 찾지 못하면 127, 찾았지만 실행할 수 없으면 126)를 따르는 셸이 돌려주는 값입니다. 그래서 엔트리포인트가 셸 스크립트이고 그 안의 명령이 실패할 때 주로 보입니다. 엔트리포인트 바이너리 자체가 이미지에 없으면 셸을 거치지 않고 컨테이너 런타임이 프로세스 시작에 실패하므로, Reason이 StartError로 표시되고 메시지에 executable file not found in $PATH가 남습니다(이때 종료 코드는 런타임에 따라 다릅니다).
Exit 0(Completed)인데 계속 재시작될 때
restartPolicy: Always인 Deployment에서는 정상 종료(0)도 재시작 대상입니다. 일회성 작업이 아닌데 0으로 끝난다면, 메인 프로세스(PID 1)가 포그라운드에 머물지 않는 경우가 대부분입니다.
- 데몬이 백그라운드로 빠지는 설정: nginx라면
nginx -g 'daemon off;'처럼 포그라운드로 실행합니다. 공식 nginx 이미지의 기본 CMD가 이 형태입니다. - 엔트리포인트 스크립트가 서버를
&로 띄우고 끝나는 경우: 마지막 줄을exec <서버 명령>으로 바꿔 서버가 PID 1이 되게 합니다.exec를 쓰면 SIGTERM도 서버 프로세스로 바로 전달됩니다.
#!/bin/sh
# entrypoint.sh — 준비 작업 후 서버를 PID 1로 실행
set -e
/app/migrate.sh
exec /app/server --port=8080exec format error: 이미지와 노드의 CPU 아키텍처 불일치
logs --previous에 exec format error 한 줄만 남고 즉시 죽는다면, 이미지가 노드와 다른 아키텍처로 빌드됐을 가능성이 큽니다. arm64 Mac에서 빌드한 이미지를 amd64 노드에서 실행할 때가 대표적입니다.
# 노드 아키텍처
kubectl get nodes -L kubernetes.io/arch
# 이미지에 들어 있는 플랫폼 목록
docker buildx imagetools inspect <registry>/<image>:<tag>노드 아키텍처가 이미지 목록에 없으면 그 플랫폼으로 다시 빌드합니다. 아키텍처가 다른 노드가 섞여 있다면 멀티 플랫폼 이미지로 빌드해 푸시합니다(Docker 멀티 플랫폼 빌드 문서).
docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/<image>:<tag> --push .로그가 아예 안 남는 즉시 종료 디버깅
가장 짜증나는 케이스. --previous를 쳐도 로그가 비어 있고, 컨테이너가 너무 빨리 죽어 exec도 못 들어가는 상황입니다. 이럴 땐 컨테이너를 일부러 살려두고 내부를 직접 봅니다.
# 임시로 엔트리포인트를 sleep으로 덮어 컨테이너를 살려둠
command: ["sleep", "3600"]이 상태로 배포한 뒤 내부에 들어가 손으로 실행해 봅니다.
kubectl exec -it <pod> -- sh
# 안에서 직접 실행해 진짜 에러 메시지 확인
/app/entrypoint.sh원본 이미지를 건드리기 싫다면 ephemeral container를 붙이는 kubectl debug도 좋습니다.
kubectl debug -it <pod> --image=busybox:1.36 --target=app -- sh컨테이너가 너무 빨리 죽어 --target으로 붙을 수 없다면, 명령을 셸로 바꾼 Pod 사본을 만듭니다. 원본 Pod는 그대로 두고 사본 안에서 엔트리포인트를 손으로 실행해 볼 수 있습니다(공식 디버깅 문서).
kubectl debug <pod> -it --copy-to=<pod>-debug --container=app -- sh
# 확인이 끝나면 사본 삭제
kubectl delete pod <pod>-debug실무 경험 한마디: 새벽 장애 대응에서 제가 가장 자주 만난 CrashLoopBackOff 원인은 "환경변수·시크릿 키 설정 오류"와 "OOMKilled"였습니다. 그래서 호출 받으면 무조건
describe로 Exit Code부터 봅니다. 137이면 메모리, 설정 관련 메시지면 Secret·ConfigMap, 그 외면logs --previous로 직행합니다.
결론: 진단 체크리스트
kubectl get pod→ RESTARTS 카운트와 STATUS 확인kubectl describe pod→ Events의Back-off restarting...와 Exit Code 확인kubectl logs <pod> --previous→ 죽은 컨테이너 로그 확보- Exit Code로 분기: 137→메모리, 1→앱 예외, secret/env→설정, probe→프로브
- 로그가 없으면
command: sleepoverride 또는kubectl debug로 내부 진입
이미지를 못 받아오는 ImagePullBackOff는 2편, 스케줄링 단계에서 막히는 Pending은 1편을 참고하세요. 컨테이너는 떴는데 외부에서 접속이 안 된다면, 다음 4편 Service/Endpoint 연결 실패 트러블슈팅에서 이어집니다.
참고: 공식 문서
이 글에서 다루는 동작·설정·에러의 1차 출처는 다음 공식 문서입니다. 버전별 옵션과 정확한 동작은 여기서 확인하세요.
- Pod 라이프사이클: 컨테이너 재시작과 백오프
- 실행 중인 Pod 디버깅(kubectl debug)
- Liveness·Readiness·Startup 프로브 설정
- 컨테이너 리소스 관리(OOMKilled)
- Secret · ConfigMap
- Probe API 레퍼런스(필드 기본값)
- Bash 종료 상태(126·127) · Docker 멀티 플랫폼 빌드
자주 묻는 질문 (FAQ)
Q. kubectl logs --previous를 쳤는데 "previous terminated container not found"가 나옵니다.
A. 아직 컨테이너가 재시작되지 않았거나(첫 크래시 직후), 노드가 이전 컨테이너를 정리한 경우입니다. 잠깐 기다렸다가 RESTARTS가 올라간 뒤 다시 치거나, kubectl describe의 Last State 블록에서 종료 이유를 확인하세요.
Q. Exit Code 137인데 kubectl top pod로 보면 메모리가 limit보다 낮습니다. 왜 OOM이 날까요?
A. 순간 피크에서 limit을 넘겼다가 죽은 뒤 측정된 값일 수 있습니다. top은 현재값만 보여주므로, Prometheus 등으로 container_memory_working_set_bytes의 피크를 확인하고 limit을 그 위로 올리세요. 137이 SIGKILL이라는 점에서 외부 강제 종료(예: 노드 자원 압박)도 의심해볼 수 있습니다.
Q. CrashLoopBackOff 상태에서 재시작 간격이 점점 길어지는데 정상인가요?
A. 정상입니다. kubelet은 재시작 지연을 10초, 20초, 40초…로 두 배씩 늘리고 300초(5분)에서 멈춥니다. 컨테이너가 10분 동안 문제없이 실행되면 백오프 타이머가 리셋되고, RESTARTS 숫자 자체는 그대로 남습니다(Pod 라이프사이클 문서). 1.35부터 베타(기본 활성)인 KubeletCrashLoopBackOffMax를 쓰면 노드별로 최대 지연을 조정할 수 있습니다(기능 게이트 목록). 간격이 길다고 장애가 심한 건 아니니, 간격보다 종료 코드와 로그에 집중하세요.
글마다 출처와 확인 범위가 다릅니다. 아래에 확인일·확인 범위·출처가 기록된 글만 그 범위에서 확인된 것입니다. 기록이 없는 글은 적용 전 사용 중인 버전의 공식 문서로 직접 확인하고, 오류는 이메일로 알려주세요. 편집 정책 · 이메일로 제보
이 글에는 사람이 확인한 날짜·범위 기록이 아직 없습니다.
댓글
첫 번째 댓글을 남겨보세요.