"메모리가 모자라면 BestEffort → Burstable → Guaranteed 순으로 죽는다." 많이 들은 설명인데, Node-pressure Eviction 공식 문서에는 "kubelet does not use the pod's QoS class to determine the eviction order"라고 적혀 있다.
노드에서 파드를 없애는 주체는 kubelet(eviction)과 커널 OOM killer 둘이다. 두 경로를 소스까지 따라가 보니 둘 다 "request보다 얼마나 더 쓰는가"로 움직였고, QoS 순서는 그 결과를 대충 맞히는 라벨이었다.
kubelet은 housekeeping-interval(기본 10s)마다 memory.available을 임계값과 비교해 파드 단위로 퇴출한다. 그런데 그 사이 메모리가 급하게 오르면 kubelet이 MemoryPressure를 보기도 전에 커널 OOM killer가 프로세스를 SIGKILL한다(문서의 Known issues). 그래서 두 경로를 따로 봐야 한다.
pkg/kubelet/eviction/helpers.go의 메모리 압박 정렬은 한 줄이다.
// request 초과 여부 → PriorityClass → (usage − request) 순으로 비교
orderedBy(exceedMemoryRequests(stats), priority, memory(stats)).Sort(pods)
BestEffort는 request가 0이라 1단계에서 늘 "초과" 그룹이고, Guaranteed는 limit = request라 늘 "미초과" 그룹이다. 흔히 말하는 QoS 순서는 이 1단계의 부산물이고, 같은 그룹 안에서는 Priority가 먼저 비교된다.
커널은 Priority를 모른다. 대신 kubelet이 컨테이너를 만들 때 pkg/kubelet/qos/policy.go의 공식대로 oom_score_adj를 넣어 둔다.
| QoS | oom_score_adj |
|---|---|
| Guaranteed | -997 |
| BestEffort | 1000 |
| Burstable | 1000 − 1000 × request / 노드 메모리 (3~999로 클램프) |
mm/oom_kill.c의 oom_badness()는 rss + swap + 페이지테이블(페이지 단위)에 adj × totalpages / 1000을 더해 점수를 만든다. 노드 전체 페이지를 T, 사용량을 U, request를 R이라 하고 Burstable 공식을 대입하면 이렇게 된다.
points = U + (1000 − 1000·R/T) · T/1000 = T + (U − R)
T는 모든 컨테이너에 똑같이 붙으니 순위는 U − R로만 갈린다. kubelet의 3단계 비교자와 같은 축이다.
두 킬러 모두 "request 대비 초과 사용량"을 보고, QoS는 그 근사치일 뿐이다.
공식을 숫자로 대입해 봤다. 노드 메모리 16Gi, 스왑 없음, 컨테이너당 프로세스 하나라고 가정한다.
| 파드 | QoS | request | usage | Priority | adj | 커널 점수(Gi 환산) |
|---|---|---|---|---|---|---|
| A | BestEffort | 0 | 1Gi | 0 | 1000 | 1 + 16 = 17 |
| B | Burstable | 2Gi | 5Gi | 1000 | 875 | 5 + 14 = 19 |
| C | Burstable | 4Gi | 3Gi | 0 | 750 | 3 + 12 = 15 |
| D | Guaranteed | 4Gi | 3.9Gi | 0 | -997 | 3.9 − 15.95 ≈ -12 |
kubelet 순서는 A → B → D → C다. 초과 그룹(A, B) 안에서는 Priority가 낮은 A가 먼저다. 미초과 그룹은 Priority가 같으니 usage − request가 큰 D(-0.1Gi)가 C(-1Gi)보다 앞선다. 즉 Guaranteed가 Burstable보다 먼저 퇴출된다.
커널 OOM이 먼저 터지면 B가 죽는다. 커널은 Priority를 모르고, B의 초과량 3Gi가 A의 1Gi보다 크다. 어느 경로가 먼저 발동했느냐에 따라 피해자가 달라진다.
-997도 면제가 아니다. 커널이 후보에서 빼는 값은 -1000(OOM_SCORE_ADJ_MIN)뿐이라, 음수 점수라도 남은 후보가 그것 하나면 죽는다. 확인은 이렇게 한다.
kubectl exec web -- cat /proc/1/oom_score_adj # Burstable req 4Gi / 노드 16Gi → 750
kubectl exec web -- cat /proc/1/oom_score # 0~2000 스케일, 이 순서가 U−R 순서와 맞는지 비교
다만 문서가 경고하듯 oom_badness는 프로세스 단위라, 컨테이너 안에 작은 프로세스가 여럿이면 이 근사가 깨진다.
kubelet은 (request 초과 → Priority → usage − request)로, 커널은 T + (U − R)로 고른다. QoS 클래스는 어느 쪽에서도 직접 입력이 아니다. 컨테이너 limit에 닿는 memcg OOM은 이전 글 cgroup v2 메모리 컨트롤러와 OOM에 정리했고, 다음엔 Memory QoS(memory.min/memory.high)가 OOM 이전에 긋는 보호선을 파 볼 생각이다.