Published on

리눅스 OOMKilled 원인 추적 - cgroup·dmesg·ulimit

Authors

서버에서 프로세스가 갑자기 죽고, 로그에는 남는 게 없고, 컨테이너 환경이라면 상태가 OOMKilled로만 찍히는 경우가 많습니다. 이때 흔히 “메모리 증설”로 끝내지만, 실제 원인은 다음 중 하나인 경우가 많습니다.

  • 커널 OOM Killer가 시스템 전체 메모리 압박으로 프로세스를 죽임
  • cgroup 메모리 한도에 걸려 해당 cgroup 안에서만 OOM이 발생함
  • ulimit(특히 ulimit -v/-m) 같은 프로세스 한도에 걸려 할당이 실패하고 애플리케이션이 종료함
  • 메모리는 남아 있는데도 page cache, slab, 메모리 단편화, overcommit 정책 때문에 “할당 불가”가 발생함

이 글은 “왜 죽었는지”를 운영 관점에서 증명 가능한 형태로 추적하는 흐름을 제공합니다.

1) OOMKilled의 유형부터 구분하기

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

1-1. 시스템 OOM (Global OOM)

  • 커널이 시스템 전체 메모리 압박을 감지
  • OOM Killer가 특정 프로세스를 선택해 kill
  • 흔히 dmesg/journalctl -k에 OOM 로그가 남음

1-2. cgroup OOM (cgroup memory limit)

  • 컨테이너, systemd slice, k8s pod 등 특정 cgroup의 memory.max 또는 memory.limit_in_bytes에 도달
  • 시스템 전체 메모리는 남아 있어도 해당 그룹 안에서만 OOM
  • 이 경우도 커널 로그에 흔적이 남지만 “cgroup out of memory” 형태가 많음

이 구분이 중요한 이유는 해결책이 달라서입니다. Global OOM이면 노드/호스트의 메모리 압박 자체를 봐야 하고, cgroup OOM이면 해당 서비스의 리밋, 워킹셋, 누수, 캐시 정책을 봐야 합니다.

참고로 쿠버네티스에서 OOMKilled가 반복된다면 리밋과 런타임 GC까지 함께 봐야 합니다. 관련 내용은 K8s OOMKilled 반복? 메모리 리밋·GC 진단법도 같이 읽으면 흐름이 이어집니다.

2) 1차 증거 수집: dmesg와 커널 로그에서 “누가” “왜” 죽었는지 확인

가장 먼저 커널 로그에서 OOM 이벤트를 찾습니다.

2-1. dmesg에서 OOM 흔적 찾기

sudo dmesg -T | egrep -i "out of memory|oom-killer|killed process|cgroup out of memory" | tail -n 200

자주 보이는 패턴은 다음과 같습니다.

  • Out of memory: Killed process 12345 (myapp) total-vm:... anon-rss:...
  • oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=... (cgroup 관련 힌트)
  • Memory cgroup out of memory: Killed process ... (명시적 cgroup OOM)

2-2. journalctl로 커널 메시지 확인

sudo journalctl -k --since "-2h" | egrep -i "out of memory|oom-killer|killed process|memcg" 

dmesg가 로테이션되거나 부팅 이후 시간이 길면 journalctl -k가 더 안정적입니다.

2-3. OOM 로그에서 읽어야 할 핵심 필드

OOM 로그에는 보통 아래 정보가 포함됩니다.

  • PID / 프로세스명: 실제로 kill된 대상
  • anon-rss: 익명 메모리(힙/스택 등) 사용량. 누수/버퍼 증가와 강하게 연관
  • file-rss: 파일 매핑 RSS. mmap 기반 캐시나 대규모 파일 매핑 사용 시 증가
  • total-vm: 가상 메모리 크기. 실제 물리 사용량과 다를 수 있음
  • constraint=CONSTRAINT_MEMCG: cgroup 제한에 걸렸다는 강력한 신호

여기서 “kill된 프로세스”가 항상 “원인 제공자”는 아닐 수 있습니다. OOM Killer는 점수 기반으로 희생양을 고르기 때문에, 메모리를 많이 쓴 프로세스가 아니라도 죽을 수 있습니다. 그래서 다음 단계에서 cgroup과 시스템 메모리 압박을 교차 검증해야 합니다.

3) 2차 증거: cgroup 메모리 한도와 현재 사용량 확인

컨테이너/서비스 단위로 메모리를 제한했다면, 시스템 메모리가 남아도 해당 그룹만 OOM이 날 수 있습니다.

3-1. 내 프로세스가 속한 cgroup 경로 확인

PID를 알고 있다면:

PID=12345
cat /proc/$PID/cgroup

출력에서 0::/some/path 또는 컨트롤러별 경로가 보입니다. 이 경로가 곧 “어느 cgroup 한도에 묶여 있었는지”를 알려줍니다.

