Published on

K8s CrashLoopBackOff·OOMKilled 원인과 해결

Authors

서버가 멀쩡한데 Pod만 계속 재시작하고, 이벤트에는 CrashLoopBackOff가 찍히며, 가끔은 OOMKilled까지 보이면 운영자는 두 가지를 동시에 의심하게 됩니다. (1) 컨테이너 프로세스가 비정상 종료(Exit Code != 0)하거나 (2) 커널/컨테이너 런타임이 프로세스를 강제 종료(OOM Kill) 했다는 신호입니다. 문제는 이 둘이 서로 얽혀서 “재시작 → 초기화 메모리 급증 → OOMKilled → 재시작” 같은 루프를 만들기 쉽다는 점입니다.

이 글은 원인 분류 → 빠른 진단 명령 → 케이스별 해결 → 재발 방지 체크리스트 순서로 정리합니다.

CrashLoopBackOff와 OOMKilled의 관계 이해

  • CrashLoopBackOff: Kubernetes가 컨테이너를 재시작하려고 시도했지만, 짧은 시간 내 반복 실패하여 백오프(backoff) 를 걸고 있는 상태입니다. 근본 원인은 대개 컨테이너 프로세스 종료입니다.
  • OOMKilled: 리눅스 OOM Killer 또는 cgroup 메모리 제한에 의해 프로세스가 강제 종료된 상태입니다. Kubernetes에서는 Last State: TerminatedReason: OOMKilled로 표시됩니다.

즉, OOMKilled는 CrashLoopBackOff의 “대표적인 원인 중 하나” 입니다. 반대로, CrashLoopBackOff가 OOMKilled가 아닌 경우도 매우 많습니다(예: 잘못된 환경변수, Secret 누락, 프로브 실패로 인한 kill 등).

1분 진단: kubectl로 원인 좁히기

아래 순서대로 보면 대부분의 케이스는 5분 내에 윤곽이 나옵니다.

1) Pod 이벤트와 종료 사유 확인

kubectl -n <ns> describe pod <pod-name>

여기서 핵심 포인트:

  • State: Waiting / Reason: CrashLoopBackOff
  • Last State: Terminated / Reason: OOMKilled 또는 Error
  • Exit Code: 137(OOMKilled 가능성 큼), Exit Code: 1(앱 에러), Exit Code: 143(SIGTERM 정상 종료 흐름일 수도)
  • Events 섹션의 Killing / Back-off restarting failed container / Readiness probe failed

2) 직전 크래시 로그 확인

kubectl -n <ns> logs <pod-name> -c <container-name> --previous
  • --previous는 “바로 직전에 죽은 컨테이너” 로그를 봅니다.
  • 애플리케이션이 시작 직후 죽는다면, 이 로그가 거의 유일한 단서인 경우가 많습니다.

3) 리소스 사용량(있다면) 확인

kubectl -n <ns> top pod <pod-name>

top이 0%로만 나오거나 동작하지 않으면 Metrics Server/metrics API 문제일 수 있습니다. 이 경우는 별도 점검이 필요합니다: EKS에서 kubectl top이 0%일 때 Metrics API 점검

OOMKilled: 가장 흔한 원인과 해결

원인 A) memory limit이 너무 작다(초기화/캐시/워크로드 피크)

가장 흔한 케이스입니다. 특히 다음 패턴에서 잘 터집니다.

  • JVM/Node/Python 앱이 시작 시 JIT/캐시/모듈 로딩으로 메모리 피크
  • 대량 트래픽 시 버퍼/큐/커넥션 증가
  • 로그/메트릭 에이전트가 함께 떠서 합산 메모리 증가

해결 1) requests/limits 재설계

  • requests.memory: 스케줄링 기준(노드에 자리 잡기)
  • limits.memory: cgroup 상한(넘으면 OOMKilled)

예시(너무 타이트한 limit을 완화):

resources:
  requests:
    cpu: "200m"
    memory: "512Mi"
  limits:
    cpu: "1"
    memory: "1Gi"

운영 팁:

  • limit을 올리기 전에 “왜 그만큼 필요한지”를 확인하세요(메모리 누수 vs 정상 피크).
  • HPA를 쓰더라도, 단일 Pod가 감당 못 하는 피크가 있으면 scale-out 전에 죽을 수 있습니다.

해결 2) 언어/런타임별 메모리 상한 설정

  • JVM: 컨테이너 환경에서 힙이 limit에 비해 과도하게 잡히면 OOM이 납니다.
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=50"
  • Node.js: 기본 힙 상한이 컨테이너 limit과 불일치할 수 있습니다.
NODE_OPTIONS="--max-old-space-size=768"
  • Python (gunicorn/uvicorn): 워커 수가 많으면 메모리가 선형 증가합니다. 워커 수를 CPU 기반으로만 잡지 말고 메모리도 고려하세요.

원인 B) 메모리 누수(특히 장시간 실행 후)

증상:

  • 처음엔 정상 → 시간이 지나면서 RSS가 계속 증가 → limit 도달 시 OOMKilled

대응:

  • 애플리케이션 레벨에서 heap dump/pprof 등으로 누수 지점 추적
  • 단기 완화로는 rolling restart 또는 max-requests 같은 워커 재생성 옵션

예: gunicorn의 워커 재시작(누수 완화)

gunicorn app:app --max-requests 2000 --max-requests-jitter 200

원인 C) 노드 레벨 메모리 압박(Eviction)

