Published on

EKS CrashLoopBackOff - OOMKilled·Exit 137 원인과 해결

Authors

서버가 멀쩡한데 EKS Pod가 CrashLoopBackOff로 계속 재시작되고, kubectl describe pod에는 OOMKilled가 찍히거나 Exit Code: 137만 남는 경우가 많습니다. 이때 흔히 “메모리 늘리면 되겠지”로 끝내지만, 실제 현장에서는 (1) 컨테이너 메모리 제한 때문에 커널 OOM이 난 것인지, (2) 노드 메모리 압박으로 축출(eviction)된 것인지, **(3) 앱이 SIGKILL을 맞을 만한 다른 이유(프로브, preStop, 런타임, sidecar)**가 있는지부터 분리해야 재발을 막을 수 있습니다.

이 글은 EKS/Kubernetes에서 CrashLoopBackOff + OOMKilled/137을 만났을 때 증거를 모으는 순서원인별 해결책을 실전 관점으로 정리합니다.

> 참고: CrashLoopBackOff가 사실은 liveness probe 오탐인 경우도 매우 흔합니다. 그 케이스는 별도로 정리한 글(K8s CrashLoopBackOff - liveness probe 오탐 해결)을 함께 보시면 진단 시간이 크게 줄어듭니다.

CrashLoopBackOff, OOMKilled, Exit 137의 관계

CrashLoopBackOff란?

CrashLoopBackOff는 “컨테이너가 반복적으로 죽고(restart) Kubernetes가 재시작 간격을 점점 늘리는(back-off) 상태”입니다. 즉 원인은 CrashLoopBackOff가 아니라, 반복 종료를 유발한 사건입니다.

OOMKilled란?

OOMKilled리눅스 커널 OOM Killer가 프로세스를 죽였다는 뜻입니다. Kubernetes 관점에서는 보통:

  • 컨테이너가 **자신의 cgroup 메모리 제한(resources.limits.memory)**을 초과해서 SIGKILL
  • 또는 노드가 메모리 압박을 받아 eviction이 발생(이 경우는 OOMKilled가 아닐 수도 있음)

Exit Code 137이 의미하는 것

137 = 128 + 9 이고, 여기서 9SIGKILL입니다. 즉 강제 종료(kill -9) 를 당했다는 뜻입니다.

중요 포인트:

  • Exit 137OOMKilled일 수도 있고 아닐 수도 있습니다.
  • 예: 노드가 압박으로 컨테이너 런타임이 강제 종료, 또는 사람이 kubectl delete pod로 종료(Grace 기간 후 SIGKILL), 또는 프로브/오케스트레이션이 강제 종료 등.

따라서 Exit 137을 봤다면 OOMKilled 여부를 먼저 확인해야 합니다.

1단계: “무엇이 죽였는지” 빠르게 확인하는 커맨드

Pod 이벤트/상태에서 1차 판별

kubectl get pod -n <ns> <pod> -o wide
kubectl describe pod -n <ns> <pod>

describe에서 특히 봐야 할 부분:

  • Last State: TerminatedReasonOOMKilled인지
  • Exit Code: 137 / Exit Code: 1
  • EventsKilling, Back-off restarting failed container 외에
    • Evicted
    • The node was low on resource: memory
    • Container killed by kubelet 같은 메시지가 있는지

컨테이너별 종료 사유를 JSONPath로 바로 뽑기

멀티 컨테이너(사이드카 포함)면 어떤 컨테이너가 죽는지부터 헷갈립니다.

kubectl get pod -n <ns> <pod> -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.lastState.terminated.reason}{"\t"}{.lastState.terminated.exitCode}{"\n"}{end}'

직전 로그(이전 컨테이너) 확인

재시작이 반복되면 현재 로그만 보면 원인이 사라져 있습니다.

kubectl logs -n <ns> <pod> -c <container> --previous --tail=200
  • OOM이면 보통 앱 로그가 “갑자기 끊겨” 있습니다.
  • 반대로 앱이 스스로 죽는다면(예: config error) 종료 직전 에러가 남는 경우가 많습니다.

