- Published on
리눅스 OOM Killer 로그로 메모리 누수 추적하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서비스가 갑자기 죽고 재시작되는데, 애플리케이션 로그에는 단서가 없고 노드가 멀쩡해 보인다면 가장 먼저 의심할 것 중 하나가 OOM(Out Of Memory)입니다. 리눅스 커널은 메모리가 바닥나면 OOM Killer를 실행해 “누군가를 죽여서” 시스템 전체를 살립니다. 문제는 여기서 끝이 아니라, 왜 메모리가 고갈됐는지(누수인지, 캐시인지, cgroup 제한인지) 를 밝혀야 재발을 막을 수 있습니다.
이 글은 OOM Killer 로그를 읽는 법부터 시작해서, 로그 한 줄로 “누가, 무엇을, 얼마나” 먹었는지 추적하고, 메모리 누수 후보를 빠르게 좁힌 뒤 재현·증명·완화까지 이어지는 흐름을 정리합니다.
관련해서 쿠버네티스에서 컨테이너가 반복 재시작될 때는 Kubernetes CrashLoopBackOff 원인별 10분 진단도 함께 보면 원인 분류가 훨씬 빨라집니다.
1) OOM의 종류부터 구분하기: “호스트 OOM” vs “cgroup OOM”
OOM이라고 다 같은 OOM이 아닙니다.
- 호스트 OOM(전역 OOM): 노드 전체 메모리가 부족해 커널이 전역 OOM Killer를 실행합니다. 보통
dmesg/kern.log에 “Out of memory”와 함께 메모리 상태 덤프가 찍힙니다. - cgroup OOM(컨테이너 OOM): 쿠버네티스/도커 같은 cgroup 제한 때문에 해당 그룹만 OOM이 납니다. 노드는 여유가 있어도 컨테이너는 죽을 수 있습니다. 로그에
Memory cgroup out of memory같은 문구가 등장합니다.
실무에서 “메모리 누수”라고 생각했는데, 실제로는 limit이 너무 낮아서 정상적인 피크를 못 버틴 것인 경우가 많습니다. 그래서 첫 단계는 “어느 레벨에서 OOM이 났는지”를 분리하는 겁니다.
2) OOM Killer 로그는 어디서 보나
시스템(호스트)에서 확인
배포판과 설정에 따라 위치가 다릅니다.
dmesg -T | grep -i -E 'out of memory|oom-killer|killed process|memory cgroup'journalctl -k -b | grep -i -E 'out of memory|oom|killed process|cgroup'- 파일 기반 로그라면
grep -i oom /var/log/kern.log또는/var/log/messages
쿠버네티스에서 확인
kubectl describe pod <pod>에서Last State: Terminated의Reason: OOMKilled확인- 노드 커널 로그는 노드에 접속하거나(관리형이면 제한적) 로깅 에이전트로 수집해야 합니다.
주의: 본문에서 kubectl describe pod <pod>처럼 꺾쇠가 들어가면 MDX에서 JSX로 오인될 수 있으니, 반드시 인라인 코드로 감쌌습니다.
3) OOM 로그의 핵심 패턴 3가지
아래는 흔히 보는 패턴입니다(배포판/커널 버전에 따라 형태는 다르지만 의미는 유사합니다).
패턴 A: 전역 OOM 덤프
Out of memory: Killed process 12345 (myservice) total-vm:10485760kB, anon-rss:2048000kB, file-rss:10240kB, shmem-rss:0kB, UID:1000 pgtables:8192kB oom_score_adj:0
여기서 바로 읽어야 할 포인트는 다음입니다.
Killed process 12345 (myservice): 누가 죽었는지(프로세스명/pid)total-vm: 가상메모리(VIRT). 크다고 해서 실제 사용이 큰 건 아닙니다.anon-rss: 익명 메모리(RSS 중 힙/스택 등). 누수는 대개 여기로 쌓입니다.file-rss: 파일 매핑(페이지 캐시/메모리맵). 캐시 성격이 강할 수 있습니다.pgtables: 페이지 테이블. 프로세스가 매핑을 많이 만들면 커질 수 있습니다.oom_score_adj: 조정치. 특정 프로세스를 덜/더 죽게 만들 수 있습니다.
패턴 B: cgroup OOM
Memory cgroup out of memory: Kill process 23456 (java) score 987 or sacrifice child
Killed process 23456 (java) total-vm:... anon-rss:... file-rss:...
이 경우는 “노드 메모리 부족”이 아니라 “그 cgroup의 한도 초과”가 트리거입니다. 즉, 컨테이너 limit과 실제 워킹셋을 비교해야 합니다.
패턴 C: OOM 직전의 메모리 상태 테이블
커널은 종종 OOM 직전에 “누가 얼마나 쓰는지” 테이블을 찍습니다.
Tasks state (memory values in pages):
[ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
[ 12345 ] 1000 12345 2621440 512000 8388608 0 0 myservice
여기서 rss는 페이지 단위로 나오기도 합니다. 페이지 크기가 보통 4KB이므로, 대략 rss_pages * 4KB로 바꿔 감을 잡습니다.
4) 로그만으로 “누수 후보”를 좁히는 체크리스트
OOM 로그의 숫자를 보고 다음 질문에 답하면, 누수인지 아닌지 방향이 잡힙니다.
4-1) anon-rss가 크면: 힙 누수 가능성이 높다
- 애플리케이션 힙(예: Java heap, Node.js heap, Python 객체)이 증가하는 패턴
- 네이티브 메모리(예: JNI, glibc
malloc)가 증가하는 패턴
다음 확인으로 이어집니다.
- 프로세스 기준:
cat /proc/<pid>/status의VmRSS,RssAnon,RssFile - 매핑 기준:
cat /proc/<pid>/smaps_rollup
위 명령에서 <pid>는 인라인 코드로 감싸야 MDX에서 안전합니다.
4-2) file-rss가 크면: mmap, 페이지 캐시, 파일 버퍼를 의심
- 대용량 파일을
mmap으로 읽어두고 해제하지 않는 경우 - 로그/데이터 파일을 지속적으로 읽는 워크로드
- 컨테이너에서
emptyDir같은 볼륨 I/O가 많을 때
다만 페이지 캐시는 필요하면 줄어들 수 있으므로, “file-rss가 크다”만으로 누수라고 단정하면 안 됩니다.
4-3) swap이 있나 없나: OOM이 빨리 오느냐 늦게 오느냐
- swap이 없으면(특히 쿠버네티스 노드) 메모리 압박이 오자마자 OOM으로 직행합니다.
- swap이 있으면 OOM이 늦춰지지만, 지연(latency)과 스로틀링이 커질 수 있습니다.
5) 재현 가능한 “증거” 만들기: 시계열로 쌓아라
OOM 로그는 사건의 “스냅샷”입니다. 누수를 확정하려면 시간에 따른 증가를 보여야 합니다.
5-1) 최소 비용: ps/smem//proc 폴링
다음 스크립트는 특정 프로세스의 RSS를 주기적으로 기록합니다.
#!/usr/bin/env bash
set -euo pipefail
PID="$1"
INTERVAL="${2:-5}"
while true; do
ts=$(date -Iseconds)
rss_kb=$(awk '/VmRSS/ {print $2}' "/proc/${PID}/status" 2>/dev/null || echo "NA")
anon_kb=$(awk '/RssAnon/ {print $2}' "/proc/${PID}/status" 2>/dev/null || echo "NA")
file_kb=$(awk '/RssFile/ {print $2}' "/proc/${PID}/status" 2>/dev/null || echo "NA")
echo "${ts} rss_kb=${rss_kb} anon_kb=${anon_kb} file_kb=${file_kb}"
sleep "${INTERVAL}"
done
set -euo pipefail을 쓰는 스크립트는 운영에서 유용하지만, 예상치 못한 종료가 디버깅을 어렵게 만들기도 합니다. 이 패턴이 낯설면 bash set -euo pipefail로 스크립트 터질 때 대처법을 같이 참고해 두면 좋습니다.
5-2) cgroup 메모리도 같이 기록(컨테이너 OOM 의심 시)
cgroup v2라면 보통 아래 경로를 봅니다.
cat /sys/fs/cgroup/memory.currentcat /sys/fs/cgroup/memory.maxcat /sys/fs/cgroup/memory.stat
cgroup v1이라면 memory.usage_in_bytes, memory.limit_in_bytes 등이 쓰입니다.
핵심은 “프로세스 RSS는 안정적인데 cgroup 사용량만 오른다” 같은 역전 현상을 잡는 것입니다. 이런 경우는 파일 캐시/공유 메모리/다른 프로세스 합산 영향일 수 있습니다.
6) 언어/런타임별로 누수 의심 지점을 빠르게 좁히기
6-1) Java
- 컨테이너에서
-Xmx를 limit에 맞추지 않으면 네이티브 영역 포함해 OOM이 납니다. anon-rss가 커지고, GC 로그에서 Old 영역이 계속 증가하면 누수 가능성이 큽니다.- 힙덤프(
jmap)는 비용이 크니, 먼저Native Memory Tracking또는jcmd로 범위를 좁히는 전략이 안전합니다.
6-2) Node.js
- V8 힙 제한이 기본값에 묶여 있는데, 네이티브 애드온/버퍼가 커져서 OOM이 나기도 합니다.
Buffer사용 패턴(스트림 처리, 누적 배열)이 흔한 원인입니다.
6-3) Python
- 전역 캐시, LRU 캐시, pandas 같은 대형 객체 참조가 끊기지 않는 경우가 많습니다.
- 멀티프로세스/포크 모델에서는 copy-on-write가 깨지며 RSS가 급증할 수 있습니다.
7) OOM 발생 후 “왜 이 프로세스가 죽었나”: oom_score 이해
커널은 대략적으로 “메모리를 많이 쓰고, 덜 중요한” 프로세스를 죽이려고 합니다.
oom_score는/proc/<pid>/oom_score로 확인oom_score_adj는/proc/<pid>/oom_score_adj로 조정
운영에서 중요한 프로세스가 계속 죽는다면 oom_score_adj를 낮추는 방법이 있지만, 이는 “증상 완화”일 뿐이고 근본 원인(누수/한도/부하)을 먼저 해결해야 합니다.
8) 실전 트러블슈팅 플로우(10분 버전)
kubectl describe pod <pod>에서OOMKilled여부 확인(쿠버네티스라면)- 노드 커널 로그에서
Killed process라인 확보(dmesg -T또는journalctl -k) anon-rssvsfile-rss비중으로 “힙 누수” vs “파일/캐시” 1차 분류- cgroup OOM 문구가 있으면 limit과
memory.current를 비교 - 같은 시점의
top/ps/smem스냅샷을 확보(가능하면 시계열) - 애플리케이션 레벨 메트릭(힙, GC, 요청량)과 교차 검증
- 재현 가능한 최소 시나리오를 만들고, 누수 지점(캐시/컬렉션/버퍼/맵)을 축소
9) 예방과 완화: “누수 잡기 전”에 서비스부터 살리는 방법
- 리소스 limit/requests 재조정: 정상 피크를 버틸 수 있는지 먼저 확인
- 메모리 상한 설정: Java
-Xmx, Node--max-old-space-size등 - 서킷 브레이커/백프레셔: 입력이 누적되며 메모리가 증가하는 구조를 차단
- 관측성 추가: RSS/anon/file, cgroup 사용량, 힙 사용량을 대시보드로 상시 노출
특히 쿠버네티스에서는 “OOM으로 죽어서 재시작되니 괜찮다”가 아니라, 재시작 루프가 다른 장애로 이어질 수 있습니다. CrashLoop 패턴까지 겹치면 원인 분류가 복잡해지니, 앞서 언급한 Kubernetes CrashLoopBackOff 원인별 10분 진단의 체크리스트로 상태를 분리해 두는 게 좋습니다.
10) 마무리: OOM 로그는 ‘범인 특정’이 아니라 ‘수사 시작점’
OOM Killer 로그의 가치는 “죽은 프로세스 이름”이 아니라, anon/file/shmem, cgroup 여부, 당시 메모리 테이블 같은 정황증거에 있습니다. 이 정황으로 누수 후보를 좁히고, /proc과 cgroup 시계열을 쌓아 증가 패턴을 증명하면, 그 다음은 언어/런타임별 도구로 누수 지점을 정확히 찌를 수 있습니다.
다음에 OOM이 터지면 “메모리가 부족했나 보다”에서 멈추지 말고, 커널이 남긴 한 줄을 출발점으로 원인을 재구성해 보세요. 로그 해석만 제대로 해도, 누수의 70%는 방향이 잡힙니다.