# 메모리 request=limit이 노드를 지키는 이유 - QoS 클래스와 OOM

메모리를 많이 쓰는 파드 하나 때문에 같은 노드의 관계없는 파드들이 함께 죽는 장애가 있습니다. 컨테이너에 메모리 limit이 걸려 있어도 발생하는데, 원인은 Kubernetes에 두 종류의 OOM이 있고 둘의 동작이 전혀 다르기 때문입니다.

이 글에서는 cgroup 단위 OOM과 노드 커널 OOM의 차이, QoS 클래스가 결정되는 정확한 조건, QoS가 oom_score_adj와 축출 순서에 어떻게 반영되는지, 그리고 request=limit을 선택할 때의 트레이드오프를 정리합니다.

# 1. 두 종류의 OOM

구분 발동 주체 범위 결과
cgroup OOM 컨테이너의 메모리 cgroup 한도 초과 해당 컨테이너만 컨테이너 kill 후 재시작
노드 OOM 노드 전체 물리 메모리 고갈 노드의 아무 프로세스나 커널이 점수로 골라 kill

첫 번째는 격리가 지켜지는 정상 동작입니다. 공식 문서도 이렇게 설명합니다.

Any Container exceeding a resource limit will be killed and restarted by the kubelet without affecting other Containers in that Pod.

문제는 두 번째입니다. 노드 전체 메모리가 바닥나면 리눅스 커널의 OOM killer가 동작하는데, 이때는 한도를 넘긴 컨테이너가 아니라 커널이 계산한 점수가 가장 높은 프로세스가 죽습니다. 원인을 제공한 파드가 아니라 옆에 있던 파드가 죽는 상황이 여기서 나옵니다.

노드 전체 메모리가 바닥나는 이유는 단순합니다. 스케줄러는 request 합계로 배치하고, 컨테이너는 limit까지 쓸 수 있기 때문입니다. request와 limit의 격차가 곧 오버커밋 폭입니다.

노드 메모리 100Gi
├─ 파드 A: request 10Gi / limit 40Gi
├─ 파드 B: request 10Gi / limit 40Gi
├─ 파드 C: request 10Gi / limit 40Gi
└─ 스케줄러 관점: 30Gi 사용 → 여유 충분
   실제 최악: 120Gi 요구 → 노드 OOM

# 2. QoS 클래스가 결정되는 조건

Kubernetes는 파드를 세 QoS 클래스로 분류합니다. 조건은 문서에 명확히 정의돼 있습니다.

클래스 조건
Guaranteed 파드의 모든 컨테이너가 memory·cpu의 request와 limit을 모두 지정하고, 각각 request == limit
Burstable Guaranteed 조건 미달이며, 컨테이너 중 하나 이상이 memory 또는 cpu의 request나 limit을 지정함
BestEffort 어떤 컨테이너에도 memory·cpu의 request와 limit이 전혀 없음

여기서 자주 놓치는 지점이 둘 있습니다.

  • CPU도 조건에 포함됩니다. 메모리만 request=limit으로 맞추면 Guaranteed가 되지 않고 Burstable에 머뭅니다.
  • 파드의 모든 컨테이너가 조건을 만족해야 합니다. 사이드카 하나가 limit을 빠뜨리면 파드 전체가 Burstable이 됩니다.
# 실제 판정 결과 확인
kubectl get pod <pod> -o jsonpath='{.status.qosClass}'
# Guaranteed / Burstable / BestEffort

Kubernetes v1.34부터는 파드 레벨 리소스 지정도 같은 방식으로 판정에 사용됩니다.

# 3. QoS가 실제로 바꾸는 것 ① - oom_score_adj

리눅스 OOM killer는 각 프로세스의 메모리 사용량으로 oom_score를 계산하고, 여기에 oom_score_adj를 더한 값이 가장 큰 프로세스를 죽입니다. kubelet은 컨테이너를 띄울 때 QoS에 따라 이 값을 설정합니다.

QoS oom_score_adj
Guaranteed -997
BestEffort 1000
Burstable min(max(2, 1000 - (1000 × memoryRequestBytes) / machineMemoryCapacityBytes), 999)

Burstable 공식은 "노드 메모리 용량 중 얼마를 request로 잡았는가" 로 보호 정도를 정합니다. 소스 주석이 밝히는 의도는 request보다 적게 쓰는 Burstable 컨테이너를 보호하는 것입니다. 노드 메모리의 10%를 request한 컨테이너는 oom_score_adj가 약 900이 되고, request를 크게 잡을수록 값이 작아져 보호받습니다. 즉 request를 작게 잡고 limit만 크게 잡은 컨테이너는 노드가 위험해졌을 때 가장 먼저 죽는 쪽에 서게 됩니다.

-997은 사실상 커널이 마지막까지 건드리지 않는 값입니다. 같은 값이 system-node-critical 우선순위 파드에도 적용됩니다.

# 노드에서 실제 값 확인
PID=$(pgrep -f <프로세스>)
cat /proc/$PID/oom_score_adj
cat /proc/$PID/oom_score

이 계산은 kubelet 구현 세부사항이라 버전에 따라 달라질 수 있습니다. 예를 들어 Burstable의 oom_score_adj가 PriorityClass를 반영하지 않는 점은 업스트림에서 논의 중인 사안입니다. 정확한 값은 사용하는 버전의 pkg/kubelet/qos/policy.go를 확인하는 편이 안전합니다.

# 4. QoS가 실제로 바꾸는 것 ② - 노드 압박 축출 순서

kubelet은 노드 메모리가 임계값에 닿으면 커널 OOM보다 먼저 파드를 축출(evict)합니다. 이때 순서가 QoS를 따릅니다.