Pod가 OOMKilled가 아니라 Evicted로 죽기도 합니다. describe pod의 이벤트에 The node was low on resource: memory 등이 보이면 노드 압박입니다.

대응:

  • 노드 타입 상향/노드 수 증가(Cluster Autoscaler)
  • 과도한 DaemonSet(로그/보안 에이전트) 메모리 점검
  • requests를 현실적으로 설정해 과밀 스케줄링 방지

CrashLoopBackOff: OOM이 아닌 대표 원인들

원인 D) liveness/readiness/startup probe 설정이 공격적

앱이 아직 준비되지 않았는데 liveness가 먼저 실패하면 kubelet이 컨테이너를 계속 kill합니다. 특히 초기화가 느린 앱에서 흔합니다.

권장 패턴:

  • 느리게 뜨는 앱: startupProbe로 부팅 구간 보호
  • livenessProbe는 “죽었을 때만 실패”하도록 보수적으로
  • readinessProbe는 트래픽 차단 용도

예시:

startupProbe:
  httpGet:
    path: /healthz
    port: 8080
  failureThreshold: 30
  periodSeconds: 2

livenessProbe:
  httpGet:
    path: /live
    port: 8080
  initialDelaySeconds: 30
  timeoutSeconds: 2
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /ready
    port: 8080
  timeoutSeconds: 2
  periodSeconds: 5
  failureThreshold: 2

체크 포인트:

  • readiness 실패는 재시작을 유발하지 않습니다.
  • liveness 실패는 재시작을 유발합니다.

원인 E) 환경변수/Secret/ConfigMap 누락 또는 권한 문제

컨테이너는 뜨지만 앱이 시작하면서 설정을 못 읽고 종료 → CrashLoopBackOff.

진단:

  • kubectl describe pod에서 MountVolume.SetUp failed / secret not found
  • 앱 로그에 AccessDenied, NoSuchKey, permission denied

EKS에서 AWS 리소스 권한 문제(예: S3 403)도 흔한 크래시 원인입니다. 관련해서는 이 글의 체크리스트가 도움이 됩니다: EKS Pod에서 S3 403 AccessDenied 원인 10가지

원인 F) 종료 처리 미흡(Graceful shutdown 실패)로 재시작 루프 확대

Pod가 재시작될 때 SIGTERM을 받는데, 애플리케이션이 종료 훅에서 오래 걸리거나 데드락이 걸리면 다음 문제가 생깁니다.

  • 새 Pod가 뜨기 전에 구 Pod가 끝나지 않아 리소스가 겹침
  • 연결 정리가 안 되어 외부 의존성(DB 등)에 부담

대응:

  • terminationGracePeriodSeconds를 현실적으로
  • 애플리케이션에서 SIGTERM 처리(서버 close, 큐 flush, 워커 stop)

예시:

terminationGracePeriodSeconds: 60
lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 10"]

preStopsleep은 로드밸런서/엔드포인트 갱신 시간을 벌어주지만, 근본적으로는 앱이 정상 종료 루틴을 가져야 합니다.

실전 디버깅 루틴(재현 가능한 체크리스트)

1) “OOMKilled인지”부터 확정

kubectl -n <ns> get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
  • OOMKilled면 리소스/런타임 튜닝으로 바로 들어갑니다.
  • Error면 로그/설정/프로브/의존성 문제로 접근합니다.

2) Exit Code로 힌트 얻기

  • 137: SIGKILL(대개 OOM)
  • 143: SIGTERM(정상 종료 흐름일 수도)
  • 1: 앱 내부 에러
kubectl -n <ns> get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.exitCode}'

3) 노드/이벤트에서 eviction, OOM pressure 확인

kubectl -n <ns> get events --sort-by=.lastTimestamp | tail -n 50
kubectl describe node <node-name>

재발 방지: 운영 관점의 설계 포인트

리소스는 “관측 → 가설 → 조정”으로 반복

  • 메모리 limit은 안전장치지만, 너무 낮으면 안정성을 오히려 해칩니다.
  • 반대로 무작정 올리면 노드 밀도가 떨어지고 비용이 증가합니다.

권장:

  1. 관측(메트릭/로그)으로 피크 패턴 파악
  2. 초기화 피크 vs 누수 vs 트래픽 피크 구분
  3. limit 조정 + 런타임 상한 설정 + 프로브 완화

장애를 “연쇄”로 보라

CrashLoopBackOff는 종종 다른 장애의 결과입니다.

  • DB 커넥션 고갈 → 앱이 시작 시 연결 실패로 종료 → CrashLoopBackOff
  • 외부 API 5xx → 초기화 단계에서 실패 → 재시작 루프

외부 의존성 실패에 대해선 재시도/폴백/서킷브레이커가 크래시를 막는 실전 대안이 됩니다. 패턴 정리는 다음 글이 참고됩니다: OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커

결론: 가장 빠른 해결 순서

  1. describe podLast State / Reason / Exit Code 확인
  2. logs --previous직전 크래시 로그 확보
  3. OOMKilled면: limit 상향 + 런타임 상한(JVM/Node) + 워커 수/캐시 점검
  4. OOM이 아니면: probe(특히 liveness/startup)·설정 누락·권한·의존성 장애를 우선 확인
  5. 마지막으로 노드 압박/eviction까지 확장

원하시면 사용 중인 런타임(JVM/Node/Python/Go), 현재 resources, probe 설정, 그리고 describe pod 출력 일부를 주시면 “딱 필요한 수준”의 수정안(YAML 패치 형태)으로 구체화해 드릴게요.