- Published on
OOM Killer가 프로세스를 죽였을 때 — dmesg 로그 해독부터 cgroup OOM 구분까지
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — 애플리케이션 로그가 끝까지 멀쩡한데 프로세스가 없습니다
장애 보고는 대개 이렇게 옵니다. "서비스가 죽었는데 애플리케이션 로그에는 아무 에러도 없습니다. 마지막 줄이 정상 요청 처리입니다." 그리고 컨테이너 환경이라면 종료 코드가 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에 겁니다. 사후 통보보다 사전 경고가 필요합니다.