2단계: OOMKilled인지, 노드 메모리 압박(eviction)인지 구분

케이스 A: Reason: OOMKilled가 명확한 경우

이 경우는 거의 항상 컨테이너 메모리 limit 초과입니다.

확인할 것:

  • resources.limits.memory가 너무 낮지 않은가?
  • JVM/Node/Python 등 런타임이 limit을 인지하고 힙을 제한하고 있는가?
  • 순간 피크(배치, 캐시 워밍, 대형 응답 생성)로 limit을 넘는가?

케이스 B: Exit 137인데 OOMKilled는 아닌 경우

이때는 다음을 의심합니다.

  1. 노드 메모리 압박으로 eviction
  • 이벤트에 Evicted 또는 low on resource: memory가 찍힙니다.
  • Pod 자체는 limit을 안 넘었는데도 노드 전체가 부족해서 밀려날 수 있습니다.
  1. 종료(graceful) 실패 후 SIGKILL
  • terminationGracePeriodSeconds 동안 종료가 안 되면 kubelet이 SIGKILL을 보냅니다.
  • 이 경우도 137이 나올 수 있습니다.
  1. liveness probe 오탐으로 강제 재시작

3단계: 리소스 설정을 “증거 기반”으로 조정하기

requests/limits가 왜 중요한가

  • requests: 스케줄링 보장(노드에 자리 잡기)
  • limits: 상한선(cgroup 제한). 메모리 limit을 넘으면 OOMKilled

특히 EKS에서 다음 조합이 사고를 만듭니다.

  • requests는 낮게 잡아 쉽게 스케줄링
  • limits는 타이트하게 잡음
  • 실제 트래픽/피크에서 메모리 스파이크 → OOMKilled → CrashLoopBackOff

현재 사용량(근사치) 확인

metrics-server가 있다면:

kubectl top pod -n <ns>
kubectl top pod -n <ns> --containers
kubectl top node

주의: kubectl top은 순간값이라 OOM 직전 피크를 놓치기 쉽습니다. 가능하면 Prometheus/Grafana에서 container_memory_working_set_bytes 같은 시계열로 피크를 확인하세요.

(예시) 안전한 리소스 템플릿

아래는 “일단 죽지 않게” 잡는 예시이고, 서비스 특성에 맞게 조정해야 합니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  template:
    spec:
      containers:
        - name: api
          image: your-image:tag
          resources:
            requests:
              cpu: "200m"
              memory: "512Mi"
            limits:
              cpu: "1"
              memory: "1Gi"

실무 팁:

  • OOMKilled가 났다면 limits.memory만 올리는 것은 임시방편입니다.
  • requests.memory도 함께 올려야 노드에 “제대로 자리”를 잡고, 노드 압박/eviction 가능성을 줄입니다.

4단계: 런타임별 OOM 패턴과 해결

JVM (Spring Boot 등): limit을 몰라서 힙이 과도해지는 문제

컨테이너 환경에서 JVM은 버전에 따라 cgroup을 잘못 인식하거나, 기본 힙이 너무 커질 수 있습니다.

권장 접근:

  • -XX:MaxRAMPercentage로 힙 상한을 명시
  • 메타스페이스/직접 메모리(Direct), 스레드 스택 등도 고려

예:

env:
  - name: JAVA_TOOL_OPTIONS
    value: "-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50 -XX:+ExitOnOutOfMemoryError"

Node.js: old space 기본값/버전별 차이

Node는 힙 제한이 컨테이너 limit과 다르게 동작할 수 있습니다.

예:

env:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=768"

Python: 캐시/버퍼/대형 객체 누수

  • pandas/pyarrow 등은 순간 메모리 피크가 큼
  • gunicorn worker 수가 많으면 선형으로 메모리 증가

점검:

  • worker 수/threads 조정
  • 요청당 큰 객체 생성 여부
  • 이미지/압축/파싱 로직의 peak memory

5단계: 노드 메모리 압박(Eviction)일 때의 해결

