OOM 킬러 (Out-Of-Memory Killer)
리눅스 커널의 메모리 고갈 대응 메커니즘인 OOM Killer를 심층 분석합니다. 페이지(Page) 할당 slowpath에서 OOM이 트리거되는 경로, oom_badness() 스코어링 알고리즘, OOM Reaper의 비동기 회수, vm.overcommit_memory 정책, cgroup v2 memory.oom.group 계층적 OOM, PSI 기반 선제적 방지 전략, earlyoom/systemd-oomd 유저스페이스 킬러, ftrace/dmesg 디버깅(Debugging) 기법, 컨테이너(Container)/DB/임베디드 환경별 운영 플레이북을 커널 소스 기반으로 분석합니다.
핵심 요약
- 최후의 수단 — OOM Killer는 직접 회수(direct reclaim), kswapd, compaction이 모두 실패한 후 호출되는 마지막 방어선입니다.
- 점수 기반 희생자 선정 —
oom_badness()가 각 프로세스(Process)의 RSS 사용량과oom_score_adj가중치를 조합하여 0~1000 점수를 산출하고, 최고 점수 프로세스를 종료합니다. - OOM Reaper — 커널 4.6+에서 도입된 전용 커널 스레드(Kernel Thread)가 SIGKILL을 받은 프로세스의 익명 페이지(Anonymous Page)를 비동기적으로 즉시 회수하여 교착 상태(Deadlock)를 방지합니다.
- 오버커밋 정책 —
vm.overcommit_memory0(휴리스틱)/1(항상 허용)/2(엄격 제한)으로 가상 메모리(Virtual Memory) 할당 전략을 제어합니다. - cgroup 계층적 OOM — cgroup v2의
memory.oom.group을 사용하면 개별 프로세스 대신 전체 cgroup을 단위로 종료할 수 있습니다.
단계별 이해
- 메모리 할당 실패 경로 이해
alloc_pages() → __alloc_pages_slowpath() → __alloc_pages_may_oom()으로 이어지는 OOM 진입 경로를 파악합니다. - 스코어링 알고리즘 분석
oom_badness()가 RSS 기반으로 점수를 계산하고 oom_score_adj로 보정하는 방식을 이해합니다. - OOM Reaper 동작 추적
SIGKILL 전달 후 OOM Reaper가 익명 페이지를 비동기 회수하는 흐름을 확인합니다. - 오버커밋과 cgroup 정책 설정
vm.overcommit_memory, memory.max, memory.oom.group 등을 실무에 맞게 설정합니다. - 모니터링과 대응 구축
PSI, earlyoom, systemd-oomd를 활용한 선제적 OOM 방지 체계를 구성합니다.
Documentation/admin-guide/mm/concepts.rst, Documentation/admin-guide/cgroup-v2.rst (memory controller 섹션).
OOM Killer의 현재 구현은 mm/oom_kill.c에 있으며, overcommit 로직은 mm/util.c의 __vm_enough_memory()에 구현되어 있습니다.
종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.
OOM 개요: 왜 OOM Killer가 필요한가
리눅스 커널은 가상 메모리 오버커밋(overcommit) 모델을 사용합니다. 프로세스가 malloc()이나 mmap()으로 메모리를 요청하면, 실제 물리 페이지를 즉시 할당하지 않고 가상 주소 공간(Address Space)만 확장합니다. 물리 페이지는 실제로 접근(page fault)할 때 비로소 할당됩니다. 이 지연(Latency) 할당(lazy allocation) 전략은 메모리 효율성을 극대화하지만, 근본적인 문제를 내포합니다.
모든 프로세스가 약속받은 가상 메모리를 동시에 사용하면, 물리 메모리(Physical Memory) + 스왑(Swap) 공간이 부족해지는 순간이 옵니다. 커널은 이 상황에서 다음 순서로 메모리 회수(Memory Reclaim)를 시도합니다:
| 단계 | 메커니즘 | 비용 | 설명 |
|---|---|---|---|
| 1 | 페이지 캐시(Page Cache) 회수 | 낮음 | 파일 백업 페이지를 디스크로 반환 (clean page drop) |
| 2 | kswapd 비동기 회수 | 낮음~중간 | 백그라운드 스레드(Thread)가 watermark 이하 시 회수 |
| 3 | 직접 회수 (direct reclaim) | 중간 | 할당 요청 컨텍스트에서 동기적 회수 |
| 4 | Compaction | 중간~높음 | 고차 할당을 위한 단편화(Fragmentation) 해소 |
| 5 | 스왑 아웃 | 높음 | 익명 페이지를 스왑 디바이스로 이동 |
| 6 | OOM Killer | 치명적 | 프로세스를 강제 종료하여 메모리 확보 |
1~5단계가 모두 실패하면, 커널은 두 가지 선택지만 남습니다: 시스템 전체를 멈추거나, 일부 프로세스를 희생시켜 나머지를 살리거나. OOM Killer는 후자를 선택하는 메커니즘입니다.
OOM Killer 역사
| 커널 버전 | 변경 | 의미 |
|---|---|---|
| 2.6.11 | /proc/<pid>/oom_adj 도입 | 사용자 공간(User Space)에서 OOM 점수 조정 가능 |
| 2.6.36 | oom_score_adj 도입, oom_adj 폐기 | -1000~+1000 범위의 선형 스코어링 |
| 4.6 | OOM Reaper 도입 | 비동기 메모리 회수로 교착 방지 |
| 4.19 | cgroup v2 memory.oom.group | cgroup 단위 OOM kill 지원 |
| 5.2 | PSI (Pressure Stall Information) | 메모리 압력 모니터링 인터페이스 |
| 6.1 | per-cgroup OOM 개선 | memcg OOM 핸들링 리팩토링 |
메모리 워터마크(Watermark)와 OOM 관계
리눅스 커널은 각 메모리 존(Zone)에 세 단계의 워터마크(min, low, high)를 설정하여 메모리 가용성을 관리합니다. OOM Killer는 이 워터마크 체계의 최후 방어선에 위치합니다. vm.min_free_kbytes가 WMARK_MIN의 기준이 되며, vm.watermark_scale_factor가 min↔low↔high 간격을 결정합니다.
# 현재 워터마크 확인
grep -E 'min|low|high' /proc/zoneinfo | head -12
# min 16384 ← WMARK_MIN (페이지 단위)
# low 20480 ← WMARK_LOW
# high 24576 ← WMARK_HIGH
# 워터마크 간격 조절 (kswapd를 더 일찍 깨움)
sysctl -w vm.watermark_scale_factor=150 # 기본 10 → 150 (1.5%)
# WMARK_MIN 기준값 변경
sysctl -w vm.min_free_kbytes=131072 # 128MB
vm.watermark_scale_factor를 높이면 WMARK_LOW와 WMARK_HIGH 간격이 넓어져 kswapd가 더 일찍, 더 오래 동작합니다. 이는 direct reclaim 빈도를 줄이고 OOM 발생 확률을 낮춥니다. 메모리 집약적 워크로드에서는 기본값 10을 150~300으로 올리는 것이 효과적입니다.
OOM 트리거 경로: alloc slowpath에서 OOM까지
OOM Killer가 호출되는 정확한 경로를 이해하려면 페이지 할당의 slowpath를 추적해야 합니다. __alloc_pages()가 빠른 경로에서 실패하면 __alloc_pages_slowpath()로 진입하고, 여러 회수 시도 후 최종적으로 __alloc_pages_may_oom()을 호출합니다.
핵심 코드 경로를 살펴보겠습니다:
/* mm/page_alloc.c — __alloc_pages_may_oom() 핵심 흐름 */
static inline struct page *
__alloc_pages_may_oom(gfp_t gfp_mask, unsigned int order,
const struct alloc_context *ac,
unsigned long *did_some_progress)
{
struct oom_control oc = {
.zonelist = ac->zonelist,
.nodemask = ac->nodemask,
.memcg = NULL,
.gfp_mask = gfp_mask,
.order = order,
};
*did_some_progress = 0;
/* __GFP_NOFAIL은 OOM을 건너뛰지 않음 — 반드시 재시도 */
if (gfp_mask & __GFP_NOFAIL)
goto retry;
/* OOM 직렬화: 동시에 하나의 OOM만 진행 */
if (!mutex_trylock(&oom_lock)) {
*did_some_progress = 1;
return NULL; /* 다른 OOM 진행 중 → 재시도 */
}
/* 마지막 watermark 체크 — 그사이 페이지가 해제되었을 수 있음 */
if (get_page_from_freelist(gfp_mask, order, ac)) {
mutex_unlock(&oom_lock);
return page;
}
/* 진짜 OOM 진입 */
if (out_of_memory(&oc))
*did_some_progress = 1;
mutex_unlock(&oom_lock);
return NULL;
}
설명
oom_lock mutex는 시스템 전체에서 동시에 여러 OOM이 발생하는 것을 방지합니다. mutex_trylock()이 실패하면 다른 CPU에서 이미 OOM을 처리 중이므로, 현재 할당 요청은 단순히 재시도합니다. 또한 OOM 진입 직전에 마지막으로 freelist를 확인하여 불필요한 kill을 방지합니다.
struct oom_control
out_of_memory()에 전달되는 struct oom_control은 OOM 결정에 필요한 모든 컨텍스트를 담고 있습니다:
/* include/linux/oom.h */
struct oom_control {
struct zonelist *zonelist; /* 할당 시도한 zonelist */
nodemask_t *nodemask; /* NUMA 노드 마스크 */
struct mem_cgroup *memcg; /* memcg OOM 시 해당 cgroup */
const struct oom_control *parent; /* 상위 cgroup context */
gfp_t gfp_mask; /* 원래 할당 GFP 플래그 */
unsigned int order; /* 할당 order */
unsigned long totalpages; /* 스코어링용 총 페이지 수 */
struct task_struct *chosen; /* 선택된 희생자 */
long chosen_points; /* 희생자의 badness 점수 */
};
oom_badness() 스코어링 알고리즘
oom_badness()는 OOM Killer의 핵심입니다. 각 프로세스에 0~1000 범위의 "나쁜 정도" 점수를 매기고, 가장 높은 점수를 받은 프로세스가 종료 대상이 됩니다. 점수 산출 공식은 놀라울 정도로 단순합니다:
/* mm/oom_kill.c — oom_badness() 핵심 로직 */
long oom_badness(struct task_struct *p,
unsigned long totalpages)
{
long points;
long adj;
/* oom_score_adj == -1000이면 절대 kill하지 않음 */
adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN) /* -1000 */
return LONG_MIN;
/* RSS + Swap + PageTable 페이지 합산 */
points = get_mm_rss(p->mm) +
get_mm_counter(p->mm, MM_SWAPENTS) +
mm_pgtables_bytes(p->mm) / PAGE_SIZE;
/* oom_score_adj 보정: 전체 메모리 대비 비율 조정 */
adj *= totalpages / 1000;
points += adj;
return points > 0 ? points : 1;
}
설명
스코어링의 핵심 원리는 "더 많은 메모리를 사용하는 프로세스를 종료하면 더 많은 메모리가 확보된다"는 것입니다. oom_score_adj는 이 기본 원리에 정책적 가중치를 추가합니다. 예를 들어 SSH 데몬처럼 시스템 관리에 필수적인 프로세스는 oom_score_adj=-999로 설정하여 보호하고, 덜 중요한 배치 작업은 +500으로 올려 우선 종료 대상으로 만듭니다.
oom_badness() 실전 수치 계산 예제
다음은 32GB(8,388,608페이지) 메모리 시스템에서 4개 프로세스의 OOM 점수가 어떻게 계산되는지 보여주는 예제입니다. oom_badness()는 각 프로세스의 RSS + swap + 페이지 테이블 사용량에 oom_score_adj 가중치를 적용하여 최종 점수를 산출합니다.
| 프로세스 | RSS (페이지) | Swap (페이지) | PageTable (페이지) | 합계 | oom_score_adj | adj 보정값 | 최종 점수 | 결과 |
|---|---|---|---|---|---|---|---|---|
| nginx | 51,200 (200MB) | 0 | 100 | 51,300 | 0 | 0 | 51,300 | - |
| java-app | 1,572,864 (6GB) | 262,144 (1GB) | 3,600 | 1,838,608 | 0 | 0 | 1,838,608 | ← 희생자 |
| sshd | 2,560 (10MB) | 0 | 20 | 2,580 | -999 | −8,380,220 | 1 (하한) | 보호됨 |
| batch-job | 768,000 (3GB) | 128,000 (500MB) | 1,800 | 897,800 | 500 | +4,194,304 | 5,092,104 | ← adj 높으면 역전 |
/* 점수 계산 과정 (batch-job 예시) */
points = 897800; /* RSS + swap + pgtables */
adj = oc->totalpages / 1000 * oom_score_adj;
= 8388608 / 1000 * 500;
= 4194304;
points += adj; /* 897800 + 4194304 = 5092104 */
/* batch-job(5,092,104) > java-app(1,838,608) → batch-job이 희생자 */
oom_score_adj=500 설정으로 인해 최종 점수가 역전됩니다. adj = totalpages/1000 × oom_score_adj이므로 32GB 시스템에서 adj=500은 약 4GB 상당의 가중치를 추가합니다. 반대로 sshd의 adj=-999는 약 −8.4GB에 해당하여 사실상 OOM 대상에서 제외됩니다.
select_bad_process() / oom_evaluate_task()
out_of_memory()는 전체 프로세스 목록을 순회하며 oom_evaluate_task()로 각 태스크(Task)를 평가합니다:
/* mm/oom_kill.c — 희생자 선정 루프 */
static void select_bad_process(struct oom_control *oc)
{
struct task_struct *p;
rcu_read_lock();
for_each_process(p) {
/* 커널 스레드, init 프로세스(PID 1) 등 스킵 */
if (oom_unkillable_task(p))
continue;
/* 이미 exit 중인 태스크는 메모리 곧 해제됨 → 스킵 */
if (task_will_free_mem(p)) {
oc->chosen = p;
break;
}
/* oom_badness() 점수 계산 */
oom_evaluate_task(oc, p);
}
rcu_read_unlock();
}
static int oom_evaluate_task(struct oom_control *oc,
struct task_struct *task)
{
long points = oom_badness(task, oc->totalpages);
/* 현재 최고 점수보다 높으면 희생자 교체 */
if (points == LONG_MIN || points <= oc->chosen_points)
return 1; /* 계속 순회 */
oc->chosen = task;
oc->chosen_points = points;
return 1;
}
/proc/[pid]/oom_score 읽기
사용자 공간에서 각 프로세스의 현재 OOM 점수를 확인할 수 있습니다:
# 모든 프로세스의 OOM 점수를 높은 순으로 정렬
for pid in /proc/[0-9]*; do
name=$(cat "$pid/comm" 2>/dev/null)
score=$(cat "$pid/oom_score" 2>/dev/null)
adj=$(cat "$pid/oom_score_adj" 2>/dev/null)
printf "%-6s %-20s score=%-6s adj=%s\n" \
"${pid##*/}" "$name" "$score" "$adj"
done | sort -t= -k2 -rn | head -20
oom_score_adj 튜닝 가이드
/proc/[pid]/oom_score_adj는 -1000에서 +1000까지의 정수 값으로, OOM Killer의 희생자 선정에 직접적인 영향을 줍니다. 이 값을 적절히 설정하는 것이 OOM 방지 전략의 핵심입니다.
| 값 | 의미 | 사용 예 |
|---|---|---|
-1000 | OOM 완전 면제 | sshd, init, systemd (최소한의 필수 프로세스만) |
-999 ~ -500 | 강력한 보호 | DB 서버, 핵심 서비스 |
-499 ~ -1 | 약한 보호 | 중요 애플리케이션 |
0 | 기본값 (조정 없음) | 일반 프로세스 |
+1 ~ +500 | 우선 종료 대상 | 캐시(Cache) 워커, 배치 작업 |
+501 ~ +1000 | 최우선 종료 대상 | 테스트 프로세스, 일시적 작업 |
# sshd를 OOM에서 보호 (-1000 = 완전 면제)
echo -1000 > /proc/$(pidof sshd)/oom_score_adj
# PostgreSQL을 강력히 보호
echo -900 > /proc/$(pidof postgres)/oom_score_adj
# 배치 워커를 우선 종료 대상으로 설정
echo +500 > /proc/$(pidof batch-worker)/oom_score_adj
oom_score_adj=-1000을 남용하지 마세요. 모든 프로세스가 면제되면 OOM Killer는 종료할 대상을 찾지 못하고, 커널은 panic하거나 시스템이 완전히 멈춥니다. 진짜 필수 프로세스(sshd, init 등)에만 사용하세요.
systemd에서 oom_score_adj 설정
# /etc/systemd/system/myservice.service
[Service]
OOMScoreAdjust=-900
ExecStart=/usr/bin/myservice
# 또는 drop-in override:
systemctl edit myservice
# [Service]
# OOMScoreAdjust=-900
폐기된 oom_adj
레거시 /proc/[pid]/oom_adj (-17~+15)는 커널 2.6.36에서 폐기되었습니다. 커널 내부적으로 oom_adj 값을 oom_score_adj로 자동 변환합니다:
/* 변환 공식 */
oom_score_adj = oom_adj * (OOM_SCORE_ADJ_MAX / -OOM_DISABLE);
/* oom_adj * (1000 / 17) ≈ oom_adj * 58.8 */
/* 특수값: oom_adj == -17 → oom_score_adj = -1000 (면제) */
OOM Reaper: 비동기 메모리 회수
커널 4.6에서 도입된 OOM Reaper는 OOM Killer의 근본적인 교착 문제를 해결합니다. SIGKILL을 받은 프로세스가 mmap_lock을 보유한 채 블록되어 있으면, 그 프로세스의 exit_mmap()이 영원히 실행되지 않아 메모리가 해제되지 않습니다. OOM Reaper는 이 교착을 우회합니다.
/* mm/oom_kill.c — OOM Reaper 핵심 코드 (간략화) */
static bool oom_reap_task_mm(struct task_struct *tsk,
struct mm_struct *mm)
{
struct vm_area_struct *vma;
bool ret = true;
/* trylock으로 교착 회피 — 실패 시 나중에 재시도 */
if (!mmap_read_trylock(mm))
return false;
/* 모든 VMA를 순회하며 익명 페이지만 해제 */
for_each_vma(vmi, vma) {
/* VM_HUGETLB, VM_PFNMAP 등 특수 매핑은 스킵 */
if (vma->vm_flags & (VM_HUGETLB | VM_PFNMAP))
continue;
/* 파일 백업 VMA는 스킵 (page cache에서 관리) */
if (vma_is_anonymous(vma) || !(vma->vm_flags & VM_SHARED))
unmap_page_range(&tlb, vma, vma->vm_start,
vma->vm_end, NULL);
}
mmap_read_unlock(mm);
return ret;
}
mmap_read_trylock()이 실패하면, OOM Reaper는 최대 10회 재시도(MAX_OOM_REAP_RETRIES)합니다. 모든 재시도가 실패하면 해당 태스크를 포기하고, 프로세스의 자연적인 exit_mmap()에 의존합니다.
TIF_MEMDIE 메커니즘 상세
TIF_MEMDIE는 OOM Killer가 선정한 희생자 프로세스에 설정하는 스레드 정보 플래그(Thread Information Flag)입니다. 이 플래그는 단순한 표시가 아니라, 메모리 할당 경로에서 특별한 권한을 부여하는 핵심 메커니즘입니다.
/* include/linux/sched.h — TIF_MEMDIE 관련 헬퍼 */
static inline bool tsk_is_oom_victim(struct task_struct *tsk)
{
return tsk->signal->oom_mm;
}
/* mm/oom_kill.c — mark/exit 쌍 */
void mark_oom_victim(struct task_struct *tsk)
{
struct mm_struct *mm = tsk->mm;
if (!cmpxchg(&tsk->signal->oom_mm, NULL, mm)) {
mmgrab(mm);
set_bit(MMF_OOM_VICTIM, &mm->flags);
}
/* 핵심: oom_victims 카운터 증가 */
atomic_inc(&oom_victims);
trace_mark_victim(task_pid_nr(tsk));
}
/* exit_mmap() 완료 후 호출 */
void exit_oom_victim(void)
{
if (!atomic_dec_return(&oom_victims))
wake_up_all(&oom_victims_wait);
}
oom_victims atomic 카운터가 0보다 크면 out_of_memory()는 새로운 OOM kill을 시작하지 않고 true를 반환합니다. 이는 현재 희생자가 메모리를 반환하기 전에 다른 프로세스가 연쇄적으로 kill되는 것을 방지합니다. 이 보호가 없으면 대규모 메모리 할당 폭주 시 불필요한 프로세스 다수가 종료될 수 있습니다.
vm.overcommit_memory 0/1/2 정책
오버커밋 정책은 가상 메모리 할당 요청을 수락할지 거부할지 결정합니다. vm.overcommit_memory sysctl로 제어하며, OOM 발생 빈도에 직접적인 영향을 줍니다.
오버커밋 관련 sysctl 설정
# 현재 설정 확인
cat /proc/sys/vm/overcommit_memory # 0, 1, 또는 2
cat /proc/sys/vm/overcommit_ratio # 기본 50 (%)
cat /proc/sys/vm/overcommit_kbytes # 0 (ratio 대신 절대값 사용 시)
# CommitLimit과 현재 커밋 상태 확인
grep -E 'Commit(Limit|ted_AS)' /proc/meminfo
# CommitLimit: 16384000 kB ← 허용 한도
# Committed_AS: 12500000 kB ← 현재 커밋된 양
# 모드 2 + overcommit_ratio 80%로 변경
sysctl -w vm.overcommit_memory=2
sysctl -w vm.overcommit_ratio=80
# 영구 적용: /etc/sysctl.d/99-overcommit.conf
vm.overcommit_memory = 2
vm.overcommit_ratio = 80
CommitLimit 계산
CommitLimit = (물리 RAM - hugetlb_pages) * overcommit_ratio / 100 + swap_total
예시:
물리 RAM = 16 GB, swap = 4 GB, overcommit_ratio = 50
CommitLimit = (16 - 0) * 50/100 + 4 = 12 GB
Committed_AS > 12 GB 시 → 새로운 mmap() 요청 거부 (ENOMEM)
overcommit_kbytes가 0이 아니면 overcommit_ratio 대신 절대값(KB)으로 CommitLimit을 계산합니다. 대규모 서버에서 비율보다 절대값이 직관적일 수 있습니다.
__vm_enough_memory() 구현 분석
__vm_enough_memory()는 가상 메모리 할당 요청을 수락할지 거부할지 결정하는 핵심 함수입니다. mmap(), brk(), mremap() 등의 시스템 호출에서 호출되며, vm.overcommit_memory 설정에 따라 다르게 동작합니다.
/* mm/util.c — __vm_enough_memory() 핵심 로직 (간략화) */
int __vm_enough_memory(struct mm_struct *mm,
long pages, int cap_sys_admin)
{
long allowed;
vm_acct_memory(pages); /* Committed_AS에 pages 추가 */
/* 모드 1: 항상 허용 (Always Overcommit) */
if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS)
return 0;
/* 모드 0: 경험적(Heuristic) 판단 */
if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) {
long free = global_zone_page_state(NR_FREE_PAGES);
free += global_node_page_state(NR_FILE_PAGES);
free -= global_node_page_state(NR_SHMEM);
if (free <= 0)
goto error;
free += get_nr_swap_pages();
if (free > pages)
return 0;
goto error;
}
/* 모드 2: 엄격한 한도 (Never Overcommit) */
allowed = vm_commit_limit();
if (cap_sys_admin)
allowed += sysctl_admin_reserve_kbytes >> (PAGE_SHIFT - 10);
if (vm_committed_as(mm) <= allowed)
return 0;
error:
vm_unacct_memory(pages); /* 거부 시 Committed_AS 복원 */
return -ENOMEM;
}
코드 설명
- 7행
vm_acct_memory()가Committed_ASper-cpu 카운터에 요청된 페이지 수를 선제적으로 추가합니다. 거부 시vm_unacct_memory()로 되돌립니다. - 10~11행
OVERCOMMIT_ALWAYS(모드 1)는 무조건 허용합니다. 할당 시점에 물리 메모리가 부족하면 OOM이 발생할 수 있어 위험합니다. - 14~24행
OVERCOMMIT_GUESS(모드 0, 기본값)는 여유 메모리 + 파일 캐시 − 공유 메모리 + 스왑을 합산하여 경험적으로 판단합니다. 정확한 커밋 한도 대신 현재 시점의 여유 자원을 기준으로 합니다. - 27~31행
OVERCOMMIT_NEVER(모드 2)는vm_commit_limit()이 반환하는 CommitLimit과 현재 Committed_AS를 비교합니다.CAP_SYS_ADMIN권한이 있으면admin_reserve_kbytes(기본 8MB)만큼 추가 허용합니다.
OVERCOMMIT_GUESS는 NR_FILE_PAGES(파일 캐시)를 회수 가능한 메모리로 간주합니다. 그러나 mlock()된 파일 캐시, tmpfs 데이터(NR_SHMEM으로 차감됨), 그리고 dirty 페이지의 writeback 지연은 고려하지 않습니다. 이 때문에 경험적 판단이 실제 가용 메모리보다 낙관적으로 계산되어 OOM이 발생할 수 있습니다. 데이터베이스처럼 메모리를 정밀 관리하는 서비스에는 모드 2가 권장됩니다.
cgroup v2 memory.oom.group 계층적 OOM
cgroup v2 메모리 컨트롤러는 계층적 OOM을 지원합니다. memory.max를 초과한 cgroup 내에서 개별 프로세스 대신 cgroup 전체를 OOM kill 단위로 사용할 수 있습니다.
cgroup v2 메모리 제한 파라미터
| 파일 | 기본값 | 동작 | 초과 시 |
|---|---|---|---|
memory.min | 0 | 절대 보호: 이 양 이하로 회수 불가 | 해당 없음 (보호 한도) |
memory.low | 0 | 소프트 보호: 회수 우선순위 낮춤 | best-effort 보호 |
memory.high | max | 소프트 제한: 초과 시 직접 회수 강제 | 프로세스 throttling |
memory.max | max | 하드 제한: 초과 시 OOM 트리거 | memcg OOM kill |
memory.oom.group | 0 | 1이면 OOM 시 cgroup 전체 kill | 그룹 단위 종료 |
# cgroup v2 메모리 제한 설정 예시
mkdir -p /sys/fs/cgroup/web.slice
# 하드 제한 4GB, 소프트 보호 2GB
echo "4G" > /sys/fs/cgroup/web.slice/memory.max
echo "2G" > /sys/fs/cgroup/web.slice/memory.low
# OOM 발생 시 cgroup 전체 kill 활성화
echo 1 > /sys/fs/cgroup/web.slice/memory.oom.group
# 현재 메모리 사용량 확인
cat /sys/fs/cgroup/web.slice/memory.current
cat /sys/fs/cgroup/web.slice/memory.stat
memcg OOM 처리 흐름
cgroup 메모리 제한에 의한 OOM은 전역 OOM과 다른 경로를 따릅니다:
/* mm/memcontrol.c — memcg OOM 진입 */
static enum oom_status mem_cgroup_oom(
struct mem_cgroup *memcg, gfp_t mask, int order)
{
/* memcg 범위 내에서만 희생자 탐색 */
if (memcg->oom_group)
/* oom.group=1: cgroup 전체 프로세스에 SIGKILL */
mem_cgroup_kill(memcg);
else
/* oom.group=0: 기존 방식으로 개별 프로세스 선정 */
out_of_memory(&oc);
}
oom.group=1로 전체를 함께 종료시킨 후 깨끗하게 재시작(Reboot)하는 것이 운영적으로 더 안정적입니다.
PSI 기반 선제적 OOM 방지
PSI(Pressure Stall Information)는 커널 5.2+에서 제공하는 메모리/IO/CPU 압력 모니터링 인터페이스입니다. OOM이 발생하기 전에 메모리 압력을 감지하여 선제적으로 대응할 수 있게 해줍니다.
PSI 인터페이스
# /proc/pressure/memory 읽기
cat /proc/pressure/memory
some avg10=4.50 avg60=2.30 avg300=1.10 total=1234567
full avg10=0.80 avg60=0.40 avg300=0.20 total=567890
# some: 최소 한 태스크가 메모리 대기 중인 시간 비율
# full: 모든 태스크가 메모리 대기 중인 시간 비율 (= 시스템 정지)
# avg10/60/300: 10초/60초/300초 이동 평균 (%)
# PSI 트리거 등록 (poll 기반 모니터링)
# "some 150000 1000000" = 1초 윈도우 내 150ms 이상 some 압력 감지
echo "some 150000 1000000" > /proc/pressure/memory
# → poll/epoll로 감시하면 트리거 시 POLLPRI 이벤트 발생
PSI 트리거 활용 코드
/* PSI 트리거 기반 선제적 OOM 방지 예시 */
int monitor_memory_pressure(void)
{
int fd = open("/proc/pressure/memory", O_RDWR | O_NONBLOCK);
const char *trigger = "some 150000 1000000";
/* PSI 트리거 등록: 1초 내 150ms 이상 메모리 압력 */
write(fd, trigger, strlen(trigger));
struct pollfd fds = { .fd = fd, .events = POLLPRI };
while (1) {
int ret = poll(&fds, 1, -1);
if (ret > 0 && (fds.revents & POLLPRI)) {
/* 메모리 압력 감지! 선제적 조치 수행 */
drop_caches();
kill_low_priority_tasks();
}
}
}
earlyoom / systemd-oomd 유저스페이스 OOM 킬러
커널 OOM Killer는 "이미 너무 늦은" 시점에 동작합니다. 유저스페이스 OOM 킬러는 메모리 압력이 임계값에 도달하면 커널 OOM 전에 개입합니다.
earlyoom
earlyoom은 단순하고 효과적인 유저스페이스 OOM 킬러입니다:
| 특징 | 설명 |
|---|---|
| 감시 주기 | 100ms마다 /proc/meminfo와 /proc/pressure/memory 체크 |
| SIGTERM 단계 | RAM/Swap 사용률이 첫 번째 임계값 초과 시 (기본 90%) |
| SIGKILL 단계 | SIGTERM으로 해결 안 될 때 두 번째 임계값 초과 시 (기본 95%) |
| 대상 선정 | oom_score 최고 프로세스 (커널과 동일한 기준) |
| 보호 기능 | --prefer/--avoid regex로 대상 선택/제외 |
# earlyoom 설치 및 실행
apt install earlyoom # Debian/Ubuntu
dnf install earlyoom # Fedora/RHEL
# 기본 설정으로 실행
earlyoom -m 10 -s 10 # RAM 10%, Swap 10% 남으면 SIGTERM
# 고급 설정
earlyoom \
-m 5,3 \ # SIGTERM at 5% free, SIGKILL at 3% free
-s 10,5 \ # swap thresholds
--avoid '^(sshd|postgres)$' \ # 보호 대상
--prefer '^(chromium|java)$' \ # 우선 종료 대상
-n \ # 시스템 로그에 알림 전송
-r 3600 # 1시간마다 메모리 통계 보고
# /etc/default/earlyoom 파일로 영구 설정
EARLYOOM_ARGS="-m 5,3 -s 10,5 --avoid '^(sshd|postgres)$' -n"
systemd-oomd
systemd-oomd는 systemd 248+에서 기본 제공되는 PSI 기반 OOM 킬러입니다. cgroup 단위로 동작하며 systemd 서비스 설정과 자연스럽게 통합됩니다.
# systemd-oomd 상태 확인
systemctl status systemd-oomd
# 서비스별 OOM 정책 설정
# /etc/systemd/system/myapp.service.d/override.conf
[Service]
ManagedOOMSwap=kill # swap 사용률 기반 kill
ManagedOOMMemoryPressure=kill # PSI 메모리 압력 기반 kill
ManagedOOMMemoryPressureLimit=60% # 60% 압력 초과 시 kill
ManagedOOMPreference=avoid # 이 서비스는 보호 (avoid/omit/none)
# 모니터링
oomctl # 현재 OOM 모니터링 상태
journalctl -u systemd-oomd -f # 실시간 로그
ftrace/dmesg로 OOM 디버깅
OOM이 발생하면 원인 분석이 필수적입니다. 커널은 ftrace 이벤트와 dmesg 출력으로 풍부한 디버깅 정보를 제공합니다.
ftrace OOM 관련 이벤트
# ftrace를 사용한 OOM 관련 이벤트 추적
# 1. OOM score adj 변경 추적
echo 1 > /sys/kernel/tracing/events/oom/oom_score_adj_update/enable
# 2. 페이지 할당 실패 추적
echo 1 > /sys/kernel/tracing/events/kmem/mm_page_alloc/enable
# 3. 직접 회수(direct reclaim) 추적
echo 1 > /sys/kernel/tracing/events/vmscan/mm_vmscan_direct_reclaim_begin/enable
echo 1 > /sys/kernel/tracing/events/vmscan/mm_vmscan_direct_reclaim_end/enable
# 4. 트레이스 로그 확인
cat /sys/kernel/tracing/trace_pipe
# 5. 종합 추적 스크립트
echo 0 > /sys/kernel/tracing/tracing_on
echo "nop" > /sys/kernel/tracing/current_tracer
echo 1 > /sys/kernel/tracing/events/oom/enable
echo 1 > /sys/kernel/tracing/events/vmscan/enable
echo 1 > /sys/kernel/tracing/tracing_on
# ... OOM 재현 ...
echo 0 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace > /tmp/oom-trace.log
perf record -e oom:* -a -- sleep 60으로 OOM 이벤트를 perf로 기록하면 타임스탬프와 스택트레이스를 함께 분석할 수 있습니다.
OOM dmesg 메시지 해부
OOM이 발생하면 커널은 상세한 진단 메시지를 dmesg에 출력합니다. 이 메시지를 읽는 법을 정확히 알아야 원인 분석이 가능합니다.
[ 234.567890] myapp invoked oom-killer:
gfp_mask=0x6200ca(GFP_HIGHUSER_MOVABLE),
order=0, oom_score_adj=0
[ 234.567891] CPU: 2 PID: 1234 Comm: myapp Not tainted 6.1.0 #1
[ 234.567895] Mem-Info:
[ 234.567896] active_anon:524288 inactive_anon:262144 isolated_anon:0
active_file:16384 inactive_file:8192 isolated_file:0
unevictable:4096 dirty:256 writeback:0
slab_reclaimable:32768 slab_unreclaimable:16384
mapped:65536 shmem:8192 pagetables:4096
[ 234.567900] Node 0 DMA free:2048kB min:64kB low:80kB high:96kB
[ 234.567901] Node 0 DMA32 free:32768kB min:16384kB low:20480kB high:24576kB
[ 234.567902] Node 0 Normal free:8192kB min:65536kB low:81920kB high:98304kB
[ 234.567910] oom-kill:constraint=CONSTRAINT_NONE,
nodemask=(null),
cpuset=/,
mems_allowed=0,
oom_memcg=/user.slice/user-1000.slice,
task_memcg=/user.slice/user-1000.slice/session.scope,
task=myapp, pid=1234, uid=1000
[ 234.567915] Out of memory: Killed process 1234 (myapp)
total-vm:4194304kB, anon-rss:3145728kB,
file-rss:8192kB, shmem-rss:4096kB,
UID:1000, pgtables:12288kB, oom_score_adj:0
주요 필드 해설
| 필드 | 의미 | 분석 포인트 |
|---|---|---|
gfp_mask | OOM을 유발한 메모리 할당 플래그 | GFP_KERNEL이면 커널 할당, GFP_HIGHUSER_MOVABLE이면 유저 할당 |
order | 요청한 페이지 order (2^N pages) | order=0(4KB)이면 단순 부족, 높은 order이면 단편화 문제 |
active_anon | 활성 익명 페이지 수 | 높으면 프로세스 메모리 사용량이 많음 |
inactive_file | 비활성 파일 캐시 페이지 | 낮으면 이미 캐시가 거의 회수됨 |
slab_reclaimable | 회수 가능한 slab 메모리 | 높은데 OOM이면 shrinker 문제 가능 |
constraint | OOM 제약 유형 | CONSTRAINT_MEMCG이면 cgroup 제한, CONSTRAINT_NONE이면 시스템 전체 |
total-vm | 프로세스 가상 메모리 총량 | anon-rss와 비교하여 실제 사용률 파악 |
anon-rss | 익명 RSS (실제 물리 메모리) | OOM 점수의 핵심 요소 |
# dmesg에서 OOM 관련 메시지만 추출
dmesg | grep -i "oom\|out of memory\|killed process"
# 최근 OOM kill 이력 (journald)
journalctl -k --grep="oom-kill\|Out of memory" --since="1 hour ago"
# OOM 메시지를 파일로 저장하여 분석
dmesg -T | grep -A 50 "invoked oom-killer" > /tmp/oom-analysis.log
OOM 로그 자동 분석
OOM 발생 시 dmesg에 출력되는 정보를 자동으로 파싱하여 핵심 정보를 추출하는 스크립트입니다.
#!/bin/bash — OOM 로그 분석 스크립트
# 사용법: ./oom-analyze.sh [dmesg 로그 파일]
LOG="${1:-/var/log/kern.log}"
echo "=== OOM Killer 발생 요약 ==="
# OOM 발생 횟수
OOM_COUNT=$(grep -c "invoked oom-killer" "$LOG")
echo "총 OOM 발생 횟수: $OOM_COUNT"
# kill된 프로세스 목록 (이름, PID, RSS)
echo ""
echo "=== Kill된 프로세스 목록 ==="
grep "Killed process" "$LOG" | \
sed -E 's/.*Killed process ([0-9]+) \(([^)]+)\) total-vm:([0-9]+)kB, anon-rss:([0-9]+)kB.*/PID=\1 CMD=\2 VM=\3kB RSS=\4kB/'
# 메모리 제약 유형 분석
echo ""
echo "=== OOM 제약 유형 ==="
grep -oP 'constraint=\K[A-Z_]+' "$LOG" | sort | uniq -c | sort -rn
# CONSTRAINT_NONE → 전역 메모리 부족
# CONSTRAINT_MEMCG → cgroup 한도 초과
# CONSTRAINT_CPUSET → NUMA cpuset 제한
# GFP 플래그 분석
echo ""
echo "=== 할당 컨텍스트 (GFP 플래그) ==="
grep -oP 'gfp_mask=0x[0-9a-f]+\(\K[^)]+' "$LOG" | sort | uniq -c | sort -rn
# oom_score_adj 분포
echo ""
echo "=== 현재 프로세스별 OOM 점수 (상위 10) ==="
for pid in /proc/[0-9]*; do
p=$(basename "$pid")
score=$(cat "$pid/oom_score" 2>/dev/null) || continue
name=$(cat "$pid/comm" 2>/dev/null)
adj=$(cat "$pid/oom_score_adj" 2>/dev/null)
echo "$score $p $name (adj=$adj)"
done | sort -rn | head -10
systemd-oomd가 PSI 기반으로 사용자 공간에서 OOM을 처리합니다. systemctl status systemd-oomd로 상태를 확인하고, oomctl 명령으로 모니터링 대상 cgroup과 현재 압력 수준을 확인할 수 있습니다. ManagedOOMSwap=kill, ManagedOOMMemoryPressure=kill 옵션으로 서비스 단위의 사전 OOM 정책을 설정할 수 있습니다.
OOM 우선순위 설계 전략
프로덕션 환경에서 OOM 발생 시 어떤 프로세스가 먼저 종료될지 체계적으로 설계해야 합니다. 다음은 일반적인 서버 환경의 우선순위 가이드라인입니다.
| 계층 | oom_score_adj | 예시 프로세스 | 정책 |
|---|---|---|---|
| 절대 보호 | -1000 | sshd, init/systemd | 시스템 관리 접근을 보장 |
| 최우선 보호 | -900 | DB (PostgreSQL, MySQL) | 데이터 무결성(Integrity) 보호 |
| 높은 보호 | -500 | 핵심 비즈니스 서비스 | 서비스 가용성 보장 |
| 기본 | 0 | 웹 서버 워커, API 서버 | 기본 점수로 경쟁 |
| 우선 종료 | +300 | 배치 처리, 크론 작업 | 지연 실행 가능 |
| 최우선 종료 | +800 | 캐시 워커, 로그 수집기 | 손실 허용, 즉시 종료 |
-900~-500 범위를 사용하세요.
자동화 스크립트
#!/bin/bash
# oom-priority-setup.sh — OOM 우선순위 자동 설정
declare -A OOM_POLICY=(
[sshd]="-1000"
[postgres]="-900"
[mysqld]="-900"
[nginx]="-200"
[php-fpm]="0"
[batch-worker]="+500"
[logstash]="+700"
)
for proc in "${!OOM_POLICY[@]}"; do
adj="${OOM_POLICY[$proc]}"
for pid in $(pgrep -x "$proc"); do
echo "$adj" > "/proc/$pid/oom_score_adj" 2>/dev/null && \
echo "[OK] $proc (PID $pid) → oom_score_adj=$adj"
done
done
cgroup 기반 OOM 보호 패턴 (컨테이너/K8s)
컨테이너 환경에서 OOM 관리는 cgroup 메모리 컨트롤러와 오케스트레이션 플랫폼의 이중 레이어로 동작합니다.
Kubernetes OOM과 eviction
Kubernetes는 두 단계의 메모리 보호 메커니즘을 사용합니다:
| 메커니즘 | 계층 | 동작 | 복구 |
|---|---|---|---|
| kubelet eviction | 유저스페이스 | memory.available < evictionThreshold 시 Pod 퇴거 | 다른 노드에 재스케줄링 |
| cgroup OOM | 커널 | 컨테이너 memory.max 초과 시 OOM kill | 컨테이너 재시작 (restartPolicy) |
| 커널 전역 OOM | 커널 | 노드 전체 메모리 부족 | 예측 불가, 중요 프로세스 피해 가능 |
Pod QoS 클래스와 OOM 우선순위
| QoS 클래스 | 조건 | oom_score_adj | eviction 순서 |
|---|---|---|---|
| Guaranteed | 모든 컨테이너 requests=limits | -997 | 마지막 (최강 보호) |
| Burstable | requests < limits | 2~999 | 중간 |
| BestEffort | requests/limits 미설정 | 1000 | 가장 먼저 |
# Kubernetes Pod 메모리 설정 예시 (Guaranteed QoS)
# pod-guaranteed.yaml
apiVersion: v1
kind: Pod
metadata:
name: critical-app
spec:
containers:
- name: app
image: myapp:latest
resources:
requests:
memory: "2Gi" # requests == limits → Guaranteed
limits:
memory: "2Gi"
# Burstable QoS 예시
resources:
requests:
memory: "1Gi" # requests < limits → Burstable
limits:
memory: "4Gi"
Docker 컨테이너 OOM 설정
# Docker 메모리 제한 설정
docker run -d \
--memory="2g" \ # memory.max = 2GB
--memory-reservation="1g" \ # memory.low = 1GB (소프트 보호)
--memory-swap="3g" \ # memory + swap = 3GB
--oom-score-adj=-500 \ # OOM 보호
--oom-kill-disable=false \ # OOM kill 허용 (기본값)
myapp:latest
# Docker OOM 이벤트 확인
docker inspect --format='{{.State.OOMKilled}}' mycontainer
docker events --filter "event=oom"
DB 서버 OOM 방지 패턴
데이터베이스 서버는 OOM에 가장 취약하면서도 가장 보호가 필요한 워크로드입니다. 대용량 공유 버퍼(Buffer), 정렬 버퍼, 연결별 메모리가 합산되면 물리 메모리를 쉽게 초과합니다.
PostgreSQL OOM 방지
# /etc/postgresql/15/main/postgresql.conf
# shared_buffers: 전체 RAM의 25% (전형적 권장값)
shared_buffers = '4GB' # 16GB RAM 기준
# work_mem: 정렬/해시 연산용 (연결당 × 쿼리당)
# 주의: 200 연결 × 5 정렬 연산 = 1000 × work_mem
work_mem = '16MB' # 보수적으로 설정
# maintenance_work_mem: VACUUM, CREATE INDEX 용
maintenance_work_mem = '512MB'
# effective_cache_size: OS 캐시 포함 총 캐시 크기
effective_cache_size = '12GB' # 쿼리 플래너 힌트 (실제 할당 아님)
# 최대 연결 수 제한 (각 연결은 ~10MB 기본 메모리)
max_connections = 200
# 메모리 계산:
# shared_buffers(4GB) + max_connections×work_mem(200×16MB=3.2GB)
# + maintenance(512MB) + OS overhead ≈ 8-9GB
# → 16GB RAM에서 안전
# PostgreSQL OOM 보호 systemd 설정
# /etc/systemd/system/postgresql.service.d/oom-protect.conf
[Service]
OOMScoreAdjust=-900
MemoryMax=12G
MemoryHigh=10G
MySQL/MariaDB OOM 방지
# /etc/mysql/mysql.conf.d/mysqld.cnf
# InnoDB 버퍼 풀: 전체 RAM의 50-70%
innodb_buffer_pool_size = 8G # 16GB RAM 기준
# 연결당 버퍼 (보수적으로)
sort_buffer_size = 2M
join_buffer_size = 2M
read_buffer_size = 1M
tmp_table_size = 64M
# 최대 연결 수
max_connections = 150
# 메모리 계산:
# innodb_buffer_pool(8GB) + 150×(sort+join+read)(~750MB)
# + tmp_tables + OS ≈ 10-11GB
총 예상 메모리 = innodb_buffer_pool_size + (max_connections * (sort_buffer_size + join_buffer_size + read_buffer_size + thread_stack)) + 기타 전역 버퍼이 값이 물리 RAM의 80%를 초과하지 않도록 설정하세요.
임베디드/IoT OOM 전략
임베디드 시스템은 메모리가 제한적(수십~수백 MB)이고 스왑이 없는 경우가 많아 OOM 관리가 더 중요합니다.
오버커밋 비활성화
# 임베디드 시스템: 오버커밋 모드 2 (엄격) 권장
sysctl -w vm.overcommit_memory=2
sysctl -w vm.overcommit_ratio=95 # RAM의 95%까지만 커밋
# mlockall()로 중요 프로세스 메모리 고정
# → 페이지 아웃 방지, 실시간 응답성 보장
실시간(Real-time) 프로세스 메모리 고정
/* 임베디드 실시간 앱에서 메모리 고정 */
int main(void)
{
/* 현재 + 미래의 모든 매핑을 RAM에 고정 */
if (mlockall(MCL_CURRENT | MCL_FUTURE) < 0) {
perror("mlockall failed");
return 1;
}
/* 스택도 사전 할당하여 page fault 방지 */
char stack_prefault[256 * 1024];
memset(stack_prefault, 0, sizeof(stack_prefault));
/* 실시간 루프 진입 */
realtime_loop();
return 0;
}
워치독과 OOM 연계
# 메모리 워치독 스크립트 (임베디드용)
#!/bin/bash
MEM_THRESHOLD=90 # 90% 사용률 임계값
while true; do
mem_used=$(free | awk '/Mem:/ {printf "%.0f", $3/$2*100}')
if [ "$mem_used" -gt "$MEM_THRESHOLD" ]; then
logger -p kern.warning "Memory usage ${mem_used}% - triggering cleanup"
# 페이지 캐시 강제 해제
sync
echo 3 > /proc/sys/vm/drop_caches
# 비필수 프로세스 종료
killall -TERM log-collector 2>/dev/null
fi
sleep 5
done
zram을 사용하면 RAM의 일부를 압축 스왑으로 활용할 수 있습니다. 메모리 1~2배의 효과적인 스왑 공간을 확보하여 OOM 발생을 지연시킵니다.
CONFIG 옵션
OOM 관련 커널 빌드 옵션과 sysctl 파라미터를 정리합니다.
주요 sysctl 상세
| 파라미터 | 기본값 | 설명 | 권장값 |
|---|---|---|---|
vm.panic_on_oom | 0 | OOM 시 panic 대신 프로세스 kill | 0 (프로덕션), 1 (kdump 분석 필요 시) |
vm.oom_kill_allocating_task | 0 | 1이면 OOM 유발 태스크 자체를 kill | 0 (기본값 유지) |
vm.oom_dump_tasks | 1 | OOM 시 모든 태스크 덤프(Dump) | 1 (디버깅용, 대규모 환경에서 0) |
vm.min_free_kbytes | 자동 계산 | 커널 예비 메모리 | RAM의 1~3% (64MB~256MB) |
# 프로덕션 서버 권장 sysctl 설정
# /etc/sysctl.d/99-oom.conf
# OOM 시 프로세스 kill (panic 아님)
vm.panic_on_oom = 0
# OOM 시 전체 태스크 목록 덤프 (디버깅용)
vm.oom_dump_tasks = 1
# 최소 여유 메모리 128MB
vm.min_free_kbytes = 131072
# 오버커밋: 휴리스틱 (기본)
vm.overcommit_memory = 0
# watermark 스케일 팩터 (기본 10 → 150으로 증가)
# kswapd가 더 일찍 깨어나 direct reclaim 빈도를 줄임
vm.watermark_scale_factor = 150
OOM 대응 플레이북
OOM이 발생했을 때 체계적으로 대응하기 위한 단계별 가이드입니다.
1단계: 즉시 조치 (5분 이내)
# 1. OOM 확인
dmesg -T | tail -100 | grep -i "oom\|killed"
# 2. 현재 메모리 상태
free -h
cat /proc/meminfo | grep -E "MemTotal|MemFree|MemAvail|Buffers|Cached|SwapTotal|SwapFree|Committed"
# 3. 메모리 사용 상위 프로세스
ps aux --sort=-%mem | head -20
# 4. 페이지 캐시 응급 해제
sync && echo 3 > /proc/sys/vm/drop_caches
# 5. 죽은 프로세스 재시작
systemctl restart myservice
2단계: 원인 분석 (30분)
# 1. OOM dmesg 상세 분석
dmesg -T | grep -B5 -A50 "invoked oom-killer"
# 2. 메모리 누수 의심 프로세스 확인
# smem이 설치되어 있다면:
smem -rs pss | head -20
# pmap으로 특정 프로세스 메모리 맵 분석
pmap -x $PID | tail -1
# 3. cgroup 메모리 사용량 확인
cat /sys/fs/cgroup/*/memory.current 2>/dev/null
cat /sys/fs/cgroup/*/memory.max 2>/dev/null
# 4. PSI 확인
cat /proc/pressure/memory
# 5. slab 메모리 분석 (커널 메모리 누수 의심 시)
slabtop -o | head -20
3단계: 재발 방지 (1일)
# 1. oom_score_adj 재검토
for pid in /proc/[0-9]*; do
name=$(cat "$pid/comm" 2>/dev/null)
adj=$(cat "$pid/oom_score_adj" 2>/dev/null)
[[ "$adj" -ne 0 ]] && echo "$name (${pid##*/}): adj=$adj"
done
# 2. earlyoom 설치/설정
apt install earlyoom
systemctl enable --now earlyoom
# 3. 메모리 모니터링 알림 설정
# Prometheus + node_exporter + alertmanager 조합
# 또는 간단한 cron 스크립트
# 4. 애플리케이션 메모리 제한 재검토
# Java: -Xmx, -XX:MaxRAMPercentage
# Python: ulimit -v, resource.setrlimit()
# Go: GOMEMLIMIT
# Node.js: --max-old-space-size
OOM 방지 체크리스트
| 항목 | 확인 | 설정 |
|---|---|---|
| vm.overcommit_memory | sysctl vm.overcommit_memory | 서버: 0, DB: 2, 임베디드: 2 |
| oom_score_adj | 핵심 서비스 보호 설정 확인 | sshd=-1000, DB=-900 |
| earlyoom/systemd-oomd | 유저스페이스 OOM 킬러 동작 확인 | systemctl status earlyoom |
| cgroup 메모리 제한 | 컨테이너별 memory.max 설정 | RAM의 70~80% 이내 |
| 모니터링 | PSI, /proc/meminfo 감시 | PSI some > 20% 알림 |
| swap 설정 | 적절한 swap 공간 확보 | RAM의 50~100% (상황별) |
| vm.min_free_kbytes | 커널 예비 메모리 | RAM의 1~3% |
| 앱 메모리 제한 | JVM -Xmx, Go GOMEMLIMIT 등 | cgroup 제한의 70~80% |
OOM Notifier Chain
커널 모듈(Kernel Module)은 oom_notify_list notifier chain에 콜백(Callback)을 등록하여 OOM 발생 시 알림을 받을 수 있습니다. 이를 통해 자체 메모리 회수 로직을 실행하거나 OOM kill을 회피할 수 있습니다.
/* 커널 모듈에서 OOM notifier 등록 */
static struct notifier_block my_oom_nb;
static int my_oom_handler(struct notifier_block *nb,
unsigned long action, void *data)
{
struct oom_control *oc = data;
pr_warn("OOM notifier: order=%u, memcg=%p\n",
oc->order, oc->memcg);
/* 자체 캐시에서 메모리 회수 시도 */
unsigned long freed = my_cache_shrink(1024);
if (freed > 0)
return NOTIFY_STOP; /* OOM kill 중단 요청 */
return NOTIFY_OK; /* OOM 진행 허용 */
}
static int __init my_init(void)
{
my_oom_nb.notifier_call = my_oom_handler;
my_oom_nb.priority = 0;
register_oom_notifier(&my_oom_nb);
return 0;
}
static void __exit my_exit(void)
{
unregister_oom_notifier(&my_oom_nb);
}
module_init(my_init);
module_exit(my_exit);
설명
NOTIFY_STOP을 반환하면 OOM Killer의 프로세스 kill을 중단시킬 수 있습니다. 단, 이는 notifier 핸들러(Handler)가 실제로 충분한 메모리를 해제한 경우에만 사용해야 합니다. 거짓 NOTIFY_STOP은 시스템을 교착 상태에 빠뜨릴 수 있습니다.
SysRq를 이용한 수동 OOM 트리거
디버깅 또는 긴급 상황에서 SysRq 키 조합으로 OOM Killer를 수동 호출할 수 있습니다.
# SysRq 활성화 확인
cat /proc/sys/kernel/sysrq # 1 = 전체 활성화
# OOM Killer 수동 트리거
echo f > /proc/sysrq-trigger # 'f' = 수동 OOM kill
# 키보드: Alt+SysRq+F (물리 콘솔에서)
# 그 외 유용한 SysRq 명령
echo m > /proc/sysrq-trigger # 메모리 정보 덤프
echo t > /proc/sysrq-trigger # 모든 태스크 백트레이스
oom_score_adj=-1000이 아닌 한 종료될 수 있습니다. 디버깅 목적으로만 사용하세요.
NUMA 환경의 OOM 특수성
NUMA 시스템에서 OOM은 전역 메모리 부족이 아닌 특정 NUMA 노드의 메모리 부족으로 발생할 수 있습니다. 전체 시스템에 메모리가 충분해도 프로세스의 메모리 정책(cpuset, mempolicy)에 의해 특정 노드에서만 할당이 제한되면 OOM이 트리거됩니다.
# NUMA 노드별 메모리 확인
numactl --hardware
cat /sys/devices/system/node/node*/meminfo | grep MemFree
# NUMA interleave로 OOM 완화
numactl --interleave=all myapp
# zone_reclaim_mode: 0이면 원격 노드도 사용
sysctl vm.zone_reclaim_mode=0
vm.panic_on_oom과 kdump 연계
특정 환경에서는 OOM 발생 시 프로세스를 kill하는 대신 의도적으로 커널 panic을 유발하고, kdump로 메모리 덤프를 수집하여 사후 분석하는 전략이 유용합니다.
# OOM 시 panic 활성화
sysctl -w vm.panic_on_oom=1
# panic 후 자동 재부팅 (60초 후)
sysctl -w kernel.panic=60
# kdump 설정 (crashkernel 커널 파라미터 필요)
# GRUB: crashkernel=256M
systemctl enable --now kdump
# panic_on_oom 값 의미:
# 0 = OOM kill (기본) — 프로세스를 kill하여 시스템 유지
# 1 = 항상 panic — OOM 시 즉시 panic (kdump 수집)
# 2 = memcg OOM에서는 kill, 전역 OOM에서만 panic
crash 유틸리티로 분석하면, OOM 시점의 정확한 메모리 맵(Memory Map), 프로세스 목록, 커널 데이터 구조를 상세히 조사할 수 있습니다.
메모리 누수 탐지와 OOM 예방
OOM의 근본 원인이 메모리 누수인 경우, 누수를 탐지하고 수정하는 것이 최선의 방지책입니다.
유저스페이스 메모리 누수 탐지
# 1. Valgrind (개발 환경)
valgrind --leak-check=full --show-leak-kinds=all ./myapp
# 2. AddressSanitizer (컴파일 시)
gcc -fsanitize=address -g -o myapp myapp.c
./myapp # 누수 감지 시 상세 리포트 출력
# 3. /proc/[pid]/smaps 추적 (프로덕션)
# RSS 증가 추이를 모니터링
while true; do
awk '/Rss:/ {sum += $2} END {print sum " kB"}' \
/proc/$PID/smaps
sleep 60
done
# 4. Go 프로그램: pprof heap 분석
go tool pprof http://localhost:6060/debug/pprof/heap
# 5. Java: jmap으로 힙 덤프
jmap -dump:format=b,file=heap.hprof $PID
커널 메모리 누수 탐지
# kmemleak 활성화 (CONFIG_DEBUG_KMEMLEAK=y 필요)
echo scan > /sys/kernel/debug/kmemleak
cat /sys/kernel/debug/kmemleak
# slab 메모리 추적
slabtop -s c # 캐시별 정렬
# /proc/slabinfo 주기적 모니터링
watch -n 5 "cat /proc/slabinfo | sort -k2 -rn | head -20"
vm.overcommit_memory 모드별 성능 비교
오버커밋 모드의 선택은 성능, 안정성, 메모리 효율성 간의 트레이드오프입니다.
| 기준 | 모드 0 (Heuristic) | 모드 1 (Always) | 모드 2 (Never) |
|---|---|---|---|
| mmap/brk 성공률 | 높음 | 최고 | 제한적 |
| OOM 발생 확률 | 중간 | 최고 | 거의 없음 |
| 메모리 효율 | 높음 | 최고 | 낮음 (미사용 가상 공간도 커밋) |
| fork() 성공률 | 높음 | 최고 | 낮음 (COW에도 전체 크기 커밋) |
| 예측 가능성 | 중간 | 낮음 | 최고 |
| 적합 환경 | 범용 서버 | 과학 계산, 스파스 배열 | DB, 금융, 의료 |
vm.overcommit_memory=2를 권장합니다. fork() 기반의 프로세스 모델 때문에 모드 0에서 OOM이 발생하면 postmaster가 모든 연결을 끊고 복구 모드에 진입하여 서비스 중단이 발생합니다.
out_of_memory() 함수 상세 흐름
OOM Killer의 진입점(Entry Point)인 out_of_memory() 함수의 내부 로직을 단계별로 분석합니다.
/* mm/oom_kill.c — out_of_memory() 주요 흐름 (간략화) */
bool out_of_memory(struct oom_control *oc)
{
/* 1. sysctl_panic_on_oom 체크 */
if (sysctl_panic_on_oom == 2 && !oc->memcg)
panic("Out of memory: system-level panic_on_oom=2");
if (sysctl_panic_on_oom == 1)
panic("Out of memory: panic_on_oom=1");
/* 2. 이미 exit 중인 프로세스 체크 */
if (oom_killer_disabled)
return false;
/* 3. sysctl_oom_kill_allocating_task 체크 */
if (sysctl_oom_kill_allocating_task &&
current->mm && !oom_unkillable_task(current) &&
current->signal->oom_score_adj != OOM_SCORE_ADJ_MIN) {
get_task_struct(current);
oc->chosen = current;
oom_kill_process(oc, "Out of memory (oom_kill_allocating_task)");
return true;
}
/* 4. 희생자 선정 */
select_bad_process(oc);
if (!oc->chosen) {
/* 모든 프로세스가 면제 → dump + panic */
dump_header(oc, NULL);
panic("Out of memory and no killable processes...\n");
}
/* 5. 이미 exit 진행 중인 프로세스면 기다림 */
if (task_will_free_mem(oc->chosen)) {
mark_oom_victim(oc->chosen);
wake_oom_reaper(oc->chosen);
return true;
}
/* 6. 프로세스 kill 실행 */
oom_kill_process(oc, "Out of memory");
return true;
}
설명
핵심 포인트는 select_bad_process()가 적절한 희생자를 찾지 못하면 커널이 panic()한다는 것입니다. 이는 모든 프로세스가 oom_score_adj=-1000이거나 커널 스레드인 경우에 발생합니다. 따라서 oom_score_adj=-1000의 남용은 시스템 전체 다운을 유발할 수 있습니다.
OOM 관련 안티패턴
실무에서 자주 발생하는 OOM 관련 잘못된 관행들을 정리합니다.
| 안티패턴 | 문제점 | 올바른 접근 |
|---|---|---|
oom_score_adj=-1000 남용 |
모든 프로세스가 면제되면 OOM 시 kernel panic | 절대 필수 프로세스만 -1000, 나머지는 -900~-500 |
vm.overcommit_memory=1 무분별 사용 |
OOM 발생 확률이 극대화 | sparse array 등 특수 용도에만 사용 |
--oom-kill-disable=true (Docker) |
컨테이너가 무한 대기 상태에 빠짐 | OOM kill 허용 + 적절한 memory limit 설정 |
| swap 완전 비활성화 | 메모리 여유 공간 급감 시 즉시 OOM | 최소한의 swap(1~2GB) 유지 |
| vm.min_free_kbytes 과도 설정 | 사용 가능 메모리 감소, 오히려 OOM 위험 증가 | RAM의 1~3% 범위 유지 |
| OOM 무시 (모니터링 없음) | 반복 OOM으로 서비스 불안정 | earlyoom + PSI 모니터링 + 알림 설정 |
| JVM -Xmx를 cgroup limit과 동일하게 설정 | JVM 메타스페이스/스택/네이티브 메모리가 limit 초과 | -Xmx는 cgroup limit의 70~80%로 설정 |
-Xmx는 Java 힙 크기만 제한합니다. 메타스페이스, 코드 캐시, 스레드 스택, Direct ByteBuffer 등은 별도 메모리를 사용합니다. -Xmx=4G인 Java 앱이 실제로 5~6GB를 사용하는 것은 정상이므로, cgroup memory.max는 충분한 여유를 두고 설정해야 합니다.
고급 주제: GFP 플래그와 OOM 억제
모든 메모리 할당이 OOM을 유발하는 것은 아닙니다. GFP 플래그에 따라 OOM Killer 호출이 억제되는 경우가 있습니다.
| GFP 플래그 | OOM 동작 | 사용 컨텍스트 |
|---|---|---|
__GFP_NORETRY | OOM 호출하지 않고 즉시 NULL 반환 | 선택적 할당 (캐시, 최적화 버퍼) |
__GFP_RETRY_MAYFAIL | 여러 번 재시도 후 실패 허용 | 대용량 할당 (order>1) |
__GFP_NOFAIL | 절대 실패 불가 — OOM 무한 재시도 | 파일시스템(Filesystem) 저널, 필수 메타데이터 |
GFP_KERNEL | 일반 OOM 경로 (직접 회수 + OOM) | 대부분의 커널 할당 |
GFP_ATOMIC | OOM 불가 (슬립(Sleep) 불가 컨텍스트) | 인터럽트(Interrupt) 핸들러, spinlock 내부 |
/* GFP 플래그별 할당 패턴 */
/* 1. 실패 허용: 캐시 확장 시도 */
page = alloc_pages(GFP_KERNEL | __GFP_NORETRY, 0);
if (!page)
return -ENOMEM; /* 정상적인 실패 처리 */
/* 2. 절대 실패 불가: 파일시스템 저널 */
page = alloc_pages(GFP_KERNEL | __GFP_NOFAIL, 0);
/* NULL 반환 불가 — 메모리가 확보될 때까지 블록 */
/* 3. 인터럽트 컨텍스트: OOM 불가능 */
page = alloc_pages(GFP_ATOMIC, 0);
if (!page)
return -ENOMEM; /* 긴급 예비(WMARK_MIN 이하)에서도 실패 */
OOM 테스트와 시뮬레이션
OOM 대응 체계를 검증하려면 안전한 환경에서 OOM을 의도적으로 유발해야 합니다.
# 방법 1: stress-ng를 이용한 메모리 소진
stress-ng --vm 4 --vm-bytes 2G --vm-keep --timeout 60s
# 방법 2: cgroup 기반 격리된 OOM 테스트
mkdir -p /sys/fs/cgroup/oom-test
echo "100M" > /sys/fs/cgroup/oom-test/memory.max
echo $$ > /sys/fs/cgroup/oom-test/cgroup.procs
# 이 셸에서 100MB 이상 할당하면 cgroup OOM 발생
python3 -c "x = bytearray(200 * 1024 * 1024)"
# 방법 3: SysRq 수동 OOM
echo f > /proc/sysrq-trigger
# 방법 4: C 프로그램으로 점진적 메모리 소진
/* oom-test.c — 점진적 메모리 소진 테스트 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
size_t chunk = 64 * 1024 * 1024; /* 64MB chunks */
size_t total = 0;
while (1) {
void *p = malloc(chunk);
if (!p) {
fprintf(stderr, "malloc failed at %zu MB\n",
total / (1024 * 1024));
break;
}
/* 실제로 터치해야 물리 페이지 할당 */
memset(p, 0xAA, chunk);
total += chunk;
printf("Allocated: %zu MB\n", total / (1024 * 1024));
usleep(100000); /* 100ms 간격 */
}
return 0;
}
OOM 발생 시 커널 내부 타임라인
out_of_memory() 호출 체인 분석
OOM Killer는 페이지 할당 실패 시 명확한 호출 체인(Call Chain)을 따라 트리거됩니다. __alloc_pages_slowpath()에서 직접 회수(Direct Reclaim)와 메모리 압축(Compaction)이 모두 실패한 후, __alloc_pages_may_oom()이 oom_lock 뮤텍스를 획득하고 out_of_memory()를 호출합니다. 이 함수는 OOM 제약 조건(constraint)을 판별한 뒤, select_bad_process()로 전체 태스크(Task)를 순회하며 oom_badness()로 점수를 산출하여 최고 점수 프로세스를 희생자로 선정합니다.
/* mm/page_alloc.c — __alloc_pages_may_oom() 핵심 로직 (간략화) */
static inline struct page *
__alloc_pages_may_oom(gfp_t gfp_mask, unsigned int order,
const struct alloc_context *ac,
unsigned long *did_some_progress)
{
struct oom_control oc = {
.zonelist = ac->zonelist,
.nodemask = ac->nodemask,
.memcg = NULL,
.gfp_mask = gfp_mask,
.order = order,
};
struct page *page;
*did_some_progress = 0;
/* OOM을 억제하는 GFP 플래그 체크 */
if (!(gfp_mask & __GFP_FS) || !(gfp_mask & __GFP_NOFAIL))
if (!(gfp_mask & __GFP_DIRECT_RECLAIM))
return NULL;
/* 전역 OOM 직렬화 */
if (!mutex_trylock(&oom_lock)) {
*did_some_progress = 1;
return NULL; /* 다른 OOM 진행 중 → 재시도 */
}
/* 마지막으로 한 번 더 할당 시도 */
page = get_page_from_freelist(gfp_mask | __GFP_HARDWALL, order, ac);
if (page)
goto out;
/* OOM Killer 진입 */
if (out_of_memory(&oc))
*did_some_progress = 1;
out:
mutex_unlock(&oom_lock);
return page;
}
코드 설명
- 5~11행
struct oom_control을 스택에 초기화합니다. zonelist와 nodemask는 할당 컨텍스트(Allocation Context)에서 복사하며, memcg는 전역 OOM이므로 NULL입니다. - 16~18행
__GFP_FS플래그가 없거나__GFP_DIRECT_RECLAIM이 없는 할당은 OOM을 트리거하지 않고 NULL을 반환합니다. 파일시스템 재진입 위험을 방지합니다. - 21~24행
oom_lock뮤텍스를 비블로킹(Non-blocking)으로 획득 시도합니다. 이미 다른 컨텍스트에서 OOM이 진행 중이면did_some_progress=1을 설정하여 재시도를 유도합니다. - 27~29행OOM 직전에
get_page_from_freelist()를 한 번 더 시도합니다. OOM lock 대기 중 다른 프로세스가 메모리를 해제했을 수 있기 때문입니다. - 32~33행
out_of_memory()가 true를 반환하면 희생자를 선정하고 kill했다는 의미입니다. 이후 할당자가 재시도할 때 회수된 메모리를 사용할 수 있습니다.
호출 체인의 핵심 설계 원칙은 직렬화(Serialization)입니다. oom_lock 뮤텍스가 동시에 여러 OOM이 실행되는 것을 방지하여 한꺼번에 다수의 프로세스가 불필요하게 종료되는 것을 막습니다. 또한 OOM 진입 직전에 마지막 할당 시도를 수행하여 불필요한 kill을 최소화합니다.
struct oom_control 구조체 분석
struct oom_control은 OOM 판단에 필요한 모든 컨텍스트를 하나의 구조체로 묶어 전달합니다. 할당 요청의 GFP 플래그, NUMA 노드 제약, cgroup 소속, 선정된 희생자와 점수까지 OOM 결정의 전 과정을 이 구조체가 추적합니다.
/* include/linux/oom.h — struct oom_control 정의 */
struct oom_control {
/* 할당 요청 컨텍스트 */
struct zonelist *zonelist; /* 탐색할 zone 목록 */
nodemask_t *nodemask; /* 허용된 NUMA 노드 마스크 */
struct mem_cgroup *memcg; /* NULL이면 전역 OOM */
gfp_t gfp_mask; /* 원본 GFP 플래그 */
int order; /* 요청된 페이지 차수 */
/* select_bad_process() 결과 */
struct task_struct *chosen; /* 선정된 희생자 */
long chosen_points; /* 희생자의 oom_badness 점수 */
/* OOM 범위 정보 */
unsigned long totalpages; /* adj 정규화 기준 */
enum oom_constraint constraint; /* OOM 제약 유형 */
};
코드 설명
- 4행
zonelist는 할당 요청이 탐색하는 zone 순서 목록입니다. NUMA 시스템에서 로컬 노드부터 원격 노드까지의 폴백(Fallback) 순서를 정의합니다. - 5행
nodemask는 cpuset이나 메모리 정책으로 제한된 NUMA 노드 집합입니다. OOM 범위를 이 노드들로 한정합니다. - 6행
memcg가 NULL이면 전역(System-wide) OOM, non-NULL이면 해당 cgroup 내부에서만 희생자를 선정합니다. - 7행
gfp_mask는 원래 할당 요청의 플래그로,__GFP_NOFAIL등의 특수 플래그를 확인하는 데 사용됩니다. - 8행
order는 요청된 페이지 차수입니다. 고차(High-order) 할당 실패 시 OOM 동작이 달라질 수 있습니다. - 11행
chosen은select_bad_process()가 순회 후 선정한 최고 점수 태스크를 가리킵니다. NULL이면 kill 가능한 프로세스가 없다는 의미입니다. - 12행
chosen_points는 선정된 희생자의oom_badness()반환값으로, 로그 출력과 디버깅에 활용됩니다. - 15행
totalpages는oom_score_adj정규화의 기준입니다. 전역 OOM에서는 전체 RAM+Swap, memcg OOM에서는 cgroup의memory.max에 해당합니다. - 16행
constraint는constrained_alloc()이 판별한 OOM 유형으로, dmesg 로그에 출력되어 OOM 원인 분석에 핵심 정보를 제공합니다.
totalpages는 oom_score_adj의 절대적 영향력을 결정하는 중요한 필드입니다. 전역 OOM에서는 시스템 전체 메모리(RAM + Swap)가, memcg OOM에서는 해당 cgroup의 memory.max 값이 사용됩니다. 따라서 동일한 oom_score_adj라도 memcg 환경에서는 영향이 제한적입니다.
oom_badness() 스코어링 알고리즘 상세 분석
oom_badness()는 프로세스의 "메모리 탐욕도"를 정량화하는 핵심 함수입니다. RSS(Resident Set Size), 스왑 엔트리(Swap Entry), 페이지 테이블(Page Table) 크기를 합산한 뒤, oom_score_adj로 정규화된 보정을 적용합니다. 반환값은 0~totalpages + 1 범위이며, LONG_MIN은 해당 프로세스가 OOM 면제(exempt)됨을 의미합니다.
/* mm/oom_kill.c — oom_badness() 전체 로직 (간략화) */
long oom_badness(struct task_struct *p,
unsigned long totalpages)
{
long points;
long adj;
/* 1. kill 불가 프로세스 제외 */
if (oom_unkillable_task(p))
return LONG_MIN;
/* 2. mm이 없는 커널 스레드 제외 */
p = find_lock_task_mm(p);
if (!p)
return LONG_MIN;
/* 3. oom_score_adj 읽기 */
adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN) {
task_unlock(p);
return LONG_MIN; /* -1000 = 절대 면제 */
}
/* 4. 물리 메모리 사용량 합산 */
points = get_mm_rss(p->mm); /* file + anon + shmem */
points += get_mm_counter(p->mm, MM_SWAPENTS); /* 스왑 엔트리 수 */
points += mm_pgtables_bytes(p->mm) / PAGE_SIZE; /* 페이지 테이블 */
task_unlock(p);
/* 5. oom_score_adj 보정 적용 */
adj *= totalpages / 1000;
points += adj;
return points > 0 ? points : 1;
}
코드 설명
- 9~10행
oom_unkillable_task()는 init 프로세스(PID 1), kthread, oom_score_adj가 OOM_SCORE_ADJ_MIN인 프로세스를 확인합니다. 이들은LONG_MIN을 반환하여 OOM 후보에서 완전히 제외됩니다. - 13~15행
find_lock_task_mm()은 프로세스의mm_struct를 찾아 잠금(Lock)을 획득합니다. 커널 스레드는 mm이 없으므로 LONG_MIN을 반환합니다. 스레드 그룹에서 유효한 mm을 가진 스레드를 찾습니다. - 18~22행
oom_score_adj가 정확히-1000(OOM_SCORE_ADJ_MIN)이면 절대 면제입니다. 이는/proc/PID/oom_score_adj에-1000을 기록하여 설정합니다. - 25행
get_mm_rss()는MM_FILEPAGES + MM_ANONPAGES + MM_SHMEMPAGES를 합산합니다. 파일 캐시(File Cache)도 RSS에 포함되므로 대량의 파일 I/O를 수행하는 프로세스도 높은 점수를 받을 수 있습니다. - 26행스왑 아웃(Swap-out)된 페이지도 해당 프로세스의 "실제 메모리 사용"으로 간주합니다. 스왑에 밀려났다고 점수가 낮아지면 OOM 후 다시 스왑 인(Swap-in)으로 메모리가 부족해질 수 있기 때문입니다.
- 27행페이지 테이블 자체도 물리 메모리를 소비합니다. 대규모 가상 주소 공간을 가진 프로세스는 수백 MB의 페이지 테이블을 사용할 수 있어 이를 점수에 반영합니다.
- 31~32행보정값은
adj * totalpages / 1000으로 계산됩니다. 예를 들어 totalpages가 4,000,000(16GB)이고 adj가 500이면 +2,000,000 포인트가 추가됩니다. adj가 음수면 points가 감소하여 보호 효과를 줍니다. - 34행최종 점수가 0 이하가 되면 1을 반환합니다. 0점은 면제(LONG_MIN)와 구분해야 하므로 최소 1점을 보장합니다.
- 프로세스 A: RSS 2GB(524,288 pages) + Swap 512MB(131,072) + PT 4MB(1,024) = 656,384
- 프로세스 B: RSS 500MB(131,072) + oom_score_adj=900 → 131,072 + 900×5,242,880/1000 = 4,849,664
- 결과: oom_score_adj=900인 프로세스 B가 RSS가 훨씬 작음에도 불구하고 더 높은 점수를 받아 우선 kill 됩니다.
oom_kill_process() 실행 과정
oom_kill_process()는 선정된 희생자를 실제로 종료하는 실행 단계입니다. 단순히 SIGKILL을 보내는 것이 아니라, 먼저 희생자의 자식 프로세스(Child Process) 중 독립 mm을 가진 것을 찾아 대신 kill할 수 있는지 검토하고, mark_oom_victim()으로 TIF_MEMDIE 플래그를 설정한 뒤, OOM Reaper를 깨워 비동기 메모리 회수를 시작합니다.
/* mm/oom_kill.c — oom_kill_process() 핵심 로직 (간략화) */
static void oom_kill_process(struct oom_control *oc,
const char *message)
{
struct task_struct *victim = oc->chosen;
struct task_struct *child, *t;
struct mm_struct *mm;
unsigned int victim_points;
/* dmesg에 OOM 정보 출력 */
dump_header(oc, victim);
pr_err("%s: Killed process %d (%s) total-vm:%lukB, "
"anon-rss:%lukB, file-rss:%lukB, shmem-rss:%lukB, "
"UID:%u pgtables:%lukB oom_score_adj:%hd\n",
message, ...);
/* 자식 프로세스 중 독립 mm을 가진 것 검토 */
list_for_each_entry(child, &victim->children, sibling) {
if (child->mm == victim->mm)
continue; /* 같은 mm 공유 → 스킵 */
if (oom_badness(child, oc->totalpages) >
oom_badness(victim, oc->totalpages)) {
put_task_struct(victim);
victim = child;
get_task_struct(victim);
}
}
/* 희생자에 OOM 마크 설정 */
mark_oom_victim(victim);
/* 같은 mm을 공유하는 모든 스레드에 SIGKILL */
mm = victim->mm;
for_each_process(t) {
if (t->mm == mm && t != victim &&
!(t->flags & PF_KTHREAD)) {
do_send_sig_info(SIGKILL, SEND_SIG_PRIV, t, PIDTYPE_TGID);
mark_oom_victim(t);
}
}
/* 희생자 본체에 SIGKILL */
do_send_sig_info(SIGKILL, SEND_SIG_PRIV, victim, PIDTYPE_TGID);
/* OOM Reaper 깨우기 — 비동기 메모리 회수 시작 */
wake_oom_reaper(victim);
put_task_struct(victim);
}
코드 설명
- 10~14행
dump_header()와pr_err()가 dmesg에 OOM 상세 정보를 출력합니다. total-vm, anon-rss, file-rss, shmem-rss, UID, pgtables, oom_score_adj가 포함되어 사후 분석에 핵심 정보를 제공합니다. - 17~26행자식 프로세스 중 독립적인 mm을 가진 프로세스를 검토합니다. 예를 들어
fork()후exec()한 자식은 새로운 mm을 가지며, 자식의 oom_badness 점수가 부모보다 높으면 자식을 대신 kill합니다. 이는 부모(서버 데몬 등)를 보호하면서 메모리를 효과적으로 회수하기 위함입니다. - 29행
mark_oom_victim()은TIF_MEMDIE플래그를 설정합니다. 이 플래그가 설정된 프로세스는 메모리 예비(reserve) 영역에 접근할 수 있어, exit 경로에서 메모리 할당이 필요한 경우에도 진행할 수 있습니다. - 32~39행같은 mm을 공유하는 모든 스레드(예:
clone(CLONE_VM))에 SIGKILL을 전달합니다. 하나의 스레드만 kill하면 나머지 스레드가 해제된 mm에 접근하여 크래시할 수 있기 때문입니다. - 42행희생자 본체에 SIGKILL을 전송합니다.
SEND_SIG_PRIV는 커널 권한으로 시그널을 보내므로 시그널 마스크에 의해 차단되지 않습니다. - 45행
wake_oom_reaper()는 전용 커널 스레드를 깨워 희생자의 익명 페이지를 즉시 회수합니다. 프로세스가 exit를 완료하기 전에도 메모리를 확보할 수 있어 OOM livelock을 방지합니다.
mark_oom_victim()은 TIF_MEMDIE를 설정하고 oom_victims atomic 카운터를 증가시킵니다. 프로세스가 exit_mmap()을 완료하면 exit_oom_victim()이 호출되어 카운터를 감소시키고 oom_reaper_wait를 깨웁니다. oom_victims가 0이 아닌 동안에는 freeze 작업이 대기하여, OOM 처리 중 suspend/hibernate가 교착하지 않도록 보호합니다.
자식 프로세스 Kill 결정 로직 상세
oom_kill_process()의 자식 프로세스 스캔은 단순한 최적화가 아니라, 서버 데몬의 안정성을 위한 핵심 설계입니다. Apache, Nginx, PostgreSQL 등의 서버는 fork 모델로 요청을 처리하며, 자식 프로세스가 실제 메모리를 더 많이 사용하는 경우가 흔합니다.
/* 자식 프로세스 kill 결정의 세 가지 핵심 조건 */
/* 조건 1: 독립 mm 여부 검사 */
if (child->mm == victim->mm)
continue;
/*
* clone(CLONE_VM)으로 생성된 스레드는 mm을 공유.
* fork() 후 exec()한 자식만 독립 mm을 가짐.
* 같은 mm을 공유하는 스레드를 kill하면 mm 해제 없이
* 나머지 스레드가 dangling mm에 접근 → 커널 패닉.
*/
/* 조건 2: oom_badness 점수 비교 */
if (oom_badness(child, oc->totalpages) >
oom_badness(victim, oc->totalpages)) {
put_task_struct(victim);
victim = child;
get_task_struct(victim);
}
/*
* 자식의 점수가 부모보다 높으면 자식을 kill.
* get/put_task_struct로 참조 카운터를 관리하여
* 스캔 중 프로세스가 사라지는 것을 방지.
*/
/* 조건 3: oom_score_adj가 반영됨 */
/*
* 자식에 oom_score_adj=-1000이 설정되어 있으면
* oom_badness()가 0을 반환 → 절대 선택되지 않음.
* 예: sshd가 fork한 세션 프로세스에 -1000 설정 가능.
*/
__oom_kill_process()는 for_each_process()로 전체 프로세스 목록을 순회하며, 희생자와 같은 mm을 공유하는 모든 스레드에 SIGKILL을 전송합니다. 이는 자식 스캔과 별개의 단계로, POSIX 스레드 그룹이나 clone(CLONE_VM)으로 생성된 모든 태스크가 포함됩니다. 하나라도 누락하면 해제된 메모리 영역에 접근하여 use-after-free가 발생합니다.
Memory cgroup OOM 경로 분석
cgroup v2의 메모리 컨트롤러는 memory.max 한도를 초과할 때 전역 OOM과 별도의 경로를 통해 OOM을 트리거합니다. mem_cgroup_oom()이 charge 실패 시 호출되어 mem_cgroup_out_of_memory()를 거쳐 해당 cgroup 내부에서만 희생자를 선정합니다. memory.oom.group=1이 설정된 경우 개별 프로세스 대신 cgroup 전체가 종료됩니다.
/* mm/memcontrol.c — memcg OOM 경로 (간략화) */
/* 1. charge 실패 시 진입 */
static int mem_cgroup_oom(struct mem_cgroup *memcg,
gfp_t mask, int order)
{
/* OOM 이벤트 통지: memory.events 업데이트 */
memcg_memory_event(memcg, MEMCG_OOM);
/* memory.oom.group이 설정된 상위 cgroup 탐색 */
if (mem_cgroup_oom_group(memcg)) {
/* cgroup 전체를 kill (모든 프로세스) */
mem_cgroup_oom_group_kill(memcg);
return 0;
}
/* 개별 프로세스 OOM */
mem_cgroup_out_of_memory(memcg, mask, order);
return 0;
}
/* 2. cgroup 범위 OOM Killer 호출 */
static bool mem_cgroup_out_of_memory(struct mem_cgroup *memcg,
gfp_t gfp_mask, int order)
{
struct oom_control oc = {
.zonelist = NULL, /* memcg OOM은 zone 무관 */
.nodemask = NULL,
.memcg = memcg, /* 핵심: cgroup 범위 지정 */
.gfp_mask = gfp_mask,
.order = order,
};
/* totalpages = cgroup 한도 기반 */
oc.totalpages = mem_cgroup_get_max(memcg) ?:
totalram_pages();
return out_of_memory(&oc);
}
/* 3. memory.oom.group 계층 탐색 */
static struct mem_cgroup *
mem_cgroup_oom_group(struct mem_cgroup *memcg)
{
struct mem_cgroup *iter;
/* 현재 cgroup에서 루트까지 상위 계층 순회 */
for (iter = memcg; iter; iter = parent_mem_cgroup(iter)) {
if (iter->oom_group)
return iter; /* 가장 가까운 oom_group=1 조상 반환 */
}
return NULL;
}
코드 설명
- 7행
memcg_memory_event(MEMCG_OOM)은memory.events파일의oom카운터를 증가시킵니다. 모니터링 시스템은 이 이벤트를inotify/poll로 감시할 수 있습니다. - 10~14행
mem_cgroup_oom_group()이 non-NULL을 반환하면 해당 cgroup의 모든 프로세스를 일괄 kill합니다. 이는 마이크로서비스 환경에서 일부 프로세스만 죽어 불완전한 상태가 되는 것을 방지합니다. - 17행
oom_group이 아닌 경우mem_cgroup_out_of_memory()를 호출하여 cgroup 내에서 개별 프로세스 OOM을 수행합니다. - 26~30행
oom_control초기화에서zonelist과nodemask는 NULL입니다. memcg OOM은 NUMA 토폴로지와 무관하게 cgroup 소속만으로 범위를 결정하기 때문입니다. - 28행
memcg필드가 non-NULL로 설정되어select_bad_process()가 이 cgroup에 속한 태스크만 순회합니다. - 33~34행
totalpages는mem_cgroup_get_max()로 해당 cgroup의memory.max를 기준으로 산출됩니다. max가 미설정이면 시스템 전체 RAM을 사용합니다. 이 값이oom_score_adj보정의 기준이 됩니다. - 45~49행
mem_cgroup_oom_group()은 현재 cgroup에서 루트까지 상향 순회하여oom_group=1인 가장 가까운 조상을 찾습니다. 이를 통해 상위 cgroup에oom_group을 설정하면 하위 cgroup 전체를 단위로 kill할 수 있습니다.
memory.oom.group=1을 설정하면, Pod 내 하나의 컨테이너가 메모리 한도를 초과할 때 Pod 전체가 종료됩니다. 이는 부분 장애보다 깔끔한 재시작이 바람직한 마이크로서비스 아키텍처에 적합합니다. systemd에서는 OOMPolicy=kill로 동일한 효과를 얻을 수 있습니다.
memory.events를 활용한 OOM 모니터링
cgroup v2의 memory.events 파일은 메모리 관련 이벤트 카운터를 제공합니다. OOM이 발생하기 전의 경고 신호를 감지하여 사전에 대응할 수 있습니다.
# memory.events 카운터 확인
cat /sys/fs/cgroup/myapp/memory.events
# low 0 ← memory.low 임계값 도달 횟수
# high 142 ← memory.high 임계값 도달 (스로틀링 발생)
# max 3 ← memory.max 한도 도달 (할당 실패/OOM 직전)
# oom 1 ← OOM Killer 발동 횟수
# oom_kill 2 ← OOM으로 실제 kill된 프로세스 수
# oom_group_kill 0 ← memory.oom.group에 의한 그룹 kill 수
# 계층적 이벤트 (하위 cgroup 포함)
cat /sys/fs/cgroup/myapp/memory.events.local
# ↑ local은 해당 cgroup만, events는 하위 포함
# 실시간 모니터링 (inotify 기반)
python3 -c "
import select, os
fd = os.open('/sys/fs/cgroup/myapp/memory.events', os.O_RDONLY)
poll = select.poll()
poll.register(fd, select.POLLPRI)
while True:
poll.poll() # memory.events 변경 시 깨어남
os.lseek(fd, 0, 0)
print(os.read(fd, 4096).decode())
"
# memory.pressure (PSI) 기반 사전 경고
cat /sys/fs/cgroup/myapp/memory.pressure
# some avg10=0.00 avg60=5.32 avg300=2.18 total=1847291
# full avg10=0.00 avg60=1.05 avg300=0.42 total=389102
# ↑ full avg60 > 5%이면 OOM 위험 신호
# PSI 트리거 기반 자동 대응
echo "some 100000 1000000" > /proc/pressure/memory
# ↑ 1초 윈도우에서 100ms 이상 메모리 압력 시 알림
memory.high 카운터가 증가하면 스로틀링이 발생하고 있다는 의미이며, memory.max 카운터가 증가하면 OOM 직전 상태입니다. Prometheus의 container_memory_events_total 메트릭으로 수집하고, max > 0인 경우 경고(alert)를 설정하는 것이 권장됩니다. PSI의 full avg60 > 10%은 애플리케이션 성능이 메모리 부족으로 심각하게 저하되고 있음을 나타냅니다.
실전 OOM 사례 분석
프로덕션 환경에서 자주 발생하는 OOM 시나리오와 그 원인 분석, 해결 방안을 정리합니다. 각 사례는 실제 dmesg 출력 패턴을 기반으로 합니다.
사례 1: Java 힙 메모리 과다 할당
Java 애플리케이션이 -Xmx 설정보다 컨테이너 메모리 한도가 낮아 발생하는 가장 흔한 OOM 패턴입니다.
[Mon Mar 15 14:23:07 2026] java invoked oom-killer:
gfp_mask=0x1100cca(GFP_HIGHUSER_MOVABLE),
order=0, oom_score_adj=0
[Mon Mar 15 14:23:07 2026] memory: usage 2097152kB, limit 2097152kB,
failcnt 847
[Mon Mar 15 14:23:07 2026] Memory cgroup out of memory:
Killed process 4521 (java)
total-vm:5765432kB, anon-rss:2043876kB,
file-rss:42108kB, shmem-rss:0kB
| 분석 항목 | 값 | 해석 |
|---|---|---|
| usage = limit | 2097152kB (2GB) | cgroup 메모리 한도에 정확히 도달 |
| failcnt | 847 | 한도 초과 시도 847회 (문제가 지속적이었음) |
| anon-rss | ~2GB | Java 힙이 거의 모든 메모리를 소비 |
| total-vm | ~5.5GB | 가상 메모리는 2배 이상 (Java 스레드 스택 등) |
| gfp_mask | GFP_HIGHUSER_MOVABLE | 일반 사용자 페이지 할당 (익명 페이지) |
# 해결: 컨테이너 메모리 한도를 힙 + 오버헤드로 설정
# Java 힙 2GB + 네이티브/스택/메타 ~512MB = 최소 2.5GB 필요
docker run --memory=3g -e JAVA_OPTS="-Xmx2g -Xms2g -XX:MaxMetaspaceSize=256m" myapp
# 또는 Java가 cgroup 한도를 자동 인식하도록 설정 (JDK 11+)
java -XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-jar myapp.jar
사례 2: Fork 폭탄 / 프로세스 폭주
의도치 않은 무한 fork나 프로세스 누수로 인해 페이지 테이블과 커널 스택이 메모리를 고갈시키는 사례입니다.
[Tue Mar 16 03:12:45 2026] Out of memory: Killed process 1 (systemd)
total-vm:21456kB, anon-rss:8432kB,
file-rss:0kB, shmem-rss:0kB
[Tue Mar 16 03:12:45 2026] oom-kill:constraint=CONSTRAINT_NONE,
nodemask=(null),
task=bash,pid=98431,uid=1000
[Tue Mar 16 03:12:45 2026] Tasks state (memory values in pages):
[ pid ] uid tgid total_vm rss pgtables_bytes
[ 98431] 1000 98431 1132 340 53248
... (수천 개의 유사한 프로세스)
# 예방: 프로세스 수 제한
ulimit -u 4096 # 사용자당 최대 프로세스 수
echo "* hard nproc 4096" >> /etc/security/limits.conf
# cgroup v2로 PID 제한
echo 1000 > /sys/fs/cgroup/myapp/pids.max
# systemd 서비스 단위 제한
# [Service]
# TasksMax=512
# MemoryMax=4G
사례 3: tmpfs/shmem 메모리 고갈
tmpfs에 대량 파일을 쓰거나 공유 메모리 세그먼트가 과도하게 커진 경우입니다. tmpfs 데이터는 스왑 아웃 가능하지만, 스왑이 없거나 부족하면 OOM이 발생합니다.
[Wed Mar 17 09:45:12 2026] postgres invoked oom-killer:
gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE),
order=0, oom_score_adj=0
[Wed Mar 17 09:45:12 2026] Mem-Info:
active_anon:1245320 inactive_anon:892156
active_file:324 inactive_file:108
shmem:1048576 ← 4GB의 공유 메모리!
slab_reclaimable:12456 slab_unreclaimable:89234
# 진단: tmpfs/shmem 사용량 확인
df -h /dev/shm /tmp /run
ipcs -m # System V 공유 메모리 세그먼트
grep Shmem /proc/meminfo # 전체 shmem 사용량
# 해결: tmpfs 크기 제한
mount -o remount,size=2G /dev/shm
# PostgreSQL: shared_buffers를 물리 메모리의 25% 이하로 설정
gfp_mask로 할당 종류 파악, (2) constraint로 전역/memcg/NUMA 구분, (3) failcnt로 지속성 판단, (4) anon-rss vs file-rss vs shmem-rss 비율로 메모리 소비 패턴 분석, (5) oom_score_adj로 우선순위 정책 확인. 이 5가지를 순서대로 확인하면 대부분의 OOM 원인을 파악할 수 있습니다.
참고자료
커널 문서
- Memory Management Concepts -- 리눅스 커널 메모리 관리 개념을 설명합니다
- The /proc Filesystem -- oom_score 등 /proc 인터페이스를 다룹니다
- proc(5) man page -- oom_score_adj 설정에 대한 상세 설명입니다
LWN 기사
- Taming the OOM killer (2008) -- OOM killer 제어 방법에 대한 초기 논의입니다
- Another OOM killer rewrite (2010) -- OOM killer 재설계에 대한 논의입니다
- Toward more predictable OOM handling (2015) -- 예측 가능한 OOM 처리를 위한 개선 방향입니다
- The OOM killer's new heuristics (2018) -- OOM killer의 새로운 휴리스틱 알고리즘을 소개합니다
- OOM detection rework (2022) -- OOM 감지 메커니즘의 재설계를 다룹니다
커널 소스
- mm/oom_kill.c -- OOM killer 핵심 구현 코드입니다
- include/linux/oom.h -- OOM 관련 API 헤더 파일입니다
- mm/page_alloc.c -- __alloc_pages_may_oom() 경로를 포함하는 페이지 할당 구현입니다