3-2. cgroup v2에서 메모리 한도/사용량 확인

대부분 최신 배포판은 cgroup v2를 사용합니다. 예를 들어 cgroup 경로가 /sys/fs/cgroup/my.slice/my.service라면:

CG="/sys/fs/cgroup/my.slice/my.service"
cat $CG/memory.max
cat $CG/memory.current
cat $CG/memory.peak 2>/dev/null || true
cat $CG/memory.events
  • memory.max: 제한값. max면 무제한
  • memory.current: 현재 사용량
  • memory.peak: 피크(지원되는 커널에서만)
  • memory.events: OOM/pressure 이벤트 카운트

특히 memory.events는 매우 유용합니다.

  • oom 또는 oom_kill 카운트가 증가했다면 “cgroup 한도에서 OOM이 났다”는 강력한 증거입니다.

3-3. cgroup v1에서 확인해야 할 파일

구형 환경에서는 다음을 봅니다.

CG="/sys/fs/cgroup/memory/some/path"
cat $CG/memory.limit_in_bytes
cat $CG/memory.usage_in_bytes
cat $CG/memory.max_usage_in_bytes
cat $CG/memory.failcnt
cat $CG/memory.oom_control
  • memory.failcnt가 증가하면 제한으로 인한 할당 실패가 있었다는 의미입니다.

3-4. systemd 서비스라면 MemoryMax 설정 확인

systemd로 서비스가 관리된다면 unit에 메모리 제한이 걸려 있을 수 있습니다.

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

MemoryMax가 설정되어 있으면 그 값이 사실상 cgroup memory.max로 반영됩니다.

4) 3차 증거: ulimit로 인한 할당 실패/비정상 종료 확인

OOMKilled처럼 보이지만, 실제로는 프로세스가 malloc 실패나 JVM 네이티브 메모리 할당 실패로 스스로 죽는 경우가 있습니다. 이때는 커널 OOM 로그가 없을 수 있습니다.

4-1. 현재 셸/서비스의 ulimit 확인

ulimit -a

특히 아래 항목을 확인합니다.

  • max memory size (보통 ulimit -m)
  • virtual memory (보통 ulimit -v)
  • open files (메모리와 직접 관련은 없지만 장애 동반 가능)

4-2. systemd 서비스의 Limit* 설정 확인

서비스는 로그인 셸의 ulimit과 다를 수 있습니다.

systemctl show my.service | egrep "LimitAS|LimitRSS|LimitNOFILE|LimitNPROC"
  • LimitAS는 가상 메모리 주소 공간 제한으로, 값이 낮으면 큰 힙/매핑을 쓰는 프로그램이 쉽게 죽습니다.

4-3. ulimit로 죽는 경우의 징후

  • dmesg에 OOM killer 로그가 없음
  • 애플리케이션 로그에 Cannot allocate memory가 남거나, 런타임이 예외를 던지고 종료
  • ulimit -v 또는 systemd LimitAS가 비정상적으로 낮게 설정됨

5) 시스템 메모리 압박인지 확인: free, vmstat, PSI

cgroup/ulimit을 확인했는데도 애매하면 “호스트가 진짜로 메모리 압박이었는지”를 확인합니다.

5-1. free로 큰 그림 보기

free -h

리눅스는 page cache를 적극적으로 쓰므로 available을 중심으로 봐야 합니다.

5-2. vmstat로 스왑/리클레임 징후 확인

vmstat 1 10
  • si/so가 증가하면 스왑 인/아웃이 발생 중
  • r이 높고 wa가 높으면 I/O 대기와 함께 메모리 압박이 동반되는 경우가 있음

5-3. PSI(Pressure Stall Information)로 “막힌 시간” 확인

커널이 PSI를 지원하면 다음으로 메모리 압박을 정량화할 수 있습니다.

cat /proc/pressure/memory

some/full 수치가 높으면 메모리 회수/대기가 실제로 시스템 시간을 잡아먹고 있었다는 뜻입니다.

6) “누가 메모리를 먹었나”를 프로세스/맵 단위로 좁히기

OOM 직전 상태를 못 봤다면, 재현하거나 다음 발생 시점에 바로 캡처할 준비를 해야 합니다.

6-1. RSS 상위 프로세스 빠르게 보기

ps -eo pid,ppid,comm,rss,vsz,%mem --sort=-rss | head -n 20

6-2. 특정 PID의 메모리 맵 요약

PID=12345
cat /proc/$PID/status | egrep "VmRSS|VmSize|RssAnon|RssFile|VmSwap"
cat /proc/$PID/smaps_rollup 2>/dev/null | egrep "Rss:|Pss:|Swap:|Anonymous:|File:"
  • RssAnon이 크면 힙/익명 메모리 증가(누수, 캐시, 버퍼)
  • RssFile이 크면 파일 매핑 또는 page cache 성격
  • VmSwap이 늘면 스왑 압박 동반

