메모리 관리 (Memory Management)
Linux 커널의 물리 및 가상 메모리 관리 서브시스템을 심층 분석합니다. Buddy Allocator, Slab/SLUB, 페이지 테이블, NUMA 아키텍처 등을 다룹니다.
핵심 요약
- 물리 메모리는 Node → Zone → Page(4KB) 계층으로 관리됩니다.
- Buddy Allocator — 페이지 단위(2^n)로 물리 메모리를 할당/해제하며, 외부 단편화를 줄입니다.
- Slab/SLUB — 커널 오브젝트(task_struct, inode 등)를 캐시하여 빈번한 할당을 최적화합니다.
- 페이지 테이블 — 가상 주소를 물리 주소로 변환하는 다단계(PGD→PUD→PMD→PTE) 매핑 구조입니다.
- kmalloc / vmalloc — 커널 내 메모리 할당 함수. kmalloc은 물리 연속, vmalloc은 가상 연속입니다.
단계별 이해
- 물리 구조 파악 — RAM은 Node(NUMA 단위) → Zone(DMA, Normal, HighMem) → Page Frame(4KB)으로 조직됩니다.
cat /proc/buddyinfo로 현재 시스템의 Buddy 상태를 직접 확인해 보세요. - Buddy Allocator 이해 — 요청된 크기를 만족하는 가장 작은 2^n 블록을 찾아 할당하고, 해제 시 인접 블록과 병합(coalescing)합니다.
이 과정이 "buddy"라는 이름의 유래입니다 — 짝(buddy)끼리 합쳐지기 때문입니다.
- Slab 할당자 이해 — 자주 생성/소멸되는 커널 오브젝트를 위해 미리 할당해 둔 캐시(pool)입니다.
cat /proc/slabinfo로 현재 활성 Slab 캐시를 확인할 수 있습니다. - 가상 메모리 매핑 — 각 프로세스는 독립적인 가상 주소 공간을 가지며, 페이지 테이블을 통해 물리 메모리에 매핑됩니다.
TLB(Translation Lookaside Buffer)가 이 변환을 하드웨어 수준에서 캐싱합니다.
물리 메모리 구조 (Physical Memory Organization)
Linux 커널은 물리 메모리를 노드(Node), 존(Zone), 페이지 프레임(Page Frame)의 3단계 계층으로 관리합니다. 이 구조는 NUMA와 UMA 시스템을 모두 지원하도록 설계되었습니다.
메모리 존 (Memory Zones)
각 NUMA 노드의 물리 메모리는 하드웨어 특성에 따라 존(Zone)으로 분류됩니다:
| Zone | 범위 (x86_64) | 용도 |
|---|---|---|
ZONE_DMA | 0 ~ 16MB | ISA DMA 전용 (레거시 디바이스) |
ZONE_DMA32 | 0 ~ 4GB | 32비트 주소 DMA 가능 디바이스 |
ZONE_NORMAL | 4GB ~ 끝 | 일반 커널 메모리 할당 |
ZONE_MOVABLE | 설정 가능 | 메모리 핫플러그, 마이그레이션 가능 |
32비트 시스템에서는 ZONE_HIGHMEM (896MB 이상)이 존재하지만, 64비트 시스템에서는 모든 물리 메모리가 직접 매핑되므로 ZONE_HIGHMEM이 필요 없습니다.
struct page
커널은 모든 물리 페이지 프레임을 struct page로 추적합니다. 이 구조체는 메모리 효율을 위해 union을 적극 활용합니다:
struct page {
unsigned long flags; /* Atomic flags (PG_locked, PG_dirty, ...) */
union {
struct {
union {
struct list_head lru; /* LRU list */
struct {
void *__filler;
unsigned int mlock_count;
};
};
struct address_space *mapping;
pgoff_t index;
unsigned long private;
};
struct { /* slab allocator */
unsigned long _slab_cache;
void *freelist;
};
struct { /* compound page (huge page) */
unsigned long compound_head;
};
};
atomic_t _refcount;
atomic_t _mapcount;
};
Buddy Allocator
Buddy Allocator는 물리 페이지 할당의 핵심 알고리즘입니다. 메모리를 2의 거듭제곱 크기의 블록으로 관리하며, 외부 단편화(external fragmentation)를 최소화합니다.
Buddy 알고리즘 원리
커널은 0차(4KB)부터 10차(4MB)까지 총 11개의 free list를 유지합니다. 페이지 할당 시 요청된 order의 free list에서 블록을 꺼내고, 없으면 상위 order를 분할합니다.
페이지 할당 API
/* Low-level page allocation */
struct page *alloc_pages(gfp_t gfp, unsigned int order);
void __free_pages(struct page *page, unsigned int order);
/* Get the virtual address from alloc_pages */
unsigned long __get_free_pages(gfp_t gfp, unsigned int order);
void free_pages(unsigned long addr, unsigned int order);
/* Single page shortcuts */
struct page *alloc_page(gfp_t gfp);
unsigned long __get_free_page(gfp_t gfp);
unsigned long get_zeroed_page(gfp_t gfp);
/proc/buddyinfo를 통해 각 존의 buddy 상태를 확인할 수 있습니다. 각 열은 order 0부터 10까지의 free 블록 수를 나타냅니다.
Slab/SLUB Allocator
Buddy Allocator는 최소 한 페이지(4KB) 단위로 할당하므로, 작은 오브젝트(수 바이트~수 KB)에는 비효율적입니다. Slab Allocator는 이 문제를 해결하기 위해 오브젝트 풀링을 제공합니다. 동일 크기의 오브젝트를 미리 할당된 슬랩 페이지에 배치하여 내부 단편화를 줄이고, 할당/해제 속도를 극대화합니다.
현대 Linux 커널(6.x)은 기본적으로 SLUB allocator를 사용합니다. 기존 SLAB(복잡한 큐 기반)과 SLOB(임베디드용 최소 구현)은 커널 6.5에서 제거되었습니다.
| 항목 | SLAB (제거됨) | SLUB (현재 기본) | SLOB (제거됨) |
|---|---|---|---|
| 설계 철학 | 복잡한 per-CPU 큐 (array_cache) | 간결한 per-CPU slab + 인라인 메타데이터 | K&R 스타일 first-fit 할당 |
| 메타데이터 | 별도 관리 구조체 (off-slab) | 슬랩 페이지 내 인라인 (struct slab) | 블록 헤더 |
| 캐시 머징 | 미지원 | 동일 크기/정렬 캐시 자동 병합 | 해당 없음 |
| NUMA 지원 | 있음 | 있음 (노드별 partial list) | 없음 |
| 디버깅 | 제한적 | 풍부 (red zone, poisoning, tracking) | 없음 |
| 대상 환경 | 범용 | 범용 (모든 환경) | 임베디드 (메모리 < 64MB) |
SLUB 아키텍처
SLUB의 핵심 설계 원칙은 간결성입니다. 별도의 slab 관리 구조체 없이 페이지 디스크립터(struct slab, struct page와 union)에 메타데이터를 저장합니다. 3단계 계층 구조로 할당 성능을 최적화합니다:
핵심 자료구조
kmem_cache — 캐시 디스크립터
각 오브젝트 타입(또는 kmalloc 크기 클래스)마다 하나의 kmem_cache가 존재합니다. 캐시의 오브젝트 크기, 정렬, 슬랩 페이지 order, per-CPU/per-Node 관리 구조를 포함합니다.
/* include/linux/slub_def.h */
struct kmem_cache {
/* Per-CPU 데이터 (Level 1) */
struct kmem_cache_cpu __percpu *cpu_slab;
/* 오브젝트 크기 정보 */
unsigned int object_size; /* 사용자 요청 크기 */
unsigned int size; /* 실제 할당 크기 (정렬 + 메타데이터 포함) */
unsigned int offset; /* freelist 포인터 오프셋 (오브젝트 내 위치) */
/* 슬랩 페이지 레이아웃 */
unsigned int oo; /* 최적 order + objects 수 (인코딩) */
unsigned int min; /* 최소 order + objects 수 (fallback) */
unsigned int max; /* 최대 order */
unsigned int inuse; /* 오브젝트 내 사용 바이트 수 */
unsigned int align; /* 정렬 요구사항 */
/* 관리 제어 */
slab_flags_t flags; /* SLAB_HWCACHE_ALIGN, SLAB_POISON, ... */
unsigned int cpu_partial; /* CPU partial list 최대 오브젝트 수 */
unsigned int min_partial; /* Node partial list 최소 슬랩 수 */
gfp_t allocflags; /* Buddy 할당 시 GFP 플래그 */
int refcount; /* 참조 카운트 (머징 시 공유) */
/* 초기화 콜백 */
void (*ctor)(void *); /* 오브젝트 생성자 (선택적) */
/* Per-Node 데이터 (Level 3) */
struct kmem_cache_node *node[MAX_NUMNODES];
/* 식별 */
const char *name; /* 캐시 이름 (/proc/slabinfo에 표시) */
struct list_head list; /* slab_caches 전역 리스트 연결 */
/* 보안 하드닝 */
unsigned long random; /* freelist 포인터 XOR 난수 */
/* 사용자 추적 (CONFIG_SLUB_DEBUG) */
unsigned int useroffset; /* usercopy 허용 영역 시작 오프셋 */
unsigned int usersize; /* usercopy 허용 영역 크기 */
};
kmem_cache_cpu — Per-CPU 슬랩 관리
/* include/linux/slub_def.h */
struct kmem_cache_cpu {
union {
struct {
void **freelist; /* 다음 할당 가능 오브젝트 포인터 */
unsigned long tid; /* 트랜잭션 ID (cmpxchg_double용) */
};
freelist_aba_t freelist_tid;
};
struct slab *slab; /* 현재 활성 슬랩 페이지 */
#ifdef CONFIG_SLUB_CPU_PARTIAL
struct slab *partial; /* CPU 로컬 partial 리스트 (Level 2) */
#endif
};
tid (Transaction ID)는 Per-CPU slab의 락프리 할당을 구현하는 핵심입니다. this_cpu_cmpxchg_double()로 freelist와 tid를 원자적으로 교체하여, 다른 CPU의 간섭 없이 할당/해제를 수행합니다. tid가 불일치하면 재시도합니다.
struct slab — 슬랩 페이지 메타데이터
/* mm/slab.h — struct page와 union으로 동일 메모리 공유 */
struct slab {
unsigned long __page_flags;
struct kmem_cache *slab_cache; /* 소속 캐시 */
union {
struct {
union {
struct list_head slab_list; /* node partial 리스트 연결 */
struct {
struct slab *next; /* CPU partial 리스트 연결 */
int slabs; /* 남은 슬랩 수 (리스트 내) */
};
};
void *freelist; /* 첫 번째 free 오브젝트 */
union {
unsigned long counters;
struct {
unsigned inuse:16; /* 사용 중인 오브젝트 수 */
unsigned objects:15; /* 총 오브젝트 수 */
unsigned frozen:1; /* Per-CPU 활성 슬랩 여부 */
};
};
};
};
unsigned int __unused;
atomic_t __page_refcount;
};
frozen=1이면 이 슬랩이 특정 CPU의 활성 슬랩으로 사용 중이라는 뜻입니다. frozen 상태의 슬랩은 node partial list에서 제외되며, 해당 CPU만 접근할 수 있어 별도의 락이 필요 없습니다. 슬랩이 가득 차거나 비워지면 frozen이 해제되고 node partial list로 이동합니다.
kmem_cache_node — NUMA 노드별 관리
/* mm/slab.h */
struct kmem_cache_node {
spinlock_t list_lock; /* partial 리스트 보호 락 */
unsigned long nr_partial; /* partial 슬랩 수 */
struct list_head partial; /* partial 슬랩 이중 연결 리스트 */
#ifdef CONFIG_SLUB_DEBUG
atomic_long_t nr_slabs; /* 총 슬랩 수 */
atomic_long_t total_objects; /* 총 오브젝트 수 */
struct list_head full; /* full 슬랩 리스트 (디버그 전용) */
#endif
};
오브젝트 메모리 레이아웃
SLUB에서 각 오브젝트는 다음과 같은 레이아웃을 가집니다. 디버깅 플래그에 따라 추가 영역이 삽입됩니다.
일반 모드 (디버깅 OFF):
┌──────────────────────────────────────┐
│ user data (object_size) │ ← 사용자가 사용하는 영역
│ [ FP: freelist ptr at offset ] │ ← free 상태일 때만 유효
├──────────────────────────────────────┤
│ padding (size - object_size) │ ← 정렬 패딩
└──────────────────────────────────────┘
디버깅 모드 (slub_debug=FZPU):
┌──────────────────────────────────────┐
│ Red Zone (앞) │ ← 오버플로 탐지 (0xbb)
├──────────────────────────────────────┤
│ user data (object_size) │
├──────────────────────────────────────┤
│ Red Zone (뒤) │ ← 언더플로 탐지 (0xbb)
├──────────────────────────────────────┤
│ Padding │ ← 정렬 패딩 (0x5a)
├──────────────────────────────────────┤
│ Track (alloc) │ ← 할당 호출자 정보 (addr, pid, timestamp)
│ Track (free) │ ← 해제 호출자 정보
└──────────────────────────────────────┘
Poisoning 패턴:
할당 직후: 0x6b (POISON_FREE 마커 해제)
해제 직후: 0x6b (POISON_FREE → use-after-free 시 탐지)
마지막 바이트: 0xa5 (POISON_END → 오브젝트 끝 경계 검증)
/* mm/slub.c — freelist 포인터 위치 결정 */
/*
* offset: 오브젝트 내 freelist 포인터 저장 위치
* - 기본값: object 시작 (offset = 0)
* - 생성자(ctor)가 있으면 object_size 바로 뒤
* - SLUB은 free 오브젝트의 freelist 포인터를
* user data 영역 내에 저장 (별도 메모리 불필요)
*/
static inline void *freelist_ptr(
const struct kmem_cache *s,
void *ptr, unsigned long ptr_addr)
{
#ifdef CONFIG_SLAB_FREELIST_HARDENED
/* 보안 하드닝: FP를 XOR로 난독화 */
return (void *)((unsigned long)ptr ^
s->random ^
swab(ptr_addr));
#else
return ptr;
#endif
}
할당 경로 (Allocation Path)
SLUB 할당은 3단계 fallback 구조로 설계되어, 대부분의 할당이 락 없이 빠르게 완료됩니다.
/* mm/slub.c — 할당 Fastpath (인라인, ~10ns) */
static __always_inline void *slab_alloc_node(
struct kmem_cache *s, struct list_lru *lru,
gfp_t gfpflags, int node, unsigned long addr,
size_t orig_size)
{
void *object;
struct kmem_cache_cpu *c;
struct slab *slab;
unsigned long tid;
/* === Fastpath: Per-CPU freelist에서 바로 할당 === */
redo:
/* 현재 CPU의 freelist와 tid를 읽기 */
c = raw_cpu_ptr(s->cpu_slab);
tid = c->tid;
barrier();
object = c->freelist;
slab = c->slab;
/* freelist가 비어있거나 NUMA 노드 불일치 → slowpath */
if (unlikely(!object || !slab ||
!node_match(slab, node))) {
object = __slab_alloc(s, gfpflags, node, addr, c, orig_size);
} else {
/* freelist에서 첫 오브젝트 팝 (다음 free 오브젝트로 이동) */
void *next_object = get_freepointer_safe(s, object);
/* 원자적 CAS: freelist=next, tid 증가 */
if (unlikely(!this_cpu_cmpxchg_double(
s->cpu_slab->freelist, s->cpu_slab->tid,
object, tid,
next_object, next_tid(tid))))
goto redo; /* 경쟁 발생 시 재시도 */
prefetch_freepointer(s, next_object); /* L1 캐시 프리페치 */
}
return object;
}
/* mm/slub.c — Slowpath 요약 (__slab_alloc) */
static void *__slab_alloc(
struct kmem_cache *s, gfp_t gfpflags,
int node, unsigned long addr,
struct kmem_cache_cpu *c, size_t orig_size)
{
/* Step 1: 현재 슬랩의 slab→freelist 시도 */
/* → cpu_slab→freelist가 비었지만 slab 자체에 free obj 있을 수 있음 */
/* Step 2: CPU Partial List에서 슬랩 가져오기 (lockless) */
/* → c→partial에서 슬랩을 꺼내 c→slab으로 승격 */
/* Step 3: Node Partial List에서 가져오기 (list_lock 필요) */
/* → 노드의 partial 리스트에서 적합한 슬랩 검색 */
/* Step 4: Buddy Allocator에서 새 슬랩 페이지 할당 */
/* → allocate_slab() → alloc_pages() */
/* 각 단계에서 성공하면 즉시 오브젝트 반환 */
}
해제 경로 (Free Path)
/* mm/slub.c — 해제 Fastpath */
static __always_inline void do_slab_free(
struct kmem_cache *s, struct slab *slab,
void *head, void *tail, int cnt, unsigned long addr)
{
void *prior;
unsigned long tid;
struct kmem_cache_cpu *c;
redo:
c = raw_cpu_ptr(s->cpu_slab);
tid = c->tid;
barrier();
/* Fastpath: 해제하려는 오브젝트가 현재 CPU의 슬랩에 속하면 */
if (likely(slab == c->slab)) {
/* freelist 헤드에 오브젝트를 push (LIFO) */
set_freepointer(s, tail, c->freelist);
if (unlikely(!this_cpu_cmpxchg_double(
s->cpu_slab->freelist, s->cpu_slab->tid,
c->freelist, tid,
head, next_tid(tid))))
goto redo; /* 경쟁 시 재시도 */
} else {
/* Slowpath: 다른 CPU의 슬랩 → __slab_free() */
/* frozen 상태 확인, partial list 이동, 빈 슬랩 해제 등 */
__slab_free(s, slab, head, tail, cnt, addr);
}
}
/* Slowpath 해제 시 발생하는 상황들 */
/*
* 1. 해제 후 slab이 full→partial: node partial list에 추가
* 2. 해제 후 slab이 비어짐 (inuse=0):
* - node partial이 min_partial 이하면 partial list에 유지
* - min_partial 초과면 Buddy에 반환 (discard_slab)
* 3. 원격 CPU의 frozen slab: slab→freelist에 직접 push (cmpxchg)
*/
할당과 해제 모두 Fastpath에서 락을 사용하지 않습니다. this_cpu_cmpxchg_double()을 통한 원자적 연산만으로 freelist를 조작하므로, 일반적인 할당/해제는 약 10~20ns에 완료됩니다. 리눅스 커널의 전체 kmalloc 호출 중 약 90% 이상이 이 Fastpath로 처리됩니다.
슬랩 머징 (Slab Merging)
SLUB은 크기, 정렬, 플래그가 호환되는 캐시를 자동으로 병합하여 메모리 효율을 높입니다. 예를 들어, 동일 크기의 두 kmem_cache_create() 호출이 하나의 캐시를 공유할 수 있습니다.
/* mm/slab_common.c — 머징 가능 여부 판단 */
static struct kmem_cache *find_mergeable(
unsigned int size, unsigned int align,
slab_flags_t flags, const char *name,
void (*ctor)(void *))
{
struct kmem_cache *s;
/* 머징 불가 조건 */
if (flags & SLAB_NEVER_MERGE) return NULL;
if (ctor) return NULL; /* 생성자 있으면 불가 */
if (flags & SLAB_NO_MERGE) return NULL;
list_for_each_entry_reverse(s, &slab_caches, list) {
if (s->size - size >= sizeof(void *))
continue; /* 크기 차이 너무 큼 */
if (s->size < size)
continue; /* 너무 작음 */
if ((flags & SLUB_MERGE_SAME) != (s->flags & SLUB_MERGE_SAME))
continue; /* 플래그 불일치 */
if (s->align < align)
continue; /* 정렬 부족 */
s->refcount++; /* 참조 카운트 증가 */
return s; /* 머징 대상 발견 */
}
return NULL;
}
# 머징 상태 확인
cat /sys/kernel/slab/*/aliases | sort -rn | head
# 특정 캐시의 별칭(aliased) 캐시 확인
cat /sys/kernel/slab/kmalloc-192/aliases
# 머징 비활성화 (부트 파라미터)
slub_nomerge # 모든 캐시 머징 비활성화
# → /proc/slabinfo에 개별 캐시가 모두 표시됨
# → 디버깅 시 어떤 서브시스템이 메모리를 사용하는지 명확히 구분 가능
# 프로그래밍으로 머징 방지
# kmem_cache_create() 시 SLAB_NO_MERGE 또는 생성자(ctor) 지정
슬랩 플래그
| 플래그 | 설명 | 용도 |
|---|---|---|
SLAB_HWCACHE_ALIGN | 오브젝트를 하드웨어 캐시 라인에 정렬 | L1 캐시 효율 최적화 (빈번 접근 구조체) |
SLAB_POISON | 할당/해제 시 패턴 채움 (0x6b / 0xa5) | use-after-free, 미초기화 탐지 |
SLAB_RED_ZONE | 오브젝트 앞뒤에 red zone 삽입 (0xbb) | 버퍼 오버런/언더런 탐지 |
SLAB_ACCOUNT | cgroup 메모리 카운터에 계상 | 컨테이너 메모리 제한 적용 |
SLAB_RECLAIM_ACCOUNT | 회수 가능 슬랩으로 마크 | shrinker로 메모리 회수 가능 |
SLAB_PANIC | 캐시 생성 실패 시 커널 패닉 | 필수 캐시 (fork 실패 = 시스템 무용) |
SLAB_TYPESAFE_BY_RCU | RCU grace period 내 슬랩 페이지 유지 | RCU 보호 오브젝트 (슬랩 재사용 안전) |
SLAB_MEM_SPREAD | NUMA 노드 간 오브젝트 분산 | VFS inode/dentry 캐시 등 (6.2에서 제거) |
SLAB_NO_MERGE | 이 캐시를 다른 캐시와 병합 금지 | 디버깅, 보안 격리 |
SLAB_CACHE_DMA | DMA zone에서 페이지 할당 | ISA DMA 장치용 (레거시) |
SLAB_CACHE_DMA32 | DMA32 zone에서 페이지 할당 | 32비트 DMA 주소 필요 장치 |
보안 하드닝
SLUB은 공격자가 freelist를 조작하여 커널 코드 실행을 탈취하는 것을 방지하기 위한 여러 하드닝 메커니즘을 제공합니다.
| CONFIG 옵션 | 메커니즘 | 방어 대상 |
|---|---|---|
CONFIG_SLAB_FREELIST_RANDOM | 슬랩 초기화 시 freelist 순서를 랜덤으로 셔플 | 힙 스프레이 공격 (오브젝트 배치 예측 방지) |
CONFIG_SLAB_FREELIST_HARDENED | freelist 포인터를 XOR(random, swab(addr))로 난독화 | freelist 포인터 덮어쓰기 공격 |
CONFIG_INIT_ON_ALLOC_DEFAULT_ON | 할당 시 오브젝트를 0으로 초기화 | 정보 누수 (이전 데이터 잔류) |
CONFIG_INIT_ON_FREE_DEFAULT_ON | 해제 시 오브젝트를 0으로 초기화 | use-after-free 시 데이터 노출 |
CONFIG_RANDOM_KMALLOC_CACHES | kmalloc 캐시를 16개 랜덤 버킷으로 분리 (6.6+) | cross-cache 공격 (동일 캐시 슬랩 재사용 공격) |
CONFIG_HARDENED_USERCOPY | copy_to/from_user() 시 슬랩 경계 검증 | 슬랩 오버리드/정보 누수 |
CONFIG_KFENCE | 샘플링 기반 electric-fence 오류 탐지 (프로덕션용) | out-of-bounds, use-after-free (낮은 오버헤드) |
/* mm/slub.c — Freelist Randomization */
static void shuffle_freelist(
struct kmem_cache *s, struct slab *slab)
{
unsigned int count = slab->objects;
void **list; /* 임시 배열 */
unsigned int idx, i;
/* Fisher-Yates 셔플로 freelist 순서 랜덤화 */
for (i = count - 1; i > 0; i--) {
idx = get_random_u32_below(i + 1);
/* list[i] ↔ list[idx] 교환 */
swap(list[i], list[idx]);
}
/* 셔플된 순서로 freelist 포인터 재연결 */
for (i = 0; i < count - 1; i++)
set_freepointer(s, list[i], list[i + 1]);
set_freepointer(s, list[count - 1], NULL);
}
/* CONFIG_RANDOM_KMALLOC_CACHES (6.6+) — 랜덤 버킷 선택 */
/*
* kmalloc-256 → kmalloc-rnd-01-256, kmalloc-rnd-02-256, ..., kmalloc-rnd-15-256
* 할당 시 호출자 주소의 해시로 버킷 선택
* → cross-cache 공격이 16배 어려워짐
*/
Slab API
/* kmem_cache 생성/해제 */
struct kmem_cache *kmem_cache_create(
const char *name, /* /proc/slabinfo에 표시할 이름 */
unsigned int size, /* 오브젝트 크기 */
unsigned int align, /* 정렬 (0이면 자동) */
slab_flags_t flags, /* SLAB_HWCACHE_ALIGN 등 */
void (*ctor)(void *) /* 생성자 (NULL 가능) */
);
void kmem_cache_destroy(struct kmem_cache *s);
/* 지정 usersize로 생성 (usercopy hardening) */
struct kmem_cache *kmem_cache_create_usercopy(
const char *name, unsigned int size,
unsigned int align, slab_flags_t flags,
unsigned int useroffset, /* copy_to_user 허용 시작 오프셋 */
unsigned int usersize, /* copy_to_user 허용 크기 */
void (*ctor)(void *)
);
/* Object 할당/해제 */
void *kmem_cache_alloc(struct kmem_cache *s, gfp_t gfp);
void *kmem_cache_alloc_node(struct kmem_cache *s, gfp_t gfp, int node);
void *kmem_cache_zalloc(struct kmem_cache *s, gfp_t gfp); /* zero-init */
void kmem_cache_free(struct kmem_cache *s, void *obj);
/* 벌크 할당/해제 (배치 처리로 오버헤드 최소화) */
int kmem_cache_alloc_bulk(struct kmem_cache *s, gfp_t gfp,
size_t nr, void **p); /* nr개 일괄 할당 */
void kmem_cache_free_bulk(struct kmem_cache *s,
size_t nr, void **p); /* nr개 일괄 해제 */
/* 캐시 축소 (메모리 압박 시 빈 슬랩 반환) */
int kmem_cache_shrink(struct kmem_cache *s);
/* 범용 할당 (내부적으로 size에 맞는 kmem_cache 사용) */
void *kmalloc(size_t size, gfp_t gfp);
void *kzalloc(size_t size, gfp_t gfp); /* zero-initialized */
void kfree(const void *obj);
Slab Cache 사용 예제
struct my_data {
int id;
char name[64];
struct list_head list;
};
static struct kmem_cache *my_cache;
static int __init my_init(void)
{
struct my_data *obj;
my_cache = kmem_cache_create("my_data_cache",
sizeof(struct my_data), 0,
SLAB_HWCACHE_ALIGN, NULL);
if (!my_cache)
return -ENOMEM;
obj = kmem_cache_alloc(my_cache, GFP_KERNEL);
if (!obj) {
kmem_cache_destroy(my_cache);
return -ENOMEM;
}
obj->id = 42;
strscpy(obj->name, "hello", sizeof(obj->name));
/* ... use obj ... */
kmem_cache_free(my_cache, obj);
return 0;
}
static void __exit my_exit(void)
{
/* 모든 오브젝트가 해제된 후에만 호출 가능 */
kmem_cache_destroy(my_cache);
}
/* 벌크 할당 예제 (네트워크 패킷 버퍼 등) */
static void batch_alloc_example(void)
{
void *objects[32];
int allocated;
/* 32개를 한 번에 할당 (반복 kmem_cache_alloc보다 ~30% 빠름) */
allocated = kmem_cache_alloc_bulk(my_cache, GFP_KERNEL,
32, objects);
if (allocated < 32) {
/* 부분 성공 — allocated개만 할당됨 */
kmem_cache_free_bulk(my_cache, allocated, objects);
return;
}
/* ... use objects[0..31] ... */
/* 일괄 해제 */
kmem_cache_free_bulk(my_cache, 32, objects);
}
커널 주요 슬랩 캐시
| 캐시 이름 | 오브젝트 | 사용처 | 일반적 크기 |
|---|---|---|---|
task_struct | struct task_struct | 프로세스/스레드 디스크립터 | ~6~10KB |
mm_struct | struct mm_struct | 메모리 디스크립터 | ~1KB |
vm_area_struct | struct vm_area_struct | 가상 메모리 영역 | ~200B |
dentry | struct dentry | 디렉토리 엔트리 캐시 | ~192B |
inode_cache | struct inode | VFS inode 캐시 | ~600B |
filp | struct file | 파일 디스크립터 | ~256B |
signal_cache | struct signal_struct | 프로세스 시그널 정보 | ~1KB |
files_cache | struct files_struct | FD 테이블 | ~704B |
TCP | struct tcp_sock | TCP 소켓 | ~2KB |
sk_buff_head_cache | struct sk_buff | 네트워크 패킷 메타데이터 | ~256B |
radix_tree_node | struct radix_tree_node | 기수 트리 (페이지 캐시) | ~576B |
kmalloc-{8..8192} | 범용 | kmalloc()/kzalloc() | 8~8192B |
kmalloc-cg-* | cgroup 계상 범용 | cgroup 메모리 제한 대상 kmalloc | 8~8192B |
# 상위 슬랩 캐시 메모리 사용량 확인
slabtop -o -s c | head -20
# 특정 캐시 상세 정보
cat /sys/kernel/slab/dentry/object_size # 오브젝트 크기
cat /sys/kernel/slab/dentry/slab_size # 메타데이터 포함 크기
cat /sys/kernel/slab/dentry/objs_per_slab # 슬랩당 오브젝트 수
cat /sys/kernel/slab/dentry/order # 슬랩 페이지 order
cat /sys/kernel/slab/dentry/partial # partial 슬랩 수
cat /sys/kernel/slab/dentry/cpu_partial # CPU partial 최대값
cat /sys/kernel/slab/dentry/objects # 현재 오브젝트 수
# 캐시 통계 (CONFIG_SLUB_STATS 필요)
cat /sys/kernel/slab/dentry/alloc_fastpath
cat /sys/kernel/slab/dentry/alloc_slowpath
cat /sys/kernel/slab/dentry/free_fastpath
cat /sys/kernel/slab/dentry/free_slowpath
페이지 테이블 (Page Table)
x86_64에서 Linux는 5단계 페이지 테이블 구조를 사용합니다 (4단계가 기본, 5단계는 CONFIG_X86_5LEVEL 시 활성):
PTE 플래그
| 플래그 | 비트 | 설명 |
|---|---|---|
_PAGE_PRESENT | 0 | 페이지 존재 여부 |
_PAGE_RW | 1 | 읽기/쓰기 권한 (0=읽기전용) |
_PAGE_USER | 2 | 유저 모드 접근 가능 |
_PAGE_PWT | 3 | Page Write-Through |
_PAGE_PCD | 4 | Page Cache Disable |
_PAGE_ACCESSED | 5 | 최근 접근됨 |
_PAGE_DIRTY | 6 | 수정됨 (쓰기 발생) |
_PAGE_NX | 63 | No-Execute (실행 방지) |
GFP 플래그 (Get Free Pages)
메모리 할당 함수에 전달하는 GFP 플래그는 할당 동작을 결정합니다:
| 플래그 | 컨텍스트 | 설명 |
|---|---|---|
GFP_KERNEL | 프로세스 | 일반 커널 할당. 슬립 가능, I/O 가능, 파일시스템 재진입 가능 |
GFP_ATOMIC | 인터럽트/atomic | 슬립 불가. 비상 예약 풀 사용 가능 |
GFP_NOWAIT | 어디서든 | 슬립 불가, 비상 풀 미사용 |
GFP_NOIO | I/O 서브시스템 | 슬립 가능하나 I/O 불가 |
GFP_NOFS | 파일시스템 | 슬립 가능하나 파일시스템 호출 불가 |
GFP_DMA | DMA | ZONE_DMA에서 할당 |
GFP_DMA32 | DMA | ZONE_DMA32에서 할당 |
GFP_HIGHUSER | 유저 페이지 | 유저 공간 페이지 (HIGHMEM 선호) |
인터럽트 핸들러, spinlock 보유 중, softirq 컨텍스트에서는 반드시 GFP_ATOMIC을 사용해야 합니다. GFP_KERNEL을 사용하면 스케줄링이 발생하여 deadlock이나 BUG가 트리거됩니다.
vmalloc과 가상 메모리
vmalloc()은 가상적으로 연속된 메모리를 할당하지만, 물리적으로는 비연속일 수 있습니다. DMA에는 사용할 수 없으며, 페이지 테이블 조작이 필요하므로 kmalloc보다 느립니다.
메모리 할당 API 비교
| API | 물리 연속 | 최대 크기 | 용도 |
|---|---|---|---|
kmalloc() | 연속 | ~4MB (order 10) | 일반 커널 오브젝트, DMA 버퍼 |
vmalloc() | 비연속 가능 | 가상 주소 공간 한도 | 큰 버퍼, 모듈 로딩 |
alloc_pages() | 연속 | ~4MB | low-level 페이지 할당 |
kvmalloc() | 시도 후 fallback | 무제한 | kmalloc 시도 → 실패 시 vmalloc |
dma_alloc_coherent() | 연속 | 드라이버 의존 | DMA 버퍼 (coherent) |
NUMA 아키텍처 지원
NUMA(Non-Uniform Memory Access) 시스템에서는 CPU마다 로컬 메모리 노드가 있어 접근 지연 시간이 다릅니다. 커널은 pg_data_t (= struct pglist_data)로 각 NUMA 노드를 관리합니다.
/* NUMA-aware allocation */
void *kmalloc_node(size_t size, gfp_t gfp, int node);
struct page *alloc_pages_node(int nid, gfp_t gfp, unsigned int order);
/* Get current NUMA node */
int node = numa_node_id();
/* Memory policy */
set_mempolicy(MPOL_BIND, &nodemask, maxnode);
OOM Killer
시스템의 물리 메모리와 스왑이 모두 고갈되어 페이지 할당이 불가능해지면, 커널의 OOM (Out-Of-Memory) Killer가 최후의 수단으로 프로세스를 선택적으로 종료하여 메모리를 확보합니다. OOM Killer의 핵심 로직은 mm/oom_kill.c에 구현되어 있으며, 단순한 kill 동작 이면에 오버커밋 정책, 점수 계산 알고리즘, cgroup 메모리 제한, 비동기 메모리 회수(OOM Reaper) 등 복잡한 메커니즘이 관여합니다.
OOM 발동 조건과 흐름
페이지 할당 요청이 실패하면 커널은 즉시 OOM Killer를 호출하지 않습니다. 먼저 여러 단계의 메모리 회수를 시도하고, 모든 수단이 실패한 후에야 OOM Killer가 발동합니다.
┌─────────────────────────┐
│ 페이지 할당 요청 │ __alloc_pages_noprof()
│ (GFP 플래그 포함) │
└──────────┬──────────────┘
│ 워터마크 미달
▼
┌─────────────────────────┐
│ kswapd 깨우기 │ wakeup_kswapd()
│ (비동기 백그라운드) │
└──────────┬──────────────┘
│ 여전히 부족
▼
┌─────────────────────────┐
│ Direct Reclaim │ __alloc_pages_slowpath()
│ (동기적 페이지 회수) │ → __perform_reclaim()
└──────────┬──────────────┘
│ 회수 실패
▼
┌─────────────────────────┐
│ Compaction 시도 │ __alloc_pages_direct_compact()
│ (메모리 압축/조각 해소) │
└──────────┬──────────────┘
│ 여전히 할당 불가
▼
┌─────────────────────────┐
│ OOM Killer 호출 │ __alloc_pages_may_oom()
│ → out_of_memory() │ → out_of_memory()
└─────────────────────────┘
out_of_memory() 함수는 OOM Killer의 진입점으로, kill 대상을 선택하고 SIGKILL을 전송합니다.
/* mm/oom_kill.c — out_of_memory() 핵심 로직 (단순화) */
bool out_of_memory(struct oom_control *oc)
{
/* 1. sysctl vm.panic_on_oom이 설정되어 있으면 패닉 */
check_panic_on_oom(oc);
/* 2. 현재 프로세스가 SIGKILL 대기 중이면 빠른 종료 */
if (task_will_free_mem(current))
return true;
/* 3. vm.oom_kill_allocating_task가 설정되면 현재 태스크 kill */
if (sysctl_oom_kill_allocating_task &&
!oom_unkillable_task(current) && current->mm) {
get_task_struct(current);
oc->chosen = current;
oom_kill_process(oc, "Out of memory (oom_kill_allocating_task)");
return true;
}
/* 4. oom_badness()로 점수가 가장 높은 프로세스 선택 */
select_bad_process(oc);
if (!oc->chosen) /* kill 가능한 프로세스 없음 */
return false;
/* 5. 선택된 프로세스에 SIGKILL 전송 */
oom_kill_process(oc, "Out of memory");
return true;
}
__GFP_NOFAIL 플래그로 할당을 요청하면, 커널은 할당이 성공할 때까지 무한 재시도합니다. 이 경우에도 OOM Killer가 반복 발동될 수 있으므로, __GFP_NOFAIL은 반드시 필요한 경우에만 사용해야 합니다.
oom_badness() 점수 계산
OOM Killer는 oom_badness() 함수로 각 프로세스의 "나쁜 정도"를 점수화하여, 가장 높은 점수를 받은 프로세스를 kill 대상으로 선택합니다. 커널 2.6.36 이후 현재까지 사용되는 알고리즘은 매우 단순합니다.
/* mm/oom_kill.c — oom_badness() (단순화) */
long oom_badness(struct task_struct *p, unsigned long totalpages)
{
long points;
long adj;
/* oom_score_adj가 OOM_SCORE_ADJ_MIN(-1000)이면 kill 면제 */
adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN)
return LONG_MIN;
/* RSS + swap 사용량을 기반으로 점수 계산 */
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS);
/* oom_score_adj를 전체 메모리 대비 비율로 가산 */
adj *= totalpages / 1000;
points += adj;
return points;
}
점수 계산 원리: 기본 점수는 프로세스의 RSS(Resident Set Size) + 스왑 사용량(페이지 단위)이며, 여기에 oom_score_adj 값을 전체 메모리 대비 비율로 환산하여 가감합니다. 결과적으로 메모리를 가장 많이 사용하는 프로세스가 우선 kill 대상이 됩니다.
/proc/<pid>/oom_score와 /proc/<pid>/oom_score_adj는 별도의 인터페이스입니다.
| 인터페이스 | 범위 | 용도 |
|---|---|---|
/proc/<pid>/oom_score | 0 ~ (가변) | 읽기 전용. 커널이 계산한 현재 OOM 점수 (높을수록 kill 우선) |
/proc/<pid>/oom_score_adj | -1000 ~ +1000 | 읽기/쓰기. 사용자가 설정하는 점수 조정값 |
oom_score_adj 주요 값과 의미:
| 값 | 의미 | 사용 예 |
|---|---|---|
| -1000 | OOM kill 완전 면제 | init, 필수 시스템 데몬 |
| -900 | 매우 낮은 우선순위 (kill 가능성 최소) | DB 서버, 핵심 서비스 |
| 0 | 기본값 (조정 없음) | 일반 프로세스 |
| +500 | 높은 우선순위 (kill 가능성 증가) | 임시 배치 작업 |
| +1000 | 최우선 kill 대상 | 테스트/디버그 프로세스 |
# 프로세스의 OOM score 확인
cat /proc/<pid>/oom_score
# OOM score 조정 (-1000 ~ 1000, -1000이면 kill 면제)
echo -1000 > /proc/<pid>/oom_score_adj
# 시스템 전체 프로세스의 OOM score 순위 확인
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 $adj $p $name"
done | sort -rn | head -20
오버커밋과 OOM의 관계
리눅스는 기본적으로 실제 물리 메모리보다 더 많은 가상 메모리를 프로세스에 할당(오버커밋)할 수 있습니다. 이는 fork 시 CoW(Copy-on-Write)로 실제 메모리 사용이 지연되기 때문에 효율적이지만, 모든 프로세스가 동시에 할당된 메모리를 사용하면 OOM이 발생합니다.
vm.overcommit_memory sysctl로 오버커밋 정책을 제어합니다.
| 모드 | 값 | 동작 | OOM 가능성 |
|---|---|---|---|
| 휴리스틱 | 0 (기본) | 커널이 "합리적" 범위 내에서 오버커밋 허용. 명백히 과도한 요청만 거부 | 중간 |
| 항상 허용 | 1 | 모든 mmap()/brk() 요청을 무조건 성공시킴. commit 제한 없음 | 높음 |
| 금지 | 2 | CommitLimit 초과 시 할당 거부. 오버커밋 불가 | 낮음 (대신 할당 실패 발생) |
모드 2에서 CommitLimit 계산:
CommitLimit = (Physical RAM × overcommit_ratio / 100) + Swap 크기
/proc/meminfo의 Committed_AS가 현재 커밋된 총 가상 메모리이며, 이 값이 CommitLimit을 초과하면 새 할당이 거부됩니다.
/* mm/util.c — __vm_enough_memory() 핵심 로직 (단순화) */
int __vm_enough_memory(struct mm_struct *mm, long pages, int cap_sys_admin)
{
long allowed;
if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS) /* 모드 1 */
return 0; /* 항상 허용 */
if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) { /* 모드 0 */
/* 휴리스틱: free + page cache + reclaimable slab 기반 */
allowed = totalram_pages() - hugetlb_total_pages();
allowed -= sysctl_admin_reserve_kbytes >> (PAGE_SHIFT - 10);
if (pages <= allowed)
return 0;
}
/* 모드 2: CommitLimit = RAM × ratio/100 + Swap */
allowed = vm_commit_limit();
if (!cap_sys_admin)
allowed -= sysctl_admin_reserve_kbytes >> (PAGE_SHIFT - 10);
if (percpu_counter_read_positive(&vm_committed_as) + pages > allowed)
return -ENOMEM; /* 커밋 한도 초과: 할당 거부 */
return 0;
}
# 오버커밋 정책 설정
# 0: 휴리스틱 오버커밋 (기본값)
# 1: 항상 오버커밋 허용
# 2: 오버커밋 금지 (CommitLimit 기반)
sysctl -w vm.overcommit_memory=2
sysctl -w vm.overcommit_ratio=80
# CommitLimit과 현재 커밋량 확인
grep -E "CommitLimit|Committed_AS" /proc/meminfo
# CommitLimit: 12345678 kB
# Committed_AS: 8765432 kB
모드 2(OVERCOMMIT_NEVER)는 OOM 발생을 줄이지만, 대규모 fork()가 필요한 워크로드(예: 셸 스크립트에서 자식 프로세스 대량 생성)에서 할당 실패(ENOMEM)가 빈번해질 수 있습니다. 특히 스왑이 없는 시스템에서는 CommitLimit이 물리 RAM × ratio로 제한되므로, overcommit_ratio 값을 워크로드에 맞게 조정해야 합니다.
OOM Reaper
커널 4.6에서 도입된 OOM Reaper는 OOM kill 대상 프로세스의 메모리를 비동기적으로 회수하는 커널 스레드입니다. 기존에는 OOM kill 대상 프로세스가 exit_mmap()을 통해 자신의 메모리를 직접 해제해야 했는데, 프로세스가 uninterruptible sleep(TASK_UNINTERRUPTIBLE) 상태에 있거나, 락을 잡고 있으면 메모리 해제가 지연되어 연쇄 OOM이 발생하는 문제가 있었습니다.
/* mm/oom_kill.c — oom_reap_task_mm() 핵심 로직 (단순화) */
static bool oom_reap_task_mm(struct task_struct *tsk,
struct mm_struct *mm)
{
struct vm_area_struct *vma;
bool ret = true;
/* mm->mmap_lock을 trylock으로 획득 시도 (블로킹 방지) */
if (!mmap_read_trylock(mm)) {
trace_skip_task_reaping(tsk->pid);
return false;
}
/* VMA를 순회하며 anonymous 페이지를 unmap하여 회수 */
vma_iter_init(&vmi, mm, 0);
for_each_vma(vmi, vma) {
if (vma_is_anonymous(vma) || !(VM_SHARED & vma->vm_flags)) {
struct mmu_gather tlb;
tlb_gather_mmu(&tlb, mm);
unmap_page_range(&tlb, vma, vma->vm_start,
vma->vm_end, NULL);
tlb_finish_mmu(&tlb);
}
}
mmap_read_unlock(mm);
return ret;
}
OOM Reaper의 핵심 특징:
oom_reaper커널 스레드가 kill 대상의mm_struct에서 직접 anonymous 페이지를 unmapmmap_read_trylock()을 사용하여 락 경합 시 대기하지 않고 재시도- file-backed 페이지와 shared 매핑은 건너뛰고, private anonymous 매핑만 회수
- 프로세스가 실제로 종료되지 않아도 메모리를 즉시 확보할 수 있어 연쇄 OOM 방지
OOM Reaper가 mmap_lock 획득에 실패하면 일정 시간 후 재시도합니다. 최대 재시도 횟수를 초과하면 MMF_OOM_SKIP 플래그를 설정하여 해당 프로세스를 건너뛰고, 다른 OOM kill 대상을 선택하도록 합니다.
cgroup OOM (memcg)
컨테이너나 서비스별 메모리 제한을 사용할 때, cgroup 메모리 컨트롤러(memcg)가 별도의 OOM Killer를 트리거합니다. 전역 OOM과 달리 특정 cgroup 내부에서만 kill 대상을 선택합니다.
| 항목 | cgroup v1 | cgroup v2 |
|---|---|---|
| 메모리 하드 제한 | memory.limit_in_bytes | memory.max |
| 메모리 소프트 제한 (throttling) | memory.soft_limit_in_bytes | memory.high |
| 그룹 kill | 지원 안 함 | memory.oom.group = 1 |
| OOM 이벤트 모니터링 | memory.oom_control | memory.events |
cgroup v2의 3단계 메모리 압박 제어:
memory.high초과 → 커널이 해당 cgroup의 할당 속도를 throttling (OOM 없이 감속)memory.max초과 → memcg 내부 direct reclaim 시도- reclaim 실패 → memcg OOM Killer 발동 (cgroup 내 프로세스만 대상)
# cgroup v2에서 메모리 제한 설정
echo 512M > /sys/fs/cgroup/myservice/memory.max
echo 480M > /sys/fs/cgroup/myservice/memory.high
# OOM 발생 시 cgroup 내 전체 프로세스를 그룹 kill
echo 1 > /sys/fs/cgroup/myservice/memory.oom.group
# OOM 이벤트 모니터링 (cgroup v2)
cat /sys/fs/cgroup/myservice/memory.events
# low 0 ← memory.low 이하로 회수된 횟수
# high 42 ← memory.high 초과로 throttling된 횟수
# max 3 ← memory.max 초과로 reclaim 시도된 횟수
# oom 1 ← OOM 발생 횟수
# oom_kill 2 ← OOM으로 kill된 프로세스 수
# oom_group_kill 1 ← 그룹 kill 발생 횟수
OOM 예방 전략: memory.high을 memory.max보다 약간 낮게 설정하면(예: max의 90~95%), OOM 전에 throttling이 먼저 동작하여 프로세스가 느려지지만 kill되지 않습니다. 이를 통해 갑작스러운 메모리 스파이크를 흡수하고, 워크로드가 자체적으로 메모리를 줄일 기회를 줍니다.
OOM 모니터링 및 진단
OOM이 발생하면 커널은 dmesg에 상세한 정보를 기록합니다. 이 로그를 해석하면 OOM의 원인과 kill 대상을 파악할 수 있습니다.
# 커널 OOM 로그 확인
dmesg | grep -i oom
# 일반적인 OOM 로그 형식:
# [timestamp] myprocess invoked oom-killer: gfp_mask=0x..., order=0, oom_score_adj=0
# [timestamp] ... (메모리 상태 덤프) ...
# [timestamp] Out of memory: Killed process 1234 (myprocess) total-vm:1234kB,
# anon-rss:5678kB, file-rss:910kB, shmem-rss:0kB, UID:1000
# ↑ 핵심: kill된 프로세스와 메모리 사용량
dmesg OOM 로그 주요 필드:
invoked oom-killer: OOM을 트리거한 프로세스와gfp_mask(할당 플래그)order: 요청한 페이지 order (0=4KB, 1=8KB, ...)Mem-Info: 전체 메모리 상태 (free, active, inactive, slab 등)Killed process: 실제 kill된 프로세스 PID, 이름, 메모리 사용량
PSI (Pressure Stall Information)를 이용한 조기 경보:
커널 4.20+에서 /proc/pressure/memory를 통해 메모리 압박 수준을 실시간으로 모니터링할 수 있습니다. OOM이 발생하기 전에 조기 경보를 받아 선제적으로 대응할 수 있습니다.
# PSI 메모리 압박 현황 확인
cat /proc/pressure/memory
# some avg10=0.50 avg60=1.20 avg300=0.80 total=12345678
# full avg10=0.00 avg60=0.10 avg300=0.05 total=1234567
# some: 일부 태스크가 메모리 대기 중인 시간 비율 (%)
# full: 모든 태스크가 메모리 대기 중인 시간 비율 (%)
# OOM 로그 파싱: kill된 프로세스 목록 추출
dmesg | grep "Killed process" | \
awk '{print $9, $10, $11}'
# systemd 서비스에서 메모리 제한 설정 (cgroup v2 연동)
# /etc/systemd/system/myservice.service.d/memory.conf
# [Service]
# MemoryMax=512M
# MemoryHigh=480M
# OOM 발생 시 할당을 요청한 태스크를 직접 kill (기본값: 0)
sysctl -w vm.oom_kill_allocating_task=1
# OOM 발생 시 커널 패닉 유발 (기본값: 0)
sysctl -w vm.panic_on_oom=1
vm.panic_on_oom 주의사항: 이 옵션을 1로 설정하면 OOM 발생 시 시스템이 즉시 패닉되어 재부팅됩니다. kernel.panic sysctl과 함께 사용하여 자동 재부팅을 설정하는 경우가 많지만, OOM의 근본 원인(메모리 누수, 부적절한 cgroup 설정 등)을 해결하지 않으면 재부팅 루프에 빠질 수 있습니다. 프로덕션 환경에서는 PSI 기반 모니터링과 memory.high throttling을 먼저 활용하고, panic_on_oom은 최후의 안전장치로만 사용하세요.
메모리 할당자 심화
커널 메모리 할당은 호출 컨텍스트(프로세스/인터럽트/소프트IRQ), GFP 플래그 선택, 메모리 단편화, NUMA 토폴로지, 메모리 압박 상황 등 복잡한 고려사항을 수반합니다. 할당 컨텍스트별 제약(GFP_KERNEL vs GFP_ATOMIC vs GFP_NOIO), kmalloc 내부 크기 클래스, SLUB 디버깅/튜닝, Per-CPU 할당자, mempool, CMA, compaction, 메모리 회수(kswapd/direct reclaim), 할당자 선택 전략 등을 이해해야 안정적인 커널 코드를 작성할 수 있습니다.
할당 컨텍스트별 GFP 제약사항, kmalloc 크기 클래스 상세, SLUB 디버깅, Per-CPU 할당자, mempool, CMA, compaction, 메모리 회수 메커니즘, 할당 주의사항, 할당자 선택 결정 트리 등은 메모리 관리 심화 — 메모리 할당자 심화에서 상세히 다룹니다.
메모리 관리 주요 버그 사례
리눅스 커널의 메모리 관리 서브시스템은 오랜 역사 속에서 다양한 버그와 취약점을 경험해왔습니다. 이러한 사례를 학습하면 커널 개발 시 동일한 실수를 피하고, 보다 견고한 코드를 작성할 수 있습니다.
Meltdown (CVE-2017-5754) 과 KPTI
2018년 1월 공개된 Meltdown 취약점은 현대 CPU의 투기적 실행(speculative execution) 메커니즘을 악용하여, 사용자 공간 프로세스가 커널 메모리를 읽을 수 있는 치명적인 하드웨어 수준의 결함이었습니다. CPU가 권한 검사 결과를 확정하기 전에 투기적으로 데이터를 캐시에 적재하는 동작을 이용하여, side-channel 공격으로 커널 메모리 내용을 추출할 수 있었습니다.
Meltdown의 핵심 위협: 사용자 공간 프로그램이 커널 페이지 테이블 항목(PTE)을 통해 커널 주소 공간의 데이터에 접근할 수 있었습니다. 이는 패스워드, 암호화 키, 기타 민감한 커널 데이터의 유출로 이어질 수 있는 심각한 취약점이었습니다. 특히 클라우드 환경에서 가상 머신 간 격리를 우회할 수 있어 영향 범위가 광범위했습니다.
커널은 KPTI (Kernel Page Table Isolation)를 도입하여 이 문제를 해결했습니다. KPTI는 사용자 모드와 커널 모드에서 서로 다른 페이지 테이블 세트를 사용하도록 분리합니다. 사용자 모드 페이지 테이블에는 커널 메모리 매핑이 최소한으로만 포함되어, 투기적 실행으로도 커널 데이터에 접근할 수 없게 됩니다.
/* arch/x86/mm/pti.c — KPTI 초기화 핵심 로직 */
void __init pti_init(void)
{
/* CPU가 Meltdown에 취약하지 않으면 KPTI 비활성화 */
if (!boot_cpu_has_bug(X86_BUG_CPU_MELTDOWN))
return;
pr_info("Kernel/User page tables isolation: enabled\n");
/* 사용자 모드 페이지 테이블에서 커널 매핑 제거 */
pti_clone_user_shared();
/* 커널 진입/이탈 시 CR3 전환을 위한 trampoline 설정 */
pti_setup_espfix64();
pti_setup_vsyscall();
}
# KPTI 활성화 상태 확인
cat /sys/devices/system/cpu/vulnerabilities/meltdown
# 출력 예: Mitigation: PTI
# 부트 옵션으로 KPTI 비활성화 (테스트 환경에서만 사용)
# 커널 커맨드라인에 추가:
nopti
# 또는 KASLR과 함께 비활성화
nokaslr nopti
# 빌드 시 CONFIG 옵션
CONFIG_PAGE_TABLE_ISOLATION=y
성능 영향: KPTI 활성화 시 시스템 콜마다 CR3 레지스터를 전환하여 TLB flush가 발생하므로, 시스템 콜 빈도가 높은 워크로드에서 5~30%의 성능 저하가 관측됩니다. PCID (Process-Context Identifiers)를 지원하는 CPU에서는 TLB flush 비용이 크게 감소하여 성능 영향이 줄어듭니다. 최신 CPU(Intel Ice Lake 이후)는 하드웨어 수준에서 Meltdown이 수정되어 KPTI가 불필요합니다.
GFP_KERNEL in atomic context 버그 패턴
커널 메모리 할당에서 가장 빈번하게 발생하는 버그 패턴 중 하나는 atomic context에서 sleep 가능한 할당 함수를 호출하는 것입니다. 인터럽트 핸들러, 스핀락 보유 구간, RCU read-side critical section 등 sleep이 불가능한 컨텍스트에서 GFP_KERNEL 플래그로 메모리를 할당하면, 할당자가 메모리 회수를 위해 sleep을 시도하여 데드락 또는 스케줄러 오류가 발생합니다.
치명적 결과: atomic context에서 GFP_KERNEL을 사용하면 커널이 직접 메모리 회수(direct reclaim)를 시도하면서 schedule()을 호출합니다. 스핀락을 보유한 상태에서 스케줄링이 발생하면 다른 CPU가 같은 스핀락을 획득하려 할 때 데드락이 발생하며, 인터럽트 컨텍스트에서는 BUG: scheduling while atomic 커널 패닉이 발생합니다.
/* 잘못된 패턴: 스핀락 내에서 GFP_KERNEL 사용 */
spinlock_t my_lock;
void buggy_handler(void)
{
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
/* BUG: GFP_KERNEL은 sleep 가능 — 스핀락 내에서 사용 금지! */
char *buf = kmalloc(4096, GFP_KERNEL); /* DEADLOCK 위험! */
spin_unlock_irqrestore(&my_lock, flags);
}
/* 올바른 패턴: atomic context에서는 GFP_ATOMIC 사용 */
void correct_handler(void)
{
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
/* OK: GFP_ATOMIC은 sleep하지 않음 (할당 실패 가능성 있음) */
char *buf = kmalloc(4096, GFP_ATOMIC);
if (!buf) {
spin_unlock_irqrestore(&my_lock, flags);
return -ENOMEM;
}
spin_unlock_irqrestore(&my_lock, flags);
}
/* 더 나은 패턴: 스핀락 밖에서 미리 할당 */
void best_handler(void)
{
unsigned long flags;
/* 스핀락 진입 전에 GFP_KERNEL으로 할당 (sleep 가능) */
char *buf = kmalloc(4096, GFP_KERNEL);
if (!buf)
return -ENOMEM;
spin_lock_irqsave(&my_lock, flags);
/* 이미 할당된 버퍼 사용 */
do_work(buf);
spin_unlock_irqrestore(&my_lock, flags);
kfree(buf);
}
커널은 might_sleep() 매크로를 통해 이러한 버그를 런타임에 탐지할 수 있습니다. CONFIG_DEBUG_ATOMIC_SLEEP을 활성화하면, sleep이 불가능한 컨텍스트에서 sleep 가능 함수가 호출될 때 경고 메시지를 출력합니다.
/* include/linux/kernel.h — might_sleep 디버깅 매크로 */
#ifdef CONFIG_DEBUG_ATOMIC_SLEEP
#define might_sleep() do { \
___might_sleep(__FILE__, __LINE__, 0); \
might_sleep_check(); \
} while (0)
#else
#define might_sleep() do { } while (0)
#endif
/* kmalloc 내부에서 GFP_KERNEL 계열 플래그 시 might_sleep() 호출 */
static __always_inline void *kmalloc(size_t size, gfp_t flags)
{
if (!(flags & __GFP_DIRECT_RECLAIM))
goto alloc;
might_sleep(); /* atomic context에서 호출 시 경고 출력 */
alloc:
return __kmalloc(size, flags);
}
디버깅 팁: CONFIG_DEBUG_ATOMIC_SLEEP=y와 CONFIG_PROVE_LOCKING=y(lockdep)를 함께 활성화하면, sleep-in-atomic 패턴을 매우 효과적으로 탐지할 수 있습니다. lockdep은 락 의존성 그래프를 추적하여 잠재적 데드락도 사전에 경고합니다. 개발 및 테스트 빌드에서는 항상 이 옵션들을 활성화하는 것을 권장합니다.
OOM Killer 오동작 사례
OOM Killer의 알고리즘과 동작 방식은 커널 버전에 따라 크게 변해왔으며, 다양한 오동작 사례가 보고되었습니다. 현대 커널의 oom_badness() 점수 계산과 sysctl 튜닝, cgroup OOM 설정에 대한 상세 내용은 위 OOM Killer 섹션을 참조하세요.
oom_score 계산 알고리즘의 역사적 변천: 초기 리눅스(2.6.x)에서는 badness() 함수가 프로세스의 RSS, 자식 프로세스 메모리, nice 값, 실행 시간 등 복잡한 휴리스틱으로 점수를 계산했습니다. 이 방식은 예측이 어렵고 중요한 서비스가 예상치 못하게 종료되는 문제가 있었습니다. 커널 2.6.36에서 David Rientjes가 알고리즘을 대폭 단순화하여, RSS 기반 비례 점수 + oom_score_adj 조정으로 변경했습니다 (oom_badness() 점수 계산 참조).
cgroup v1 memcg OOM과 전역 OOM 충돌: cgroup v1에서 memory.limit_in_bytes를 설정한 경우, memcg OOM이 전체 시스템 OOM과 독립적으로 동작합니다. memcg 내 프로세스가 종료되어도 전역 메모리 압박이 해소되지 않을 수 있으며, memcg OOM 처리 중 전역 OOM이 동시에 트리거되면 이중 kill이 발생하여 예상치 못한 서비스 중단으로 이어지는 사례가 보고되었습니다. cgroup v2의 memory.oom.group 기능으로 이 문제가 개선되었습니다 (cgroup OOM 참조).
교훈: OOM Killer 관련 문제를 예방하려면 ① oom_score_adj로 중요 서비스를 보호하고, ② memory.high로 OOM 전에 throttling을 적용하며, ③ PSI(/proc/pressure/memory)로 메모리 압박을 사전에 모니터링하는 것이 권장됩니다. 상세 설정 방법은 OOM 모니터링 및 진단을 참조하세요.
Slab 메모리 누수 탐지
커널 모듈이나 드라이버에서 kmalloc()/kmem_cache_alloc()으로 할당한 slab 객체를 해제하지 않으면 slab 메모리 누수가 발생합니다. 커널은 사용자 공간과 달리 프로세스 종료 시 자동 정리가 되지 않으므로, 누수된 메모리는 시스템을 재부팅하기 전까지 영구적으로 소실됩니다.
# /proc/slabinfo로 slab 캐시별 메모리 사용 현황 확인
cat /proc/slabinfo | head -20
# name <active_objs> <num_objs> <objsize> <objperslab> ...
# kmalloc-4096 1205 1232 4096 8 ...
# task_struct 312 340 6720 4 ...
# slabtop으로 실시간 모니터링 (누수 시 특정 캐시의 active_objs가 지속 증가)
slabtop -s c # 캐시 크기 순 정렬
# kmemleak 활성화 (CONFIG_DEBUG_KMEMLEAK=y 필요)
# 부트 옵션: kmemleak=on
echo scan > /sys/kernel/debug/kmemleak # 수동 스캔 트리거
cat /sys/kernel/debug/kmemleak # 누수 의심 보고서 출력
echo clear > /sys/kernel/debug/kmemleak # 보고서 초기화
kmemleak은 커널의 메모리 누수 탐지기로, mark-and-sweep 가비지 컬렉터와 유사한 방식으로 동작합니다. 주기적으로 커널 메모리를 스캔하여 어디에서도 참조되지 않는 할당된 객체를 찾아냅니다.
/* 버그 패턴 1: 할당 후 해제 누락 */
void leaky_function(void)
{
struct my_data *data = kmalloc(sizeof(*data), GFP_KERNEL);
if (!data)
return;
process_data(data);
/* BUG: kfree(data) 누락 — data 포인터가 스택에서 사라지면 누수 */
}
/* 버그 패턴 2: 에러 경로에서 해제 누락 */
int init_device(void)
{
struct resource *res_a = kmalloc(sizeof(*res_a), GFP_KERNEL);
struct resource *res_b = kmalloc(sizeof(*res_b), GFP_KERNEL);
if (!res_a || !res_b)
return -ENOMEM; /* BUG: res_a가 할당 성공했어도 해제하지 않음! */
/* ... */
return 0;
}
/* 올바른 에러 처리 패턴 */
int init_device_fixed(void)
{
struct resource *res_a, *res_b;
res_a = kmalloc(sizeof(*res_a), GFP_KERNEL);
if (!res_a)
return -ENOMEM;
res_b = kmalloc(sizeof(*res_b), GFP_KERNEL);
if (!res_b)
goto err_free_a;
/* ... */
return 0;
err_free_a:
kfree(res_a);
return -ENOMEM;
}
이중 해제(double free) 위험: 이미 해제된 slab 객체를 다시 kfree()하면 slab allocator의 freelist가 손상되어 메모리 커럽션이 발생합니다. 이는 즉시 크래시하지 않고 나중에 전혀 관련 없는 코드에서 예측 불가능한 오류로 나타날 수 있어 디버깅이 매우 어렵습니다. CONFIG_SLUB_DEBUG=y와 부트 옵션 slub_debug=FZPU를 사용하면 free 후 poisoning과 red zone 검사를 통해 이중 해제를 조기에 탐지할 수 있습니다.
/* 이중 해제(double free) 버그 패턴 */
void double_free_bug(void)
{
struct buffer *buf = kmalloc(sizeof(*buf), GFP_KERNEL);
use_buffer(buf);
kfree(buf);
/* ... 다른 코드 ... */
kfree(buf); /* BUG: 이중 해제 — slab freelist 손상! */
}
/* 안전한 패턴: 해제 후 NULL로 초기화 */
void safe_free(void)
{
struct buffer *buf = kmalloc(sizeof(*buf), GFP_KERNEL);
use_buffer(buf);
kfree(buf);
buf = NULL; /* NULL 포인터에 kfree는 안전하게 무시됨 */
/* ... 다른 코드 ... */
kfree(buf); /* OK: kfree(NULL)은 no-op */
}
# SLUB 디버깅 부트 옵션 (이중 해제/오버플로/use-after-free 탐지)
# F: sanity checks, Z: red zoning, P: poisoning, U: user tracking
slub_debug=FZPU
# 특정 slab 캐시에만 디버깅 적용 (성능 영향 최소화)
slub_debug=FZPU,kmalloc-256,task_struct
# kmemleak 누수 보고서 예시 출력
# unreferenced object 0xffff888012345678 (size 128):
# comm "modprobe", pid 1234, jiffies 4294937200
# backtrace:
# kmalloc+0x4a/0x80
# my_driver_init+0x23/0x60 [my_module]
# do_one_initcall+0x56/0x2e0
slab 누수 방지를 위한 모범 사례: (1) 모든 에러 경로에서 할당된 자원을 해제하는 goto 기반 정리 패턴을 일관되게 사용합니다. (2) devm_kmalloc() 등 devres (device resource management) API를 활용하면 디바이스 해제 시 자동으로 메모리가 정리됩니다. (3) 개발 단계에서 CONFIG_DEBUG_KMEMLEAK=y와 slub_debug를 항상 활성화하여 누수를 조기에 발견합니다. (4) 모듈의 exit 함수에서 init에서 할당한 모든 자원을 해제하는지 꼼꼼히 확인합니다.