"메모리가 부족할 때 누구를 먼저 죽이나" — QoS가 결정하는 생존 서열

한 노드에 10개의 Pod가 돌고 있었다. 메모리가 꽉 찼다. kubelet은 한 Pod를 골라 죽여야 했다. 그런데 왜 Pod였을까? 한 팀은 "랜덤인 줄 알았다"고 했다. 랜덤이 아니었다 — kubelet은 QoS(Quality of Service) 등급이라는 보이지 않는 서열에 따라 죽일 대상을 정한다. 그 서열을 모르면, 가장 중요한 Pod를 의도치 않게 가장 먼저 죽이는 대상으로 만든다.

이 글이 푸는 것은: requestslimits가 노드 자원을 어떻게 나누고, 그 결과 세 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 + 초과 사용 정도:

  1. BestEffort 먼저.
  2. Burstable 중 requests를 초과해 사용 중인 것 우선.
  3. 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 resize CLI 지원은 실측 시점 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)가 재시작 없는 자원 변경을 가능하게. 단 런타임 지원 확인 필요.

생각해 볼 문제

  1. 메모리 limit을 256Mi로 잡았는데 실사용량이 300Mi면? 어떤 이벤트로, 어떻게 보이나?
  2. BestEffort Pod 5개와 Guaranteed Pod 1개가 같은 노드에 있다. 노드 메모리가 꽉 찼다. 누가 먼저 죽나?
  3. CPU limit이 100m인데 노드 CPU는 90% 놀고 있다. 왜 앱이 느릴까?
  4. Guaranteed를 만드는 정확한 조건을 "모든 컨테이너" 관점에서 서술하라. (컨테이너가 여럿일 때)
  5. eviction이 발생하면 Pod는 어떻게 되나? 같은 노드에 다시 스케줄되나?
  6. In-place resize가 지원되지 않는 상황에서 requests를 늘리려면? (전통적 방법)

참고

'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