Published on

Linux OOM Killer 원인추적 - dmesg·cgroup·로그

Authors

서버가 갑자기 특정 프로세스를 죽이고(혹은 컨테이너가 재시작되고) 아무 경고 없이 서비스가 흔들렸다면, 높은 확률로 Linux OOM(Out-Of-Memory) Killer가 개입했을 수 있습니다. 문제는 “메모리가 부족했다”는 사실만으로는 원인 분석이 끝나지 않는다는 점입니다.

OOM은 크게 두 부류로 나뉩니다.

  • 호스트 OOM(전역 메모리 고갈): 노드 전체에서 메모리가 바닥나 커널이 희생양을 고릅니다.
  • cgroup OOM(컨테이너/서비스 단위 제한 초과): 노드는 여유가 있어도, 특정 cgroup의 memory.max/limit을 넘어서 해당 그룹 안에서 OOM이 발생합니다.

이 글에서는 dmesg(커널 로그) → cgroup 이벤트/설정 → systemd/journald 및 애플리케이션 로그를 한 줄로 이어, “누가/언제/어떤 이유로 죽었는지”를 증거 기반으로 추적하는 방법을 다룹니다. (쿠버네티스 환경이라면, 사전 점검으로 EKS Pod가 Pending(Insufficient memory)일 때 점검법도 함께 참고하면 좋습니다.)

1) OOM Killer의 시그널: 증상부터 분류하기

1-1. 프로세스가 갑자기 종료(SIGKILL)되었는가?

애플리케이션에서 다음과 같은 현상이 보이면 OOM 가능성이 큽니다.

  • 로그가 중간에서 끊기고, 종료 훅/예외 스택트레이스 없이 프로세스가 사라짐
  • systemd 서비스가 code=killed, status=9/KILL로 종료
  • 컨테이너가 OOMKilled로 재시작

systemd 환경에서는 우선 다음을 확인합니다.

# 서비스가 OOM으로 죽었는지(직접적 단서)
systemctl status my.service
journalctl -u my.service -S "-2h" --no-pager

# 종료 코드/시그널 확인
systemctl show my.service -p ExecMainStatus -p ExecMainCode -p ExecMainPID
  • status=9/KILL이면 커널/관리자가 SIGKILL을 보낸 것입니다.
  • 이때 **커널 로그(dmesg/journalctl -k)**에서 OOM 메시지가 같이 찍히는지 이어서 확인해야 합니다.

1-2. 호스트 OOM vs cgroup OOM의 빠른 구분

  • 호스트 OOM: dmesgOut of memory: Kill process ...와 함께 시스템 전체 메모리 통계가 길게 출력되는 경우가 많습니다.
  • cgroup OOM: Memory cgroup out of memory: Kill process ... 또는 oom-kill: 문구에 cgroup 경로가 함께 등장합니다.

이 구분이 중요한 이유는, 해결책이 완전히 달라지기 때문입니다.

  • 호스트 OOM: 노드 전체 메모리 압박(누수, 과도한 캐시, 동시성 폭증, swap 설정 등)
  • cgroup OOM: 컨테이너/서비스에 설정된 limit이 너무 낮거나, 워킹셋이 증가했거나, 페이지 캐시/버퍼까지 포함해 제한을 초과

2) dmesg/journalctl로 “누가 죽었는지” 확정하기

가장 확실한 1차 증거는 커널 메시지입니다.

2-1. 커널 로그에서 OOM 구간만 뽑기

# 최근 부팅 이후 커널 로그에서 OOM 관련 라인만
journalctl -k -b --no-pager | egrep -i "out of memory|oom-kill|killed process|memory cgroup"

# dmesg를 쓰는 경우(권한 필요)
dmesg -T | egrep -i "out of memory|oom-kill|killed process|memory cgroup"

