쿠버네티스를 운영하다 보면 “잘 돌던 파드가 갑자기 재시작됐다”는 상황을 반복적으로 만납니다. kubectl get pods를 보면 RESTARTS가 올라가 있고, 상세에는 OOMKilled 또는 Evicted가 찍혀 있습니다. 둘 다 “메모리 부족”이라는 인상을 주지만 발생 주체와 메커니즘이 다릅니다. OOMKill은 리눅스 커널이 컨테이너 프로세스를 죽이는 것이고, Eviction은 kubelet이 노드를 지키려 파드를 내쫓는 것입니다.

이 둘을 구분하지 못하면 엉뚱한 곳을 고칩니다. 리밋을 올려야 할 때 리퀘스트만 만지거나, 노드 디스크가 부족한데 메모리 리밋을 건드리는 식입니다. 이 글은 cgroup 레벨의 OOMKill 트리거, kubelet 축출 임계값, QoS와 축출 순서, 실전 원인 추적·예방법을 다룹니다.

OOMKilled와 Evicted는 다른 사건이다

OOMKilled는 컨테이너의 cgroup 메모리 사용량이 limits.memory에 도달했을 때 커널의 OOM killer가 그 cgroup 내부 프로세스를 SIGKILL로 종료하는 것입니다. 파드 객체는 노드에 남고 컨테이너만 재시작됩니다. 반면 Evicted는 노드 전체 리소스(메모리·디스크·inode·PID)가 위험 수위에 도달했을 때 kubelet이 파드를 골라 종료·축출하는 것입니다. 축출된 파드는 Failed로 남아 사라지고 컨트롤러가 다른 노드에 새 파드를 띄웁니다.

  • OOMKilled: 커널이 컨테이너 단위로 죽임. 원인은 리밋 초과.
  • Evicted: kubelet이 노드 압박 해소를 위해 파드를 내쫓음. 원인은 자원 고갈.

상태를 구분하는 확실한 방법은 lastState를 직접 확인하는 것입니다. 이벤트 로그는 사라지지만 종료 코드와 이유는 파드 스펙에 남습니다.

# 컨테이너가 왜 종료됐는지 확인 (lastState)
kubectl get pod my-app-7c9d 
  -o jsonpath='{.status.containerStatuses[0].lastState}' | jq
# {"terminated": {
#   "exitCode": 137,      # 128 + 9(SIGKILL) → OOMKill 의 전형
#   "reason": "OOMKilled" }}

# 노드 레벨 축출은 파드 status.reason 에 찍힌다
kubectl get pod my-app-7c9d -o jsonpath='{.status.reason}'
# Evicted

종료 코드 137은 128 + 9로 SIGKILL을 받았다는 뜻이지만, liveness probe 실패로 인한 강제 종료도 137이 될 수 있으므로 reason 필드까지 봐야 정확합니다.

cgroup 레벨에서 OOMKill이 트리거되는 원리

컨테이너의 limits.memory는 memory cgroup의 memory.max(cgroup v2)/memory.limit_in_bytes(cgroup v1)로 변환됩니다. 내부 프로세스 메모리 합계가 이 값에 도달하면 커널은 그 cgroup 안에서만 OOM killer를 발동합니다. 여기서 세는 메모리는 단순 RSS가 아니라 working set에 가깝습니다. 회수 가능한 페이지 캐시는 압박 시 반환되지만 익명 페이지(힙)와 회수 불가능한 캐시는 그대로 카운트되므로, 대용량 파일을 읽어 캐시가 쌓이면 RSS는 낮아도 cgroup 사용량이 리밋에 붙어 OOMKill이 납니다.

# cgroup v2: 노드에서 파드의 cgroup 경로를 찾아 직접 확인
POD_UID=$(kubectl get pod my-app-7c9d -o jsonpath='{.metadata.uid}')
CG=/sys/fs/cgroup/kubepods.slice/*/*${POD_UID//-/_}*/

cat ${CG}/memory.max      # 리밋(bytes). "max" 면 무제한
cat ${CG}/memory.current  # 현재 사용량(working set 포함)
cat ${CG}/memory.events   # oom_kill 1 → OOM killer 발동 횟수

memory.events의 oom_kill 카운터가 증가했다면 그 컨테이너가 자기 리밋을 초과한 것입니다. oom만 증가하고 oom_kill은 0이면 회수로 압박을 넘긴 경우라 애플리케이션이 잠깐 스톨했을 가능성을 시사합니다. 노드에서 dmesg -T | grep -i "killed process"를 보면 커널이 죽인 프로세스와 anon-rss(힙)가 리밋에 근접했는지 확인해 원인을 힙 증가로 좁힐 수 있습니다.

