Published on

K8s CrashLoopBackOff 원인별 로그·Probe 디버깅

Authors

CrashLoopBackOff는 에러 자체라기보다 컨테이너가 짧은 주기로 종료되고 Kubernetes가 재시작을 반복할 때 붙는 상태입니다. 즉, 핵심은 왜 프로세스가 종료되는가왜 Probe가 실패하는가를 분리해 추적하는 것입니다.

이 글은 다음 순서로 문제를 좁힙니다.

  1. 이벤트로 “무엇이 트리거였는지” 확인
  2. --previous 로그로 “죽기 직전” 원인 확보
  3. 종료 코드(exit code)로 유형 분류
  4. Liveness/Readiness/Startup Probe를 “설계 관점”에서 재검토
  5. 리소스(OOM/CPU)와 의존성(DB, 외부 API)까지 포함해 재현 및 완화

1) 가장 먼저 볼 것: 이벤트와 재시작 카운트

CrashLoopBackOff 디버깅은 kubectl describeEvents에서 시작합니다. 여기에는 이미지 풀 실패, Probe 실패, OOMKilled 같은 힌트가 압축돼 있습니다.

kubectl get pod -n myns
kubectl describe pod -n myns myapp-xxxxx

describe에서 특히 아래를 체크하세요.

  • State: WaitingReason: CrashLoopBackOff
  • Last State: TerminatedReason, Exit Code, Started, Finished
  • EventsBack-off restarting failed container, Unhealthy(probe 실패), OOMKilled

재시작이 빠르게 반복될수록 로그가 휘발되기 때문에, 다음 단계에서 이전 컨테이너 로그를 바로 확인해야 합니다.


2) “죽기 직전 로그”는 --previous가 핵심

CrashLoopBackOff는 컨테이너가 이미 재시작된 뒤라 현재 로그만 보면 원인이 사라진 경우가 많습니다. 이때는 반드시 --previous 옵션을 사용합니다.

kubectl logs -n myns myapp-xxxxx -c app --previous

멀티 컨테이너 Pod라면 -c로 컨테이너를 지정해야 하고, 사이드카(예: envoy, fluentbit)가 아닌 실제 앱 컨테이너를 봐야 합니다.

추가로, 로그가 너무 짧다면 종료 직전까지 더 많이 남기도록 애플리케이션의 로그 플러시를 조정합니다.

  • Node.js: 프로세스 종료 훅에서 동기 flush 지양, stdout 버퍼링 최소화
  • Java: SIGTERM 처리, graceful shutdown 로그 남기기
  • Python: PYTHONUNBUFFERED=1 고려

3) 종료 코드로 원인 범주를 빠르게 분류하기

kubectl describe podLast State: Terminated에서 Exit Code는 매우 강력한 분류 기준입니다.

자주 나오는 종료 코드/Reason

  • Exit Code: 1 또는 비정상 코드: 앱 내부 예외, 설정 누락, 시작 스크립트 실패
  • Exit Code: 137 또는 Reason: OOMKilled: 메모리 부족으로 커널이 강제 종료
  • Exit Code: 139: 세그폴트(네이티브 라이브러리, 런타임/아키텍처 이슈)
  • Reason: Error + 이벤트에 Unhealthy: Probe 실패로 kubelet이 재시작 유도

종료 코드가 OOMKilled라면 Probe를 아무리 만져도 해결이 안 됩니다. 반대로 앱이 정상 동작하는데도 Probe가 과격하면 CrashLoopBackOff로 보일 수 있습니다.


4) 원인 1: 환경변수/시크릿/설정 누락으로 즉시 종료

가장 흔한 패턴은 앱이 부팅 초기에 설정 검증을 하다가 종료하는 경우입니다.

증상

  • 로그에 missing env, cannot find config, permission denied
  • 컨테이너가 1~2초 내로 즉시 종료

체크리스트

kubectl get deploy -n myns myapp -o yaml
kubectl get secret -n myns
kubectl describe secret -n myns my-secret
kubectl exec -n myns -it myapp-xxxxx -c app -- printenv | sort

주의: CrashLoopBackOff로 너무 빨리 죽으면 exec가 어려울 수 있습니다. 이때는 디버그용 ephemeral container(클러스터 버전에 따라 지원)나, 동일 이미지로 임시 Pod를 띄워 환경을 재현합니다.

kubectl run -n myns debug-myapp --rm -it \
  --image=myrepo/myapp:tag --command -- sh

