프로덕션에서 이상한 장애 하나를 잡으려고 며칠을 썼다. Pod는 살아있고, kubelet 이벤트에도 OOMKilled가 없다. 근데 P99 응답시간이 300ms에서 갑자기 2초를 찍는다. HPA는 CPU 기반이라 안 뜨고, memory usage 그래프는 limit 근처에서 톱니처럼 움직인다. 뭔가 죽지는 않는데 계속 밀린다.
원인은 cgroup v2의 memory.high였다. K8s 1.36에서 Memory QoS 기능이 안정화되면서 kubelet이 이걸 자동으로 세팅하는 노드가 늘었는데, 그 동작을 모르면 지금 우리 클러스터에서 겪는 이런 "OOM 없는 느려짐"의 원인을 짚기 어렵다. 사실 내부적으로 커널이 어떻게 스로틀링하는지 이해하고 나면 튜닝 포인트가 명확해진다.
memory.max와 memory.high는 다른 놈이다
cgroup v1 시절엔 memory.limit_in_bytes 하나가 전부였다. 넘으면 OOM killer가 호출된다. 끝. cgroup v2로 오면서 인터페이스가 갈라졌다.
memory.max— 하드 리밋. 넘으면 OOM. 예전 v1의 limit과 동일한 역할.memory.high— 소프트 리밋. 넘어도 안 죽는다. 대신 커널이 해당 cgroup의 프로세스에 페널티를 준다.memory.min— 리클레임 대상에서 아예 제외. 이 값까지는 무조건 보장.memory.low— best-effort 보장. 다른 cgroup이 압박받을 때만 리클레임 대상이 됨.
여기서 문제가 되는 게 memory.high다. 이 값을 넘어서면 어떻게 되는지가 핵심이다.
kubelet은 memoryThrottlingFactor로 memory.high를 정한다
K8s 1.36의 Memory QoS 피처 게이트를 켜면 kubelet이 컨테이너별로 cgroup에 값을 자동으로 넣는다. Guaranteed Pod 기준으로 계산이 이렇게 된다.
memory.min = requests.memory
memory.high = requests.memory + (limits.memory - requests.memory) * memoryThrottlingFactor
memory.max = limits.memory
memoryThrottlingFactor의 기본값은 0.9다. 즉, requests가 512Mi, limits가 1Gi인 컨테이너라면 memory.high는 대략 972Mi 근처에서 결정된다. Pod가 이 지점을 넘어가는 순간 스로틀링이 시작된다. limit(1Gi)까지 아직 여유가 있지만, 커널은 이미 브레이크를 밟기 시작한 상태다.
Burstable Pod는 계산이 조금 다르다. requests.memory가 있으면 memory.min으로 들어가고, limits가 있으면 위와 동일하게 memory.high가 계산된다. limits가 없는 Burstable/BestEffort는 memory.high가 설정되지 않는다.
memory.high를 넘으면 커널이 뭘 하는가
이 부분이 진짜 재미있다. cgroup v2에서 memory.high를 초과하면 두 가지가 동시에 일어난다.
첫째, direct reclaim이 강제된다. 프로세스가 페이지를 할당하려고 하면, 커널은 그 프로세스의 컨텍스트에서 즉시 페이지 리클레임을 수행한다. 즉, 새 메모리를 얻으려면 먼저 오래된 페이지 캐시를 밀어내거나 스왑아웃을 해야 한다. 이 작업이 애플리케이션 스레드를 블록시킨다. 우리가 프로파일러에서 보는 "왜 malloc이 갑자기 밀리지?"의 정체다.
둘째, penalty sleep이 걸린다. mm/memcontrol.c의 mem_cgroup_handle_over_high()를 보면 초과분에 비례해 프로세스를 재우는 로직이 있다. 초과가 심할수록 sleep이 기하급수적으로 늘어난다. 최대 2초까지 재울 수 있게 짜여 있다. 이게 우리가 봤던 P99 2초의 정체였다.
문제는 이 스로틀링이 모니터링에 잘 안 잡힌다는 점이다. cAdvisor의 container_memory_working_set_bytes는 limit 미만으로 나오고, kubelet 이벤트에는 아무것도 없고, dmesg도 조용하다. Grafana 대시보드만 보면 정상이다. 실제로는 애플리케이션 스레드가 커널에 붙잡혀 있는데.
이걸 어떻게 관측하는가
memory.high 스로틀링을 잡으려면 cgroup의 memory.events 파일을 봐야 한다. 노드에 들어가서 직접 확인할 수 있다.
# 특정 Pod의 cgroup 경로 찾기
POD_UID=$(kubectl get pod my-app -o jsonpath='{.metadata.uid}' | tr - _)
CGROUP=$(find /sys/fs/cgroup -name "*${POD_UID}*" -type d | head -1)
cat ${CGROUP}/memory.events
# low 0
# high 4823 <- 요게 스로틀링 발생 횟수
# max 0
# oom 0
# oom_kill 0
high 카운터가 계속 올라가면 스로틀링이 걸리고 있다는 뜻이다. 프로메테우스로 뽑으려면 kubelet cAdvisor가 뽑아주는 container_memory_events_total{type="high"} 지표가 쓸 만하다. 참고로 이 지표는 상대적으로 최근에 노출되기 시작해서 오래된 kubelet에서는 없을 수 있다.
우리 팀에서는 이걸 발견하고 나서 알람 하나를 추가했다.
- alert: MemoryHighThrottling
expr: rate(container_memory_events_total{type="high"}[5m]) > 0.1
for: 10m
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }} is being throttled on memory.high"
description: "Pod가 OOM 없이 memory.high 스로틀링을 겪고 있음. limit 상향 또는 memoryThrottlingFactor 조정 필요."
튜닝 방향 세 가지
일단 원인을 알았으니 대응은 셋 중 하나다.
첫째, limits.memory를 올린다. 가장 단순하다. 그런데 이게 진짜 필요한 메모리인지, 아니면 페이지 캐시가 부풀어서 그런 건지 구분이 필요하다. RSS 기준으로 보면 여전히 여유 있는데 working set만 부푸는 케이스가 많다. 이 경우엔 limit을 올리는 게 낭비다.
둘째, memoryThrottlingFactor를 조정한다. kubelet config에서 조정 가능하다.
# /var/lib/kubelet/config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
memoryThrottlingFactor: 0.95 # 기본 0.9 → 0.95로
featureGates:
MemoryQoS: true
0.9 → 0.95로 올리면 스로틀링 시작 지점이 limit 쪽으로 밀린다. 대신 노드 전체가 압박받을 때 이 Pod가 다른 Pod보다 늦게 리클레임 대상이 되므로 워크로드 특성을 고려해야 한다.
셋째, Memory QoS를 아예 끈다. 팀 내부 논의 끝에 우리는 특정 워크로드(레이턴시 민감 API)에 한해서 이 방향을 택했다. Memory QoS는 노드 전체 안정성을 위한 기능인데, 응답시간이 중요한 서비스에서는 OOM 리스크를 감수하고 스로틀링을 없애는 게 나을 때가 있다.
그래서 어떻게 됐냐
우리는 세 번째와 두 번째를 섞어서 해결했다. 레이턴시 민감한 API는 별도 노드풀로 뽑고 거기 kubelet은 MemoryQoS를 껐다. 배치성 워크로드는 그대로 두되 memoryThrottlingFactor를 0.85로 낮춰서 오히려 더 일찍 스로틀링이 걸리게 했다. 노드 전체를 지키는 게 중요하다고 판단했다.
솔직히 이 문제 잡는데 3일 걸렸다. cgroup v2로의 전환은 조용히 진행되지만 그 안에서 커널이 하는 일은 v1과 꽤 다르다. 특히 memory.high는 v1에는 아예 없던 개념이라 이전 지식이 오히려 방해가 된다.
혹시 비슷한 증상 — Pod가 살아있는데 이유 없이 느려진다 — 겪는 분 있으면 memory.events의 high 카운터부터 확인해보길 권한다. 우리처럼 며칠 헤매지 않으려면.
'IT > Kubernets' 카테고리의 다른 글
| In-place Pod Resize, GA는 됐지만 우리 팀은 아직 조심스럽게 쓴다 — 내부 동작과 함정 (0) | 2026.08.16 |
|---|---|
| Karpenter consolidation 내부 동작 파헤치기 — 노드가 사라지기까지 (0) | 2026.08.14 |
| kubectl debug --profile, 이거 모르는 사람 꽤 많더라 (0) | 2026.08.11 |
| Karpenter v1 disruption 컨트롤러, 내부에서 무슨 일이 벌어지나 (0) | 2026.08.10 |
| Helm --set-json, 이거 모르는 분 꽤 많더라 (0) | 2026.08.09 |