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/임베디드 환경별 운영 플레이북을 커널 소스 기반으로 분석합니다.

전제 조건: 메모리 관리(Memory Management) 개요페이지 할당자(Page Allocator) 문서를 먼저 읽으세요. OOM Killer는 메모리 할당 실패 경로에서 동작하므로 기본 메모리 구조를 이해해야 합니다.
일상 비유: OOM Killer는 만석인 비행기에서 좌석을 확보해야 할 때와 비슷합니다. 가장 넓은 공간을 차지하면서 우선순위(Priority)가 낮은 승객에게 양보(Yield)를 요청하되, 기장(커널)과 VIP(oom_score_adj=-1000)는 절대 내리지 않습니다.

핵심 요약

  • 최후의 수단 — 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_memory 0(휴리스틱)/1(항상 허용)/2(엄격 제한)으로 가상 메모리(Virtual Memory) 할당 전략을 제어합니다.
  • cgroup 계층적 OOM — cgroup v2의 memory.oom.group을 사용하면 개별 프로세스 대신 전체 cgroup을 단위로 종료할 수 있습니다.

단계별 이해

  1. 메모리 할당 실패 경로 이해
    alloc_pages() → __alloc_pages_slowpath() → __alloc_pages_may_oom()으로 이어지는 OOM 진입 경로를 파악합니다.
  2. 스코어링 알고리즘 분석
    oom_badness()가 RSS 기반으로 점수를 계산하고 oom_score_adj로 보정하는 방식을 이해합니다.
  3. OOM Reaper 동작 추적
    SIGKILL 전달 후 OOM Reaper가 익명 페이지를 비동기 회수하는 흐름을 확인합니다.
  4. 오버커밋과 cgroup 정책 설정
    vm.overcommit_memory, memory.max, memory.oom.group 등을 실무에 맞게 설정합니다.
  5. 모니터링과 대응 구축
    PSI, earlyoom, systemd-oomd를 활용한 선제적 OOM 방지 체계를 구성합니다.
관련 표준: Linux 커널 문서 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)
2kswapd 비동기 회수낮음~중간백그라운드 스레드(Thread)가 watermark 이하 시 회수
3직접 회수 (direct reclaim)중간할당 요청 컨텍스트에서 동기적 회수
4Compaction중간~높음고차 할당을 위한 단편화(Fragmentation) 해소
5스왑 아웃높음익명 페이지를 스왑 디바이스로 이동
6OOM Killer치명적프로세스를 강제 종료하여 메모리 확보

1~5단계가 모두 실패하면, 커널은 두 가지 선택지만 남습니다: 시스템 전체를 멈추거나, 일부 프로세스를 희생시켜 나머지를 살리거나. OOM Killer는 후자를 선택하는 메커니즘입니다.

OOM Killer는 버그가 아닙니다. "OOM이 발생했다"는 것은 커널이 정상적으로 동작하여 시스템 전체 장애를 방지한 것입니다. 진짜 문제는 OOM이 발생하기까지의 메모리 관리 정책에 있습니다.

OOM Killer 역사

커널 버전변경의미
2.6.11/proc/<pid>/oom_adj 도입사용자 공간(User Space)에서 OOM 점수 조정 가능
2.6.36oom_score_adj 도입, oom_adj 폐기-1000~+1000 범위의 선형 스코어링
4.6OOM Reaper 도입비동기 메모리 회수로 교착 방지
4.19cgroup v2 memory.oom.groupcgroup 단위 OOM kill 지원
5.2PSI (Pressure Stall Information)메모리 압력 모니터링 인터페이스
6.1per-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 간격을 결정합니다.