requests와 limits, 그리고 QoS 클래스

쿠버네티스는 파드의 requests·limits에 따라 세 QoS 클래스를 자동 부여하고, 노드 압박 시 이 순서로 축출합니다.

  • Guaranteed: 모든 컨테이너가 requests == limits. 가장 마지막에 축출.
  • Burstable: requests가 설정됐으나 limits와 다르거나 일부만 설정. 중간.
  • BestEffort: requests·limits 둘 다 없음. 압박 시 가장 먼저 축출.

메모리 리밋을 리퀘스트보다 크게 잡으면 파드는 Burstable이 됩니다. 노드에 여유가 있으면 리밋까지 버스트하지만, 압박받으면 리퀘스트를 초과해 쓰던 파드가 우선 축출됩니다. 즉 리밋만 크게 잡고 리퀘스트를 낮게 두면 노드가 붐빌 때 제일 먼저 쫓겨나는 파드가 됩니다.

# Guaranteed QoS: requests == limits (CPU·메모리 모두) → 축출 최후순위
resources:
  requests: { cpu: "500m", memory: "512Mi" }
  limits:   { cpu: "500m", memory: "512Mi" }

다만 Guaranteed라도 자기 리밋(512Mi)을 초과하면 노드 상태와 무관하게 커널이 죽입니다. Guaranteed가 보호하는 것은 노드 축출 우선순위일 뿐 컨테이너 자신의 메모리 초과가 아닙니다.

kubelet의 축출 임계값과 신호

kubelet은 노드 리소스를 주기적으로 관찰하며 임계값을 넘으면 축출합니다. hard eviction은 넘는 즉시 유예 없이, soft eviction은 일정 시간(eviction-soft-grace-period) 지속되면 유예를 두고 죽입니다.

# kubelet 설정 (KubeletConfiguration)
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
evictionHard:                    # 임계값 넘으면 즉시 축출
  memory.available: "500Mi"      # 가용 메모리 부족
  nodefs.available: "10%"        # 노드 파일시스템 부족
  nodefs.inodesFree: "5%"        # inode 부족
  imagefs.available: "15%"       # 이미지 저장소 부족
evictionSoft:                    # 유예기간 지속 시 축출
  memory.available: "1Gi"
evictionSoftGracePeriod:
  memory.available: "1m30s"
evictionMaxPodGracePeriod: 60    # 축출 시 최대 종료 유예(초)

여기서 흔한 오해가 memory.available의 계산입니다. kubelet은 이 값을 free 메모리가 아니라 capacity - workingSet으로 봅니다. 회수 불가능한 working set이 기준이라 페이지 캐시는 회수 가능으로 간주되므로, free -m상 여유가 있어도 축출이 시작될 수 있습니다.

또 디스크 압박에 의한 축출도 주의해야 합니다. nodefs.available·imagefs.available이 임계값 아래로 떨어지면 메모리와 무관하게 파드가 축출됩니다. 로그를 쏟아내거나 emptyDir에 대용량 임시 파일을 쓰는 파드가 노드 디스크를 채워 이웃 파드까지 축출시키는 사고가 흔합니다.

JVM·Node.js 같은 런타임의 함정

OOMKill의 상당수는 관리형 런타임이 컨테이너 리밋을 인식하지 못해 발생합니다. JVM 힙, Node.js old space, 스레드 스택, 메타스페이스, 네이티브 버퍼는 별개 영역이라 이들의 합이 리밋을 넘으면 힙에 여유가 있어도 OOMKill이 납니다. JVM은 -XX:MaxRAMPercentage로 리밋의 일부만 힙에 할당하고 나머지를 non-heap에 남겨야 합니다. 힙을 100%로 잡으면 GC가 만족해도 전체 사용량이 리밋을 넘습니다.

# JVM: 리밋의 75% 만 힙으로, 나머지는 non-heap 여유분
containers:
  - name: order-api
    env:
      - name: JAVA_TOOL_OPTIONS   # 75.0 = 리밋의 75% 를 힙 상한
        value: "-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError"
    resources:
      requests: { memory: "1Gi" }
      limits:   { memory: "1Gi" }

-XX:+ExitOnOutOfMemoryError도 중요합니다. JVM은 힙이 가득 차면 OutOfMemoryError를 던지지만 프로세스는 살아남아 좀비처럼 예외만 뱉을 수 있어, 차라리 종료해 재시작하는 편이 빠릅니다. Node.js도 V8 old space 기본 상한이 컨테이너 리밋과 무관하므로 --max-old-space-size를 리밋보다 작게 잡아야 합니다.

# Node.js: 리밋 512Mi 라면 old-space 는 384MB 로 (버퍼·스택 여유 확보)
node --max-old-space-size=384 server.js