Pod limit을 올렸는데도 계속 죽는다면 “노드가 작아서” 생기는 문제일 수 있습니다.

확인 포인트

kubectl describe node <node-name>
  • ConditionsMemoryPressure=True
  • Allocatable 대비 Requested가 과도

해결 옵션

  • 노드 인스턴스 타입 업그레이드(메모리 큰 타입)
  • 노드 수 확장(Cluster Autoscaler/Karpenter)
  • 과도한 DaemonSet(로그/모니터링 에이전트) 메모리 사용량 점검
  • QoS 개선: requests를 적절히 설정해 BestEffort를 피하고, 중요한 Pod가 먼저 쫓겨나지 않게 설계

6단계: 종료(graceful) 실패로 137이 나는 케이스

앱이 SIGTERM을 받았는데 종료가 오래 걸리거나 멈추면, kubelet이 grace 기간 후 SIGKILL(137)을 보냅니다.

점검 체크리스트

  • terminationGracePeriodSeconds가 너무 짧지 않은가?
  • preStop hook이 너무 오래 걸리지 않는가?
  • SIGTERM 핸들링이 제대로 되어 있는가?

예시:

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

preStop는 트래픽 드레이닝에 유용하지만, 과하면 오히려 종료 지연으로 137을 유발합니다.

7단계: 프로브(liveness/readiness)로 인한 재시작과 OOM의 구분

OOM과 프로브 실패는 증상이 비슷합니다(재시작 반복). 하지만 해결책은 완전히 다릅니다.

  • OOM: 메모리 상한/피크/누수/런타임 옵션
  • liveness 오탐: 초기 지연, timeout, endpoint 비용, GC/Stop-the-world 타이밍, 과도한 실패 임계치

프로브가 의심되면 아래 글의 “오탐 패턴(초기 부하, GC, DB 커넥션, cold start)” 섹션이 특히 도움이 됩니다.

8단계: 재현 가능한 디버깅 루틴(현장용)

아래 루틴대로 하면 대부분의 CrashLoopBackOff + 137을 10~30분 안에 범주화할 수 있습니다.

  1. 종료 사유 확정
kubectl describe pod -n <ns> <pod>
kubectl get pod -n <ns> <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated}'
  1. 직전 로그 확인
kubectl logs -n <ns> <pod> --previous --tail=200
  1. 이벤트에서 eviction/pressure 확인
kubectl get events -n <ns> --sort-by=.lastTimestamp | tail -n 50
  1. 노드 상태 확인(특히 MemoryPressure)
kubectl describe node <node>
kubectl top node
  1. 리소스/프로브/종료 설정 점검
  • resources.requests/limits
  • livenessProbe/readinessProbe/startupProbe
  • terminationGracePeriodSeconds, preStop

9단계: “해결”을 넘어 재발 방지(권장)

  • HPA/VPA: 트래픽 변동이 크면 HPA로 수평 확장, 메모리 기반이면 VPA도 검토
  • 메모리 피크 관측: OOM은 평균이 아니라 피크가 죽입니다. 피크 지표를 대시보드에 고정
  • 부하 테스트 시나리오에 워밍/캐시/대형 요청 포함: cold start나 대형 payload에서만 터지는 OOM이 많습니다.

마무리: 137을 보면 “OOM인가?”부터, OOM이면 “왜 limit을 넘었나?”까지

정리하면,

  • CrashLoopBackOff는 결과이고, 원인은 따로 있습니다.
  • Exit 137은 SIGKILL이며, OOMKilled일 수도/아닐 수도 있습니다.
  • Reason: OOMKilled가 보이면 컨테이너 메모리 limit 초과 가능성이 매우 높고, 런타임별 힙/캐시/worker 설정까지 함께 조정해야 합니다.
  • OOMKilled가 아니라면 eviction, 종료 지연, 프로브 오탐을 우선순위로 의심하세요.

프로브 오탐으로 재시작이 반복되는 케이스는 진단 접근이 달라서, 아래 글을 같이 보면 훨씬 빨리 결론에 도달합니다.