Published on

Kubernetes OOMKilled 메모리 튜닝 실전 가이드

Authors

운영 중인 Pod가 갑자기 재시작되고 이벤트에 OOMKilled가 찍히면, 대부분은 limits.memory를 올리면 끝날 것 같지만 실제로는 그렇지 않은 경우가 많습니다. 메모리 사용량의 성격(힙, 네이티브, 페이지 캐시, 누수, 버퍼 폭증)을 구분하지 않으면, 한 번 올린 limit은 계속 올라가고 노드는 점점 불안정해집니다.

이 글은 OOMKilled를 “재현 가능한 데이터”로 바꾸고, requests/limits를 합리적으로 재설계하며, 애플리케이션 런타임까지 포함해 메모리 튜닝을 마무리하는 실전 흐름을 다룹니다.

관련해서 크래시 루프/OOMKilled의 원인 추적 흐름을 더 넓게 보고 싶다면 아래 글도 함께 보면 좋습니다.

1) OOMKilled의 “종류”부터 구분하기

Kubernetes에서 OOM은 크게 두 가지 레벨에서 발생합니다.

1-1. 컨테이너 OOMKilled (cgroup limit 초과)

  • Pod 이벤트에 OOMKilled가 명확히 남습니다.
  • 해당 컨테이너가 limits.memory를 넘는 순간 커널이 프로세스를 종료합니다.
  • 가장 흔한 케이스이며, 이 글의 주 대상입니다.

1-2. 노드 OOM (노드 전체 메모리 고갈)

  • 노드가 메모리 압박을 받으면 커널 OOM killer가 “누구를 죽일지” 결정합니다.
  • Pod가 limit을 안 넘었는데도 죽을 수 있습니다(특히 BestEffort/낮은 QoS).
  • 이 경우는 노드의 MemoryPressure, 과도한 overcommit, 페이지 캐시/버퍼, DaemonSet 메모리 폭증 등도 함께 봐야 합니다.

2) 가장 먼저 확인할 5가지: 커맨드 모음

2-1. Pod 이벤트와 종료 사유 확인

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

출력에서 다음을 확인합니다.

  • Last State: Terminated
  • Reason: OOMKilled
  • Exit Code: 137 (자주 함께 보임)
  • EventsKilling 메시지

<namespace> 같은 부등호 표기는 MDX에서 빌드 에러가 날 수 있으니, 위처럼 반드시 인라인 코드 또는 코드 블록으로 처리하는 습관을 추천합니다.

2-2. 리소스 설정값(요청/제한) 확인

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

여기서 requests.memorylimits.memory가 비어 있거나 지나치게 낮은지 확인합니다.

2-3. 실시간 사용량(메트릭 서버 기준)

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

주의할 점:

  • kubectl top은 “현재값” 중심이라 스파이크를 놓치기 쉽습니다.
  • OOM은 대개 스파이크로 발생합니다. 따라서 Prometheus 같은 시계열이 있으면 반드시 최대값(p95/p99/peak)을 봐야 합니다.

2-4. cgroup 메모리 관점으로 확인(컨테이너 내부)

컨테이너에 접속할 수 있다면 다음이 매우 유용합니다.

# cgroup v2 환경에서 자주 유효
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.events

memory.eventsoom 또는 oom_kill 카운터가 증가했는지 확인합니다.

2-5. 애플리케이션 로그에서 “직전 징후” 찾기

OOM 직전에는 흔히 아래 패턴이 있습니다.

  • 대량 요청/배치 시작
  • 특정 엔드포인트 호출 후 급증
  • 캐시 워밍/리빌드
  • 큰 파일 다운로드/압축 해제
  • 메시지 큐 backlog 처리

애플리케이션 레벨 누수 진단이 필요하다면, 메모리 폭증을 “누수” 관점으로 접근한 글도 참고가 됩니다.

3) requests/limits를 “안전하게” 다시 잡는 공식

OOM을 막는 가장 단순한 방법은 limits.memory를 올리는 것입니다. 하지만 운영에서는 다음 3가지를 동시에 만족해야 합니다.

  1. OOM이 없어야 함
  2. 노드가 과밀해지지 않아야 함
  3. HPA/스케줄링이 안정적이어야 함

3-1. 기본 원칙

  • requests.memory: **평균이 아니라 “평시 상한(p50~p75)”**에 맞춥니다.
  • limits.memory: **스파이크 상한(p95~p99 + 여유분)**에 맞춥니다.
  • 여유분은 보통 10%~30%를 잡되, GC 언어(JVM 등)나 네이티브 메모리가 큰 런타임은 더 크게 잡습니다.