보통 아래와 같은 정보가 핵심입니다.

  • Killed process: 죽은 PID/프로세스명
  • oom_score_adj: 희생양 선택에 영향을 준 조정값
  • cgroup 경로(있다면): 어떤 제한에서 터졌는지
  • 메모리 통계: MemAvailable, anon-rss, file-rss, shmem-rss, pgtables

2-2. 로그에서 반드시 읽어야 하는 필드

대표적인 커널 출력 패턴(환경에 따라 다름):

  • Out of memory: Kill process 12345 (java) score 987 or sacrifice child
  • Killed process 12345 (java) total-vm:..., anon-rss:..., file-rss:...
  • Memory cgroup out of memory: Kill process 12345 (node) ...

여기서 중요한 포인트:

  • total-vm은 가상메모리 크기라 실제 사용량과 다를 수 있습니다.
  • 실제 압박을 만든 주범은 대개 anon-rss(힙/스택 등 익명 메모리) 또는 file-rss/shmem-rss(파일/공유메모리)입니다.
  • score가 높을수록 희생될 확률이 높고, oom_score_adj로 인위적으로 올리거나 내릴 수 있습니다.

3) cgroup 관점: “왜 그 프로세스가 그 한도에서 죽었는지”

컨테이너/서비스 단위로 메모리를 제한하는 환경(Kubernetes, systemd slice, docker 등)에서는 cgroup OOM이 훨씬 흔합니다.

3-1. cgroup v2인지 v1인지 확인

stat -fc %T /sys/fs/cgroup
# cgroup2fs -> v2

이 글은 v2 기준으로 설명하되, v1도 함께 힌트를 제공합니다.

3-2. 특정 PID가 속한 cgroup 경로 찾기

PID=12345
cat /proc/$PID/cgroup

cgroup v2에서는 보통 0::/some/path 형태로 나오며, 그 경로가 곧 메모리 제한/사용량을 확인할 위치입니다.

예: /sys/fs/cgroup/some/path/

3-3. 메모리 제한/현재 사용량/최댓값 확인(v2)

CG=/sys/fs/cgroup/some/path

# 제한(없으면 max)
cat $CG/memory.max

# 현재 사용량
cat $CG/memory.current

# 피크(커널 버전에 따라 제공)
cat $CG/memory.peak 2>/dev/null || true

# OOM/압박 이벤트 카운트
cat $CG/memory.events

memory.events는 원인추적에 매우 유용합니다. 예시로 다음 항목을 봅니다.

  • oom: OOM 발생 횟수
  • oom_kill: 실제 kill 발생 횟수
  • high: memory.high에 의해 스로틀링된 횟수

만약 oom_kill이 증가했다면, “호스트 메모리는 남아도는데 컨테이너 제한으로 죽었다”가 거의 확정입니다.

3-4. systemd 서비스라면 slice 단위로 확인

systemd는 서비스마다 cgroup을 만들어 관리합니다.

systemctl show my.service -p ControlGroup
# 예: /system.slice/my.service

CG=/sys/fs/cgroup/system.slice/my.service
cat $CG/memory.max
cat $CG/memory.current
cat $CG/memory.events

또한 systemd 설정에 MemoryMax=가 걸려 있으면 그 자체가 원인일 수 있습니다.

systemctl cat my.service
systemctl show my.service -p MemoryMax -p MemoryHigh -p OOMScoreAdjust

3-5. Kubernetes(컨테이너)라면 limit과 OOMKilled를 함께

쿠버네티스에서는 커널 OOM 로그 + kubelet 이벤트 + 컨테이너 상태를 함께 봐야 합니다.

# 컨테이너 종료 이유가 OOMKilled인지
kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[*].lastState.terminated.reason}'

# 이벤트에서 OOMKill 흔적
kubectl describe pod <pod>

메모리 limit이 너무 타이트하면, 노드에 여유가 있어도 OOMKilled가 발생합니다. 노드 레벨의 “메모리 부족” 문제와는 결이 다르므로, 스케일 아웃/노드 증설만으로 해결되지 않을 수 있습니다.