6-3. 런타임별 자주 놓치는 포인트

  • Java: 힙 Xmx만이 전부가 아니라 metaspace, direct buffer, thread stack, native 메모리까지 합산됨
  • Go: RSS는 GC 이후에도 즉시 내려가지 않을 수 있고, 큰 버퍼/슬라이스 유지로 워킹셋이 커질 수 있음
  • Python: 객체 캐시/arena 특성으로 RSS가 기대보다 크게 유지될 수 있음

7) 실전 트러블슈팅 플레이북(순서대로)

장애 대응 시 “한 번에” 정리할 수 있는 체크리스트입니다.

  1. 죽은 시각을 기준으로 커널 로그 확인
    • journalctl -k --since ...로 OOM 문구 수집
  2. OOM 로그에 memcg/CONSTRAINT_MEMCG가 있는지 확인
  3. PID를 확보했다면 /proc/PID/cgroup로 cgroup 경로 확인
  4. cgroup 메모리 파일 확인
    • v2: memory.max, memory.current, memory.events
    • v1: memory.limit_in_bytes, memory.failcnt
  5. 서비스 단위 제한 확인
    • systemd: MemoryMax, LimitAS
  6. 시스템 압박 여부 확인
    • free -h, vmstat, /proc/pressure/memory
  7. 원인 프로세스의 메모리 형태 확인
    • /proc/PID/smaps_rollup로 anon/file/swap 분해

이 과정을 거치면 최소한 아래 중 하나로 결론이 수렴합니다.

  • “호스트 메모리 부족으로 글로벌 OOM 발생”
  • “cgroup 제한으로 해당 서비스만 OOM”
  • “ulimit/LimitAS 같은 프로세스 제한으로 할당 실패”
  • “스왑/리클레임 지연, 메모리 압박(PSI)로 인해 연쇄 장애”

8) 재발 방지: 관측과 가드레일

8-1. OOM 이벤트를 로그로 고정

커널 로그가 흘러가 버리면 사후 분석이 어려워집니다.

  • journald 보존 기간/용량 정책 점검
  • 노드에서는 OOM 관련 커널 로그를 별도 수집(예: fluent-bit로 journalctl -k 수집)

8-2. cgroup 메모리 피크와 이벤트를 주기 수집

  • v2 환경이면 memory.current, memory.peak, memory.events를 주기적으로 수집해 대시보드화
  • “피크가 리밋에 얼마나 근접했는지”를 보면 증설과 최적화의 우선순위가 명확해집니다.

8-3. 리밋/요청값(특히 컨테이너) 재설계

컨테이너 환경에서의 OOM 대응은 결국 “리밋과 워킹셋의 합리적 합의”입니다. 쿠버네티스 관점의 반복 OOM 진단은 위 내부 글(K8s OOMKilled 반복? 메모리 리밋·GC 진단법)에서 더 깊게 다룹니다.

9) 예시: cgroup OOM과 global OOM을 로그로 구별하는 법

아래처럼 커널 로그에 memcg 제약이 보이면 cgroup OOM 가능성이 큽니다.

oom-kill:constraint=CONSTRAINT_MEMCG, ...
Memory cgroup out of memory: Killed process ...

반대로 시스템 전체 OOM은 Mem-Info 덤프와 함께 다양한 프로세스 목록이 붙는 경우가 많습니다.

이 차이를 기반으로 다음 액션이 갈립니다.

  • cgroup OOM: memory.max 상향 또는 애플리케이션 워킹셋/캐시 감소, GC 튜닝
  • global OOM: 노드 메모리 증설, 동시 실행 작업 제한, 스왑/overcommit 정책 점검, 메모리 과다 프로세스 식별

10) 마무리

OOMKilled를 제대로 끝내려면 “죽은 프로세스 이름”이 아니라, 커널이 어떤 제약 조건에서 어떤 근거로 kill을 수행했는지를 증거로 남겨야 합니다.

  • dmesg/journalctl -k로 OOM 이벤트의 1차 원인을 확보하고
  • cgroup 메모리 파일로 “리밋 OOM인지”를 확정하며
  • ulimit과 systemd Limit*로 “할당 실패형 종료” 가능성을 제거하고
  • /proc/PID/smaps_rollup으로 메모리 형태(anon/file/swap)를 분해하면

대부분의 OOM 사건은 재현 가능한 원인으로 수렴합니다. 다음 장애부터는 “메모리 늘려야 하나요”가 아니라 “어떤 한도에서 어떤 워킹셋이 문제였는지”로 대화가 바뀌게 됩니다.