리눅스 OOM Killer 동작 원리와 oom_score_adj 튜닝

메모리가 정말 바닥나면 커널은 프로세스 하나를 희생시켜서라도 시스템 전체가 멈추는 걸 막는다. 문제는 OOM Killer가 고르는 희생양이 항상 원하는 프로세스가 아니라는 점이다 — 메모리를 많이 쓴다는 이유만으로 DB가 먼저 죽고, 죽어도 되는 임시 작업은 살아남는 경우가 있다. 이 글에서는 희생자를 고르는 기준(oom_score)과 oom_score_adj로 그 우선순위를 조정하는 방법을 실제로 메모리를 고갈시켜 검증한다.

oom_score와 oom_score_adj

파일범위의미
/proc/<pid>/oom_score0~1000커널이 계산한 현재 “죽여도 되는 정도” 점수. 메모리를 많이 쓸수록 높다(읽기 전용)
/proc/<pid>/oom_score_adj-1000~1000관리자가 직접 조정하는 가중치. -1000이면 사실상 OOM Killer 대상에서 제외, 1000이면 최우선 희생 대상

기본값 — 조정 없이는 크기가 곧 점수

200MB cgroup 안에 50MB·90MB·140MB(합 280MB > 200MB)를 할당하는 프로세스 3개를 oom_score_adj 조정 없이(전부 기본값 0) 동시에 넣었다.

small(50MB)=26524  medium(90MB)=26525  large(140MB)=26526

$ sudo dmesg -T | grep -i "killed process"
Killed process 26526 (python3) anon-rss:81292kB ... oom_score_adj:0   ← large
Killed process 26525 (python3) anon-rss:77824kB ... oom_score_adj:0   ← medium

=== 생존 확인 ===
pid 26524 ALIVE   (small, 50MB)
pid 26525 DEAD    (medium, 90MB)
pid 26526 DEAD    (large, 140MB)

큰 것부터 죽었고 가장 작은 프로세스만 살아남았다. 그런데 메모리를 많이 쓰는 게 오히려 제일 중요한 프로세스(DB 캐시 등)일 수도 있다 — 이걸 뒤집는 게 oom_score_adj다.

테스트 환경 — cgroup으로 안전하게 격리

시스템 전체 메모리+스왑을 다 채우는 대신, memory cgroup으로 200MB짜리 방을 만들어 그 안에서만 OOM을 재현했다. oom_score_adj가 victim 선정에 미치는 영향은 전역 OOM이든 cgroup 범위 OOM이든 동일한 로직(badness())을 쓴다.

$ sudo mkdir /sys/fs/cgroup/oomtest
$ echo 200M | sudo tee /sys/fs/cgroup/oomtest/memory.max
$ echo 0 | sudo tee /sys/fs/cgroup/oomtest/memory.swap.max

이 cgroup 안에 150MB씩 할당을 시도하는 프로세스 3개를 넣고(총 450MB > 200MB), 각각 oom_score_adj를 다르게 줬다.

$ echo 1000  | sudo tee /proc/$VICTIM_PID/oom_score_adj      # 최우선 희생
$ echo -1000 | sudo tee /proc/$PROTECTED_PID/oom_score_adj   # 보호
$ echo 0     | sudo tee /proc/$DEFAULT_PID/oom_score_adj     # 기본값

victim(P1)=17207    adj=1000   score=1333
protected(P2)=17208 adj=-1000  score=0
default(P3)=17209   adj=0      score=666

결과

세 프로세스가 동시에 할당을 시작하자 cgroup 한도를 즉시 넘겼고, 커널 로그에 두 번의 kill이 기록됐다.

$ sudo dmesg -T | grep -i "killed process"
Memory cgroup out of memory: Killed process 17207 (python3) ... oom_score_adj:1000
Memory cgroup out of memory: Killed process 17209 (python3) ... oom_score_adj:0
프로세스oom_score_adj결과
victim (17207)1000가장 먼저 kill — 할당 시작도 못해봄
default (17209)077MB쯤 할당하다 두 번째로 kill
protected (17208)-1000150MB 전량 할당 성공, 생존

점수가 높은 순서(1333 → 666)대로 정확히 죽었고, 점수 0인 프로세스는 나머지 둘이 죽고도 여유가 안 생기지 않는 한 살아남았다.

덩치가 가장 커도 보호된다

앞의 두 실험은 메모리 사용량이 비슷했다. 이번엔 진짜 중요한 프로세스가 그중 메모리를 가장 많이 쓰는, 더 현실적인 상황을 재현했다 — 150MB짜리 “DB”에 -1000을 걸고, 훨씬 작은 30MB·40MB 프로세스는 기본값(0) 그대로 뒀다.

bigdb(150MB, adj=-1000)=31784  small1(30MB, adj=0)=31785  small2(40MB, adj=0)=31786

$ sudo dmesg -T | grep -i "killed process"
Killed process 31786 (python3) anon-rss:43904kB ... oom_score_adj:0   ← small2
Killed process 31785 (python3) anon-rss:33660kB ... oom_score_adj:0   ← small1

=== 생존 확인 ===
pid 31784 ALIVE   (bigdb, 150MB)
pid 31785 DEAD    (small1, 30MB)
pid 31786 DEAD    (small2, 40MB)

메모리를 가장 많이 쓴 프로세스가 오히려 살아남고, 훨씬 작은 두 프로세스가 대신 죽었다. oom_score_adj는 이렇게 “크기 순”이라는 기본 규칙 자체를 뒤집을 만큼 강하게 작동한다.

주의사항

항목내용
-1000은 완전 면제가 아니다다른 모든 프로세스를 죽여도 메모리가 부족하면 -1000짜리도 결국 죽는다. “최후의 보루”일 뿐 절대 안전은 아니다
systemd 서비스는 OOMScoreAdjust로유닛 파일에 OOMScoreAdjust=-500 같은 값을 넣으면 서비스 시작 시 자동으로 적용된다. 매번 수동으로 oom_score_adj를 쓸 필요 없음
부모/자식 프로세스 상속fork된 자식은 부모의 oom_score_adj를 물려받는다. 워커 프로세스를 여럿 띄우는 서비스라면 부모에만 설정해도 전체에 반영된다
root 권한 필요자신의 프로세스가 아니면 oom_score_adj 쓰기에 CAP_SYS_RESOURCE(사실상 root) 필요

마무리

기본값만 두면 메모리를 많이 쓰는 프로세스가 곧 우선 희생 대상이지만, oom_score_adj -1000을 걸면 크기와 무관하게 보호된다. DB·메시지 브로커처럼 죽으면 안 되는 프로세스는 미리 보호해두고, 언제 죽어도 상관없는 배치/캐시 워커는 양수 값으로 먼저 희생되게 하면 장애 상황에서 살아남을 프로세스를 예측 가능한 범위로 만들 수 있다.

참고

Kernel.org – OOM Killer

답글 남기기