원인 추적: 리크인가 언더프로비저닝인가

OOMKill이 반복될 때 먼저 판단할 것은 “리크인가, 리밋이 부족한가”입니다. 대응이 정반대이기 때문입니다. 리크라면 리밋을 올려도 시간만 벌 뿐 다시 죽고, 언더프로비저닝이라면 리밋을 적정선으로 올리는 것이 정답입니다. 구분은 메모리 곡선의 모양으로 합니다. 톱니 없이 단조 증가하다 리밋에서 죽으면 리크, 오르내리다 피크에서만 리밋에 닿으면 언더프로비저닝입니다.

# PromQL: working set 을 리밋 대비 비율(%)로. 90% 상시 초과 → OOM 임박
100 * (
  container_memory_working_set_bytes{pod=~"order-api-.*", container!=""}
  / on(pod, container)
  kube_pod_container_resource_limits{resource="memory", pod=~"order-api-.*"}
)

리밋 대비 90%를 상시 넘는 파드에 경보를 걸면 OOMKill 전에 대응할 수 있습니다. 리크가 의심되면 -XX:+HeapDumpOnOutOfMemoryError·-XX:HeapDumpPath를 emptyDir에 걸어 힙 덤프를 확보해 회수 안 되는 객체를 찾습니다.

축출을 줄이는 방어 설계

노드 레벨에서 축출을 줄이는 구조적 장치도 있습니다. 먼저 requests를 실측 기반으로 정확히 잡는 게 근본입니다. 리퀘스트가 실제보다 낮으면 스케줄러가 여유가 있다고 착각해 파드를 몰아넣고 오버커밋되어 축출이 연쇄됩니다. VerticalPodAutoscaler를 updateMode: "Off"로 돌려 권장값만 얻고 검토 후 반영하는 것이 출발점입니다.

여기에 PriorityClass로 핵심 서비스에 높은 우선순위를 부여하면, 노드 압박 시 스케줄러가 배치성·베스트에포트 파드를 먼저 선점(preemption)해 자리를 만듭니다. 또 노드에 system-reserved·kube-reserved를 명시해 워크로드가 커널·kubelet·런타임 몫까지 잠식하지 못하게 막아야 합니다. 이게 없으면 파드가 노드 메모리를 끝까지 긁어 시스템 데몬이 굶고 노드가 다운됩니다.

  • requests 정확화: 오버커밋으로 인한 연쇄 축출 방지의 근본.
  • PriorityClass·system-reserved: 축출 순서 통제 + 시스템 몫 격리.
  • PodDisruptionBudget: 축출·드레인 시 최소 가용 복제본 수 보장.

마무리

OOMKilled와 Evicted를 같은 문제로 뭉뚱그리는 순간 대응은 어긋납니다. OOMKill은 컨테이너가 자기 리밋을 넘긴 사건이므로 리밋 산정과 런타임 힙 설정에서, Eviction은 노드가 압박을 받은 사건이므로 리퀘스트 정확화·시스템 예약·우선순위 설계에서 답을 찾아야 합니다. exitCode 137과 reason, cgroup의 memory.events, working set 곡선의 모양을 함께 보면 원인은 대체로 분명해집니다. 예방의 핵심은 정확한 requests/limits와 런타임이 컨테이너 경계를 존중하도록 하는 설정이며, 노드 레벨의 예약·우선순위 설계를 얹으면 트래픽 피크에도 핵심 서비스가 먼저 쫓겨나지 않는 클러스터를 유지할 수 있습니다.

자주 묻는 질문

Q. 메모리 리밋을 아주 크게 잡으면 OOMKill을 피할 수 있나요?

A. 그 컨테이너의 OOMKill은 늦출 수 있지만 노드 오버커밋을 유발해 축출 위험을 키웁니다. 리밋을 노드 용량에 육박하게 잡으면 노드 메모리가 고갈되어 다른 파드까지 축출되거나 노드가 NotReady로 빠집니다. 리크가 원인이면 리밋 확대는 죽는 시점만 미룰 뿐이니 곡선의 단조 증가 여부를 먼저 보세요.

Q. 파드가 Evicted 됐는데 노드 메모리는 넉넉해 보였습니다. 왜인가요?

A. 축출은 메모리 외에 디스크(nodefs.available·imagefs.available)와 inode(nodefs.inodesFree) 압박으로도 발생합니다. 로그나 emptyDir이 파일시스템을, 이미지 레이어가 imagefs를 채웠는지 확인하세요. 또 memory.available은 capacity - workingSet 기준이라 free -m상 여유가 있어도 working set이 높으면 축출됩니다.