- Published on
K8s OOMKilled 반복? cgroup v2 메모리 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스가 아닌 이상, Kubernetes에서 OOMKilled는 “그냥 메모리가 부족했다”로 끝나지 않습니다. 특히 최근 배포판과 EKS/AKS/GKE 노드가 cgroup v2로 이동하면서, 과거(v1)에서 통하던 지표(예: memory.usage_in_bytes)만 보고는 원인을 오판하기 쉽습니다.
이 글은 OOMKilled가 반복되는 Pod를 대상으로 cgroup v2 메모리 모델에 맞춰 “누가, 왜, 어떤 한계에 걸려 죽었는지”를 빠르게 재현·증명하는 흐름으로 구성했습니다.
> 네트워크/시스템 이슈처럼 “정상처럼 보이는데 계속 장애가 난다” 류의 진단 접근이 필요할 때는 다른 글도 참고할 만합니다: systemd 서비스가 반복 재시작될 때 원인 추적법
1) 먼저 확인: OOMKilled의 종류(컨테이너 OOM vs 노드 OOM)
Kubernetes에서 OOM은 크게 두 갈래입니다.
- 컨테이너 OOM (cgroup OOM): 해당 컨테이너/Pod의 cgroup 메모리 한계(
memory.max)를 넘어서 커널이 그 cgroup 내 프로세스를 kill - 노드 OOM (system OOM): 노드 전체 메모리가 고갈되어 커널 OOM killer가 프로세스를 kill(그 대상이 우연히 컨테이너일 수 있음)
둘은 대응이 완전히 다릅니다. 컨테이너 OOM이면 limit/사용 패턴/누수/캐시를 보지만, 노드 OOM이면 노드 메모리 압박, kubelet eviction, DaemonSet 누수, page cache 폭증 등을 봅니다.
빠른 분기 체크
# 1) Pod 종료 사유 확인
kubectl get pod -n <ns> <pod> -o jsonpath='{.status.containerStatuses[*].lastState.terminated.reason}{"\n"}'
# 2) 이벤트에서 OOM 관련 메시지 확인
kubectl describe pod -n <ns> <pod> | sed -n '/Events:/,$p'
# 3) 노드 메모리 압박/eviction 징후
kubectl describe node <node> | egrep -i 'MemoryPressure|evict|oom|pressure'
Reason: OOMKilled가 컨테이너에 찍히고, 노드가MemoryPressure=False인 경우가 많다면 컨테이너 OOM 가능성이 큽니다.- 노드 이벤트에
System OOM encountered또는eviction이 섞이면 노드 OOM/eviction을 의심하세요.
2) cgroup v2에서 꼭 알아야 할 메모리 지표 6개
cgroup v2는 파일 기반 인터페이스로 메모리 상태를 제공합니다. 컨테이너가 실제로 어디까지 쓰고, 어떤 압박을 받았는지는 아래 파일들로 가장 명확히 드러납니다.
memory.current: 현재 사용량(anonymous + file cache + kernel memory 등 포함)memory.max: 상한(= Kubernetes memory limit에 대응).max면 무제한memory.high: “부드러운” 상한(초과 시 throttle). Kubernetes는 기본적으로 잘 안 씀memory.events: OOM/limit hit 이벤트 카운터memory.stat: anon/file/slab 등 상세 breakdownpressure/memory(PSI): 메모리 압박으로 인한 stall(지연) 지표
이 중 **memory.events**는 “정말 limit 때문에 죽었는지”를 증명하는 핵심입니다.
3) Pod의 cgroup 경로 찾기(실전에서 가장 헷갈리는 부분)
cgroup v2에서 컨테이너의 cgroup 경로는 런타임(containerd/CRI-O), systemd 드라이버 여부에 따라 달라집니다. 가장 확실한 방법은 컨테이너 PID를 찾아 해당 PID의 cgroup을 역추적하는 것입니다.
(A) 노드에 접속할 수 있는 경우: PID → /proc/<pid>/cgroup
# 노드에서 실행 (예: EKS는 SSM Session Manager나 SSH)
# 1) 컨테이너 ID 확인
crictl ps | grep <pod-name>
# 2) 컨테이너 PID 확인
crictl inspect <container-id> | jq '.info.pid'
# 3) PID의 cgroup 경로 확인 (v2는 보통 0::/ 형태)
PID=$(crictl inspect <container-id> | jq -r '.info.pid')
cat /proc/$PID/cgroup
예시 출력(형태는 환경마다 다름):
0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<uid>.slice/cri-containerd-<id>.scope
이 경로를 /sys/fs/cgroup 아래에 붙이면 해당 컨테이너 cgroup 디렉터리입니다.
CG=/sys/fs/cgroup/kubepods.slice/.../cri-containerd-<id>.scope
ls $CG
(B) 노드 접속이 제한적인 경우
노드에 못 들어가면 완전한 cgroup 파일 조회는 어렵습니다. 대신:
kubectl top pod --containers로 대략적인 사용량- 애플리케이션 레벨 메트릭(Heap, RSS, cache)
- 노드/컨테이너 로그에서 OOM 스택
을 조합해야 합니다. 가능하면 운영 환경에서는 노드 진단 경로(SSM/SSH) 또는 eBPF 기반 관측을 최소한으로라도 열어두는 게 좋습니다.
4) “limit 때문에 죽었는지”는 memory.events로 판정
컨테이너 cgroup 디렉터리에서 다음을 확인합니다.
CG=/sys/fs/cgroup/.../cri-containerd-<id>.scope
cat $CG/memory.max
cat $CG/memory.current
cat $CG/memory.events
memory.events 예시:
low 0
high 0
max 12
oom 3
oom_kill 3
해석:
max증가:memory.max한계에 닿은 횟수oom/oom_kill증가: 해당 cgroup에서 OOM이 발생했고 실제 kill이 일어났다는 뜻
즉, Pod가 OOMKilled로 반복 재시작되고 있고, 같은 시점에 oom_kill이 증가한다면 “진짜로 cgroup limit OOM”입니다.
반대로 Pod는 죽는데 oom_kill이 증가하지 않는다면:
- 노드 OOM killer가 죽였거나
- 앱이 SIGKILL을 받았거나(관리자/런타임)
- 다른 리소스(예: ephemeral storage)로 죽었거나
를 의심해야 합니다.
5) memory.stat로 범인 찾기: anon(힙)인가 file cache인가 slab인가
memory.current만 보면 “많이 쓰네”밖에 알 수 없습니다. memory.stat로 분해해야 합니다.
CG=/sys/fs/cgroup/.../cri-containerd-<id>.scope
# 주요 항목만 보기
egrep 'anon |file |slab |kernel_stack|sock|shmem|zswap|zswapped' $CG/memory.stat
대표적인 패턴:
- anon이 크다: 애플리케이션 힙/RSS가 커짐(누수, 캐시, 버퍼, 워크로드 스파이크)
- file이 크다: 파일 캐시/페이지 캐시가 커짐(대용량 파일 읽기, 로그/압축/ETL)
- slab이 크다: 커널 오브젝트(네트워크/파일시스템) 사용량 증가, 커널 메모리 성격
- sock이 크다: 소켓 버퍼(대량 커넥션, 큰 send/recv buffer)
여기서 “file cache가 커서 OOM”은 흔한 오해 포인트입니다. cgroup v2에서도 file cache는 압박 시 회수되지만, 회수 속도보다 증가 속도가 빠르거나(대용량 스캔), memory.max가 너무 낮으면 OOM로 이어질 수 있습니다.
6) PSI(pressure/memory)로 ‘죽기 전’ 징후 잡기
OOM은 결과입니다. 반복되는 OOM은 보통 죽기 직전부터 latency가 튀고 stall이 누적됩니다. PSI는 이를 수치로 보여줍니다.
CG=/sys/fs/cgroup/.../cri-containerd-<id>.scope
cat $CG/pressure/memory
예시:
some avg10=0.25 avg60=0.10 avg300=0.05 total=123456
full avg10=0.05 avg60=0.02 avg300=0.01 total=23456
some: 일부 태스크가 메모리 때문에 stallfull: 모든 태스크가 stall(더 심각)
OOMKilled가 나기 전 full이 의미 있게 올라가면, limit 증설만이 아니라 GC 튜닝, 캐시 정책, 워크로드 스로틀링 같은 성능/안정성 대응이 필요할 가능성이 큽니다.
7) Kubernetes 리소스 설정에서 자주 하는 실수 5가지
1) request/limit 불균형으로 Burstable에서 스파이크 OOM
- request 낮게, limit 타이트하게 잡으면 평소엔 뜨지만 스파이크에 즉시 OOM
- 특히 JVM/Node/Python은 순간 메모리 피크가 큼
권장: 워크로드 특성에 맞춰 request를 현실적으로 올리고, limit은 “관측된 p95/p99 + 여유”로.
2) JVM/Node 런타임이 컨테이너 limit을 제대로 반영하지 못함
현대 JVM/Node는 대부분 cgroup을 인식하지만, 버전/옵션에 따라 힙이 limit 대비 과하게 잡히기도 합니다.
- JVM:
-XX:MaxRAMPercentage/-Xmx명시 - Node:
--max-old-space-size고려
3) 사이드카(Envoy 등) 메모리를 합산하지 않음
Pod limit은 컨테이너별로도 걸리지만, 노드 관점/운영 관점에서는 사이드카가 OOM의 트리거가 되기도 합니다. 특히 프록시/에이전트는 트래픽에 비례해 메모리가 증가할 수 있습니다.
4) livenessProbe가 OOM 직전 지연을 “죽음”으로 오판
메모리 압박으로 응답이 느려졌는데 liveness가 실패 → 재시작 루프 → 캐시 워밍/스파이크 반복으로 더 악화.
PSI에서 stall이 보이면 liveness의 timeout/threshold를 재검토하세요.
5) 노드 레벨 오버커밋과 eviction 정책 미스매치
노드가 빡빡한데 Pod limit만 키우면 다른 Pod가 eviction/OOM로 튕깁니다. 노드 capacity, kubelet eviction threshold, system reserved도 같이 봐야 합니다.
8) 재현 가능한 진단 체크리스트(명령어 모음)
아래 순서대로 실행하면 “증거 기반”으로 결론을 내릴 수 있습니다.
1) Pod/컨테이너 레벨
kubectl get pod -n <ns> <pod> -o wide
kubectl describe pod -n <ns> <pod>
kubectl logs -n <ns> <pod> -c <container> --previous | tail -n 200
2) 노드 레벨(가능할 때)
# 노드 메모리/스왑/커널 로그
free -m
swapon --show
sudo dmesg -T | egrep -i 'oom|killed process|out of memory' | tail -n 50
# kubelet 로그(배포판에 따라)
sudo journalctl -u kubelet --since '1 hour ago' | egrep -i 'evict|oom|memory' | tail -n 200
3) cgroup v2 레벨(컨테이너 cgroup에서)
CG=/sys/fs/cgroup/.../cri-containerd-<id>.scope
echo "== limits =="; cat $CG/memory.max
echo "== current =="; cat $CG/memory.current
echo "== events =="; cat $CG/memory.events
echo "== pressure =="; cat $CG/pressure/memory
echo "== stat (top) =="; egrep 'anon |file |slab |sock ' $CG/memory.stat
이 결과를 캡처해두면, “limit 증설이 맞는지/누수인지/캐시인지/노드 문제인지”를 회의에서 빠르게 합의할 수 있습니다.
9) 처방전: 상황별로 무엇을 바꿔야 하나
memory.events에서oom_kill이 증가하고anon이 대부분- 앱 누수/캐시/버퍼 의심
- 힙 상한 설정(JVM/Node), GC 튜닝, 메모리 프로파일링
- limit 증설은 임시방편일 수 있음
file이 급증하며 OOM- 대용량 파일 스캔/압축/로그 처리 패턴 확인
- 스트리밍 처리, chunk 크기 제한, 동시성 제한
- 필요 시 limit 증설 + 워크로드 스로틀
PSI
full이 높고 지연이 먼저 발생- liveness 조정, 워커 수/동시성 제한, backpressure
- 메모리 단순 증설보다 “피크 완화”가 효과적일 수 있음
노드 OOM/eviction 징후
- 노드 타입 업그레이드, DaemonSet 메모리 점검
- kubelet eviction threshold 및 system-reserved 설정 점검
운영 중 장애가 “간헐적”으로 보이는 경우, 원인이 메모리/CPU가 아니라 DNS나 네트워크 지연에서 시작되는 일도 있습니다. 그런 경우엔 이 글과 함께 EKS에서 CoreDNS 정상인데 DNS가 간헐 실패할 때 같은 진단 글을 병행하면 전체 원인 트리를 더 빨리 좁힐 수 있습니다.
10) 마무리: cgroup v2에서는 ‘증거 파일’로 결론을 내자
cgroup v2 환경에서 OOMKilled를 제대로 진단하려면, 단순히 kubectl top이나 애플리케이션 로그만으로는 부족합니다. 최소한 아래 3가지는 확보하세요.
memory.events로 limit OOM인지 확정memory.stat로 anon/file/slab 중 무엇이 주범인지 분해pressure/memory로 죽기 전 압박(성능 저하) 징후 확인
이 3가지만 갖추면 “limit을 올릴까요?” 같은 감각적 논쟁에서 벗어나, 재발 방지(튜닝/구조 개선/노드 정책)까지 연결되는 진단이 가능합니다.