Published on

K8s OOMKilled 반복? cgroup v2 메모리 진단

Authors

서버리스가 아닌 이상, 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 등 상세 breakdown
  • pressure/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: 일부 태스크가 메모리 때문에 stall
  • full: 모든 태스크가 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가지는 확보하세요.

  1. memory.eventslimit OOM인지 확정
  2. memory.statanon/file/slab 중 무엇이 주범인지 분해
  3. pressure/memory죽기 전 압박(성능 저하) 징후 확인

이 3가지만 갖추면 “limit을 올릴까요?” 같은 감각적 논쟁에서 벗어나, 재발 방지(튜닝/구조 개선/노드 정책)까지 연결되는 진단이 가능합니다.