CrashLoopBackOff는 원인이 아니라 증상입니다. “컨테이너가 죽어서 쿠버네티스가 재시작을 반복하는 중”이라는 뜻일 뿐, 왜 죽었는지는 아무것도 알려주지 않습니다. 그래서 이 상태를 만나면 검색부터 하기 쉬운데, 검색 결과의 해결책이 내 상황과 맞을 확률은 낮습니다.
이 글은 해결책 목록이 아니라 원인을 좁혀 나가는 순서를 정리한 것입니다. AKS에서 이 상태를 만났을 때 위에서부터 순서대로 내려가면, 대부분 3단계 안에서 범인이 나옵니다. 각 단계마다 “이 명령의 출력이 이렇게 나오면 여기서 멈추고 저기로 가라”는 분기까지 적었습니다.
전제: 재시작 카운트와 종료 코드부터 본다
무엇을 하든 이 두 명령이 먼저입니다. 여기서 나오는 Exit Code가 이후 모든 분기의 출발점입니다.
# 파드 상태와 재시작 횟수
kubectl get pods -n myapp -o wide
# 이벤트와 마지막 종료 상태 (핵심)
kubectl describe pod <POD_NAME> -n myapp
# describe가 길다면 종료 코드만 콕 집어서
kubectl get pod <POD_NAME> -n myapp \
-o jsonpath='{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
describe 출력의 Last State: Terminated 블록에 있는 Exit Code와 Reason이 사실상 진단서입니다. 아래 표로 바로 분기하세요.
| Exit Code | 의미 | 가장 흔한 실제 원인 | 가야 할 단계 |
|---|---|---|---|
| 0 | 정상 종료했는데 재시작됨 | 메인 프로세스가 백그라운드로 빠짐. 컨테이너는 PID 1이 살아 있어야 함 | 2단계 |
| 1 | 애플리케이션 예외 | 설정 누락, DB 연결 실패, 환경변수 오타 | 1단계 → 3단계 |
| 137 | SIGKILL (128+9) | OOMKilled. 메모리 limit 초과 | 4단계 |
| 139 | SIGSEGV (128+11) | 네이티브 라이브러리 크래시, 아키텍처 불일치(arm64 이미지를 amd64 노드에서) | 5단계 |
| 143 | SIGTERM (128+15) | 프로브 실패로 kubelet이 종료시킴 | 3단계 |
| 255 또는 127 | 커맨드 없음 | ENTRYPOINT 경로 오타, 스크립트 실행 권한 없음 | 2단계 |
1단계 — 죽기 직전 로그 (여기서 절반이 끝난다)
가장 흔한 실수는 kubectl logs를 그냥 치는 것입니다. 그러면 지금 막 시작해서 아직 아무것도 안 찍은 새 컨테이너의 로그가 나옵니다. 우리가 봐야 할 건 죽은 이전 컨테이너의 로그입니다.
# --previous 가 핵심. 죽은 컨테이너의 로그를 본다
kubectl logs <POD_NAME> -n myapp --previous
# 컨테이너가 여러 개면 지정
kubectl logs <POD_NAME> -n myapp -c <CONTAINER_NAME> --previous
# 재시작이 너무 빨라 못 잡을 때: 이벤트를 시간순으로
kubectl get events -n myapp --sort-by=.lastTimestamp | tail -30
여기서 스택 트레이스나 connection refused 같은 메시지가 나오면 그게 답입니다. 아무것도 안 나오고 로그가 비어 있다면 애플리케이션이 시작조차 못 한 것이므로 2단계로 갑니다.
2단계 — 컨테이너를 살려두고 안에서 확인하기
로그가 비었다는 건 ENTRYPOINT가 실행되자마자 실패했다는 뜻입니다. 문제는 컨테이너가 1초 만에 죽어서 kubectl exec로 들어갈 틈이 없다는 것입니다.
해법은 커맨드를 덮어써서 강제로 살려두는 것입니다. 원본 배포는 건드리지 말고 디버그용 복제본을 하나 띄웁니다.
# 원본 파드를 복제하되 커맨드를 sleep으로 덮어써서 살려둔다
kubectl debug <POD_NAME> -n myapp \
--copy-to=debug-pod \
--container=<CONTAINER_NAME> \
-- sleep 3600
# 들어가서 직접 확인
kubectl exec -it debug-pod -n myapp -- sh
# 안에서 할 일
# 1) ENTRYPOINT 경로가 실제로 있는지
ls -l /app/entrypoint.sh
# 2) 실행 권한이 있는지 (권한 없으면 exit 127)
# 3) 환경변수가 주입됐는지
env | sort
# 4) 손으로 직접 실행해서 진짜 에러를 본다
/app/entrypoint.sh
4번에서 나오는 에러 메시지가 kubectl logs에서는 안 보이던 진짜 원인인 경우가 많습니다. 특히 쉘 스크립트의 CRLF 개행이 흔한 함정입니다. Windows에서 작성한 entrypoint.sh를 그대로 이미지에 넣으면 exec /app/entrypoint.sh: no such file or directory라는, 파일이 분명히 있는데도 없다고 하는 황당한 에러가 납니다. dos2unix로 변환하거나 .gitattributes에 *.sh text eol=lf를 넣어 막습니다.
3단계 — 프로브가 애플리케이션을 죽이고 있는 경우
애플리케이션은 멀쩡한데 liveness probe가 기동 시간을 못 기다려서 계속 죽이는 패턴입니다. Exit Code 143(SIGTERM)이 나왔다면 이걸 먼저 의심하세요.
전형적인 시나리오는 이렇습니다. JVM 애플리케이션이 기동에 40초가 걸리는데 initialDelaySeconds가 15초로 잡혀 있습니다. 15초 시점에 프로브가 찌르고, 응답이 없으니 실패, 3회 실패하면 kubelet이 컨테이너를 죽입니다. 재시작하면 다시 40초가 필요하고, 또 15초에 죽습니다. 영원히 뜨지 못하는 무한 루프입니다.
# 현재 프로브 설정 확인
kubectl get deploy <DEPLOY> -n myapp \
-o jsonpath='{.spec.template.spec.containers[0].livenessProbe}' | jq
제대로 된 해법은 initialDelaySeconds를 무작정 늘리는 게 아니라 startupProbe를 쓰는 것입니다. 기동 전용 프로브를 따로 두면, 기동 중에는 liveness가 아예 동작하지 않고, 기동이 끝난 뒤부터 짧은 주기로 감시할 수 있습니다.
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30 # 30회 x 5초 = 최대 150초까지 기동 허용
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10 # 기동 후에는 촘촘하게 감시
failureThreshold: 3
# initialDelaySeconds 불필요 - startupProbe가 성공한 뒤에만 시작됨
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 5
덧붙이면 liveness와 readiness의 엔드포인트를 같게 쓰는 것도 흔한 사고 원인입니다. DB 연결을 확인하는 헬스체크를 liveness에 걸어두면, DB가 잠깐 느려졌을 때 애플리케이션 파드가 전부 재시작됩니다. liveness는 “프로세스가 살아 있나”만, readiness는 “지금 트래픽을 받아도 되나”를 봐야 합니다. 의존성 검사는 readiness 쪽입니다.
4단계 — Exit Code 137: OOMKilled
137이 떴다면 메모리 한도를 넘어 커널이 죽인 것입니다. 확인은 간단합니다.
kubectl describe pod <POD_NAME> -n myapp | grep -A3 "Last State"
# Reason: OOMKilled 이면 확정
# 실제 사용량 추이 확인
kubectl top pod -n myapp --containers
여기서 limit만 올리고 끝내면 같은 일이 반복됩니다. 판단 기준을 나눠야 합니다.
- 기동 직후 바로 137 → limit이 애초에 너무 낮음. 실측 후 상향
- 몇 시간~며칠 뒤 137 → 메모리 누수 의심. limit을 올리면 죽는 주기만 길어질 뿐
- 트래픽 피크에만 137 → HPA로 수평 확장할 문제
JVM 컨테이너라면 별도의 함정이 하나 더 있습니다. 힙 크기를 컨테이너 limit보다 크게 잡아두면 JVM은 자기가 쓸 수 있다고 믿고 할당하다가 커널에 맞아 죽습니다. 컨테이너 limit이 2Gi인데 -Xmx2g를 주면, 힙 외에 메타스페이스·스레드 스택·네이티브 버퍼가 추가로 필요하므로 반드시 넘칩니다. -XX:MaxRAMPercentage=75.0처럼 비율로 지정하는 편이 안전합니다.
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi" # requests의 2배 이내를 권장
# cpu limit은 생략 권장 - 스로틀링으로 지연이 튄다
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75.0"
cpu.limits를 일부러 비워둔 이유가 궁금하실 수 있습니다. CPU limit은 CFS 스로틀링을 유발해서, 평균 사용률이 낮은데도 순간 지연이 크게 튀는 현상을 만듭니다. 메모리는 초과 시 죽지만 CPU는 느려질 뿐이므로, requests만 정확히 잡고 limit은 열어두는 편이 대체로 안정적입니다. 물론 멀티테넌트 클러스터라면 이야기가 다릅니다.
5단계 — AKS에서만 나타나는 원인들
여기까지 왔는데도 원인이 안 잡히면, 쿠버네티스 일반이 아니라 AKS 고유의 요인일 가능성이 높습니다.
ACR 이미지 풀 권한
정확히는 ImagePullBackOff로 뜨지만, 재시도 과정에서 상태가 섞여 보이는 경우가 있습니다. AKS가 ACR을 당길 권한이 없으면 발생합니다.
# AKS에 ACR 풀 권한 연결 (가장 확실한 방법)
az aks update -n <AKS_NAME> -g <RG> --attach-acr <ACR_NAME>
# 권한이 실제로 붙었는지 확인
az aks show -n <AKS_NAME> -g <RG> \
--query "identityProfile.kubeletidentity.clientId" -o tsv
아키텍처 불일치 (Exit Code 139)
Apple Silicon 맥에서 빌드한 이미지를 그대로 AKS에 올리면 arm64 이미지가 amd64 노드에 배포되어 SIGSEGV로 죽습니다. 로컬에서는 잘 되는데 클러스터에서만 죽는 대표적인 경우입니다.
# 이미지 아키텍처 확인
docker manifest inspect <ACR>.azurecr.io/myapp:latest \
| jq '.manifests[].platform'
# 빌드 시 플랫폼을 명시
docker buildx build --platform linux/amd64 \
-t <ACR>.azurecr.io/myapp:v1 --push .
Key Vault 시크릿 주입 실패
CSI Secret Store로 Key Vault 시크릿을 마운트하는 구성에서, 워크로드 아이덴티티 설정이 어긋나면 마운트 자체가 실패합니다. 이때 애플리케이션은 설정 파일을 못 읽어 Exit Code 1로 죽고, 로그에는 “설정 없음”만 남아 진짜 원인이 가려집니다.
# CSI 드라이버 쪽 이벤트를 봐야 원인이 보인다
kubectl describe pod <POD_NAME> -n myapp | grep -A10 Events
kubectl logs -n kube-system -l app=secrets-store-csi-driver --tail=50
Key Vault 연동 자체를 정리하고 싶으시면 Azure Key Vault로 시크릿 안전하게 관리하기를 참고하세요.
순서 요약
다음에 같은 상황을 만나면 이 순서만 기억하시면 됩니다.
describe로 Exit Code를 확보한다 — 여기서 절반이 갈린다logs --previous로 죽은 컨테이너의 로그를 본다- 로그가 비었으면
kubectl debug --copy-to로 살려두고 안에서 직접 실행한다 - 143이면 프로브, 137이면 메모리, 139면 아키텍처를 의심한다
- 그래도 안 나오면 ACR 권한·CSI 마운트 등 AKS 고유 요인을 본다
가장 많이 하는 실수 하나만 다시 강조하면, --previous 없이 kubectl logs를 치고 “로그가 없다”고 판단하는 것입니다. 이것 하나로 30분을 날리는 경우를 자주 봅니다.