들어가며
OOMKill은 K8s 운영하면서 가장 자주 마주치는 종류의 죽음 중 하나다. 그런데 막상 "왜 죽었냐"고 물으면 Last State: OOMKilled 로그 한 줄 외엔 잘 못 말하는 경우가 많다. 사실 나도 그랬다. 우리 팀 내부 논의에서 "메모리 limit 부족이지" 정도로 넘기던 게 한 90% 였고, 정작 그 직전 kernel과 kubelet이 어떤 신호를 주고 받았는지는 들여다본 적이 별로 없었다.
이번 글에서는 cgroup v2 환경에서 Pod의 메모리가 limit 근처까지 차오를 때 노드 안에서 어떤 흐름이 도는지를 정리한다. K8s 1.28부터 cgroup v2 동작이 한 단계 정리됐고, 1.34에서 PSI 메트릭이 Beta로 올라오면서 이 영역의 운영 가시성이 꽤 좋아진 시점이다.
memory.high와 memory.max — 두 단계 방어선
cgroup v2의 메모리 컨트롤러는 임계치를 두 개 들고 있다. memory.high는 소프트 리밋이고, memory.max가 하드 리밋이다.
/sys/fs/cgroup/kubepods.slice/.../memory.high
/sys/fs/cgroup/kubepods.slice/.../memory.max
차이를 한 줄로 요약하면, memory.high를 넘으면 스로틀이고, memory.max를 넘으면 OOM이다.
memory.high를 초과하기 시작하면 kernel은 해당 cgroup 안에서 reclaim을 강하게 돌리려 한다. 페이지 캐시를 떨궈내고, 익명 페이지가 swap 가능한 상황이면 swap도 시도한다. 이 reclaim 작업 자체가 비싸기 때문에 프로세스의 사용자 시간이 줄고 latency가 튄다. 흔히 말하는 "Pod이 안 죽었는데 응답이 갑자기 느려졌다"의 절반은 이 단계에서 일어난다.
reclaim이 따라잡지 못해서 사용량이 memory.max까지 치고 올라가면, 그때부터 cgroup OOM killer가 깨어난다. 여기서 K8s 1.28부터의 변화가 중요한데, kubelet이 컨테이너 cgroup에 memory.oom.group=1을 박아두기 때문에, 이제 OOM은 컨테이너 안의 프로세스 하나가 아니라 컨테이너 통째로 같이 종료된다. 이전에는 부모 프로세스만 살아남아 좀비 같은 상태가 되거나, 부모는 멀쩡한데 워커만 뚝뚝 떨어져서 컨테이너가 정상으로 보이는 함정이 있었는데, 이게 정리됐다. JVM이나 Python multiprocess 워커 같은 케이스에서 특히 도움이 됐다.
Kubelet이 Pod의 resources.limits.memory 값을 그대로 memory.max에 매핑한다는 건 아는 분이 많지만, memory.high가 실제로 어떻게 잡히는지는 의외로 덜 알려져 있다. MemoryQoS feature gate를 켰을 때 kubelet이 (limit - request) * factor을 계산해 memory.high를 넣는데, 이 값은 Burstable 클래스의 Pod에 대해서만 의미가 있고, Guaranteed Pod는 request=limit이라 사실상 memory.high == memory.max처럼 동작한다.
kubelet의 시각 — PSI와 node-pressure eviction
여기까지가 cgroup이 혼자 처리하는 영역이고, 그 위에서 kubelet은 노드 전체의 메모리 압박을 따로 본다. 이 두 레이어가 헷갈려서 "Pod이 OOMKill 됐는지, evict 됐는지" 구분을 못 하는 경우가 종종 있다.
전통적으로 kubelet의 메모리 eviction은 memory.available이 임계치 아래로 떨어지면 발동했다. 그런데 이 신호는 후행 지표(lagging)다. 이미 free 메모리가 빠진 다음에야 알 수 있고, 그 시점이면 워크로드는 이미 한참 reclaim에 갈려 있다.
K8s 1.34에서 PSI 메트릭이 Beta로 올라온 게 이 부분을 보완한다. PSI는 Linux 4.20에서 들어온 기능인데, "리소스 부족 때문에 태스크가 얼마나 멈춰 있었는가"를 시간으로 측정한다. 즉, 사용량이 아니라 stall 시간을 본다. 사용률 80%여도 디스크 IO가 풍부하면 stall이 거의 없을 수 있고, 사용률 60%여도 cgroup 안에서 reclaim 폭주가 일어나면 stall이 치솟는다. 이 차이를 잡아주는 게 PSI의 핵심이다.
/proc/pressure/memory
some avg10=12.34 avg60=5.67 avg300=2.10 total=...
full avg10=2.10 avg60=0.80 avg300=0.10 total=...
some은 일부 태스크가 stall된 시간 비율, full은 모든 태스크가 동시에 stall된 시간 비율이다. 운영에서 의미 있는 신호는 full avg10이 갑자기 튀는 순간이다. cgroup 단위 PSI(/sys/fs/cgroup/.../memory.pressure)도 똑같이 노출되어 있어서, 노드 전체가 아니라 특정 Pod이 reclaim 지옥에 빠진 것도 잡아낼 수 있다.
K8s 1.36부터 KubeletPSI feature gate가 true로 잠긴다고 공지됐다. 즉, 이제부터는 PSI 기반 신호를 안 본다는 선택지가 사실상 사라진다. 우리 팀에서도 이걸 핑계로 노드 OS 표준에서 PSI 활성화 항목을 한 줄 추가했다. AL2023, Bottlerocket 같은 대부분의 노드 이미지는 이미 PSI가 켜져 있는데, 일부 오래된 OpenShift나 커스텀 RHEL 노드는 명시적으로 켜야 한다.
실무에서 마주치는 함정 몇 개
이 흐름을 알고 나면 디버깅 패턴이 좀 바뀐다. 내가 자주 쓰는 체크 리스트를 적자면 이렇다.
첫째, OOMKill이 떠도 그게 cgroup OOM인지 노드 전체 OOM인지부터 분리한다. dmesg | grep -i oom을 보면 차이가 보인다. cgroup OOM은 Memory cgroup out of memory: Killed process ... 형태이고, 시스템 OOM은 cgroup 정보가 빠져 있다. 시스템 OOM이 떴다는 건 사실 노드 자체에 메모리가 부족했다는 얘기라, 단일 Pod의 limit 조정으로 해결되지 않는다. 이 경우는 노드 capacity나 reserved 비율을 다시 봐야 한다.
둘째, "Pod이 OOMKill 났는데 limit을 두 배로 늘려도 또 난다"는 케이스는 거의 항상 메모리 누수이거나, 페이지 캐시까지 limit에 카운트되는 걸 못 보고 있는 경우다. cgroup v2에서 익명 페이지 + 페이지 캐시 + kernel slab 일부가 다 같이 카운트된다. 캐시 무거운 워크로드(예: Postgres, Elasticsearch)는 limit을 짠 채로 운영하면 reclaim에 갈려서 latency가 박살난다. 그래서 우리 팀은 DB류는 limit을 일부러 굉장히 넉넉하게 잡거나 아예 안 잡는 노드 그룹을 분리한다. 이게 Best Practice인지는 솔직히 아직도 갑론을박이지만, P99 latency 안정성 기준으론 효과가 명확했다.
셋째, JVM Pod이 죽는 패턴이 살짝 변했다. 1.28 이전에는 -Xmx를 limit과 비슷하게 잡아두면, JVM 워커 스레드 하나가 OOM 나도 메인 프로세스가 살아남아 재시작 신호가 안 들어가고 묘하게 hang 걸리는 일이 있었다. 1.28+ 환경에서는 memory.oom.group 덕분에 컨테이너가 깔끔하게 같이 죽고, 결과적으로 readiness probe가 빨리 실패해서 트래픽이 빨리 빠진다. 좋아진 건 맞는데, 재시작 빈도가 늘어 보일 수 있다. 우리 팀에서도 1.28 업그레이드 직후 OOMKill 그래프가 몇 일 더 높게 보이길래 잠깐 술렁였는데, 알고 보니 이전엔 "OOM 났는데 컨테이너는 살아 있어서 카운트가 안 되던" 케이스가 정상적으로 잡히기 시작한 거였다.
넷째, eviction 임계치를 PSI 기반으로 바꿔도 모든 문제가 풀리진 않는다. PSI는 어디까지나 신호이고, 결국 "어떤 Pod부터 죽일까"의 우선순위는 여전히 QoS class와 priority가 결정한다. 내 경험으로 PSI는 eviction이 너무 늦게 발동하던 패턴을 앞당기는 데 가장 큰 효과가 있었다. 노드가 swap thrash 비슷한 상태로 30분 동안 끙끙 앓다가 결국 evict되던 게, 이제는 stall이 길어진 시점에 빠르게 정리된다.
마무리
OOMKilled 한 줄 뒤에는 사실 두 개의 레이어가 있다. cgroup이 혼자 결정하는 컨테이너 단위 OOM, 그리고 kubelet이 노드 전체를 보고 결정하는 eviction. cgroup v2와 memory.oom.group, 그리고 PSI Beta 승격으로 두 레이어 모두 신호가 좀 더 명확해진 게 지난 몇 버전의 큰 흐름이다.
물론 이걸 다 안다고 OOMKill이 사라지진 않는다. 그저 죽었을 때 "왜 죽었지"를 물었을 때 한 단계 더 깊은 답을 할 수 있을 뿐이다. 그리고 그 한 단계가, 다음에 같은 일이 났을 때 같은 자리에서 또 헤매지 않게 만들어준다.
다음에는 swap이 K8s에 정식으로 들어온 이후 이 그림이 어떻게 바뀌는지도 정리해보려 한다. 1.28에서 NodeSwap이 Beta가 됐고, 우리 팀도 일부 노드 그룹에 켜고 운영 중인데 PSI와 묶어서 보면 또 다른 이야기가 된다.
'IT > Kubernets' 카테고리의 다른 글
| KEDA SQS scaler 도입했다가 thrashing에 한참 데인 이야기 (0) | 2026.04.29 |
|---|---|
| Karpenter consolidationPolicy, 이거 한 번은 짚고 가자 (0) | 2026.04.28 |
| Cluster Autoscaler에서 Karpenter로 옮기다 새벽에 멘탈 나간 썰 (0) | 2026.04.25 |
| kubectl debug로 Kubernetes Pod 트러블슈팅 완전 정복 (0) | 2026.04.25 |
| Kubernetes CrashLoopBackOff 완벽 트러블슈팅 가이드 (0) | 2026.04.24 |