3-2. QoS 클래스도 함께 고려

  • requestslimits가 모두 설정되고 동일하면 Guaranteed
  • 둘 다 설정되지만 다르면 Burstable
  • 둘 다 없으면 BestEffort

노드 OOM 상황에서 BestEffort가 먼저 희생됩니다. 즉, 중요한 워크로드일수록 최소한 requests.memory는 반드시 지정하는 것이 안전합니다.

3-3. 예시: Burstable로 시작하는 현실적인 템플릿

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

실전에서는 위처럼 Burstable로 시작한 뒤, 관측 데이터를 기반으로 requests를 올리거나, limits를 내리거나, 혹은 둘을 같게 맞춰 Guaranteed로 전환합니다.

4) “limit만 올렸는데도” OOM이 나는 흔한 이유

4-1. 힙 외 메모리(네이티브, 스레드 스택, mmap, direct buffer)

특히 JVM, Node.js, Python(네이티브 라이브러리 사용), Go(CGo)에서 자주 발생합니다.

  • JVM: -Xmx만 보고 limit을 잡으면 실패합니다. Metaspace, CodeCache, Thread stack, Direct buffer가 추가로 먹습니다.
  • Node.js: V8 heap 상한이 기본적으로 컨테이너 메모리를 제대로 반영하지 않거나(환경/버전/옵션에 따라), native addon이 별도 메모리를 씁니다.

4-2. 동시성 폭증(큐 backlog, 스레드/코루틴 무제한)

“요청이 밀리면 더 많은 작업을 띄워서 처리”하는 구조는 메모리 상한이 없으면 OOM으로 이어집니다.

  • 워커 수 제한
  • 큐 consumer concurrency 제한
  • 업로드/다운로드 스트림 처리(버퍼 상한)

4-3. 캐시 전략 실패(무제한 캐시)

  • LRU 없이 Map에 계속 쌓임
  • 요청 파라미터 다양화로 캐시 키 폭발
  • 이미지/바이너리 캐시를 메모리에 적재

5) 런타임별 튜닝 포인트(바로 적용 가능한 값)

5-1. JVM: -Xmx를 limit의 50%~70%로 시작

컨테이너 limit이 1024Mi인데 -Xmx1024m로 주면, 힙 외 메모리까지 합쳐서 OOM이 나기 쉽습니다.

권장 시작점(보수적):

  • -Xmx를 limit의 60% 전후
  • Direct buffer를 쓰면 -XX:MaxDirectMemorySize도 명시
  • 스레드가 많으면 -Xss(스택) 고려

예시:

JAVA_TOOL_OPTIONS="-Xms512m -Xmx640m -XX:MaxDirectMemorySize=128m -XX:MaxMetaspaceSize=192m"

Kubernetes 매니페스트 예시:

env:
  - name: JAVA_TOOL_OPTIONS
    value: "-Xms512m -Xmx640m -XX:MaxDirectMemorySize=128m -XX:MaxMetaspaceSize=192m"
resources:
  limits:
    memory: "1024Mi"

5-2. Node.js: --max-old-space-size로 힙 상한 고정

Node는 힙이 limit을 침범하지 않도록 상한을 명시하는 편이 안전합니다.

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

위 값은 MB 단위입니다. 예를 들어 limit이 1024Mi라면 512~700 정도로 시작하고, 네이티브 사용량(이미지 처리, 암호화, grpc 등)에 따라 조정합니다.

5-3. Python: 워커/스레드 수가 곧 메모리

Gunicorn, uWSGI 같은 멀티프로세스 모델은 워커 수가 메모리 사용량을 선형으로 늘립니다.

  • 워커 수를 CPU 기준으로만 잡지 말고, 워커당 RSS를 측정해 limit에 맞추기
  • 프리로드(--preload)는 메모리 공유에 유리할 수 있지만, 라이브러리/할당 패턴에 따라 오히려 역효과가 날 수 있어 A/B로 확인

Gunicorn 예시:

gunicorn app:app -w 2 --threads 4 --max-requests 2000 --max-requests-jitter 200

--max-requests는 누수 의심 상황에서 “주기적 리사이클”로 OOM을 완화하는 실전 옵션입니다(근본 해결 전 임시방편으로 유효).

5-4. Go: GOMEMLIMIT로 컨테이너 친화적으로

Go는 최근 버전에서 GOMEMLIMIT로 목표 힙 상한을 설정할 수 있습니다.

GOMEMLIMIT=700MiB
GOGC=100

