Published on

EKS Pod CrashLoopBackOff와 OOMKilled 진단법

Authors

서버리스가 아닌 EKS 환경에서 CrashLoopBackOff는 흔하지만, 그 원인이 OOMKilled(메모리 부족으로 커널이 프로세스를 강제 종료)라면 대응 방식이 완전히 달라집니다. 단순히 재시작 횟수를 줄이거나 restartPolicy를 바꿔서는 해결되지 않고, 컨테이너 메모리 제한과 애플리케이션의 실제 메모리 사용 패턴을 일치시키는 쪽으로 접근해야 합니다.

이 글은 다음 목표로 구성합니다.

  • CrashLoopBackOff가 정말 OOMKilled인지 빠르게 확정
  • 어떤 레벨에서 메모리가 터졌는지(컨테이너 limit, 노드 메모리 압박, 런타임 설정) 분리
  • EKS에서 자주 발생하는 케이스(JVM, Node.js, Python, 사이드카, gRPC/대용량 요청)별 처방
  • 재발 방지(모니터링, HPA/VPA, 메모리 프로파일링, 리소스 정책)

관련해서 리소스 지표가 비정상적으로 보일 때는 EKS에서 kubectl top이 0%일 때 Metrics API 점검도 함께 확인해두면 좋습니다.

1) 증상 정리: CrashLoopBackOff와 OOMKilled의 관계

  • CrashLoopBackOffKubelet이 컨테이너를 계속 재시작했는데, 짧은 시간 내에 계속 실패해서 백오프(재시작 간격 증가)에 들어간 상태입니다.
  • OOMKilled는 컨테이너 프로세스가 메모리 한도에 도달했을 때 커널 OOM killer가 프로세스를 죽인 결과입니다.

즉, OOMKilled는 “종료 이유”이고, CrashLoopBackOff는 “반복 재시작의 상태”입니다. 둘이 동시에 나타나는 경우가 많습니다.

2) 30초 안에 OOMKilled 확정하는 커맨드

아래 순서로 보면 거의 대부분 확정할 수 있습니다.

2.1 Pod 이벤트와 종료 이유 확인

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

확인 포인트:

  • Last State: Terminated 아래 Reason: OOMKilled 여부
  • Exit Code: 137(OOMKilled에서 자주 보이는 종료 코드) 여부
  • Events에서 Back-off restarting failed container 반복

Exit Code: 137은 SIGKILL로 종료된 케이스에 흔하지만, 항상 OOM만 의미하진 않습니다. 그래서 Reason: OOMKilled를 우선 신뢰합니다.

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

kubectl -n <namespace> logs <pod-name> -c <container-name> --previous

애플리케이션이 OOM 직전까지 어떤 작업을 했는지 확인합니다. 예를 들어:

  • 대용량 JSON 파싱
  • 이미지/압축 처리
  • 대규모 캐시 워밍
  • gRPC 메시지 과대

gRPC/대용량 메시지로 메모리가 급증하는 케이스는 네트워크 레벨 증상으로는 502 등으로 보이기도 합니다. 유사 케이스로 EKS에서 413 없이 502? gRPC 최대 메시지 해결도 참고할 만합니다.

2.3 리소스 설정(요청/제한) 확인

kubectl -n <namespace> get pod <pod-name> -o jsonpath='{.spec.containers[*].resources}'

여기서 핵심은:

  • resources.limits.memory가 지나치게 낮지 않은지
  • requests.memory가 너무 낮아 노드 스케줄링이 불리하게 되지 않는지

requests는 “스케줄링 기준”, limits는 “실제 상한”입니다. OOMKilled는 주로 limits.memory를 넘을 때 발생합니다.

3) 컨테이너 OOM vs 노드 메모리 압박(OOM) 구분

OOM은 크게 두 부류가 있습니다.

  • 컨테이너 limit OOM: 해당 컨테이너의 limits.memory를 넘어서서 종료
  • 노드 메모리 압박 OOM: 노드 전체 메모리가 부족해져서, Kubelet/커널이 특정 Pod를 축출(evict)하거나 프로세스를 종료