해결 방향

  • envFrom로 ConfigMap/Secret 주입 시 키 이름 오타 확인
  • Secret이 base64 인코딩된 값인지 확인(이중 인코딩 실수 빈번)
  • 파일 마운트 경로 권한(fsGroup, runAsUser) 확인

외부 인증/토큰 설정이 원인인 경우도 많습니다. OAuth/JWT 설정 문제는 애플리케이션이 부팅 단계에서 검증하다가 죽기도 하니, 관련 패턴은 JWT kid 누락·불일치로 JWKS 검증 실패 해결 같은 체크리스트를 참고해 원인 범주를 좁힐 수 있습니다.


5) 원인 2: 이미지/엔트리포인트/아키텍처 불일치

증상

  • 로그 없이 바로 종료
  • 이벤트에 exec format error, no such file or directory(엔트리포인트)

진단

kubectl describe pod -n myns myapp-xxxxx
# Events에서 ImagePullBackOff, ErrImagePull, exec format error 확인

특히 exec format error는 ARM/AMD64 아키텍처가 맞지 않을 때 자주 발생합니다(예: Apple Silicon에서 빌드한 이미지를 AMD64 노드에 배포).

해결 방향

  • 멀티 아키텍처 이미지 빌드(docker buildx) 적용
  • ENTRYPOINT/CMD 경로 및 실행 권한 확인
  • 쉘 스크립트라면 shebang(#!/bin/sh) 및 LF 줄바꿈 확인

6) 원인 3: OOMKilled(메모리 부족)로 재시작 반복

증상

  • Last State: TerminatedReason: OOMKilled
  • Exit Code: 137

리소스 확인

kubectl top pod -n myns
kubectl describe pod -n myns myapp-xxxxx | sed -n '1,200p'

requestslimits를 함께 봐야 합니다.

  • requests는 스케줄링 기준
  • limits를 넘으면 OOMKilled

해결 방향

  • 메모리 limits 상향 또는 누수 제거
  • JVM이면 -Xms, -Xmxlimits에 맞게 설정
  • Node.js면 --max-old-space-size 조정
  • 대용량 초기 로딩(캐시 워밍, 모델 로딩)을 지연하거나 온디맨드로 전환

OOM이 발생하면 Probe 조정은 임시 처방일 뿐이고, 근본은 메모리 프로파일링입니다.


7) 원인 4: Probe 실패로 kubelet이 “죽인다”

Kubernetes에서 재시작은 앱이 자발적으로 죽는 경우뿐 아니라, Liveness Probe 실패로 kubelet이 컨테이너를 강제 재시작시키는 경우도 많습니다.

Probe 3종의 역할을 정확히 분리

  • Readiness: 트래픽을 받을 준비 여부(실패해도 재시작하지 않음)
  • Liveness: 살아있는지(실패하면 재시작)
  • Startup: 부팅이 느린 앱 보호(Startup이 통과하기 전까지 Liveness/Readiness를 유예)

즉, 부팅이 느린 앱에 Liveness만 두면, 부팅 중에 죽는 것으로 오인해 무한 재시작이 됩니다.

이벤트에서 Probe 실패 확인

kubectl describe pod의 Events에 보통 아래처럼 찍힙니다.

  • Unhealthy: Readiness probe failed: ...
  • Unhealthy: Liveness probe failed: ...

흔한 실수 1: Liveness에 “외부 의존성”을 넣음

예를 들어 Liveness가 DB 연결까지 확인하면, DB 장애 시 앱은 살아있는데도 kubelet이 계속 재시작합니다. 이건 장애를 더 키웁니다.

  • 외부 의존성 체크는 Readiness에 넣고
  • Liveness는 프로세스 자체의 건강(이벤트 루프/스레드 덤프/간단한 핑) 정도로 제한하세요.

흔한 실수 2: 초기화 시간이 긴데 initialDelaySeconds가 짧음

Spring Boot, Next.js SSR, 대형 번들 로딩, 마이그레이션 수행 등으로 30~60초 이상 걸릴 수 있습니다. 이때는 Startup Probe를 권장합니다.

권장 예시: Startup + Readiness + Liveness

아래 YAML은 httpGet 기반의 전형적인 안전 구성입니다.

livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 3
readinessProbe:
  httpGet:
    path: /readyz
    port: 8080
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 3
startupProbe:
  httpGet:
    path: /startupz
    port: 8080
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 24  # 5초 * 24 = 최대 120초 부팅 허용

핵심은 다음입니다.

  • 부팅 중에는 startupProbe만 평가
  • 부팅 완료 후에만 livenessProbe가 앱을 재시작할 권한을 가짐