메모리 워터마크 계층과 OOM 트리거 관계 Zone 여유 메모리 충분 WMARK_HIGH kswapd 활성 WMARK_LOW Direct Reclaim WMARK_MIN OOM 영역 회수 불가 → kill 정상 운영 (Normal) 페이지 캐시 적극 활용, 할당 즉시 성공, kswapd 휴면 kswapd 비동기 회수 (Background Reclaim) kswapd 스레드가 깨어나 LRU 스캔, 페이지 캐시/스왑 아웃 목표: WMARK_HIGH까지 회복 · 할당자는 대기 없이 진행 Direct Reclaim (동기 회수) 할당 요청 컨텍스트에서 직접 LRU 스캔 + Compaction 수행 할당 지연(latency) 발생 · MAX_RECLAIM_RETRIES(16회) 재시도 OOM Killer 발동 Direct Reclaim + Compaction 모두 실패 __alloc_pages_may_oom() → out_of_memory() oom_badness() 최고 점수 프로세스 SIGKILL WMARK_MIN = vm.min_free_kbytes 기반 · WMARK_LOW = MIN + (managed × scale_factor/10000) · WMARK_HIGH = LOW + (managed × scale_factor/10000)
워터마크 수준에 따른 메모리 관리 메커니즘 계층. OOM Killer는 WMARK_MIN 이하에서 모든 회수가 실패한 최후의 경우에만 발동합니다.
# 현재 워터마크 확인
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
워터마크 튜닝과 OOM 방지: 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()을 호출합니다.

OOM Trigger 경로: alloc_pages slowpath __alloc_pages(gfp, order) Fast Path (watermark OK?) YES 페이지 반환 NO __alloc_pages_slowpath() Direct Reclaim + Compaction 성공 페이지 반환 실패 재시도 횟수 초과? YES __alloc_pages_may_oom() out_of_memory() oom_kill_process() → SIGKILL oom_lock (mutex) 동시 OOM 방지: 한 번에 하나의 OOM만 진행
alloc_pages()에서 OOM Killer까지의 전체 호출 경로. 각 단계에서 메모리 확보에 성공하면 조기 반환합니다.

핵심 코드 경로를 살펴보겠습니다:

/* 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 범위의 "나쁜 정도" 점수를 매기고, 가장 높은 점수를 받은 프로세스가 종료 대상이 됩니다. 점수 산출 공식은 놀라울 정도로 단순합니다:

oom_badness() 스코어링 알고리즘 1단계: RSS 수집 get_mm_rss(mm) = file_rss + anon_rss + shmem_rss 2단계: Swap 추가 + get_mm_counter(mm, MM_SWAPENTS) 3단계: 페이지 테이블 + mm_pgtables_bytes(mm) / PAGE_SIZE points = RSS + Swap + PageTables 4단계: oom_score_adj 보정 adj = task->signal->oom_score_adj (범위: -1000 ~ +1000) points += adj * totalpages / 1000 최종 점수 (0 ~ 1000) adj=-1000 → OOM 면제 | adj=+1000 → 최우선 kill OOM 면제 조건 oom_score_adj == -1000 → LONG_MIN 반환 (즉시 스킵)
oom_badness()는 RSS + Swap + PageTables 합산 후 oom_score_adj로 보정하여 최종 점수를 산출합니다
/* 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의 위력: 위 예제에서 batch-job은 java-app보다 실제 메모리 사용량이 적지만, 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 방지 전략의 핵심입니다.

의미사용 예
-1000OOM 완전 면제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는 이 교착을 우회합니다.

OOM Reaper 비동기 회수 흐름 OOM Killer 경로 OOM Reaper 스레드 oom_kill_process() SIGKILL 전송 mark_oom_victim(task) TIF_MEMDIE 플래그 설정 wake_oom_reaper() 프로세스가 mmap_lock에 블록됨 → exit_mmap() 지연 oom_reaper (kthread) wait_event(oom_reaper_wait) 희생자가 등록될 때까지 sleep oom_reap_task_mm() mmap_read_trylock() 사용 unmap_page_range() VMA_LOCK 없이 익명 페이지 직접 해제 (file-backed 제외) 메모리 즉시 확보! 핵심 포인트 Reaper는 mmap_read_trylock()을 사용하므로 write lock을 기다리지 않아 교착 상태를 회피합니다
OOM Reaper는 별도 커널 스레드에서 비동기적으로 익명 페이지를 회수하여, mmap_lock 교착 상태를 우회합니다
/* 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;
}
OOM Reaper 재시도: mmap_read_trylock()이 실패하면, OOM Reaper는 최대 10회 재시도(MAX_OOM_REAP_RETRIES)합니다. 모든 재시도가 실패하면 해당 태스크를 포기하고, 프로세스의 자연적인 exit_mmap()에 의존합니다.

TIF_MEMDIE 메커니즘 상세

TIF_MEMDIE는 OOM Killer가 선정한 희생자 프로세스에 설정하는 스레드 정보 플래그(Thread Information Flag)입니다. 이 플래그는 단순한 표시가 아니라, 메모리 할당 경로에서 특별한 권한을 부여하는 핵심 메커니즘입니다.

TIF_MEMDIE 플래그 생명주기와 효과 시간 mark_oom_victim() TIF_MEMDIE 설정 SIGKILL → exit 경로 메모리 예비 접근 허용 exit_oom_victim() TIF_MEMDIE 해제 TIF_MEMDIE 활성 기간 동안의 효과 워터마크 우회 ALLOC_OOM 플래그로 WMARK_MIN 이하에서도 할당 → exit 경로 진행 보장 중복 OOM 방지 oom_victims 카운터 > 0이면 새로운 OOM kill 억제 → 연쇄 kill 방지 Freeze 차단 oom_victims > 0이면 suspend/hibernate 대기 → OOM 처리 완료 보장 할당 경로에서의 TIF_MEMDIE 검사 __alloc_pages_slowpath() → gfp_to_alloc_flags() → ALLOC_OOM 설정 → __zone_watermark_ok()에서 WMARK_MIN 미만에서도 할당 허용 이 메커니즘이 없으면 OOM 희생자가 exit 도중 메모리 할당 실패로 교착(deadlock) 가능
TIF_MEMDIE 플래그의 설정부터 해제까지의 생명주기와 세 가지 핵심 효과
/* 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 카운터와 OOM 직렬화: oom_victims atomic 카운터가 0보다 크면 out_of_memory()는 새로운 OOM kill을 시작하지 않고 true를 반환합니다. 이는 현재 희생자가 메모리를 반환하기 전에 다른 프로세스가 연쇄적으로 kill되는 것을 방지합니다. 이 보호가 없으면 대규모 메모리 할당 폭주 시 불필요한 프로세스 다수가 종료될 수 있습니다.

vm.overcommit_memory 0/1/2 정책

오버커밋 정책은 가상 메모리 할당 요청을 수락할지 거부할지 결정합니다. vm.overcommit_memory sysctl로 제어하며, OOM 발생 빈도에 직접적인 영향을 줍니다.

vm.overcommit_memory 세 가지 모드 모드 0: 휴리스틱 (기본값) "합리적인" 크기의 요청은 허용, 비합리적 요청은 거부 기준: free RAM + free swap + pagecache 일부 + reclaimable slab OOM 발생 가능 대부분의 서버에서 기본 모드로 충분 모드 1: 항상 허용 (OVERCOMMIT_ALWAYS) 모든 mmap/brk 요청을 무조건 허용 검사: 없음 __vm_enough_memory()가 항상 0 반환 OOM 위험 최대 과학 계산(sparse array) 등 특수 용도에만 사용 모드 2: 엄격 제한 (OVERCOMMIT_NEVER) CommitLimit 이내만 허용 초과 시 ENOMEM 반환 CommitLimit = (RAM * overcommit_ratio/100) + swap_total OOM 거의 불가 금융/의료 등 예측 가능성이 중요한 환경
모드 0(기본)은 휴리스틱 검사, 모드 1은 무제한 허용, 모드 2는 CommitLimit 기반 엄격 제한

오버커밋 관련 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 vs overcommit_ratio: 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_AS per-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)만큼 추가 허용합니다.
모드 0의 함정: OVERCOMMIT_GUESSNR_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 메모리 제한 계층과 OOM / (root cgroup) web.slice memory.max=4G memory.oom.group=1 nginx (800MB) PID 1001 php-fpm (3.5GB) PID 1002-1010 batch.slice memory.max=2G memory.oom.group=0 worker-A (1GB) PID 2001 worker-B (1.2GB) PID 2002 oom.group=1: 전체 그룹 kill web.slice이 4GB 초과 시 nginx + php-fpm 모두 SIGKILL (일관된 서비스 재시작 보장) oom.group=0: 개별 프로세스 kill batch.slice이 2GB 초과 시 worker-B만 SIGKILL (점수 높음) (worker-A는 생존) cgroup v2 메모리 제한 계층 memory.min (하한 보장) < memory.low (소프트 보호) < memory.high (스로틀링) < memory.max (하드 제한 → OOM)
memory.oom.group=1 설정 시 해당 cgroup의 모든 프로세스가 함께 종료됩니다

cgroup v2 메모리 제한 파라미터

파일기본값동작초과 시
memory.min0절대 보호: 이 양 이하로 회수 불가해당 없음 (보호 한도)
memory.low0소프트 보호: 회수 우선순위 낮춤best-effort 보호
memory.highmax소프트 제한: 초과 시 직접 회수 강제프로세스 throttling
memory.maxmax하드 제한: 초과 시 OOM 트리거memcg OOM kill
memory.oom.group01이면 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);
}
memory.oom.group 사용 사례: 웹 서버처럼 여러 워커 프로세스가 하나의 서비스를 구성하는 경우, 하나만 kill하면 서비스가 불안정해집니다. oom.group=1로 전체를 함께 종료시킨 후 깨끗하게 재시작(Reboot)하는 것이 운영적으로 더 안정적입니다.

PSI 기반 선제적 OOM 방지

PSI(Pressure Stall Information)는 커널 5.2+에서 제공하는 메모리/IO/CPU 압력 모니터링 인터페이스입니다. OOM이 발생하기 전에 메모리 압력을 감지하여 선제적으로 대응할 수 있게 해줍니다.

PSI 기반 선제적 OOM 방지 흐름 시간 → 정상 운영 PSI some < 10% 경고 단계 PSI some > 40% earlyoom SIGTERM 위험 단계 PSI some > 70% earlyoom SIGKILL systemd-oomd 동작 커널 OOM PSI full = 100% OOM Killer 호출 메모리 압력 수준 압력↑ 선제적 개입 영역 earlyoom / systemd-oomd가 PSI 트리거로 조기 대응 커널 최후 방어선 유저스페이스 개입 실패 시 커널 OOM Killer 발동
PSI 메트릭을 활용하면 커널 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    # 실시간 로그
earlyoom vs systemd-oomd: earlyoom은 시스템 전역에서 프로세스 단위로 동작하고 설정이 단순합니다. systemd-oomd는 cgroup/서비스 단위로 동작하며 systemd 생태계와 긴밀히 통합됩니다. 소규모 서버/데스크톱은 earlyoom, 대규모 서비스 환경은 systemd-oomd가 적합합니다.

ftrace/dmesg로 OOM 디버깅

OOM이 발생하면 원인 분석이 필수적입니다. 커널은 ftrace 이벤트와 dmesg 출력으로 풍부한 디버깅 정보를 제공합니다.

OOM 디버깅 도구 흐름 OOM 이벤트 발생 dmesg / kmsg OOM 요약 메시지 프로세스별 RSS/oom_score 메모리 zone 통계 ftrace 이벤트 oom:oom_score_adj_update kmem:mm_page_alloc vmscan:mm_vmscan_direct_reclaim* /proc 인터페이스 /proc/meminfo /proc/[pid]/oom_score /proc/pressure/memory 사후 분석 어떤 프로세스가 kill되었나 실시간 추적 OOM 직전 할당 패턴 분석 현재 상태 점검 메모리 압력/점수 모니터링 추천: dmesg 사후분석 + ftrace 사전감지 + /proc 상시모니터링 조합
세 가지 도구를 조합하면 OOM의 원인과 패턴을 효과적으로 분석할 수 있습니다

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와 함께 사용: 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_maskOOM을 유발한 메모리 할당 플래그GFP_KERNEL이면 커널 할당, GFP_HIGHUSER_MOVABLE이면 유저 할당
order요청한 페이지 order (2^N pages)order=0(4KB)이면 단순 부족, 높은 order이면 단편화 문제
active_anon활성 익명 페이지 수높으면 프로세스 메모리 사용량이 많음
inactive_file비활성 파일 캐시 페이지낮으면 이미 캐시가 거의 회수됨
slab_reclaimable회수 가능한 slab 메모리높은데 OOM이면 shrinker 문제 가능
constraintOOM 제약 유형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 연동: systemd 248+에서는 systemd-oomd가 PSI 기반으로 사용자 공간에서 OOM을 처리합니다. systemctl status systemd-oomd로 상태를 확인하고, oomctl 명령으로 모니터링 대상 cgroup과 현재 압력 수준을 확인할 수 있습니다. ManagedOOMSwap=kill, ManagedOOMMemoryPressure=kill 옵션으로 서비스 단위의 사전 OOM 정책을 설정할 수 있습니다.

OOM 우선순위 설계 전략

프로덕션 환경에서 OOM 발생 시 어떤 프로세스가 먼저 종료될지 체계적으로 설계해야 합니다. 다음은 일반적인 서버 환경의 우선순위 가이드라인입니다.

계층oom_score_adj예시 프로세스정책
절대 보호-1000sshd, init/systemd시스템 관리 접근을 보장
최우선 보호-900DB (PostgreSQL, MySQL)데이터 무결성(Integrity) 보호
높은 보호-500핵심 비즈니스 서비스서비스 가용성 보장
기본0웹 서버 워커, API 서버기본 점수로 경쟁
우선 종료+300배치 처리, 크론 작업지연 실행 가능
최우선 종료+800캐시 워커, 로그 수집기손실 허용, 즉시 종료
oom_score_adj=-1000 남용 금지: 이 값은 OOM Killer가 절대 건드리지 않는 프로세스입니다. 과도하게 사용하면 OOM 상황에서 종료할 대상이 없어 시스템 전체가 먹통이 됩니다. 보호가 필요하면 -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_adjeviction 순서
Guaranteed모든 컨테이너 requests=limits-997마지막 (최강 보호)
Burstablerequests < limits2~999중간
BestEffortrequests/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"
--oom-kill-disable=true 위험: 컨테이너의 OOM kill을 비활성화하면, 메모리 초과 시 컨테이너의 모든 할당이 블록되어 무한 대기 상태에 빠질 수 있습니다. 프로덕션에서는 절대 사용하지 마세요.

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
메모리 계산 공식 (MySQL):
총 예상 메모리 = 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 활용: 스왑 디바이스가 없는 임베디드 시스템에서 zram을 사용하면 RAM의 일부를 압축 스왑으로 활용할 수 있습니다. 메모리 1~2배의 효과적인 스왑 공간을 확보하여 OOM 발생을 지연시킵니다.

CONFIG 옵션

OOM 관련 커널 빌드 옵션과 sysctl 파라미터를 정리합니다.

OOM 관련 커널 설정 구조 커널 빌드 CONFIG CONFIG_MEMCG cgroup 메모리 컨트롤러 (memcg OOM 필수) CONFIG_MEMCG_SWAP cgroup 스왑 계정 (swap OOM 정확도) CONFIG_PSI Pressure Stall Information (선제적 감지) CONFIG_CGROUPS cgroup 프레임워크 (cgroup v2 OOM) CONFIG_NUMA NUMA 지원 (노드별 OOM 처리) CONFIG_COMPACTION 메모리 컴팩션 (OOM 진입 전 마지막 시도) CONFIG_ZSWAP / CONFIG_ZRAM 압축 스왑 (OOM 지연 효과) CONFIG_TRACING ftrace OOM 이벤트 추적 런타임 sysctl 파라미터 vm.overcommit_memory 오버커밋 정책 (0/1/2) vm.overcommit_ratio 모드 2 CommitLimit 비율 (기본 50%) vm.panic_on_oom OOM 시 panic 여부 (0=kill, 1=panic, 2=memcg만 kill) vm.oom_kill_allocating_task OOM 트리거 태스크 자체를 kill (0/1) vm.oom_dump_tasks OOM 시 모든 태스크 메모리 정보 덤프 vm.min_free_kbytes 최소 여유 메모리 (watermark 기준) vm.watermark_boost_factor 외부 단편화 시 watermark 상승 계수 vm.watermark_scale_factor watermark 간격 조절 (기본 10 = 0.1%)
왼쪽은 커널 빌드 시 설정하는 CONFIG 옵션, 오른쪽은 런타임에 변경 가능한 sysctl 파라미터

주요 sysctl 상세

파라미터기본값설명권장값
vm.panic_on_oom0OOM 시 panic 대신 프로세스 kill0 (프로덕션), 1 (kdump 분석 필요 시)
vm.oom_kill_allocating_task01이면 OOM 유발 태스크 자체를 kill0 (기본값 유지)
vm.oom_dump_tasks1OOM 시 모든 태스크 덤프(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_memorysysctl 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   # 모든 태스크 백트레이스
프로덕션 환경 주의: SysRq+F는 즉시 OOM Killer를 호출합니다. 프로세스가 oom_score_adj=-1000이 아닌 한 종료될 수 있습니다. 디버깅 목적으로만 사용하세요.

NUMA 환경의 OOM 특수성

NUMA 시스템에서 OOM은 전역 메모리 부족이 아닌 특정 NUMA 노드의 메모리 부족으로 발생할 수 있습니다. 전체 시스템에 메모리가 충분해도 프로세스의 메모리 정책(cpuset, mempolicy)에 의해 특정 노드에서만 할당이 제한되면 OOM이 트리거됩니다.

NUMA 환경에서의 OOM 발생 시나리오 Node 0 (메모리 고갈) CPU 0-7 (local access) RAM 32GB 사용: 31.8GB (99%) cpuset.mems=0 프로세스 → OOM! 다른 노드 사용 불가로 local에서 OOM 발생 constraint=CONSTRAINT_CPUSET (dmesg 확인) Node 1 (여유 있음) CPU 8-15 (local access) RAM 32GB 사용: 12GB (38%) 20GB 여유 그러나 Node 0 프로세스에게 사용 불가 QPI 해결: zone_reclaim_mode=0, 또는 cpuset.mems 확장, 또는 numactl --interleave
전체 메모리가 충분해도 NUMA 바인딩에 의해 특정 노드에서 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
kdump 분석: OOM panic 후 생성된 vmcore를 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, 금융, 의료
PostgreSQL과 overcommit: PostgreSQL 공식 문서는 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%로 설정
Java와 cgroup OOM: JVM의 -Xmx는 Java 힙 크기만 제한합니다. 메타스페이스, 코드 캐시, 스레드 스택, Direct ByteBuffer 등은 별도 메모리를 사용합니다. -Xmx=4G인 Java 앱이 실제로 5~6GB를 사용하는 것은 정상이므로, cgroup memory.max는 충분한 여유를 두고 설정해야 합니다.

고급 주제: GFP 플래그와 OOM 억제

모든 메모리 할당이 OOM을 유발하는 것은 아닙니다. GFP 플래그에 따라 OOM Killer 호출이 억제되는 경우가 있습니다.

GFP 플래그OOM 동작사용 컨텍스트
__GFP_NORETRYOOM 호출하지 않고 즉시 NULL 반환선택적 할당 (캐시, 최적화 버퍼)
__GFP_RETRY_MAYFAIL여러 번 재시도 후 실패 허용대용량 할당 (order>1)
__GFP_NOFAIL절대 실패 불가 — OOM 무한 재시도파일시스템(Filesystem) 저널, 필수 메타데이터
GFP_KERNEL일반 OOM 경로 (직접 회수 + OOM)대부분의 커널 할당
GFP_ATOMICOOM 불가 (슬립(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 테스트는 반드시 격리(Isolation)된 환경(VM, cgroup)에서 수행하세요. 프로덕션 서버에서 실행하면 중요 프로세스가 OOM kill될 수 있습니다.

OOM 발생 시 커널 내부 타임라인

OOM 발생 시 커널 내부 타임라인 t T0: alloc 실패 watermark 미충족 T1: direct reclaim LRU 스캔+회수 시도 T2: compaction 고차 할당 시 시도 T3: oom_lock OOM 직렬화 잠금 T4: 희생자 선정 oom_badness() 순회 T5: SIGKILL + OOM Reaper 깨움 전체 OOM 처리 시간: 수 밀리초 ~ 수백 밀리초 (프로세스 수, 메모리 크기에 의존) T5 이후: 메모리 회수 1. OOM Reaper가 익명 페이지 비동기 회수 2. 프로세스 exit_mmap()으로 나머지 해제 3. 대기 중인 할당 요청이 재시도 4. TIF_MEMDIE로 희생자에게 메모리 예비 접근 허용 실패 시나리오 1. 희생자가 mmap_lock에 블록 → Reaper 재시도 2. 모든 프로세스 면제 → kernel panic 3. kill 후에도 메모리 부족 → 연쇄 OOM 4. __GFP_NOFAIL 할당이 무한 재시도
T0(할당 실패)부터 T5(SIGKILL)까지의 커널 내부 처리 순서와 이후 시나리오

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()로 점수를 산출하여 최고 점수 프로세스를 희생자로 선정합니다.

out_of_memory() 결정 트리 (Decision Tree) __alloc_pages_may_oom() mutex_trylock(&oom_lock) 실패 할당 재시도 성공 out_of_memory(oc) sysctl_panic_on_oom 체크 !=0 panic() constrained_alloc() — NUMA/memcg 제약 판별 sysctl_oom_kill_allocating_task 체크 활성 current 즉시 kill select_bad_process(oc) for_each_process → oom_badness() 점수 비교 oom_kill_process(oc) chosen=NULL panic oom_lock은 동시에 하나의 OOM만 진행되도록 보장 · constrained_alloc()은 CONSTRAINT_NONE / CONSTRAINT_CPUSET / CONSTRAINT_MEMCG 반환
__alloc_pages_may_oom()에서 oom_kill_process()까지의 전체 결정 트리
/* 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 결정의 전 과정을 이 구조체가 추적합니다.

struct oom_control 필드 관계도 struct oom_control struct zonelist *zonelist nodemask_t *nodemask struct mem_cgroup *memcg gfp_t gfp_mask int order struct task_struct *chosen long chosen_points unsigned long totalpages enum oom_constraint constraint NUMA zone 탐색 순서 cpuset/mems 허용 노드 NULL: 전역 OOM non-NULL: memcg OOM (memory.max 초과) select_bad_process()가 설정하는 희생자 enum oom_constraint CONSTRAINT_NONE 전역 메모리 부족 CONSTRAINT_CPUSET cpuset 노드 제한 OOM CONSTRAINT_MEMORY_POLICY NUMA 메모리 정책 제한 CONSTRAINT_MEMCG cgroup 메모리 제한 초과 totalpages의 역할 adj = oc->chosen->signal->oom_score_adj points += adj * totalpages / 1000 totalpages가 클수록 oom_score_adj의 절대적 영향이 커짐
struct oom_control의 각 필드와 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행chosenselect_bad_process()가 순회 후 선정한 최고 점수 태스크를 가리킵니다. NULL이면 kill 가능한 프로세스가 없다는 의미입니다.
  • 12행chosen_points는 선정된 희생자의 oom_badness() 반환값으로, 로그 출력과 디버깅에 활용됩니다.
  • 15행totalpagesoom_score_adj 정규화의 기준입니다. 전역 OOM에서는 전체 RAM+Swap, memcg OOM에서는 cgroup의 memory.max에 해당합니다.
  • 16행constraintconstrained_alloc()이 판별한 OOM 유형으로, dmesg 로그에 출력되어 OOM 원인 분석에 핵심 정보를 제공합니다.

totalpagesoom_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점을 보장합니다.
스코어링 계산 예시: 16GB RAM + 4GB Swap 시스템에서 totalpages = 5,242,880 (20GB/4KB)일 때:
  • 프로세스 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를 깨워 비동기 메모리 회수를 시작합니다.

oom_kill_process() 실행 흐름 oom_kill_process(oc, message) dump_header(oc) — dmesg 출력 자식 프로세스 스캔 독립 mm을 가진 자식이 있으면 대신 kill 검토 발견 자식 kill mark_oom_victim(victim) TIF_MEMDIE 설정 → 메모리 예비 접근 허용 __oom_kill_process(victim) do_send_sig_info(SIGKILL, victim) + 같은 mm을 공유하는 모든 스레드에 SIGKILL wake_oom_reaper(victim)
oom_kill_process()의 실행 단계: 자식 검토 → TIF_MEMDIE 설정 → SIGKILL → 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()과 exit_oom_victim(): 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 결정 트리 OOM 희생자 (부모 프로세스) 예: httpd master (PID 1234) list_for_each_entry(child, &victim→children) child→mm == victim→mm ? YES 같은 mm 공유 → continue (스킵) NO (독립 mm) oom_badness(child) > oom_badness(victim) ? YES victim = child NO 유지 (다음 자식) 실제 시나리오: Apache httpd prefork httpd master (PID 1234): RSS = 50MB, oom_badness = 12,800 ├─ httpd worker (PID 1235): mm == master→mm (vfork/clone) → 스킵 ├─ httpd worker (PID 1236): 독립 mm, RSS = 200MB, oom_badness = 51,200 → victim 교체! └─ httpd worker (PID 1237): 독립 mm, RSS = 180MB, oom_badness = 46,080 → 유지 (51,200 > 46,080) 결과: PID 1236 (worker)이 kill → master는 생존하여 새 worker를 재생성
자식 프로세스 스캔은 fork 기반 서버 데몬에서 master 프로세스를 보호하고 메모리를 가장 많이 사용하는 worker를 종료합니다.
/* 자식 프로세스 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()의 mm 공유 스레드 처리: 최종 희생자가 결정되면 __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 전체가 종료됩니다.

Memory cgroup OOM 경로 try_charge_memcg() — charge 실패 try_to_free_mem_cgroup_pages() 회수 성공 charge 재시도 실패 mem_cgroup_oom(memcg, gfp) memory.oom.group 체크 (상위 계층 포함) =1 cgroup 전체 kill 모든 프로세스에 SIGKILL =0 mem_cgroup_out_of_memory(memcg) out_of_memory(oc) oc->memcg = memcg (cgroup 범위 제한) select_bad_process(oc) cgroup 내 태스크만 순회 → 최고 점수 kill totalpages = memcg의 memory.max / PAGE_SIZE · 전역 OOM과 달리 oom_lock 불필요 (memcg 자체 동기화)
try_charge_memcg() 실패에서 cgroup 내 OOM kill까지의 경로
/* 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 초기화에서 zonelistnodemask는 NULL입니다. memcg OOM은 NUMA 토폴로지와 무관하게 cgroup 소속만으로 범위를 결정하기 때문입니다.
  • 28행memcg 필드가 non-NULL로 설정되어 select_bad_process()가 이 cgroup에 속한 태스크만 순회합니다.
  • 33~34행totalpagesmem_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 실전 패턴: Kubernetes Pod에서 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 이상 메모리 압력 시 알림
OOM 사전 방지 모니터링 체계: 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 = limit2097152kB (2GB)cgroup 메모리 한도에 정확히 도달
failcnt847한도 초과 시도 847회 (문제가 지속적이었음)
anon-rss~2GBJava 힙이 거의 모든 메모리를 소비
total-vm~5.5GB가상 메모리는 2배 이상 (Java 스레드 스택 등)
gfp_maskGFP_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% 이하로 설정
OOM 사후 분석 핵심 체크리스트: OOM 로그에서 (1) gfp_mask로 할당 종류 파악, (2) constraint로 전역/memcg/NUMA 구분, (3) failcnt로 지속성 판단, (4) anon-rss vs file-rss vs shmem-rss 비율로 메모리 소비 패턴 분석, (5) oom_score_adj로 우선순위 정책 확인. 이 5가지를 순서대로 확인하면 대부분의 OOM 원인을 파악할 수 있습니다.

참고자료

커널 문서

LWN 기사

커널 소스