- Published on
Kubernetes OOMKilled 진단과 메모리 누수 추적 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡히 돌다가 갑자기 Pod가 재시작되고 OOMKilled가 찍히면, 대부분은 “메모리가 부족했나 보다”로 끝나기 쉽습니다. 하지만 Kubernetes의 OOM은 컨테이너 cgroup 한도 초과, 노드 메모리 압박에 따른 축출(eviction), 애플리케이션 메모리 누수, 캐시/버퍼의 비정상 성장, 런타임(GC) 튜닝 실패, 사이드카/로그 파이프라인의 메모리 폭주 등 원인 스펙트럼이 넓습니다.
이 글은 OOMKilled를 원인별로 분류하고, “어디서 죽었는지”를 이벤트/메트릭/덤프/프로파일링으로 좁혀 메모리 누수까지 추적하는 절차를 제공합니다.
> 참고로, 간헐 장애(502/504)와 함께 OOM이 섞여 나타나는 경우도 많습니다. 타임아웃/커넥션 유지/워커 튜닝과 함께 보려면 Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝도 같이 점검하면 원인 분리가 빨라집니다.
1) OOMKilled의 종류부터 구분하기
OOMKilled 한 줄만 보고는 원인을 확정할 수 없습니다. 먼저 어떤 OOM인지를 나눠야 합니다.
1-1. 컨테이너 OOM (cgroup limit 초과)
- Pod/컨테이너의
resources.limits.memory를 넘으면 커널이 해당 cgroup 내 프로세스를 OOM-kill - 보통
kubectl describe pod에Last State: Terminated/Reason: OOMKilled가 명확히 남음
1-2. 노드 메모리 압박으로 인한 Eviction
- 노드 전체 메모리가 부족해 kubelet이 Pod를 축출
- Pod 상태는
Evicted로 보이거나, 이벤트에The node was low on resource: memory가 남음 - 이 경우는 “내 컨테이너가 limit을 넘었는가”와 별개로, 노드 차원의 압박이 핵심
1-3. 애플리케이션 자체 OOM / 런타임 에러
- Python의
MemoryError, Java의OutOfMemoryError등 앱 레벨에서 먼저 터질 수 있음 - 이때는 컨테이너가 OOMKilled가 아니라 프로세스 exit code로 죽을 수도 있고, 재시작 정책에 의해 재기동
2) 5분 안에 끝내는 1차 현장 감식 체크리스트
2-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
describe에서 다음을 확인합니다.
State / Last State의Reason: OOMKilledExit Code: 137(SIGKILL) 여부- 이벤트에
Evicted,MemoryPressure문구가 있는지
2-2. 리소스 요청/제한이 적절했는지
kubectl get pod -n <ns> <pod> -o jsonpath='{.spec.containers[*].resources}'
requests.memory가 너무 낮으면 노드 스케줄링은 되지만 실제 피크에서 쉽게 죽습니다.limits.memory가 너무 낮으면 GC/버퍼/캐시가 잠깐 튀는 순간에도 OOMKilled가 납니다.
2-3. “누가” 메모리를 쓰는지(컨테이너 vs 사이드카)
사이드카(Envoy, Fluent Bit, log shipper)가 있는 경우 메모리 주범이 앱이 아닐 수 있습니다.
kubectl top pod -n <ns> <pod> --containers
컨테이너별 사용량을 보고, OOMKilled가 난 컨테이너가 실제로 메모리를 가장 많이 쓰는지 확인합니다.
3) 원인별 진단 시나리오
3-1. limit이 너무 낮아서 “정상 피크”에도 죽는 경우
특징:
- 트래픽 스파이크/배치 시간대에만 OOM
- 메모리 그래프가 톱니처럼 올라갔다가 kill
- 재시작 후 한동안 정상
대응:
- 피크 메모리(working set) + 여유분을 기준으로 limit을 재설정
- 워커/동시성/버퍼를 줄여 피크를 낮춤
예: Gunicorn/Uvicorn 워커 수가 과도하면 워커당 메모리 * N으로 증가합니다.
# deployment.yaml (예시)
resources:
requests:
memory: "512Mi"
limits:
memory: "1024Mi"
env:
- name: WEB_CONCURRENCY
value: "2" # 워커 수를 줄여 피크 메모리 완화
3-2. 노드 메모리 압박(Eviction)으로 죽는 경우
특징:
kubectl describe pod이벤트에Evicted또는low on resource: memory- 같은 노드에 있는 다른 Pod들도 함께 불안정
- 특정 노드에서만 반복
대응:
requests.memory를 현실화(너무 낮으면 노드 과밀)- 노드 증설/오토스케일, 메모리 큰 인스턴스 타입으로 변경
- 메모리 많이 쓰는 워크로드를 분리(노드풀 분리)
노드 상태 확인:
kubectl describe node <node> | egrep -i "MemoryPressure|Allocated resources|Non-terminated Pods" -n
3-3. 메모리 누수(또는 누수처럼 보이는) 경우
특징:
- 시간이 지날수록 RSS/working set이 단조 증가
- 트래픽이 없어도 증가하거나, 특정 요청 패턴 후 증가
- 재시작하면 정상으로 돌아오지만 다시 증가
여기서 중요한 포인트는 “진짜 누수”와 “캐시/버퍼/아레나 단편화”를 분리하는 것입니다.
- 진짜 누수: 객체/버퍼/핸들이 해제되지 않아 계속 쌓임
- 누수처럼 보임: 캐시가 커짐, Python/Go 메모리 아레나가 OS에 반환되지 않음, jemalloc/tcmalloc 전략, 페이지 캐시
4) 메트릭으로 OOM을 ‘재현 가능한 사건’으로 만들기
OOM은 사후 로그만 보면 단서가 부족합니다. 다음 3종 메트릭을 같이 봐야 원인이 좁혀집니다.
4-1. Working set vs RSS vs Cache
container_memory_working_set_bytes: 실제로 회수 어려운 메모리(실무에서 가장 유용)container_memory_rss: 익명 메모리(힙 등)container_memory_cache: 파일 캐시(로그/다운로드/임시파일 영향)
PromQL 예시:
# 컨테이너 워킹셋(가장 추천)
sum by (pod, container) (
container_memory_working_set_bytes{namespace="$ns", pod=~"$pod"}
)
# RSS와 Cache를 비교
sum by (pod, container) (container_memory_rss{namespace="$ns", pod=~"$pod"})
sum by (pod, container) (container_memory_cache{namespace="$ns", pod=~"$pod"})
해석 가이드:
- RSS가 꾸준히 증가하면 힙 누수/객체 누적 가능성
- Cache가 증가하면 파일 캐시/로그/임시파일 가능성
4-2. OOM 이벤트 카운트
노드/컨테이너 OOM 이벤트를 수집하면 “언제부터, 얼마나 자주”가 명확해집니다.
# kube-state-metrics 사용 시(환경별 지표명 상이 가능)
increase(kube_pod_container_status_restarts_total{namespace="$ns", pod=~"$pod"}[1h])
4-3. 트래픽/큐 길이/동시성과의 상관관계
OOM이 트래픽에 의해 촉발되는지 확인합니다.
- RPS 증가 → 메모리 급증이면: 요청당 버퍼/응답 바디/모델 컨텍스트/배치 큐가 원인
- 큐 길이 증가 → 메모리 증가면: 작업 적체로 객체가 쌓임
비동기 파이썬 서비스라면 작업 누적/태스크 정리가 누수처럼 보일 수 있습니다. 관련 패턴은 Python asyncio Task was destroyed but it is pending 경고 원인 5가지와 완벽 해결법에서 “태스크가 살아남는” 전형적인 경우를 참고해도 좋습니다.
5) 덤프/프로파일링로 ‘누수의 실체’를 잡는 방법
5-1. Python: tracemalloc + heapy + objgraph
Python은 컨테이너에서 OOM으로 죽기 전에 주기적으로 스냅샷을 남기면 가장 빨리 원인을 찾습니다.
(1) tracemalloc 스냅샷 저장
# app/memtrace.py
import os
import time
import tracemalloc
tracemalloc.start(25)
def dump_top(snapshot, key_type="lineno", limit=20):
top_stats = snapshot.statistics(key_type)
lines = ["Top allocations:"]
for stat in top_stats[:limit]:
lines.append(str(stat))
return "\n".join(lines)
def periodic_snapshot(interval_sec=60):
while True:
time.sleep(interval_sec)
snap = tracemalloc.take_snapshot()
report = dump_top(snap)
path = f"/tmp/tracemalloc-{int(time.time())}.log"
with open(path, "w") as f:
f.write(report)
# 필요하면 stdout 로깅도 가능
print(report)
if __name__ == "__main__":
periodic_snapshot(int(os.getenv("MEM_SNAPSHOT_INTERVAL", "60")))
운영에서 그대로 돌리기 부담되면, 특정 엔드포인트/특정 헤더가 있을 때만 스냅샷을 뜨도록 가드하는 방식이 안전합니다.
(2) 의심 지점: 전역 캐시/세션 누적
대표적으로 HTTP 클라이언트 세션을 요청마다 만들고 닫지 않으면 소켓/버퍼가 쌓일 수 있습니다. aiohttp 계열 이슈는 aiohttp ClientSession is closed 재현과 근본 해결에서 “세션 생명주기” 관점으로 점검하면 누수/리소스 고갈을 같이 잡는 데 도움이 됩니다.
5-2. Go: pprof로 힙 증가 원인 추적
Go 서비스는 pprof가 가장 효율적입니다.
// main.go
import (
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
http.ListenAndServe("0.0.0.0:6060", nil)
}()
// ... your server
}
수집:
go tool pprof -http=:0 http://<pod-ip>:6060/debug/pprof/heap
inuse_space가 계속 증가하는 타입/함수를 찾고- 캐시(map), 슬라이스 누적, 고루틴 누수(채널 대기) 등을 의심합니다.
5-3. Java: Heap dump + GC 로그
Java는 heap dump와 GC 로그가 정석입니다.
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps- G1GC/ParallelGC에 따라 튜닝 포인트가 달라짐
- 컨테이너 환경에서는
-XX:MaxRAMPercentage로 힙 상한을 명확히
K8s에서 덤프 파일을 남길 때는 emptyDir가 아니라 PVC나 외부 스토리지로 빼야 OOM 직후에도 보존됩니다.
6) kubelet/커널 로그로 “누가 kill했는지” 확인하기
컨테이너가 SIGKILL(137)로 죽었다면, 노드에서 OOM killer 로그를 보면 프로세스가 명확해집니다.
# 노드에 접근 가능할 때
sudo dmesg -T | egrep -i "oom|killed process|out of memory" | tail -n 50
# systemd journal
sudo journalctl -k | egrep -i "oom|killed process" | tail -n 100
여기서 확인할 것:
- 어떤 프로세스가 kill 되었는지(앱? 사이드카? 다른 Pod?)
- cgroup 경로가 해당 Pod인지
7) 해결 전략: ‘limit 올리기’만으로 끝내면 재발한다
OOM의 해결은 보통 3단계로 접근하는 게 안전합니다.
7-1. 단기: 장애를 멈추는 완화책
limits.memory상향(단, 노드 압박 유발하지 않게)- 동시성 제한(워커 수/스레드/큐 사이즈)
- 요청 바디/응답 스트리밍 처리로 메모리 피크 감소
7-2. 중기: 누수/비정상 성장 지점 제거
- 전역 캐시 상한(LRU/TTL) 설정
- 큐 적체 시 backpressure 적용
- 대용량 객체 복사/누적 제거
- 사이드카 메모리 제한 및 버퍼 설정(로그 shipper buffer 등)
7-3. 장기: 관측/가드레일 구축
- OOM 이벤트 알람 + working set 추세 알람
- 배포 전 부하 테스트에서 메모리 “시간에 따른 증가율(slope)” 검증
- 메모리 스냅샷/pprof/heap dump를 운영에서 안전하게 채집하는 스위치 제공
8) 실전 디버깅 플로우(요약)
describe pod로 OOMKilled vs Evicted 구분top pod --containers로 주범 컨테이너 확인- Prometheus에서
working_set/rss/cache로 증가 패턴 확인 - 트래픽/큐/동시성과 상관관계 분석
- 언어별 도구(tracemalloc/pprof/heap dump)로 누수 지점 특정
- 단기 완화(리밋/동시성) + 근본 수정(캐시 상한/세션 생명주기/버퍼)
- 재발 방지 알람/부하 테스트에 메모리 slope 추가
9) 부록: OOM 진단에 유용한 kubectl/PromQL 모음
kubectl
# 최근 이벤트(축출/재시작 단서)
kubectl get events -n <ns> --sort-by=.metadata.creationTimestamp | tail -n 50
# 특정 파드의 재시작 횟수
kubectl get pod -n <ns> <pod> -o jsonpath='{.status.containerStatuses[*].restartCount}'
# 컨테이너별 사용량
kubectl top pod -n <ns> <pod> --containers
PromQL
# 메모리 리밋 대비 사용률(working set 기준)
(
sum by (pod, container) (container_memory_working_set_bytes{namespace="$ns"})
)
/
(
sum by (pod, container) (kube_pod_container_resource_limits{namespace="$ns", resource="memory"})
)
# 재시작 증가
increase(kube_pod_container_status_restarts_total{namespace="$ns"}[30m])
OOMKilled는 “메모리가 부족하다”가 아니라, 어떤 경로로 부족해졌는지를 밝히는 문제입니다. 컨테이너 한도 초과인지, 노드 압박인지, 누수인지가 갈리면 해결책도 완전히 달라집니다. 위 절차대로 이벤트→메트릭→덤프/프로파일링 순으로 증거를 쌓으면, 단순 리밋 증설이 아닌 재발 없는 근본 해결까지 도달할 수 있습니다.