Published on

Kubernetes CrashLoopBackOff와 OOMKilled(ExitCode 137) 해결

Authors

서버가 멀쩡해 보이는데 Pod가 계속 재시작되고, kubectl get pods 에서 상태가 CrashLoopBackOff 로 바뀌며, kubectl describe pod 를 보면 OOMKilled 또는 Exit Code: 137 이 찍히는 케이스가 있습니다. 이 조합은 쿠버네티스 운영에서 가장 흔하면서도, 원인 스펙트럼이 넓어 초기에 시간을 많이 잡아먹는 장애 유형입니다.

이 글에서는 ExitCode 137 이 의미하는 바를 정확히 해석하고, 정말로 OOMKilled인지(혹은 다른 SIGKILL인지) 구분하는 방법, 그리고 재발 방지를 위한 리소스/애플리케이션 튜닝과 운영 설정(HPA, VPA, 프로브, 롤링 업데이트)까지 단계별로 정리합니다.

CrashLoopBackOff, OOMKilled, ExitCode 137의 관계

CrashLoopBackOff는 “원인”이 아니라 “증상”

CrashLoopBackOff 는 컨테이너가 반복적으로 종료되고 kubelet이 재시작을 지수 백오프로 늦추는 상태입니다. 즉, 아래 중 하나가 반복되고 있다는 신호입니다.

  • 애플리케이션이 스스로 크래시(예: 예외, 설정 오류, 포트 바인딩 실패)
  • 프로브 실패로 kubelet이 강제 종료
  • 노드/커널 또는 cgroup 제한에 의해 강제 종료(OOMKilled 포함)

따라서 CrashLoopBackOff 만 보고는 결론을 낼 수 없고, “왜 종료되었는지”를 먼저 확정해야 합니다.

ExitCode 137은 보통 SIGKILL(9)

리눅스에서 종료 코드 137 은 일반적으로 128 + 9 로, 프로세스가 SIGKILL 로 종료되었음을 의미합니다. 쿠버네티스에서 SIGKILL 이 발생하는 대표 원인은 다음입니다.

  • 컨테이너 메모리 제한(limits.memory)을 초과해 cgroup OOM으로 강제 종료됨 => OOMKilled: true
  • 노드 전체 메모리 압박으로 커널 OOM Killer가 프로세스를 죽임
  • 사용자가 kubectl delete pod --force 등으로 강제 종료
  • 컨테이너 런타임/노드 이슈로 강제 kill

핵심은 137 = OOMKilled 로 단정하기 전에, OOMKilled 플래그와 노드 로그 근거를 함께 확인하는 것입니다.

1단계: kubectl로 “OOMKilled인지”부터 확정

Pod 이벤트와 종료 사유 확인

아래 명령으로 컨테이너의 마지막 종료 상태를 확인합니다.

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

여기서 다음을 봅니다.

  • Last State: Terminated
  • Reason: OOMKilled 또는 Reason: Error
  • Exit Code: 137
  • Started / Finished 시간
  • Events 섹션의 Killing container 메시지

좀 더 정확히는 JSONPath로 lastState 를 뽑아보면 빠릅니다.