4) 로그 타임라인 엮기: 커널 → 서비스 → 애플리케이션

OOM 원인추적의 핵심은 시간축 상관관계입니다.

  1. 커널이 kill한 시각(T0)
  2. systemd/kubelet이 재시작한 시각(T0+α)
  3. 애플리케이션이 메모리를 급증시킨 시각(T0-β)

4-1. 커널 OOM 시각을 기준으로 journalctl 범위 조회

# 예: OOM이 13:05:12에 발생했으면 전후 10분
journalctl -k --since "2026-02-23 12:55:00" --until "2026-02-23 13:15:00" --no-pager

# 같은 구간에서 서비스 로그 확인
journalctl -u my.service --since "2026-02-23 12:55:00" --until "2026-02-23 13:15:00" --no-pager

이 구간에서 흔히 찾는 단서:

  • 트래픽 급증, 배치/크론 시작
  • 특정 요청 패턴(대용량 파일 처리, 압축/이미지 변환, 대규모 쿼리)
  • GC 폭주/힙 확장 로그(자바), 워커 수 증가(노드/파이썬), 캐시 워밍

DB 쿼리 폭증이나 락/데드락으로 인해 작업이 쌓이며 메모리가 증가하는 경우도 있어, 필요하면 Spring Boot HikariCP 풀 고갈·DB 타임아웃 10분 진단처럼 “대기열이 메모리를 먹는” 전형적인 병목도 함께 점검하는 것이 좋습니다.

5) “왜 하필 그 프로세스?”: OOM 점수와 희생양 선택

커널은 모든 프로세스를 죽이지 않고, 가장 적절한 희생양을 고릅니다. 이때 중요한 값이 oom_scoreoom_score_adj입니다.

5-1. 죽은 PID의 점수 확인(사후엔 어려울 수 있음)

프로세스가 이미 죽었다면 /proc/<pid>는 사라집니다. 하지만 유사 상황 재현/관찰 시 다음을 확인합니다.

PID=12345
cat /proc/$PID/oom_score
cat /proc/$PID/oom_score_adj
  • oom_score_adj는 -1000(거의 안 죽음) ~ +1000(잘 죽음)
  • systemd/Kubernetes는 특정 프로세스(예: kubelet, sshd)를 보호하기 위해 조정할 수 있습니다.

5-2. 무작정 보호(oom_score_adj 낮추기)의 함정

중요 프로세스를 보호하려고 oom_score_adj=-1000을 남발하면, OOM 시 다른 프로세스가 대신 죽고 시스템이 더 불안정해질 수 있습니다.

권장 접근:

  • 원인 메모리 폭증을 줄이거나
  • cgroup limit을 현실화하고
  • 메모리 사용량을 관측 가능하게 만든 뒤
  • 마지막 수단으로만 보호 정책을 조정

6) 실전 디버깅 레시피: “OOM 한 번”을 재현 가능한 이슈로 만들기

6-1. 10분 내 1차 결론 체크리스트

아래 질문에 답을 채우면, 원인 범위가 급격히 좁혀집니다.

  1. 커널 로그에 OOM이 찍혔나? (journalctl -k)
  2. 호스트 OOM인가, cgroup OOM인가? (로그 문구 + memory.events)
  3. 죽은 프로세스(PID/커맨드라인)는 무엇인가?
  4. 죽기 직전 메모리 사용(anon/file/shmem)은 어떤 성격인가?
  5. 제한값(memory.max/K8s limit/systemd MemoryMax)은 얼마였나?
  6. 같은 시각에 무엇이 시작/증가했나? (배치, 트래픽, 대용량 요청, DB 지연)

6-2. 원인별로 다음 액션이 달라진다