3.1 노드 압박 여부 확인

kubectl describe node <node-name>

Conditions에서 MemoryPressureTrue인지 봅니다.

또는 이벤트에서 Evicted가 보이면 노드 압박 가능성이 큽니다.

3.2 노드 압박이면 무엇을 바꿔야 하나

  • Pod의 requests.memory를 현실적으로 올려서 “과밀 스케줄링”을 방지
  • 노드 타입 업그레이드 또는 노드 수 증가(Cluster Autoscaler)
  • 메모리 많이 먹는 워크로드를 분리(노드 그룹 분리, taint/toleration)

컨테이너 limit OOM이면 애플리케이션과 limits.memory 조정이 핵심이고, 노드 압박이면 “클러스터 용량 설계”가 핵심입니다.

4) OOMKilled의 대표 원인 7가지(실전 관점)

4.1 limits.memory가 너무 낮다

가장 흔합니다. 특히 개발 환경에서 256Mi 같은 값으로 시작했다가 트래픽/데이터가 늘면서 터집니다.

대응:

  • 최근 피크 사용량을 관측한 뒤 limits를 상향
  • 피크가 일시적이라면 애플리케이션 메모리 스파이크 원인 제거

4.2 JVM 힙이 컨테이너 메모리와 불일치

Java는 컨테이너 메모리 제한과 힙/메타스페이스/네이티브 메모리 합이 어긋나면 쉽게 OOMKilled가 납니다.

권장 접근:

  • -Xmx를 컨테이너 limits.memory보다 충분히 낮게 설정
  • 또는 -XX:MaxRAMPercentage로 컨테이너 기준 동적 설정

예시(컨테이너 메모리 1024Mi라면, 힙은 60~70% 정도부터 시작):

JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50"

그리고 -XX:MaxMetaspaceSize, -XX:ReservedCodeCacheSize 등도 워크로드에 따라 영향을 줍니다.

4.3 Node.js의 힙 기본값/상한 미조정

Node.js는 버전에 따라 기본 힙 상한이 컨테이너 환경에서 애매하게 동작할 수 있습니다. 대용량 객체를 다루거나 SSR, 번들링, 이미지 처리 등을 하면 급격히 증가합니다.

대응:

NODE_OPTIONS="--max-old-space-size=512"

--max-old-space-size는 MB 단위입니다. 컨테이너 limits.memory에 맞춰 여유를 남기세요(네이티브/버퍼/스레드 스택도 필요).

4.4 Python: 캐시/데이터프레임/대용량 파싱

Python은 객체 오버헤드가 크고, Pandas/NumPy 사용 시 순간적으로 메모리를 크게 먹을 수 있습니다.

대응 힌트:

  • 스트리밍 처리로 변경(한 번에 다 읽지 않기)
  • gzip/zip 해제 시 임시 버퍼 크기 확인
  • 워커 프로세스 수(예: gunicorn workers) 조정

4.5 사이드카(Envoy, Fluent Bit 등)가 메모리를 잡아먹는다

애플리케이션 컨테이너만 보고 limits를 잡으면, 사이드카가 같이 터질 수 있습니다.

대응:

  • 각 컨테이너별 resources를 분리해서 설정
  • 로그/프록시 버퍼 설정 점검

4.6 HPA가 트래픽을 따라가지 못해 Pod당 부하가 과도

Pod 수가 충분히 늘기 전에 단일 Pod가 큰 요청을 다 받아 메모리 스파이크가 날 수 있습니다.

대응:

  • HPA의 metrics, stabilizationWindowSeconds, behavior 조정
  • 스케일 아웃이 느리다면 최소 레플리카 상향

4.7 애플리케이션 버그: 메모리 릭

특정 시간 이후 지속적으로 메모리가 증가한다면 릭 가능성이 큽니다.

대응:

  • 힙 덤프/프로파일링 도입
  • 요청별 캐시가 누적되는지(키 폭발) 확인

5) EKS에서 관측으로 “재발 방지”까지 가는 체크리스트

