# 메모리 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
BestEffortPods running on that Node, followed byBurstableand finallyGuaranteedPods.
여기에 중요한 단서가 붙습니다.
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.available은 free -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로 만들 필요는 없습니다. 사용량이 튀는 워크로드부터 격차를 없애는 것이 비용 대비 효과가 큽니다.
# 참고
- Kubernetes - Pod Quality of Service Classes (opens new window)
- Kubernetes - Node-pressure Eviction (opens new window)
- Kubernetes - Configure Quality of Service for Pods (opens new window)
- kubernetes/kubernetes -
pkg/kubelet/qos/policy.go(opens new window) - kubernetes#131169 - Burstable oom_score_adj 와 PriorityClass (opens new window)