"메모리가 부족할 때 누구를 먼저 죽이나" — QoS가 결정하는 생존 서열
한 노드에 10개의 Pod가 돌고 있었다. 메모리가 꽉 찼다. kubelet은 한 Pod를 골라 죽여야 했다. 그런데 왜 그 Pod였을까? 한 팀은 "랜덤인 줄 알았다"고 했다. 랜덤이 아니었다 — kubelet은 QoS(Quality of Service) 등급이라는 보이지 않는 서열에 따라 죽일 대상을 정한다. 그 서열을 모르면, 가장 중요한 Pod를 의도치 않게 가장 먼저 죽이는 대상으로 만든다.
이 글이 푸는 것은: requests와 limits가 노드 자원을 어떻게 나누고, 그 결과 세 QoS 등급(Guaranteed/Burstable/BestEffort)이 어떻게 매겨지며, 노드 압박 시 누가 먼저 죽는가다.
requests와 limits — 두 개의 자원 경계
컨테이너마다 자원(CPU/메모리)에 두 값을 지정할 수 있다.
resources:
requests: {cpu: "500m", memory: "256Mi"} # 보장받는 최소
limits: {cpu: "1", memory: "512Mi"} # 최대 상한
- requests: 보장*되는 최소 자원. 스케줄러는 이 합이 노드 용량 이하인 노드에만 Pod를 스케줄한다(08장 Filter). 그리고 kubelet/cgroup이 이만큼은 *항상 쓸 수 있게 보장한다.
- limits: 최대 상한. 이 값을 넘으면:
- CPU: throttle(제한). 느려질 뿐 죽지 않음.
- 메모리: OOMKilled(죽음). 메모리는 타협이 안 된다.
핵심 차이: CPU limit 초과는 천천히 됩니다(throttle), 메모리 limit 초과는 갑자기 죽습니다(OOMKilled). 그래서 메모리 limit을 너무 타이트하게 잡으면 뜬금없이 Pod가 죽는다 — "OOMKilled" 이벤트로 원인이 명확하지만, 잘못된 limit 설정이 흔한 원인.
requests와 limits의 관계가 QoS를 만든다
세 QoS 등급은 requests/limits을 어떻게 설정했느냐에 따라 자동으로 매겨진다. 사용자가 직접 고르지 않는다.
flowchart TD
Q1["Q1: 모든 컨테이너에<br/>requests == limits 인가?"] -->|"예"| G["Guaranteed"]
Q1 -->|"아니오"| Q2["Q2: requests/limits 중<br/>하나라도 설정된 게 있는가?"]
Q2 -->|"예"| B["Burstable"]
Q2 -->|"아니오"| BE["BestEffort"]
| 등급 | 조건 | 노드 압박 시 |
|---|---|---|
| Guaranteed | 모든 컨테이너의 requests == limits (CPU·메모리 둘 다) | 가장 마지막으로 죽음 |
| Burstable | requests/limits 중 일부만 설정, 또는 requests < limits | 중간. 초과 사용분에 따라 우선순위 |
| BestEffort | requests/limits 아예 없음 | 가장 먼저 죽음 |
이 서열이 "노드 메모리가 부족할 때 누가 먼저 죽나"의 답이다. kubelet은 BestEffort를 먼저 죽이고, 그래도 모자라면 Burstable(초과 사용 중인)을 죽이고, Guaranteed는 거의 끝까지 보존한다.
왜 BestEffort가 위험한가
requests/limits를 아예 안 주면 가장 쉽게 죽는다. 그런데 많은 초보 매니페스트가 아무 자원 설정 없이 배포된다 — 그 순간 그 Pod는 BestEffort, 즉 노드가 조금만 압박받아도 첫 번째 희생양이 된다.
또한 스케줄러는 requests가 없으면 "이 Pod는 자원을 0 쓴다"로 간주해 아무 노드에나 스케줄한다. 노드가 과밀해지고, 결국 압박 → BestEffort 다수 사망의 연쇄가 생긴다. 자원 설정은 선택이 아니라 필수인 이유다.
eviction(퇴거) — 노드가 자원 부족 시 kubelet이 죽이는 과정
노드의 자원(특히 메모리)이 부족해지면 kubelet은 퇴거(eviction)를 시작한다. 기준:
- memory.available이 임계값(기본 100Mi 아래)으로 떨어지면.
- nodefs.available(노드 디스크)이 임계값 아래로 떨어지면.
퇴거 순서는 QoS + 초과 사용 정도:
- BestEffort 먼저.
- Burstable 중 requests를 초과해 사용 중인 것 우선.
- Guaranteed는 마지막까지.
# Kubernetes 1.36 — Pod의 QoS 등급 확인
kubectl get pod <name> -o jsonpath='{.status.qosClass}{"\n"}'
Burstable
# OOMKilled 이력 확인 (limits 초과로 죽은 경우)
kubectl describe pod <name> | grep -A3 "Last State"
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
eviction과 node-pressure taint는 짝이다. 노드가 압박 상태가 되면 kubelet이 그 노드에 taint(
node-pressure)를 붙이고, 새 Pod는 그 노드로 스케줄 안 된다. 그리고 Node Controller가 기존 Pod를 정리(02장). 이 흐름이 "노드가 꽉 찼을 때 자동으로 부하가 퍼지는" 기전이다.
Guaranteed를 쓰면 항상 안전한가 — 아니다
Guaranteed(requests==limits)가 "가장 안전"이지만 만능이 아니다:
- limits를 너무 높게 잡으면 노드 자원이 낭비된다 — 보장받는 양이 크니 다른 Pod가 못 들어간다.
- limits를 너무 낮게 잡으면 실사용량이 limits를 넘어 OOMKilled.
- Guaranteed의 진짜 의미는 자원 경계가 예측 가능하다는 것 — "이 Pod는 정확히 이만큼 쓴다고 보장한다"는 명시. 미션 크리티컬 워크로드에 적합.
실무의 흔한 선택: 중요 서비스는 Guaranteed, 일반은 Burstable(request만, limits는 여유 있게). BestEffort는 디버깅/일회성 외엔 피한다.
In-place Pod Resize (1.33 GA) — 재시작 없이 자원 변경
전통적으로 requests/limits를 바꾸려면 Pod를 재시작해야 했다 — 새 Pod로 교체. 그래서 운영 중 자원 조정이 곤란했다. In-place Pod Resize(KEP-1287, 1.33에서 GA)가 이를 바꿨다. 실행 중인 Pod의 자원을 재시작 없이 변경한다.
# Kubernetes 1.36 — 실행 중 Pod 자원 변경 (1.33+ GA)
kubectl patch pod <name> --subresource resize --patch \
'{"spec":{"containers":[{"name":"app","resources":{"requests":{"cpu":"1"},"limits":{"cpu":"1"}}}]}}'
정확한 1.36 상태와
--subresource resizeCLI 지원은 실측 시점 CHANGELOG로 확인. 컨테이너 런타임 지원(containerd/CRI-O)도 필요.
이 기능이 중요한 이유: 트래픽 급증 시 재시작 없이 CPU를 늘리는 온디맨드 자원 조정이 가능해진다. 단, 모든 자원/런타임 조합이 지원되는 건 아니어서 실측 필요.
CPU throttling — 느려지지만 안 죽는다
CPU limit을 초과하면 cgroup이 CPU 할당을 throttle(제한)한다. Pod는 죽지 않지만 응답이 느려진다. 이것도 흔한 성능 함정:
- limit을 너무 낮게 잡으면 앱이 CPU 여유가 있는데도 느려진다(노드 전체 CPU는 남아도 그 Pod의 limit이 막음).
- 특히 지연에 민감한 서비스에서 CPU limit이 의외의 성능 저하를 일으킨다.
# throttling 여부는 cAdvisor/메트릭에서 확인
kubectl top pod <name>
throttle 탐지는 container 메트릭(cgroup CPU 통계)에서. 상세는 Prometheus/cAdvisor. 미검증 환경은 주석.
직접 확인하기
# Kubernetes 1.36 — 세 QoS를 한 클러스터에서 만들기
# Guaranteed
kubectl run g --image=nginx:1.27 --restart=Never \
--overrides='{"spec":{"containers":[{"name":"g","image":"nginx:1.27","resources":{"requests":{"cpu":"100m","memory":"128Mi"},"limits":{"cpu":"100m","memory":"128Mi"}}}]}}'
# Burstable
kubectl run b --image=nginx:1.27 --restart=Never \
--overrides='{"spec":{"containers":[{"name":"b","image":"nginx:1.27","resources":{"requests":{"cpu":"50m","memory":"64Mi"}}}]}}'
# BestEffort
kubectl run be --image=nginx:1.27 --restart=Never
for p in g b be; do kubectl get pod $p -o jsonpath="{$p: .status.qosClass}{'\n'}"; done
확인할 것: g→Guaranteed, b→Burstable, be→BestEffort. 설정에 따라 자동 분류됨.
g: Guaranteed
b: Burstable
be: BestEffort
CPU manager static 정책 — CPU를 전용으로
기본적으로 CPU는 시간 분할(time-slicing)로 Pod들에 공유. 하지만 CPU 민감 워크로드(실시간 처리, 고주파 트레이딩)는 공유의 지터를 피하고 싶다. CPU manager의 static 정책이 Guaranteed Pod의 CPU를 전용(exclusive)으로 묶어준다:
# kubelet config
cpuManagerPolicy: static
static 모드에서 Guaranteed Pod(requests==limits, 정수 CPU)의 CPU를 다른 Pod와 공유하지 않게 고정. OS 스케줄러가 그 코어를 다른 Pod에 안 준다. 캐시 친화성·지터 감소. 단 노드 전체 CPU 활용도는 떨어짐(전용이므로).
이것이 "Guaranteed + 정수 CPU + static 정책"의 조합이 실시간 워크로드에 쓰이는 이유. 일반 워크로드는 기본 none(공유) 정책으로 충분.
topology manager — NUMA/노드 토폴로지까지 고려
더 깊이: topology manager(1.x)가 CPU·메모리·장치를 물리 토폴로지(NUMA 노드 등)에 맞춰 할당. 고성능 워크로드(데이터플레인, ML)가 "같은 NUMA 노드 안에서 CPU+메모리"를 쓰게 해 지연 최소화. topology manager 정책(best-effort/restricted/single-numa-node)이 이 품질을 결정.
이 CPU manager + topology manager 조합이 "일반 Pod"와 "극단 성능 Pod"를 나누는 설정의 끝. 일반 클러스터는 안 켜도 되지만, 전용 고성능 노드 풀에서는 핵심 튜닝.
memory와의 비대칭 — CPU는 throttle, 메모리는 OOM
11장 본문에서 "CPU 초과는 throttle, 메모리 초과는 OOMKilled"라고 했다. 이 비대칭의 근원은 자원의 성질:
- CPU는 시간 분할 가능: 한 코어를 여러 Pod가 나눠 쓸 수 있다. 초과하면 느려질 뿐.
- 메모리는 분할 불가능(한 바이트를 두 Pod가 동시에 못 씀): 초과하면 누군가 죽어야. 커널이 OOM killer로 가장 많이 쓰는 프로세스를 죽임.
이 비대칭이 limit 설정의 위험도 차이를 만든다. CPU limit은 "느려질 위험"만. 메모리 limit은 "죽을 위험". 그래서 메모리 limit은 앱 실제 사용량에 밀접하게 맞춰야 — 너무 낮으면 잦은 OOM, 너무 높으면 노드 낭비. CPU limit은 상대적으로 여유롭게.
eviction과 OOMKilled의 차이 — 두 죽음
노드 자원 부족으로 Pod가 죽는 두 경로를 구분해야:
- eviction(kubelet 주도): 노드 메모리/디스크가 임계에 가까워 kubelet*이 사전에 Pod를 퇴거. QoS 순서(BestEffort → Burstable). *계획적 죽음.
- OOMKilled(커널 주도): 메모리가 진짜로 바닥*나 커널이 즉시 프로세스를 죽임. Pod의 limits 초과 또는 노드 전체 메모리 고갈. *비계획적 죽음.
flowchart LR
MEM_LOW["메모리 임계 근접"] --> EVI{"kubelet eviction<br/>(QoS 순서)"}
EVI --> EVIC["Pod 퇴거 (graceful)"]
MEM_OUT["메모리 진짜 고갈"] --> OOM{"커널 OOM killer"}
OOM --> KILL["프로세스 즉시 kill (OOMKilled)"]
eviction이 먼저 발동해야 우아하게 처리된다. eviction 임계 설정이 너무 느껴지게(높게) 있으면 — kubelet이 대처하기 전에 커널 OOM이 먼저 발동해 무차별 kill. 좋은 eviction 임계가 "우아한 죽음 vs 무차별 죽음"의 분기.
흔히 묻는 것, 흔히 틀리는 것
| 오해 | 정정 |
|---|---|
| "requests와 limits는 같다" | requests=보장 최소, limits=최대 상한. 다른 의미 |
| "QoS는 사용자가 직접 지정한다" | requests/limits 설정에 따라 자동 분류 |
| "CPU limit 초과하면 Pod가 죽는다" | throttle(느려짐)이지 죽음이 아님. 메모리만 OOMKilled |
| "BestEffort는 자원을 안 써서 유리하다" | 반대. 가장 먼저 죽고 스케줄도 과밀 노드로. 위험 |
| "Guaranteed면 절대 죽지 않는다" | eviction 우선순위가 가장 낮을 뿐. 노드 자체가 죽으면 같이 죽음 |
| "자원 설정을 안 하면 노드 전체를 쓸 수 있다" | throttle/eviction에서 불리. "보장 없음"이지 "전체 사용 가능"이 아님 |
요약 — 이 글의 결론
- requests(보장 최소)와 limits(최대 상한)가 두 경계. 스케줄러는 requests 합으로 노드 선택, cgroup은 limits로 상한 강제.
- CPU 초과는 throttle(느려짐), 메모리 초과는 OOMKilled(죽음) — 타협 가능성이 다르다. 메모리 limit을 타이트하게 잡으면 뜬금없이 Pod가 죽는다.
- QoS 세 등급(Guaranteed/Burstable/BestEffort)은 requests/limits 설정에 따라 자동 부여. eviction(퇴거) 서열이 된다.
- BestEffort가 가장 먼저 죽는다. 자원 설정 안 한 Pod는 노드 압박의 첫 희생양 — 설정은 선택이 아닌 필수.
- eviction + node-pressure taint가 노드 꽉 참을 자동으로 처리 — 압박 노드는 새 Pod 차단, 기존 Pod는 QoS 순으로 정리.
- In-place Pod Resize(1.33 GA)가 재시작 없는 자원 변경을 가능하게. 단 런타임 지원 확인 필요.
생각해 볼 문제
- 메모리 limit을 256Mi로 잡았는데 실사용량이 300Mi면? 어떤 이벤트로, 어떻게 보이나?
- BestEffort Pod 5개와 Guaranteed Pod 1개가 같은 노드에 있다. 노드 메모리가 꽉 찼다. 누가 먼저 죽나?
- CPU limit이 100m인데 노드 CPU는 90% 놀고 있다. 왜 앱이 느릴까?
- Guaranteed를 만드는 정확한 조건을 "모든 컨테이너" 관점에서 서술하라. (컨테이너가 여럿일 때)
- eviction이 발생하면 Pod는 어떻게 되나? 같은 노드에 다시 스케줄되나?
- In-place resize가 지원되지 않는 상황에서 requests를 늘리려면? (전통적 방법)
참고
- Kubernetes 공식 문서 - Resource Management for Pods and Containers - 접근 2026-07-12 (requests/limits)
- Kubernetes 공식 문서 - Pod Quality of Service Classes - 접근 2026-07-12 (Guaranteed/Burstable/BestEffort)
- Kubernetes 공식 문서 - Node-pressure Eviction - 접근 2026-07-12 (eviction 임계값, 순서)
- Kubernetes 공식 문서 - OOMKilled와 limits - 접근 2026-07-12
- KEP-1287 In-place Pod Resize - 접근 2026-07-12 (1.33 GA)
- Kubernetes 1.36 CHANGELOG - 접근 2026-07-12
'Tech Artifacts > Kubernetes' 카테고리의 다른 글
| Kubernetes - 13. node administration (0) | 2026.07.13 |
|---|---|
| Kubernetes - 12. autoscaling (0) | 2026.07.13 |
| Kubernetes - 10. config secret (0) | 2026.07.13 |
| Kubernetes - 09. storage (0) | 2026.07.13 |
| Kubernetes - 08. scheduling (0) | 2026.07.13 |