When a Node runs out of resources, Kubernetes will first evict BestEffort Pods running on that Node, followed by Burstable and finally Guaranteed Pods.

여기에 중요한 단서가 붙습니다.

When this eviction is due to resource pressure, only Pods exceeding resource requests are candidates for eviction.

request를 넘겨 쓰는 파드만 축출 후보입니다. request=limit인 파드는 정의상 request를 초과할 수 없으므로 이 경로에서 사실상 제외됩니다. 3절의 oom_score_adj와 합치면, request=limit은 두 방어선 모두에서 보호받는 설정입니다.

# 5. request=limit의 트레이드오프

그렇다고 모든 워크로드를 Guaranteed로 만드는 것이 정답은 아닙니다.

항목 request=limit (Guaranteed) request<limit (Burstable)
노드 OOM 위험 낮음 (오버커밋 없음) 높음 (격차만큼 초과 가능)
축출 우선순위 가장 마지막 중간, request 초과 시 후보
노드 집적도 낮음 (실사용보다 많이 예약) 높음
비용 높음 낮음
적합한 대상 메모리 사용량이 튀는 수집기·파서·캐시, 상태가 있는 워크로드 사용량이 평탄하고 예측 가능한 API 서버

판단 기준은 "이 워크로드의 메모리 사용량이 예측 가능한가" 입니다. 입력 크기에 따라 사용량이 몇 배씩 튀는 종류(로그·이미지·영상 수집기, 배치 파서)는 격차를 열어 두면 언젠가 노드를 넘어뜨립니다. 반대로 사용량이 평탄한 서비스까지 Guaranteed로 만들면 노드가 텅 빈 채로 예약만 가득 차게 됩니다.

apiVersion: v1
kind: Pod
metadata:
  name: collector
spec:
  containers:
    - name: collector
      image: registry.example.com/collector:1.0.0
      resources:
        requests:
          memory: "4Gi"
          cpu: "1"
        limits:
          memory: "4Gi"   # request 와 동일
          cpu: "1"        # CPU 도 맞춰야 Guaranteed
    - name: sidecar       # 사이드카도 조건을 만족해야 한다
      image: registry.example.com/sidecar:1.0.0
      resources:
        requests:
          memory: "128Mi"
          cpu: "50m"
        limits:
          memory: "128Mi"
          cpu: "50m"

# 6. 설정을 강제하고 검증하기

사람이 매번 맞추는 대신 두 가지 장치를 걸 수 있습니다.

네임스페이스 기본값 - LimitRange로 누락된 값을 채워 BestEffort 파드가 생기는 것을 막습니다. 쿼터와 함께 쓰는 방법은 LimitRange와 ResourceQuota (opens new window)에 정리했습니다.

렌더 결과 테스트 - 차트나 매니페스트 생성 코드가 있다면 렌더된 파드 스펙에서 request == limit을 검사하는 테스트를 두는 편이 확실합니다.

def test_collector_memory_request_equals_limit(rendered_pod_spec):
    for container in rendered_pod_spec["containers"]:
        res = container["resources"]
        assert res["requests"]["memory"] == res["limits"]["memory"], (
            f'{container["name"]}: request != limit → Burstable 로 떨어진다'
        )

배포된 클러스터 전체를 훑어 위험한 격차를 찾을 수도 있습니다.

kubectl get pods -A -o json | jq -r '
  .items[] | select(.status.qosClass != "Guaranteed")
  | .metadata.namespace + "/" + .metadata.name + " " + .status.qosClass'

# 7. 트러블슈팅

증상 원인 해결
한도를 안 넘긴 파드가 OOMKilled 노드 전체 메모리 고갈로 커널 OOM killer가 선택 노드의 request/limit 격차 축소, 핵심 워크로드는 Guaranteed
메모리만 맞췄는데 qosClass: Burstable CPU request≠limit 또는 CPU 값 누락 CPU도 동일하게 지정
파드 하나만 고쳤는데 여전히 Burstable 사이드카 컨테이너 누락 파드의 모든 컨테이너에 적용
노드에 여유가 보이는데 파드가 축출됨 kubelet 축출 신호 memory.availablefree -m 같은 도구가 아니라 cgroupfs의 working set 기준(capacity - workingSet)이라 보이는 여유와 다름 임계값과 노드 할당 가능량 확인
노드는 안 죽는데 재시작만 반복 cgroup OOM (정상 격리) limit 상향 또는 애플리케이션 메모리 사용 조정

증상을 구분하는 가장 빠른 방법은 죽은 컨테이너의 종료 사유와 노드 커널 로그를 함께 보는 것입니다.

kubectl describe pod <pod> | grep -A3 "Last State"
# 노드에서
dmesg -T | grep -i "killed process\|out of memory"

커널 로그에 Memory cgroup out of memory가 있으면 cgroup OOM, 그런 수식어 없이 Out of memory: Killed process만 있으면 노드 전역 OOM입니다.

# 8. 마무리

  • Kubernetes의 OOM은 cgroup 단위노드 전역 두 가지이고, 무관한 파드가 함께 죽는 사고는 후자에서 발생합니다.
  • Guaranteed는 memory와 cpu 모두, 파드의 모든 컨테이너에서 request == limit일 때만 부여됩니다.
  • QoS는 oom_score_adj와 축출 순서 두 곳에 반영됩니다. request를 작게 잡고 limit만 키운 컨테이너는 노드가 위험할 때 가장 먼저 죽는 쪽에 섭니다.
  • 모든 워크로드를 Guaranteed로 만들 필요는 없습니다. 사용량이 튀는 워크로드부터 격차를 없애는 것이 비용 대비 효과가 큽니다.

# 참고