(A) cgroup limit이 너무 낮은 경우

  • 컨테이너/서비스의 메모리 limit 상향(그리고 request/limit 균형 조정)
  • 워킹셋 증가 요인 제거(캐시 크기 제한, 동시성 제한)
  • memory.high를 활용해 “죽이기 전에” 스로틀링으로 완화(v2)

(B) 메모리 누수/비정상 증가가 의심되는 경우

  • RSS 추이를 시계열로 수집(프로메테우스/노드 익스포터/cAdvisor)
  • 힙 덤프/프로파일링(자바: heap dump, node: heap snapshot)
  • 특정 요청/데이터에서만 증가하는지 샘플링

(C) 파일 캐시/버퍼가 원인처럼 보이는 경우

  • 실제로는 정상적인 페이지 캐시일 수 있음(리눅스는 남는 메모리를 캐시로 사용)
  • 하지만 cgroup 제한에서는 페이지 캐시도 압박 요인이 될 수 있으니, 파일 I/O 패턴과 캐시 전략을 점검

7) 예제: OOM 발생 지점 자동 수집 스크립트

운영 중 OOM이 뜰 때마다 수동으로 복기하기 어렵다면, “OOM 흔적”을 자동으로 모으는 것이 좋습니다. 아래는 단순 예시입니다.

#!/usr/bin/env bash
set -euo pipefail

OUT_DIR=${1:-/var/log/oom-triage}
TS=$(date +"%Y%m%d-%H%M%S")
mkdir -p "$OUT_DIR/$TS"

# 1) 커널 OOM 라인
journalctl -k -b --no-pager | egrep -i "out of memory|oom-kill|killed process|memory cgroup" \
  > "$OUT_DIR/$TS/kernel-oom-grep.log" || true

# 2) 최근 15분 커널 로그
journalctl -k --since "-15min" --no-pager > "$OUT_DIR/$TS/kernel-last-15min.log" || true

# 3) 메모리/스왑 현황
free -h > "$OUT_DIR/$TS/free.log" || true
swapon --show > "$OUT_DIR/$TS/swapon.log" || true

# 4) 상위 메모리 프로세스
ps aux --sort=-rss | head -n 30 > "$OUT_DIR/$TS/ps-top-rss.log" || true

# 5) cgroup v2 루트 이벤트(가능하면)
if stat -fc %T /sys/fs/cgroup | grep -q cgroup2fs; then
  cat /sys/fs/cgroup/memory.events > "$OUT_DIR/$TS/cgroup-root-memory-events.log" || true
fi

echo "saved to $OUT_DIR/$TS"

이 스크립트는 “사후 포렌식”을 돕는 최소한의 자료를 모읍니다. 더 나아가면, OOM 발생 시점에 해당 PID의 cgroup 경로를 찾아 memory.current/max/events까지 덤프하도록 확장할 수 있습니다.

8) 마무리: OOM 분석의 정답은 ‘증거의 연결’

OOM Killer는 결과일 뿐이고, 원인은 대개 다음 중 하나로 수렴합니다.

  • (1) 전역 메모리 고갈(호스트 OOM)
  • (2) cgroup 제한 초과(cgroup OOM)
  • (3) 병목으로 인한 대기열/버퍼 누적(간접적 메모리 증가)

가장 효율적인 접근은 dmesg/journalctl로 kill 사실을 확정하고, cgroup의 제한/이벤트로 범위를 좁힌 뒤, 서비스/애플리케이션 로그로 트리거를 특정하는 것입니다. 이 흐름만 습관화해도 “원인 모를 재시작”이 “재현 가능한 결함”으로 바뀌고, 해결책(리밋 조정 vs 누수 수정 vs 병목 제거)도 명확해집니다.

쿠버네티스/EKS라면 OOM 이슈가 스케줄링/리소스 설계 문제로 이어지는 경우가 많으니, 필요 시 EKS Pod Pending(Insufficient memory)일 때 점검법과 함께 리밋/리퀘스트 및 노드 용량 설계까지 한 번에 점검하는 것을 권장합니다.