limit이 1024Mi라면 GOMEMLIMIT650~800MiB 사이로 두고, 나머지를 스택/네이티브/여유분으로 남깁니다.

6) HPA와 메모리: “스케일 아웃”이 OOM을 해결하지 못하는 경우

HPA는 기본적으로 CPU 기반이 많고, 메모리 기반은 다음 함정이 있습니다.

  • 메모리 사용량이 “누적형”이면 스케일 아웃으로도 내려가지 않습니다(각 Pod가 계속 증가).
  • 스파이크가 짧으면 HPA가 반응하기 전에 이미 OOM이 납니다.

따라서 OOM 대응은 보통 아래 순서가 안정적입니다.

  1. Pod 당 메모리 상한을 런타임 튜닝으로 먼저 안정화
  2. 그 다음 HPA로 트래픽 대응

메모리 기반 HPA 예시(메트릭 파이프라인이 준비된 경우):

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 70

7) VPA(Vertical Pod Autoscaler)를 “권장값 생성기”로 쓰는 방법

VPA를 켜면 자동으로 Pod를 재시작하며 리소스를 바꾸기 때문에, 운영 환경에서는 곧바로 Auto를 적용하기 부담스럽습니다.

실전 팁:

  • VPA를 Off 또는 Initial로 두고
  • recommendation만 받아서 사람이 검토 후 반영

VPA 예시:

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: api-vpa
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  updatePolicy:
    updateMode: "Off"

이후 추천값 확인:

kubectl describe vpa api-vpa

8) OOM 재발 방지 체크리스트(운영 기준)

8-1. 관측 지표를 “최대값” 중심으로 바꾸기

  • container_memory_working_set_bytes의 p95/p99
  • OOM 이벤트 카운트
  • 재시작 횟수
  • 요청량과 메모리 사용량의 상관

8-2. 배포 전후 비교를 자동화

  • 동일 트래픽 구간에서 메모리 상한이 내려갔는지
  • GC 횟수/시간이 늘지 않았는지(JVM)
  • 레이턴시가 악화되지 않았는지

8-3. “메모리 절약”보다 “상한 확정”이 먼저

메모리 최적화는 시간이 걸립니다. 당장 중요한 건 OOM이 안 나는 상한을 확정하는 것입니다.

  • 런타임 힙 상한 고정
  • 동시성 상한 고정
  • 버퍼 크기 상한 고정

그 다음에 캐시/알고리즘 최적화를 진행하는 편이 안전합니다.

9) 실전 시나리오: 1Gi limit에서 OOM이 나는 API 튜닝 예시

가정:

  • limits.memory1024Mi
  • Node.js API가 트래픽 스파이크 때 OOMKilled
  • 이미지 처리 라이브러리(네이티브)가 메모리를 추가로 사용

대응 순서:

  1. kubectl describe pod로 OOMKilled 확인
  2. Prometheus(또는 로그)로 OOM 직전 peak가 900Mi 이상인지 확인
  3. NODE_OPTIONS로 V8 힙 상한을 512~640MB로 제한
  4. 이미지 처리 동시성 제한(예: 큐 worker 2개로 제한)
  5. requests.memory512Mi로 올려 안정적인 스케줄링 유도
  6. 여전히 스파이크가 크면 limits.memory1280Mi로 올리되, 원인(동시성/버퍼)을 먼저 고정

매니페스트 예시:

resources:
  requests:
    cpu: "250m"
    memory: "512Mi"
  limits:
    cpu: "1000m"
    memory: "1280Mi"
env:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=640"

여기서 핵심은 “limit을 올렸다”가 아니라, 힙 상한과 동시성 상한을 같이 고정했다는 점입니다. 이 두 가지가 없으면 limit을 올려도 언젠가 다시 OOM이 납니다.

10) 마무리: OOMKilled를 끝내는 한 줄 요약

  • OOMKilled는 단순히 메모리가 부족해서가 아니라, **상한이 정의되지 않은 메모리 소비(힙/네이티브/동시성/버퍼/캐시)**가 컨테이너 limit을 넘기면서 발생합니다.
  • 따라서 해결책도 limits.memory 증설 하나가 아니라,
    • 관측(peak)
    • requests/limits 재설계(QoS 포함)
    • 런타임 힙 상한 설정
    • 동시성/버퍼 상한 고정
    • HPA/VPA의 역할 분리 로 완성됩니다.

운영에서 자주 겪는 EKS 환경 이슈를 함께 다루는 글이 필요하다면 아래도 참고해 보세요.