Split View: OOM Killer가 프로세스를 죽였을 때 — dmesg 로그 해독부터 cgroup OOM 구분까지
OOM Killer가 프로세스를 죽였을 때 — dmesg 로그 해독부터 cgroup OOM 구분까지
들어가며 — 애플리케이션 로그가 끝까지 멀쩡한데 프로세스가 없습니다
장애 보고는 대개 이렇게 옵니다. "서비스가 죽었는데 애플리케이션 로그에는 아무 에러도 없습니다. 마지막 줄이 정상 요청 처리입니다." 그리고 컨테이너 환경이라면 종료 코드가 137로 찍혀 있습니다.
애플리케이션 로그에 아무것도 없는 것이 핵심 단서입니다. SIGKILL은 잡을 수 없고 핸들러도 돌지 않습니다. 프로세스는 명령어 하나를 실행하던 도중에 그대로 증발합니다. 그래서 흔적은 애플리케이션이 아니라 커널이 남깁니다.
sudo dmesg -T | grep -iE 'killed process|out of memory' | tail -5
# [Sat Jul 25 03:14:22 2026] Out of memory: Killed process 21874 (java) total-vm:12582912kB, anon-rss:11536876kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:22912kB oom_score_adj:0
이 한 줄이 나오면 확정입니다. 나오지 않는다면 OOM이 아닐 가능성이 큽니다. 이 글은 이 줄을 찾는 것에서 시작해, 왜 하필 그 프로세스였는지, 그리고 다시 나지 않게 하려면 무엇을 바꿔야 하는지까지 갑니다.
OOM 로그를 찾는 법과 한 줄씩 읽는 법
먼저 찾는 법입니다. dmesg 링 버퍼는 유한하므로 시간이 지나면 밀려 나갑니다. 재부팅을 건너 조회하려면 journald를 씁니다.
# 현재 부팅 세션의 커널 메시지에서 검색
sudo journalctl -k --since "2 hours ago" --grep "Out of memory"
# 이전 부팅 세션 (OOM 이후 재부팅했다면)
sudo journalctl -k -b -1 --grep "oom"
# 커널 로그가 아니라 전체에서 (systemd-oomd, 컨테이너 런타임 포함)
sudo journalctl --since "2026-07-25 03:00" --until "2026-07-25 03:30" | grep -iE 'oom|killed'
journalctl -k에 아무것도 없고 dmesg에도 없다면 Storage=volatile 설정으로 재부팅 시 로그가 사라졌을 수 있습니다. 이 경우 /etc/systemd/journald.conf의 Storage=persistent가 재발 방지의 첫 항목이 됩니다.
이제 리포트 전체를 읽습니다. 커널이 찍는 OOM 리포트는 네 부분으로 구성됩니다.
[Sat Jul 25 03:14:22 2026] java invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=0
[Sat Jul 25 03:14:22 2026] CPU: 3 PID: 21874 Comm: java Not tainted 6.8.0-45-generic #45-Ubuntu
[Sat Jul 25 03:14:22 2026] Call Trace:
[Sat Jul 25 03:14:22 2026] dump_stack_lvl+0x48/0x70
[Sat Jul 25 03:14:22 2026] dump_header+0x4f/0x240
[Sat Jul 25 03:14:22 2026] oom_kill_process+0x10d/0x1c0
[Sat Jul 25 03:14:22 2026] out_of_memory+0x246/0x580
[Sat Jul 25 03:14:22 2026] __alloc_pages_slowpath+0xb6c/0xf70
[Sat Jul 25 03:14:22 2026] Mem-Info:
[Sat Jul 25 03:14:22 2026] active_anon:3812044 inactive_anon:118233 isolated_anon:0
[Sat Jul 25 03:14:22 2026] active_file:214 inactive_file:302 unevictable:0
[Sat Jul 25 03:14:22 2026] slab_reclaimable:38210 slab_unreclaimable:91043
[Sat Jul 25 03:14:22 2026] free:41283 free_pcp:812 free_cma:0
[Sat Jul 25 03:14:22 2026] Node 0 Normal free:165132kB min:162044kB low:202552kB high:243060kB
[Sat Jul 25 03:14:22 2026] Tasks state (memory values in pages):
[Sat Jul 25 03:14:22 2026] [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
[Sat Jul 25 03:14:22 2026] [ 1042] 0 1042 28901 1204 126976 0 0 systemd-journal
[Sat Jul 25 03:14:22 2026] [ 1863] 0 1863 4521 612 73728 0 -1000 sshd
[Sat Jul 25 03:14:22 2026] [ 21874] 1000 21874 3145728 2884219 23461888 0 0 java
[Sat Jul 25 03:14:22 2026] [ 22910] 999 22910 612344 498231 4145152 0 200 postgres
[Sat Jul 25 03:14:22 2026] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/system.slice/app.service,task=java,pid=21874,uid=1000
[Sat Jul 25 03:14:22 2026] Out of memory: Killed process 21874 (java) total-vm:12582912kB, anon-rss:11536876kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:22912kB oom_score_adj:0
각 부분에서 뽑아야 할 것은 다음과 같습니다.
첫 줄의 invoked oom-killer 는 OOM을 유발한 프로세스입니다. 죽은 프로세스와 다를 수 있습니다. 할당을 요청하다 실패한 쪽이 트리거이고, 죽는 쪽은 점수로 정해집니다. order=0은 4KB 페이지 한 장을 요청했다는 뜻이고, order 값이 크면(3 이상) 물리적으로 연속된 큰 블록을 못 구한 것이므로 총량이 아니라 단편화 문제일 수 있습니다.
Mem-Info 블록은 회수 가능한 메모리가 정말 없었는지를 보여 줍니다. 위 예에서 active_file과 inactive_file이 각각 214, 302 페이지입니다. 페이지 캐시가 1MB 남짓밖에 남지 않았다는 뜻이고, 이는 캐시를 버릴 대로 버렸는데도 부족했다는 증거입니다. free가 min 워터마크 근처인 것도 같은 이야기입니다. 반대로 active_file이 수십 GB인데 OOM이 났다면 총량 문제가 아니라 특정 존이나 노드의 문제입니다.
Tasks state 표의 단위는 페이지입니다. 4KB 페이지 기준으로 java의 rss 2884219 페이지는 약 11.0GiB입니다. 마지막 줄의 anon-rss:11536876kB와 일치합니다. 이 표는 OOM 순간의 전체 스냅샷이므로, 진짜 범인이 죽은 프로세스가 아닐 때 그것을 밝혀 주는 유일한 자료입니다. rss 열로 정렬해서 읽으십시오.
oom-kill: 줄이 가장 정보량이 많은데 자주 무시됩니다. constraint가 CONSTRAINT_NONE이면 시스템 전체 메모리 부족, CONSTRAINT_MEMCG면 cgroup 상한, CONSTRAINT_CPUSET이나 CONSTRAINT_MEMORY_POLICY면 NUMA 노드 제약입니다. global_oom이라는 토큰과 task_memcg 경로도 여기 있습니다.
마지막 줄의 세 가지 RSS 구분이 유용합니다. anon-rss는 힙과 스택, file-rss는 매핑된 파일, shmem-rss는 공유 메모리와 tmpfs입니다. shmem-rss가 크다면 /dev/shm이나 tmpfs 마운트를 의심해야 합니다. tmpfs에 쓴 데이터는 파일처럼 보이지만 메모리를 점유하고 디스크로 밀려나지 않습니다.
누가 죽는지 정하는 계산 — badness와 oom_score_adj
커널은 후보마다 점수를 매기고 가장 높은 것을 죽입니다. 계산은 이렇습니다.
points = rss + swapents + (pgtables_bytes / PAGE_SIZE) # 단위: 페이지
adj = oom_score_adj * (totalpages / 1000)
points = points + adj
세 가지가 즉시 따라 나옵니다.
첫째, 점수는 가상 메모리(total_vm)가 아니라 실제 상주 메모리(RSS) 기준입니다. 64GB를 mmap으로 예약만 하고 실제로 200MB만 쓰는 프로세스는 후보가 아닙니다. top의 VIRT를 보고 범인을 지목하면 거의 항상 틀립니다.
둘째, oom_score_adj는 -1000에서 1000까지이고, 총 메모리의 1000분의 1을 단위로 하는 가감점입니다. 64GiB 머신에서 oom_score_adj=200은 약 12.8GiB에 해당하는 점수를 더합니다. 실제 RSS가 2GB인 프로세스라도 이 보정 하나로 11GB짜리 JVM을 제칠 수 있습니다. 위 로그의 postgres가 oom_score_adj=200이었지만 java의 RSS가 워낙 커서 java가 선택된 것입니다.
셋째, oom_score_adj=-1000은 특별합니다. 점수 계산 자체에서 제외되어 절대 선택되지 않습니다. 배포판이 sshd에 이 값을 설정해 두는 이유입니다. 로그의 sshd 줄에서 확인할 수 있습니다.
현재 값은 이렇게 봅니다.
# 프로세스별 점수와 보정값을 함께 정렬
for p in /proc/[0-9]*; do
pid=${p#/proc/}
[ -r "$p/oom_score" ] || continue
printf '%8s %7s %6s %s\n' \
"$(cat "$p/oom_score" 2>/dev/null)" \
"$(cat "$p/oom_score_adj" 2>/dev/null)" \
"$pid" \
"$(tr -d '\0' < "$p/comm" 2>/dev/null)"
done | sort -rn | head -8
# 2884219 0 21874 java
# 501431 200 22910 postgres
# 38210 0 3311 containerd
# 1204 0 1042 systemd-journal
# 0 -1000 1863 sshd
oom_score의 스케일은 커널 버전에 따라 다릅니다. 최근 커널은 페이지 수 기반이고 예전 커널은 0~1000으로 정규화했습니다. 절대값을 임계로 쓰지 말고 프로세스 간 상대 비교로만 쓰십시오.
값을 바꾸는 방법은 세 가지입니다.
# 1) 실행 중인 프로세스에 직접
echo -500 | sudo tee /proc/21874/oom_score_adj
# 2) systemd 유닛에 (재시작 시에도 유지)
sudo systemctl edit myapp.service
# [Service]
# OOMScoreAdjust=-500
# 3) 프로세스를 띄울 때
sudo choom -n -500 -- /usr/bin/myapp
컨테이너 환경에서는 이 값을 런타임이 대신 정합니다. 쿠버네티스는 QoS 클래스에 따라 다음과 같이 설정합니다.
| QoS 클래스 | oom_score_adj | 예시 |
|---|---|---|
| Guaranteed | -997 | requests와 limits가 모든 자원에서 동일 |
| Burstable | 1000에서 (1000 곱하기 메모리 request 나누기 노드 메모리)를 뺀 값 | 16GiB 노드에 512Mi request이면 969 |
| BestEffort | 1000 | requests와 limits를 아무것도 지정하지 않음 |
Burstable의 결과값은 -996과 999 사이로 잘립니다. 여기서 실무적으로 중요한 결론이 나옵니다. 메모리 request를 크게 잡을수록 노드 전역 OOM에서 살아남을 확률이 올라갑니다. request를 작게 적고 실제로 많이 쓰는 파드는 노드가 압박을 받는 순간 가장 먼저 죽습니다. request는 스케줄러를 속이는 숫자가 아니라 생존 순위입니다.
컨테이너 OOM과 시스템 전역 OOM은 다른 사건입니다
실무에서 가장 자주 혼동되는 지점입니다. 둘 다 종료 코드 137을 만들고 둘 다 dmesg에 OOM 로그를 남기지만, 원인도 대응도 완전히 다릅니다.
cgroup OOM 로그는 이렇게 생겼습니다.
[Sat Jul 25 04:02:11 2026] node invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=969
[Sat Jul 25 04:02:11 2026] memory: usage 524288kB, limit 524288kB, failcnt 1842
[Sat Jul 25 04:02:11 2026] swap: usage 0kB, limit 0kB, failcnt 0
[Sat Jul 25 04:02:11 2026] Memory cgroup stats for /kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/cri-containerd-3f7b91ac.scope:
[Sat Jul 25 04:02:11 2026] anon 521142272
[Sat Jul 25 04:02:11 2026] file 1048576
[Sat Jul 25 04:02:11 2026] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=cri-containerd-3f7b91ac.scope,mems_allowed=0,oom_memcg=/kubepods.slice/...,task_memcg=/kubepods.slice/...,task=node,pid=33127,uid=1000
[Sat Jul 25 04:02:11 2026] Memory cgroup out of memory: Killed process 33127 (node) total-vm:1284736kB, anon-rss:508924kB, file-rss:36104kB, shmem-rss:0kB, UID:1000 pgtables:2048kB oom_score_adj:969
구분 기준은 세 가지이고 어느 하나만으로도 확정됩니다.
- 마지막 줄이
Out of memory:인지Memory cgroup out of memory:인지 oom-kill:줄의constraint가CONSTRAINT_NONE인지CONSTRAINT_MEMCG인지memory: usage / limit / failcnt줄의 존재 여부. 이 줄은 cgroup OOM에만 나옵니다
로그를 못 구했다면 카운터로도 확인됩니다. cgroup v2는 이벤트를 누적해 둡니다.
# 파드의 cgroup 경로를 찾아서
cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/memory.events
# low 0
# high 0
# max 1842
# oom 7
# oom_kill 3
max는 상한에 부딪혀 회수가 일어난 횟수, oom은 회수 실패로 OOM 경로에 들어간 횟수, oom_kill은 실제로 죽인 횟수입니다. max만 크고 oom_kill이 0이라면 상한 근처에서 계속 회수하며 버티는 중이라는 뜻입니다. 성능은 나빠지고 있지만 아직 죽지는 않았습니다. 이 상태를 잡아내는 것이 사후 부검보다 훨씬 낫습니다.
여기서 두 번째 흔한 오해를 짚습니다. 종료 코드 137은 OOM의 증거가 아닙니다. 137은 128 더하기 9, 즉 SIGKILL로 죽었다는 뜻일 뿐입니다. liveness probe 실패 후 유예 시간이 지나서 죽어도, kubectl delete로 지워도, 노드 종료 과정에서 죽어도 137입니다. 확정하려면 컨테이너 상태의 reason 필드나 위의 커널 로그를 봐야 합니다.
kubectl get pod api-7d9f8-x2k4l -o jsonpath='{.status.containerStatuses[0].lastState.terminated}' | python3 -m json.tool
# {
# "containerID": "containerd://3f7b91ac...",
# "exitCode": 137,
# "finishedAt": "2026-07-25T04:02:11Z",
# "reason": "OOMKilled",
# "startedAt": "2026-07-25T03:11:04Z"
# }
반대 방향의 함정도 있습니다. 노드 전역 OOM이 컨테이너 안의 프로세스를 죽이면 그 파드도 137을 받습니다. 이때 reason은 OOMKilled로 나오지만 메모리 limit은 전혀 초과하지 않았을 수 있습니다. limit을 올려도 재발합니다. constraint=CONSTRAINT_NONE이면 노드 자체의 메모리를 늘리거나 다른 파드를 쫓아내는 것이 답입니다.
overcommit_memory 0/1/2가 실제로 바꾸는 것
세 값의 차이는 실패가 언제 어떤 형태로 나타나는가입니다. 총 메모리 양을 바꾸지는 않습니다.
| 값 | 이름 | 할당 시점 동작 | 실패 형태 | 쓰는 경우 |
|---|---|---|---|---|
| 0 | 휴리스틱 | 명백히 과한 단일 할당만 거부 | 주로 OOM Killer | 기본값. 대부분의 워크로드 |
| 1 | 항상 허용 | 검사하지 않음 | 항상 OOM Killer | Redis 스냅샷 fork, 대형 희소 매핑 |
| 2 | 엄격 | CommitLimit 초과 시 거부 | malloc이 ENOMEM 반환 | OOM Kill 자체를 금지해야 할 때 |
모드 2의 한계선은 다음과 같이 계산됩니다.
sysctl vm.overcommit_memory vm.overcommit_ratio
# vm.overcommit_memory = 0
# vm.overcommit_ratio = 50
grep -E 'CommitLimit|Committed_AS|MemTotal|SwapTotal' /proc/meminfo
# MemTotal: 65806232 kB
# SwapTotal: 0 kB
# CommitLimit: 32903116 kB
# Committed_AS: 48192044 kB
CommitLimit은 스왑 전체 더하기 물리 메모리의 overcommit_ratio 퍼센트입니다. 위 예에서는 스왑이 없으니 64GiB의 50%인 32GiB입니다. Committed_AS가 이미 46GiB이므로, 지금 vm.overcommit_memory=2로 바꾸면 새 할당이 즉시 실패하기 시작합니다. 스왑 없는 머신에서 기본 비율 그대로 모드 2를 켜는 것이 대표적인 자충수입니다. 쓰려면 vm.overcommit_ratio를 90 이상으로 올리거나 vm.overcommit_kbytes로 절대값을 지정해야 합니다.
모드 1이 필요한 대표 사례가 Redis입니다. BGSAVE는 fork로 자식을 만드는데, 쓰기 시 복사이므로 실제 추가 사용량은 훨씬 적지만 커널의 휴리스틱은 부모와 같은 크기의 커밋으로 볼 수 있습니다. 그래서 Redis는 기동 시 vm.overcommit_memory=1을 권고하는 경고를 찍습니다.
# 영구 적용
echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/60-overcommit.conf
sudo sysctl --system
여기서 주의할 점. 모드 1은 OOM Killer를 더 자주 부릅니다. 할당 시점에 거절하지 않으니 실제로 페이지를 만질 때 터집니다. 애플리케이션이 malloc 실패를 우아하게 처리하도록 설계됐다면 모드 2가 낫고, 그렇지 않다면(대부분의 경우) 모드 0을 유지하고 cgroup 상한으로 격리하는 편이 현실적입니다.
"스왑을 넣으면 OOM이 안 난다"는 오해
가장 널리 퍼진 오해이고 절반만 맞습니다. 스왑은 OOM을 막지 않고 늦춥니다. 그리고 늦추는 동안 시스템이 겪는 상태가 종종 즉사보다 나쁩니다.
전역 OOM은 "메모리가 0이 되면" 발동하는 것이 아니라 "회수(reclaim)를 시도했는데 진전이 없으면" 발동합니다. 스왑이 있으면 익명 페이지를 내보낼 수 있으니 회수가 진전을 만들고, 그래서 OOM이 미뤄집니다. 문제는 이 구간에서 워킹셋이 스왑을 오가는 스래싱이 일어난다는 것입니다. 프로세스는 살아 있지만 아무 일도 하지 못하고, 헬스체크는 타임아웃되고, SSH 접속조차 수 분이 걸립니다.
스래싱은 이렇게 확인합니다.
vmstat 1 5
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
# r b swpd free buff cache si so bi bo in cs us sy id wa st
# 1 18 8291840 92104 1024 38912 41216 38912 42104 39204 8210 19204 2 9 4 85 0
si(스왑 인)와 so(스왑 아웃)가 동시에 초당 수십 MB면 스래싱입니다. 한쪽만 크면 정상적인 페이지 아웃일 수 있으니 양방향을 함께 봐야 합니다. PSI를 쓰면 더 명확합니다.
cat /proc/pressure/memory
# some avg10=94.12 avg60=88.03 avg300=61.44 total=182937461283
# full avg10=71.88 avg60=66.21 avg300=44.02 total=91827364512
full이 71%라는 것은 최근 10초 중 7초 이상 모든 태스크가 메모리 때문에 멈춰 있었다는 뜻입니다. CPU는 유휴로 보이고 로드는 낮을 수 있지만 시스템은 실질적으로 정지 상태입니다.
두 가지를 더 짚습니다. cgroup 안에서는 memory.swap.max가 별도로 존재하고, 이 값이 0이면 호스트에 스왑이 아무리 많아도 그 그룹은 스왑을 쓰지 못하고 곧장 OOM으로 갑니다. 쿠버네티스는 오랫동안 노드 스왑을 비활성화하는 것이 기본이었고 최근에야 제한적으로 지원이 들어왔으므로, 컨테이너 워크로드에서 "스왑이 있으니 괜찮다"는 가정은 대체로 성립하지 않습니다.
그리고 zram이나 zswap은 성격이 다릅니다. 디스크가 아니라 메모리 안에서 압축하므로 지연이 훨씬 작고, 압축률이 좋은 워크로드에서는 실질적인 메모리 확장 효과가 있습니다. 다만 압축 자체가 CPU를 쓰고, 압축이 안 되는 데이터에는 효과가 없습니다.
재발 방지 — 커널이 죽이기 전에 개입하기
커널 OOM Killer는 최후의 수단이고, 최후의 수단은 늦습니다. 실무의 목표는 그 이전 단계에서 예측 가능하게 개입하는 것입니다.
첫째, cgroup으로 상한을 명시합니다. 전역 OOM은 어느 프로세스가 죽을지 예측하기 어렵지만 cgroup OOM은 범위가 그 그룹 안으로 한정됩니다. systemd 서비스라면 유닛에 직접 씁니다.
sudo systemctl edit myapp.service
# [Service]
# MemoryHigh=6G
# MemoryMax=8G
# OOMPolicy=stop
sudo systemctl daemon-reload
sudo systemctl restart myapp
systemctl show -p MemoryHigh -p MemoryMax -p OOMPolicy myapp.service
# MemoryHigh=6442450944
# MemoryMax=8589934592
# OOMPolicy=stop
MemoryHigh와 MemoryMax의 차이가 중요합니다. MemoryMax(cgroup의 memory.max)는 넘으면 죽입니다. MemoryHigh(memory.high)는 죽이지 않고 할당하는 쪽을 재웁니다. 초과분에 비례해 페널티 슬립이 들어가므로 애플리케이션은 급격히 느려지지만 살아 있습니다. 두 값을 함께 걸면 "6GB부터 브레이크, 8GB에서 종료"라는 완충 구간이 생기고, 그 구간에서 알람을 받아 사람이 개입할 시간을 법니다.
둘째, 압박 자체를 알람으로 겁니다. 죽은 뒤 세는 카운터보다 죽기 전의 압박이 유용합니다.
# cgroup별 메모리 압박 상위 5개
for f in /sys/fs/cgroup/system.slice/*/memory.pressure; do
v=$(awk '/^full/ {print $3}' "$f" 2>/dev/null | cut -d= -f2)
[ -n "$v" ] && printf '%7s %s\n' "$v" "${f%/memory.pressure}"
done | sort -rn | head -5
# 18.44 /sys/fs/cgroup/system.slice/myapp.service
# 0.31 /sys/fs/cgroup/system.slice/containerd.service
프로메테우스를 쓴다면 node_exporter의 node_vmstat_oom_kill 증가분과 cAdvisor의 container_oom_events_total을 함께 봅니다. 전자는 노드 전역, 후자는 컨테이너 단위이므로 앞에서 구분한 두 사건이 각각 다른 지표로 잡힙니다.
셋째, 사용자 공간 OOM 관리자를 검토합니다. 커널은 회수가 완전히 실패할 때까지 기다리지만 사용자 공간 도구는 임계를 스스로 정합니다.
earlyoom은MemAvailable과SwapFree비율을 감시하다 임계 아래로 떨어지면 SIGTERM을, 더 떨어지면 SIGKILL을 보냅니다. 설정이 단순하고 어떤 배포판에서도 동작합니다.systemd-oomd는 PSI 기반입니다. cgroup 단위로memory.pressure를 보고 일정 시간 이상 임계를 넘으면 그 슬라이스에서 가장 압박이 큰 것을 정리합니다. 압박이라는 지표를 쓰기 때문에 절대 용량 기준보다 오탐이 적습니다.
# earlyoom: 가용 메모리 5%, 스왑 5% 아래에서 개입
sudo systemctl edit earlyoom.service
# [Service]
# Environment=EARLYOOM_ARGS=-m 5 -s 5 --avoid '(^|/)(sshd|systemd)$' --prefer '(^|/)java$'
# systemd-oomd: 슬라이스별 압박 임계
sudo systemctl edit myapp.service
# [Service]
# ManagedOOMMemoryPressure=kill
# ManagedOOMMemoryPressureLimit=60%
systemd-oomd는 /etc/systemd/oomd.conf의 DefaultMemoryPressureDurationSec(기본 30초) 동안 임계를 지속적으로 넘어야 동작하므로 순간 스파이크에는 반응하지 않습니다.
넷째, 실제 사용량을 다시 측정합니다. 상한을 올리는 것은 원인 해결이 아닙니다. JVM이라면 -XX:MaxRAMPercentage가 컨테이너 상한을 인식하는지, Go라면 GOMEMLIMIT이 설정됐는지, 네이티브 힙 단편화나 glibc 아레나 문제는 아닌지를 봅니다. smem이나 /proc/PID/smaps_rollup이 RSS의 구성을 보여 줍니다.
sudo cat /proc/21874/smaps_rollup
# 7f2c00000000-7ffd1a3e5000 ---p 00000000 00:00 0 [rollup]
# Rss: 11536876 kB
# Pss: 11498210 kB
# Anonymous: 11402344 kB
# AnonHugePages: 2097152 kB
# Shared_Clean: 34120 kB
# Private_Dirty: 11402344 kB
Anonymous가 RSS의 대부분이면 애플리케이션 힙, Shared_Clean이 크면 공유 라이브러리라 실제 비용은 Pss 쪽에 가깝습니다.
마치며 — 로그의 constraint 한 줄이 대응을 가릅니다
하나만 기억한다면 이것입니다. OOM 디버깅의 첫 갈림길은 전역이냐 cgroup이냐이고, 그 답은 oom-kill: 줄의 constraint 필드에 이미 적혀 있습니다.
CONSTRAINT_NONE이면 노드 메모리가 부족한 것입니다. 파드의 limit을 올려도 재발합니다. 노드를 키우거나 밀도를 낮춰야 합니다.CONSTRAINT_MEMCG면 그 컨테이너의 상한 문제입니다. 노드는 멀쩡합니다. limit을 올리거나 애플리케이션 사용량을 줄이는 것이 맞습니다.
그리고 나머지 규칙들.
- 종료 코드 137만 보고 OOM으로 단정하지 않습니다.
reason필드나 커널 로그로 확인합니다. - 범인은
total_vm이 아니라rss로 찾습니다. 가상 메모리는 점수에 들어가지 않습니다. - 메모리 request를 정직하게 적습니다. Burstable의
oom_score_adj가 그 값으로 정해지고, 그것이 노드 압박 시 생존 순위입니다. MemoryMax하나만 걸지 말고MemoryHigh로 완충 구간을 만듭니다. 죽기 전에 느려지는 편이 진단할 시간을 줍니다.- 알람은
oom_kill카운터가 아니라memory.pressure의full에 겁니다. 사후 통보보다 사전 경고가 필요합니다.
When the OOM Killer Kills a Process — From Decoding the dmesg Report to Telling cgroup OOM Apart
Introduction — The Application Log Is Clean to the Very End, but the Process Is Gone
Incident reports usually arrive like this. "The service died but there is no error in the application log. The last line is a normal request being served." And in a container environment the exit code reads 137.
The fact that there is nothing in the application log is the key clue. SIGKILL cannot be caught and no handler runs. The process evaporates in the middle of executing a single instruction. So the trace is left by the kernel, not by the application.
sudo dmesg -T | grep -iE 'killed process|out of memory' | tail -5
# [Sat Jul 25 03:14:22 2026] Out of memory: Killed process 21874 (java) total-vm:12582912kB, anon-rss:11536876kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:22912kB oom_score_adj:0
If this one line shows up, it is confirmed. If it does not, this was probably not an OOM. This post starts from finding that line, moves on to why it had to be that particular process, and ends with what you should change so it does not happen again.
How to Find the OOM Log and How to Read It Line by Line
Finding it comes first. The dmesg ring buffer is finite, so entries are pushed out as time passes. To query across a reboot, use journald.
# Search the kernel messages of the current boot session
sudo journalctl -k --since "2 hours ago" --grep "Out of memory"
# Previous boot session (if the machine was rebooted after the OOM)
sudo journalctl -k -b -1 --grep "oom"
# Everything, not just the kernel log (includes systemd-oomd and the container runtime)
sudo journalctl --since "2026-07-25 03:00" --until "2026-07-25 03:30" | grep -iE 'oom|killed'
If journalctl -k has nothing and dmesg has nothing either, the Storage=volatile setting may have made the log disappear on reboot. In that case Storage=persistent in /etc/systemd/journald.conf becomes the first item on your prevention list.
Now read the whole report. The OOM report the kernel prints is made of four parts.
[Sat Jul 25 03:14:22 2026] java invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=0
[Sat Jul 25 03:14:22 2026] CPU: 3 PID: 21874 Comm: java Not tainted 6.8.0-45-generic #45-Ubuntu
[Sat Jul 25 03:14:22 2026] Call Trace:
[Sat Jul 25 03:14:22 2026] dump_stack_lvl+0x48/0x70
[Sat Jul 25 03:14:22 2026] dump_header+0x4f/0x240
[Sat Jul 25 03:14:22 2026] oom_kill_process+0x10d/0x1c0
[Sat Jul 25 03:14:22 2026] out_of_memory+0x246/0x580
[Sat Jul 25 03:14:22 2026] __alloc_pages_slowpath+0xb6c/0xf70
[Sat Jul 25 03:14:22 2026] Mem-Info:
[Sat Jul 25 03:14:22 2026] active_anon:3812044 inactive_anon:118233 isolated_anon:0
[Sat Jul 25 03:14:22 2026] active_file:214 inactive_file:302 unevictable:0
[Sat Jul 25 03:14:22 2026] slab_reclaimable:38210 slab_unreclaimable:91043
[Sat Jul 25 03:14:22 2026] free:41283 free_pcp:812 free_cma:0
[Sat Jul 25 03:14:22 2026] Node 0 Normal free:165132kB min:162044kB low:202552kB high:243060kB
[Sat Jul 25 03:14:22 2026] Tasks state (memory values in pages):
[Sat Jul 25 03:14:22 2026] [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
[Sat Jul 25 03:14:22 2026] [ 1042] 0 1042 28901 1204 126976 0 0 systemd-journal
[Sat Jul 25 03:14:22 2026] [ 1863] 0 1863 4521 612 73728 0 -1000 sshd
[Sat Jul 25 03:14:22 2026] [ 21874] 1000 21874 3145728 2884219 23461888 0 0 java
[Sat Jul 25 03:14:22 2026] [ 22910] 999 22910 612344 498231 4145152 0 200 postgres
[Sat Jul 25 03:14:22 2026] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/system.slice/app.service,task=java,pid=21874,uid=1000
[Sat Jul 25 03:14:22 2026] Out of memory: Killed process 21874 (java) total-vm:12582912kB, anon-rss:11536876kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:22912kB oom_score_adj:0
Here is what to pull out of each part.
The first line, the one with invoked oom-killer, is the process that triggered the OOM. It can be different from the process that died. The side that asked for an allocation and failed is the trigger, while the side that dies is decided by score. order=0 means a single 4KB page was requested, and a large order value (3 or more) means a physically contiguous large block could not be found, so the problem may be fragmentation rather than total capacity.
The Mem-Info block shows whether reclaimable memory was really gone. In the example above active_file and inactive_file are 214 and 302 pages respectively. That means barely 1MB of page cache was left, which is evidence that the cache had been dropped as far as it could go and it still was not enough. free sitting near the min watermark tells the same story. Conversely, if active_file is tens of GB and an OOM still happened, it is not a total capacity problem but a problem with a specific zone or node.
The Tasks state table is measured in pages. On 4KB pages, the rss of 2884219 pages for java is about 11.0GiB. That matches anon-rss:11536876kB on the last line. This table is a full snapshot of the moment of the OOM, so it is the only material that reveals the real culprit when the culprit is not the process that died. Read it sorted by the rss column.
The oom-kill: line carries the most information and is ignored the most often. If constraint is CONSTRAINT_NONE it is a system-wide memory shortage, if it is CONSTRAINT_MEMCG it is a cgroup limit, and if it is CONSTRAINT_CPUSET or CONSTRAINT_MEMORY_POLICY it is a NUMA node constraint. The global_oom token and the task_memcg path live here as well.
The last line splits RSS into three useful categories. anon-rss is heap and stack, file-rss is mapped files, and shmem-rss is shared memory and tmpfs. If shmem-rss is large, suspect /dev/shm or a tmpfs mount. Data written to tmpfs looks like a file but occupies memory and is never pushed out to disk.
The Calculation That Decides Who Dies — badness and oom_score_adj
The kernel scores every candidate and kills the highest one. The calculation goes like this.
points = rss + swapents + (pgtables_bytes / PAGE_SIZE) # unit: pages
adj = oom_score_adj * (totalpages / 1000)
points = points + adj
Three things follow immediately.
First, the score is based on real resident memory (RSS) and not virtual memory (total_vm). A process that merely reserves 64GB with mmap and actually touches only 200MB is not a candidate. Naming a culprit by looking at VIRT in top is almost always wrong.
Second, oom_score_adj ranges from -1000 to 1000 and is a bonus or penalty measured in thousandths of total memory. On a 64GiB machine, oom_score_adj=200 adds a score equivalent to about 12.8GiB. Even a process whose real RSS is 2GB can outrank an 11GB JVM through that one adjustment. The postgres in the log above had oom_score_adj=200, but the RSS of java was so large that java was the one selected.
Third, oom_score_adj=-1000 is special. It is excluded from the score calculation altogether and is never selected. That is why distributions set this value on sshd. You can confirm it on the sshd line of the log.
Here is how to see the current values.
# Sort processes by score together with their adjustment value
for p in /proc/[0-9]*; do
pid=${p#/proc/}
[ -r "$p/oom_score" ] || continue
printf '%8s %7s %6s %s\n' \
"$(cat "$p/oom_score" 2>/dev/null)" \
"$(cat "$p/oom_score_adj" 2>/dev/null)" \
"$pid" \
"$(tr -d '\0' < "$p/comm" 2>/dev/null)"
done | sort -rn | head -8
# 2884219 0 21874 java
# 501431 200 22910 postgres
# 38210 0 3311 containerd
# 1204 0 1042 systemd-journal
# 0 -1000 1863 sshd
The scale of oom_score differs between kernel versions. Recent kernels are page-count based, while older kernels normalized to a 0 through 1000 range. Do not use the absolute value as a threshold; use it only for relative comparison between processes.
There are three ways to change the value.
# 1) Directly on a running process
echo -500 | sudo tee /proc/21874/oom_score_adj
# 2) On a systemd unit (survives restarts)
sudo systemctl edit myapp.service
# [Service]
# OOMScoreAdjust=-500
# 3) When launching the process
sudo choom -n -500 -- /usr/bin/myapp
In a container environment the runtime sets this value for you. Kubernetes assigns it by QoS class as follows.
| QoS class | oom_score_adj | Example |
|---|---|---|
| Guaranteed | -997 | requests and limits are identical for every resource |
| Burstable | 1000 minus (1000 times memory request divided by node memory) | 969 for a 512Mi request on a 16GiB node |
| BestEffort | 1000 | no requests and no limits specified at all |
The Burstable result is clamped between -996 and 999. A practically important conclusion falls out of this. The larger you set the memory request, the higher the odds of surviving a node-wide OOM. A pod that writes a small request and actually uses a lot is the first to die the moment the node comes under pressure. A request is not a number for fooling the scheduler; it is a survival ranking.
A Container OOM and a System-Wide OOM Are Different Events
This is the point most often confused in practice. Both produce exit code 137 and both leave an OOM log in dmesg, but the cause and the response are completely different.
A cgroup OOM log looks like this.
[Sat Jul 25 04:02:11 2026] node invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=969
[Sat Jul 25 04:02:11 2026] memory: usage 524288kB, limit 524288kB, failcnt 1842
[Sat Jul 25 04:02:11 2026] swap: usage 0kB, limit 0kB, failcnt 0
[Sat Jul 25 04:02:11 2026] Memory cgroup stats for /kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/cri-containerd-3f7b91ac.scope:
[Sat Jul 25 04:02:11 2026] anon 521142272
[Sat Jul 25 04:02:11 2026] file 1048576
[Sat Jul 25 04:02:11 2026] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=cri-containerd-3f7b91ac.scope,mems_allowed=0,oom_memcg=/kubepods.slice/...,task_memcg=/kubepods.slice/...,task=node,pid=33127,uid=1000
[Sat Jul 25 04:02:11 2026] Memory cgroup out of memory: Killed process 33127 (node) total-vm:1284736kB, anon-rss:508924kB, file-rss:36104kB, shmem-rss:0kB, UID:1000 pgtables:2048kB oom_score_adj:969
There are three ways to tell them apart, and any one of them alone is conclusive.
- Whether the last line reads
Out of memory:orMemory cgroup out of memory: - Whether
constrainton theoom-kill:line isCONSTRAINT_NONEorCONSTRAINT_MEMCG - Whether the
memory: usage / limit / failcntline exists. This line appears only on a cgroup OOM
If you could not get the log, counters confirm it too. cgroup v2 accumulates events.
# Find the cgroup path of the pod first
cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/memory.events
# low 0
# high 0
# max 1842
# oom 7
# oom_kill 3
max is the number of times the limit was hit and reclaim happened, oom is the number of times reclaim failed and the OOM path was entered, and oom_kill is the number of times something was actually killed. If only max is large and oom_kill is 0, it means the group is holding on near the limit by reclaiming continuously. Performance is degrading but nothing has died yet. Catching this state is far better than a postmortem.
Here is the second common misconception. Exit code 137 is not evidence of an OOM. 137 is 128 plus 9, which only means the process was killed by SIGKILL. It is also 137 when a container dies after the grace period following a failed liveness probe, when you remove it with kubectl delete, and when it dies during node shutdown. To confirm, you have to look at the reason field of the container status or at the kernel log above.
kubectl get pod api-7d9f8-x2k4l -o jsonpath='{.status.containerStatuses[0].lastState.terminated}' | python3 -m json.tool
# {
# "containerID": "containerd://3f7b91ac...",
# "exitCode": 137,
# "finishedAt": "2026-07-25T04:02:11Z",
# "reason": "OOMKilled",
# "startedAt": "2026-07-25T03:11:04Z"
# }
There is a trap in the opposite direction too. If a node-wide OOM kills a process inside a container, that pod also receives 137. The reason then reads OOMKilled, but the memory limit may not have been exceeded at all. Raising the limit will not stop it from happening again. If constraint=CONSTRAINT_NONE, the answer is to grow the node itself or to evict other pods.
What overcommit_memory 0/1/2 Actually Changes
The difference between the three values is when and in what form the failure appears. They do not change the total amount of memory.
| Value | Name | Behavior at allocation time | Failure form | When to use it |
|---|---|---|---|---|
| 0 | heuristic | rejects only obviously excessive single allocations | mostly the OOM Killer | the default. most workloads |
| 1 | always allow | no check at all | always the OOM Killer | Redis snapshot fork, large sparse mappings |
| 2 | strict | rejects anything above CommitLimit | malloc returns ENOMEM | when OOM Kill itself must be forbidden |
The ceiling of mode 2 is calculated as follows.
sysctl vm.overcommit_memory vm.overcommit_ratio
# vm.overcommit_memory = 0
# vm.overcommit_ratio = 50
grep -E 'CommitLimit|Committed_AS|MemTotal|SwapTotal' /proc/meminfo
# MemTotal: 65806232 kB
# SwapTotal: 0 kB
# CommitLimit: 32903116 kB
# Committed_AS: 48192044 kB
CommitLimit is all of swap plus the overcommit_ratio percentage of physical memory. In the example above there is no swap, so it is 32GiB, which is 50% of 64GiB. Committed_AS is already 46GiB, so switching to vm.overcommit_memory=2 right now would make new allocations start failing immediately. Turning on mode 2 with the default ratio on a machine without swap is a textbook own goal. If you want to use it, raise vm.overcommit_ratio to 90 or higher, or specify an absolute value with vm.overcommit_kbytes.
The classic case that needs mode 1 is Redis. BGSAVE creates a child with fork, and because of copy on write the real extra usage is far smaller, but the heuristic of the kernel may see it as a commit the same size as the parent. That is why Redis prints a warning at startup recommending vm.overcommit_memory=1.
# Apply permanently
echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/60-overcommit.conf
sudo sysctl --system
One caution here. Mode 1 calls the OOM Killer more often. Since nothing is refused at allocation time, it blows up when the pages are actually touched. If the application is designed to handle a malloc failure gracefully, mode 2 is better; if it is not (which is most of the time), keeping mode 0 and isolating with cgroup limits is the realistic choice.
The Myth That "Adding Swap Means No More OOM"
This is the most widespread misconception and it is only half right. Swap does not prevent OOM, it delays it. And the state the system goes through during that delay is often worse than dying instantly.
A global OOM does not fire "when memory reaches zero" but "when reclaim was attempted and made no progress." With swap available, anonymous pages can be pushed out, so reclaim makes progress and the OOM is postponed. The problem is that in this window the working set thrashes in and out of swap. Processes are alive but get nothing done, health checks time out, and even an SSH login takes several minutes.
Here is how to confirm thrashing.
vmstat 1 5
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
# r b swpd free buff cache si so bi bo in cs us sy id wa st
# 1 18 8291840 92104 1024 38912 41216 38912 42104 39204 8210 19204 2 9 4 85 0
If si (swap in) and so (swap out) are both tens of MB per second at the same time, that is thrashing. If only one side is large it may be normal page-out, so you have to look at both directions together. PSI makes it even clearer.
cat /proc/pressure/memory
# some avg10=94.12 avg60=88.03 avg300=61.44 total=182937461283
# full avg10=71.88 avg60=66.21 avg300=44.02 total=91827364512
A full of 71% means that for more than seven of the last ten seconds every task was stalled because of memory. The CPU may look idle and the load may be low, but the system is effectively frozen.
Two more points. Inside a cgroup, memory.swap.max exists separately, and if that value is 0 the group cannot use swap no matter how much swap the host has and goes straight to OOM. Kubernetes disabled node swap by default for a long time and only recently gained limited support, so in container workloads the assumption that "we have swap, so we are fine" generally does not hold.
And zram or zswap are a different animal. They compress inside memory rather than on disk, so latency is far lower, and on workloads that compress well there is a real memory expansion effect. That said, compression itself burns CPU, and it does nothing for data that does not compress.
Preventing Recurrence — Stepping In Before the Kernel Kills
The kernel OOM Killer is a last resort, and a last resort is late. The practical goal is to intervene predictably at an earlier stage.
First, declare limits explicitly with cgroups. A global OOM makes it hard to predict which process will die, but a cgroup OOM is confined to that group. For a systemd service, write it straight into the unit.
sudo systemctl edit myapp.service
# [Service]
# MemoryHigh=6G
# MemoryMax=8G
# OOMPolicy=stop
sudo systemctl daemon-reload
sudo systemctl restart myapp
systemctl show -p MemoryHigh -p MemoryMax -p OOMPolicy myapp.service
# MemoryHigh=6442450944
# MemoryMax=8589934592
# OOMPolicy=stop
The difference between MemoryHigh and MemoryMax matters. MemoryMax (the memory.max of the cgroup) kills once exceeded. MemoryHigh (memory.high) does not kill but puts the allocating side to sleep. A penalty sleep proportional to the excess is applied, so the application slows down sharply but stays alive. Setting both creates a buffer zone of "brake at 6GB, terminate at 8GB", and an alert in that zone buys time for a human to step in.
Second, alert on the pressure itself. Pressure before death is more useful than a counter tallied after death.
# Top 5 cgroups by memory pressure
for f in /sys/fs/cgroup/system.slice/*/memory.pressure; do
v=$(awk '/^full/ {print $3}' "$f" 2>/dev/null | cut -d= -f2)
[ -n "$v" ] && printf '%7s %s\n' "$v" "${f%/memory.pressure}"
done | sort -rn | head -5
# 18.44 /sys/fs/cgroup/system.slice/myapp.service
# 0.31 /sys/fs/cgroup/system.slice/containerd.service
If you use Prometheus, watch the increase of node_vmstat_oom_kill from node_exporter together with container_oom_events_total from cAdvisor. The former is node-wide and the latter is per container, so the two events we separated above are captured by two different metrics.
Third, consider a userspace OOM manager. The kernel waits until reclaim has failed completely, but userspace tools set their own thresholds.
earlyoomwatches theMemAvailableandSwapFreeratios and sends SIGTERM when they drop below a threshold, then SIGKILL if they drop further. It is simple to configure and works on any distribution.systemd-oomdis PSI based. It watchesmemory.pressureper cgroup and, when the threshold is exceeded for a sustained period, cleans up whatever is under the most pressure in that slice. Because it uses pressure as its metric, it has fewer false positives than an absolute capacity threshold.
# earlyoom: step in below 5% available memory and 5% swap
sudo systemctl edit earlyoom.service
# [Service]
# Environment=EARLYOOM_ARGS=-m 5 -s 5 --avoid '(^|/)(sshd|systemd)$' --prefer '(^|/)java$'
# systemd-oomd: per-slice pressure threshold
sudo systemctl edit myapp.service
# [Service]
# ManagedOOMMemoryPressure=kill
# ManagedOOMMemoryPressureLimit=60%
systemd-oomd only acts when the threshold is continuously exceeded for DefaultMemoryPressureDurationSec (30 seconds by default) in /etc/systemd/oomd.conf, so it does not react to momentary spikes.
Fourth, measure the actual usage again. Raising the limit is not a root cause fix. For a JVM, check whether -XX:MaxRAMPercentage recognizes the container limit; for Go, check whether GOMEMLIMIT is set; and check whether native heap fragmentation or a glibc arena issue is at play. smem or /proc/PID/smaps_rollup shows you the composition of RSS.
sudo cat /proc/21874/smaps_rollup
# 7f2c00000000-7ffd1a3e5000 ---p 00000000 00:00 0 [rollup]
# Rss: 11536876 kB
# Pss: 11498210 kB
# Anonymous: 11402344 kB
# AnonHugePages: 2097152 kB
# Shared_Clean: 34120 kB
# Private_Dirty: 11402344 kB
If Anonymous is most of RSS it is the application heap, and if Shared_Clean is large it is shared libraries, so the real cost is closer to Pss.
Closing — One constraint Line in the Log Decides Your Response
If you remember one thing, make it this. The first fork in the road of OOM debugging is global or cgroup, and the answer is already written in the constraint field of the oom-kill: line.
CONSTRAINT_NONEmeans the node is short on memory. Raising the pod limit will not stop the recurrence. You have to grow the node or lower the density.CONSTRAINT_MEMCGmeans it is a limit problem for that container. The node is fine. Raising the limit or reducing application usage is the right move.
And then the remaining rules.
- Do not conclude OOM from exit code 137 alone. Confirm with the
reasonfield or the kernel log. - Find the culprit by
rss, not bytotal_vm. Virtual memory does not enter the score. - Write the memory request honestly. The
oom_score_adjof a Burstable pod is derived from that value, and that is your survival ranking under node pressure. - Do not set only
MemoryMax; build a buffer zone withMemoryHigh. Slowing down before dying gives you time to diagnose. - Alert on
fullinmemory.pressure, not on theoom_killcounter. You need a warning in advance, not a notification after the fact.