5.1 kubectl top으로 현재 사용량 확인(가능한 경우)

kubectl -n <namespace> top pod
kubectl -n <namespace> top pod --containers

만약 여기서 값이 0으로 나오거나 비어 있다면 Metrics Server 계열 이슈일 수 있습니다. 이 경우 EKS에서 kubectl top이 0%일 때 Metrics API 점검을 먼저 해결해야 “관측 기반” 튜닝이 가능합니다.

5.2 Prometheus/Grafana에서 꼭 봐야 하는 지표

  • 컨테이너 메모리 사용량(Working set)
  • 컨테이너 메모리 limit 대비 사용률
  • OOMKilled 카운트(쿠버네티스 이벤트/컨테이너 재시작 이유)
  • 노드 MemoryPressure, 노드 메모리 사용률

관측 포인트:

  • 스파이크형: 특정 요청/배치에 의해 순간적으로 급등
  • 누수형: 시간이 지날수록 단조 증가

6) Kubernetes 리소스 설정 예시(안전한 기본형)

아래는 웹 API 컨테이너를 가정한 예시입니다. 핵심은 requests를 너무 낮게 잡지 말고, limits는 실제 피크를 수용하되 런타임 힙 상한도 함께 맞추는 것입니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: your-registry/api:1.0.0
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "250m"
              memory: "512Mi"
            limits:
              cpu: "1000m"
              memory: "1024Mi"
          env:
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50"
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10

주의:

  • livenessProbe가 너무 공격적이면, OOM과 무관하게 재시작 루프를 만들 수 있습니다.
  • 메모리 이슈를 해결하기 전에는 readinessProbe를 더 신뢰하고, livenessProbe는 완만하게 두는 편이 장애 전파를 줄입니다.

7) OOMKilled가 반복될 때의 실전 대응 플로우

  1. kubectl describe podReason: OOMKilled 확정
  2. --previous 로그로 “어떤 요청/작업 직후에 터지는지” 확인
  3. resources.limits.memory와 런타임 힙 상한(JVM/Node)을 함께 점검
  4. top pod --containers 또는 모니터링으로 피크 사용량 확인
  5. 스파이크형이면 입력 제한/버퍼 제한/동시성 제한을 걸고, 누수형이면 프로파일링 진행
  6. 노드 MemoryPressure가 있으면 requests 상향 및 노드 용량/오토스케일 재설계

8) 자주 나오는 질문(짧게 정리)

8.1 requests만 올리면 OOMKilled가 줄어드나?

대부분은 아닙니다. OOMKilled는 컨테이너 limits.memory 초과가 직접 원인인 경우가 많습니다. 다만 노드 과밀로 인한 간접 OOM/eviction을 줄이는 데는 도움이 됩니다.

8.2 limits를 크게 잡으면 끝인가?

일시적으로는 안정화되지만, 메모리 릭이나 스파이크 원인을 해결하지 않으면 결국 더 큰 limit에서도 다시 터집니다. 또한 limit을 과도하게 키우면 노드당 Pod 밀도가 떨어져 비용이 늘 수 있습니다.

8.3 종료 코드 137이면 무조건 OOM인가?

아닙니다. SIGKILL로 죽은 케이스 전반에서 보일 수 있습니다. 반드시 describe podReason과 노드/이벤트를 함께 보세요.

마무리

EKS에서 CrashLoopBackOff를 만났을 때, 먼저 “상태”와 “원인”을 분리해야 합니다. OOMKilled라면 가장 빠른 해결은 limits.memory 상향이지만, 장기적으로는 런타임 힙 상한, 대용량 요청 처리 방식, 사이드카 리소스, HPA 반응 속도, 노드 메모리 압박까지 함께 점검해야 재발을 막을 수 있습니다.

다음 단계로는 (1) 관측 지표를 확보하고, (2) 피크 사용량 기반으로 requests/limits를 재설계하고, (3) 애플리케이션 레벨에서 스파이크를 줄이는 순서로 접근하면 운영 안정성이 크게 올라갑니다.