Probe 엔드포인트 구현 팁

  • /healthz: 프로세스/런타임 기본 상태(예: 이벤트 루프 지연, 스레드 풀 포화 등)
  • /readyz: DB 연결, 캐시, 외부 API 등 “트래픽 처리 가능 여부”
  • /startupz: 초기화 완료 플래그(마이그레이션/캐시 준비 등)

8) 원인 5: 포트/바인딩 문제로 Probe만 실패

앱이 127.0.0.1에만 바인딩하면 컨테이너 내부에서는 접근되지만, kubelet의 Probe가 네트워크 네임스페이스 관점에서 실패하는 케이스가 있습니다(환경에 따라 증상이 다르게 보일 수 있음).

진단

kubectl exec -n myns -it myapp-xxxxx -c app -- sh -lc 'netstat -tnlp || ss -tnlp'
kubectl exec -n myns -it myapp-xxxxx -c app -- sh -lc 'curl -sf http://127.0.0.1:8080/healthz && echo OK'

해결 방향

  • 서버 바인딩을 0.0.0.0로 변경
  • 컨테이너 containerPort와 실제 리스닝 포트 일치 확인

9) 원인 6: 종료 시그널 처리 미흡으로 롤링 업데이트 때 반복 크래시

롤링 업데이트나 노드 드레인 시 SIGTERM을 받았을 때, 앱이 즉시 종료하며 다음 기동에서 상태가 꼬이거나(락 파일, 임시 파일), 요청 처리 중 강제 종료로 장애가 확대될 수 있습니다.

체크

  • terminationGracePeriodSeconds가 너무 짧지 않은지
  • 앱이 SIGTERM을 받아 graceful shutdown을 수행하는지
terminationGracePeriodSeconds: 30

Java/Spring Boot는 graceful shutdown 설정을 켜고, Node.js는 process.on('SIGTERM')에서 서버 close를 수행하는 식으로 정리합니다.


10) 실전 디버깅 플로우(명령어 묶음)

아래 순서대로 실행하면 대부분의 CrashLoopBackOff는 10분 내로 원인 후보가 1~2개로 줄어듭니다.

# 1) 상태/재시작 확인
kubectl get pod -n myns -o wide

# 2) 이벤트/종료코드/Probe 실패 확인
kubectl describe pod -n myns myapp-xxxxx

# 3) 죽기 직전 로그
kubectl logs -n myns myapp-xxxxx -c app --previous

# 4) 현재 컨테이너 로그(살아있는 짧은 순간이라도)
kubectl logs -n myns myapp-xxxxx -c app --tail=200 -f

# 5) 리소스(OOM 의심 시)
kubectl top pod -n myns

# 6) Probe 엔드포인트 수동 점검
kubectl exec -n myns -it myapp-xxxxx -c app -- sh -lc 'curl -sv http://127.0.0.1:8080/healthz'

11) CrashLoopBackOff를 “재발 방지”하는 운영 팁

로그/메트릭을 먼저 남기기

  • 앱 시작 시 설정 로딩 결과(민감정보 제외) 로깅
  • 부팅 단계별 타임라인 로깅(예: DB 연결, 마이그레이션, 캐시 준비)
  • /readyz 실패 이유를 응답 바디에 짧게 남기기(내부망에서만 접근 가능하게)

배포 전략

  • 부팅이 느린 서비스는 Startup Probe로 보호
  • 외부 의존성 장애는 Readiness로 격리(트래픽 차단)하고 Liveness로 재시작 폭탄을 만들지 않기

클러스터 스케줄링/노드 리소스 이슈로 Pod가 애초에 안정적으로 뜨지 못하는 경우도 함께 고려해야 합니다. Pending 단계에서 막히는 패턴은 EKS Pod Pending 0/XX nodes available 원인별 해결에서 원인별로 정리해두었습니다.


마무리: CrashLoopBackOff는 “증상”, 답은 로그·이벤트·Probe 설계에 있다

CrashLoopBackOff를 빠르게 끝내려면 감으로 initialDelaySeconds만 늘리기보다, 다음 3가지를 분리해서 보세요.

  • 앱이 스스로 종료하는가(종료 코드/이전 로그)
  • kubelet이 Probe 실패로 죽이는가(Events의 Unhealthy)
  • 리소스/의존성 때문에 살아남지 못하는가(OOMKilled, 외부 연결 실패)

이 과정을 루틴으로 만들면, 같은 유형의 장애가 다시 왔을 때도 “어디부터 볼지”가 고정되고 MTTR이 크게 줄어듭니다.