kubectl get pod -n <namespace> <pod-name> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}{"\n"}{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'

reasonOOMKilled 로 나오면 일단 “컨테이너 메모리 제한 초과에 의한 cgroup OOM” 가능성이 매우 높습니다.

이전 로그 확인(--previous)

CrashLoop에서는 현재 컨테이너가 이미 재시작된 상태라 로그가 비어 보일 수 있습니다. 직전 컨테이너 로그를 보려면 --previous 를 사용합니다.

kubectl logs -n <namespace> <pod-name> -c <container-name> --previous
  • 애플리케이션이 OutOfMemoryError 를 찍고 종료했는지
  • 특정 요청/배치 시점에만 메모리 급증이 있는지
  • 종료 직전 GC 로그나 메모리 경고가 있는지

를 확인합니다.

2단계: “컨테이너 OOM”과 “노드 OOM”을 구분

컨테이너 OOM(cgroup OOM)

특징:

  • kubectl describeReason: OOMKilled 가 명확히 찍힘
  • 해당 컨테이너의 limits.memory 가 낮거나, 스파이크를 감당 못함
  • 노드는 멀쩡하고 다른 Pod는 정상일 수 있음

노드 OOM(커널 OOM Killer)

특징:

  • Pod에 OOMKilled 가 안 찍히기도 함(상황에 따라 다름)
  • 노드 전체가 메모리 압박 상태
  • 여러 Pod가 연쇄적으로 재시작
  • 노드 dmesg 에 OOM Killer 흔적이 남음

노드 OOM의 원인 추적은 커널 로그와 cgroup 관점이 중요합니다. 자세한 추적 방법은 내부 글인 Linux OOM Killer 원인추적 - dmesg·cgroup·로그 를 함께 참고하면 진단 속도가 크게 올라갑니다.

3단계: 리소스 설정(요청/제한)부터 현실화

requests/limits가 왜 중요하나

  • requests 는 스케줄링 기준입니다. 너무 낮으면 노드에 과밀 배치가 됩니다.
  • limits 는 강제 상한입니다. 넘으면 컨테이너는 OOMKilled로 죽습니다.

특히 자바/JVM, Node.js, 파이썬 머신러닝 워크로드는 “기본 메모리 사용량 + 피크” 편차가 커서, 낮은 limits 로는 쉽게 137 을 만듭니다.

예시: 메모리 제한을 올리고, 요청도 함께 조정

아래는 안전한 출발점 예시입니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: api
          image: myrepo/api:1.0.0
          resources:
            requests:
              cpu: "250m"
              memory: "512Mi"
            limits:
              cpu: "1"
              memory: "1024Mi"

포인트:

  • limits.memory 만 올리고 requests.memory 를 그대로 두면 노드 과밀 배치로 “노드 OOM” 가능성이 커집니다.
  • 반대로 requests 를 과하게 올리면 스케줄이 안 되어 Pending이 늘 수 있습니다.

QoS 클래스도 함께 확인

쿠버네티스는 requestslimits 조합으로 QoS 클래스를 만듭니다.

  • Guaranteed: 모든 리소스에 대해 requests == limits
  • Burstable: requests 가 있고 requests != limits
  • BestEffort: requests 없음

노드 메모리 압박 상황에서 BestEffort/Burstable이 먼저 희생됩니다. 장애가 반복된다면 해당 Pod의 QoS가 무엇인지 확인하세요.

kubectl get pod -n <namespace> <pod-name> -o jsonpath='{.status.qosClass}{"\n"}'

4단계: 애플리케이션 런타임별 “컨테이너 메모리 인지” 튜닝

리소스를 올렸는데도 재발한다면, 애플리케이션이 컨테이너 메모리 한도를 제대로 인지하지 못하거나, 힙 외 메모리(네이티브/버퍼/스레드)가 과도할 가능성이 큽니다.

JVM(Spring Boot 등): 힙만이 메모리가 아니다

자바는 다음이 합쳐져 실제 RSS를 만듭니다.

  • Java heap
  • Metaspace
  • Thread stack
  • Direct buffer(NIO)
  • JIT/Code cache
  • 네이티브 라이브러리

컨테이너에서 흔한 실수는 -Xmxlimits.memory 에 가깝게 잡아 힙 외 메모리로 OOMKilled를 만드는 것입니다.

권장 접근:

  • -XX:MaxRAMPercentage 로 컨테이너 한도 기반 자동 산정
  • 힙을 limits.memory 의 60~75% 수준에서 시작
  • 필요 시 -XX:MaxDirectMemorySize 등으로 상한 설정

예시:

JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50 -XX:+ExitOnOutOfMemoryError" \
java -jar app.jar

Spring Boot OOM 분석 및 튜닝은 케이스가 다양합니다. 힙 덤프/GC/누수 관점의 실전 절차는 Spring Boot OutOfMemoryError 덤프 분석·튜닝 7단계 도 같이 보시면 좋습니다.

Node.js: --max-old-space-size 를 명시

Node.js도 기본값이 컨테이너 한도와 어긋날 수 있습니다. old space 상한을 지정해 “한도 근처까지 쓰다 죽는” 패턴을 줄입니다.

node --max-old-space-size=768 server.js

여기서 768 은 MB 단위입니다. limits.memory1024Mi 라면, 네이티브/버퍼/오버헤드를 고려해 old space를 60~80%로 두고 시작합니다.

Python: 워커/병렬 처리 수가 메모리를 곱한다

Gunicorn/Uvicorn, Celery, 멀티프로세싱은 워커 수가 곧 메모리 배수로 이어질 수 있습니다.

  • 워커를 4개로 늘렸더니 RSS가 4배로 증가
  • 모델/캐시를 프로세스마다 로딩

이 경우는 limits 를 올리는 것보다 워커 수를 줄이거나, 프리포크 모델/공유 메모리 전략을 재검토하는 게 근본 해결입니다.

5단계: 프로브와 종료 시그널로 “불필요한 137” 제거

137 이 항상 OOM은 아니며, 프로브/종료 처리 때문에 SIGKILL 로 끝나는 경우도 있습니다.

readiness/liveness가 너무 공격적인 경우

  • initialDelaySeconds 가 너무 짧아 초기 로딩 중 계속 kill
  • timeoutSeconds 가 너무 짧아 일시적 지연에 kill
  • failureThreshold 가 낮아 스파이크에 취약

예시(완화된 설정):

livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 6
readinessProbe:
  httpGet:
    path: /ready
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 6

terminationGracePeriodSeconds와 preStop

쿠버네티스는 종료 시 먼저 SIGTERM 을 보내고, 유예 시간 후에도 안 죽으면 SIGKILL 을 보냅니다. 유예 시간이 짧으면 정상 종료가 안 되고 137 로 끝날 수 있습니다.

terminationGracePeriodSeconds: 60
containers:
  - name: api
    lifecycle:
      preStop:
        exec:
          command: ["/bin/sh", "-c", "sleep 10"]
  • 로드밸런서/Ingress에서 트래픽이 빠질 시간을 확보
  • 애플리케이션이 커넥션 드레인, 작업 중단을 처리할 시간을 확보

6단계: 관측으로 “언제, 얼마나”를 수치화

metrics-server 기반 빠른 확인

설치되어 있다면 다음으로 현재 사용량을 봅니다.

kubectl top pod -n <namespace>
kubectl top node

하지만 top 은 “현재”만 보여줍니다. OOM은 스파이크성인 경우가 많아, 시계열(프로메테우스/그라파나)로 피크를 봐야 합니다.

컨테이너 메모리 피크를 기준으로 산정

권장 산정 방식:

  • 피크 RSS의 p95 또는 p99를 기준으로 limits.memory 설정
  • requests.memory 는 평균 또는 p50~p70 수준에서 시작하되, 노드 과밀을 피할 만큼 현실적으로

그리고 “피크가 왜 생겼는지”를 코드/트래픽/배치 이벤트와 연결해야 재발이 줄어듭니다.

7단계: 자동 확장(HPA/VPA)과 배치 전략으로 재발 방지

HPA는 메모리 스파이크에 만능이 아니다

HPA를 CPU 기준으로만 걸어두면, 메모리 기반 병목에서는 스케일이 늦거나 안 될 수 있습니다. 메모리 기준 HPA도 가능하지만, 메모리 누수처럼 “시간에 따라 계속 증가”하는 유형은 HPA가 오히려 비용만 늘릴 수 있습니다.

VPA로 requests/limits 추천을 받되, 적용 방식 주의

VPA는 유용하지만, 적용 모드에 따라 Pod 재시작이 발생할 수 있습니다. 운영에서는 보통 recommendation 을 먼저 보고 수동 반영하거나, 영향 범위를 제한해 점진 적용합니다.

롤링 업데이트 시 surge/unavailable 조정

업데이트 순간에 메모리 사용량이 급증(구버전+신버전 공존)하는 경우가 있습니다.

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1
    maxUnavailable: 0

클러스터 여유가 없는데 maxSurge 가 커서 동시에 Pod가 늘어나면 노드 OOM을 유발할 수 있습니다.

실전 체크리스트(요약)

  • kubectl describe pod 에서 Reason: OOMKilled 인지 확인
  • kubectl logs --previous 로 종료 직전 로그 확인
  • 컨테이너 OOM인지, 노드 OOM인지 구분(노드 dmesg/이벤트)
  • requestslimits 를 함께 현실화(과밀 배치 방지)
  • JVM/Node/Python 런타임 메모리 상한을 컨테이너 기준으로 튜닝
  • 프로브/종료 유예 시간으로 불필요한 137 제거
  • 시계열로 피크 메모리 관측 후 p95/p99 기준으로 재설정

마무리

CrashLoopBackOff + ExitCode 137 은 “쿠버네티스가 이상하다”라기보다, 대부분 “메모리/종료 정책을 현실과 맞추지 못했다”는 신호입니다. 먼저 OOMKilled 여부를 확정하고, 컨테이너 제한 초과인지 노드 압박인지 구분한 뒤, 리소스 설정과 런타임 튜닝을 같이 손보면 재발률이 크게 떨어집니다.

특히 노드 OOM까지 의심된다면 커널 로그와 cgroup 관점의 근거 수집이 중요하니 Linux OOM Killer 원인추적 - dmesg·cgroup·로그 를, JVM 기반 서비스라면 Spring Boot OutOfMemoryError 덤프 분석·튜닝 7단계 를 같이 참고해 원인-대책을 한 번에 닫아보세요.