메모리 Cgroup (메모리 컨트롤 그룹)
리눅스 커널의 Memory Cgroup(memcg) 서브시스템을 심층 분석합니다. cgroup v2 기반 메모리 컨트롤러의 계층 구조, memory.max/high/low/min 4단계 제한 인터페이스, folio charge/uncharge 메커니즘, per-memcg LRU와 회수 경로, cgroup-aware OOM Killer, memory.stat/events 모니터링, 커널 메모리(kmem/slab/vmalloc) 계정, Swap 제어, PSI(Pressure Stall Information) 연동, THP/Writeback 상호작용, Kubernetes/Docker/systemd 컨테이너(Container) 런타임 연동, NUMA 통계, v1에서 v2 마이그레이션 가이드, 내부 자료구조(mem_cgroup, mem_cgroup_per_node)까지 커널 소스(mm/memcontrol.c, mm/vmscan.c) 기반으로 분석합니다.
핵심 요약
- 4단계 제한 모델 -- memory.min(절대 보호) < memory.low(최선 보호) < memory.high(쓰로틀링) < memory.max(경성 제한/OOM)로 단계적 메모리 제어를 수행합니다.
- 계층적 전파 -- cgroup v2에서 부모의 제한이 자식에게 자동으로 전파되며, 유효 제한(effective limit)은 부모와 자식의 최솟값입니다.
- Folio Charge -- 페이지 폴트(Page Fault) 시
mem_cgroup_charge()가 folio를 해당 memcg에 과금하고, 제한 초과 시 직접 회수(direct reclaim)를 수행합니다. - Per-memcg LRU -- 각 memcg는 독립된 lruvec을 유지하여 해당 cgroup 내에서만 페이지(Page)를 회수할 수 있습니다.
- Cgroup-aware OOM -- memory.max 초과 시 해당 memcg 내에서만 OOM kill이 발생하며,
memory.oom.group=1이면 cgroup 전체를 단위로 종료합니다.
단계별 이해
- cgroup v2 기본 구조 이해
cgroup v2 파일시스템(Filesystem)의 계층 구조와 메모리 컨트롤러 활성화 방법을 파악합니다. - 4단계 제한 인터페이스 학습
memory.min/low/high/max 각각의 의미와 상호작용을 이해합니다. - Charge/Uncharge 흐름 추적
페이지 폴트 → mem_cgroup_charge() → 제한 체크 → 회수/OOM 경로를 따라갑니다. - 모니터링 인터페이스 활용
memory.stat, memory.events, memory.pressure로 상태를 진단합니다. - 컨테이너 런타임 연동
Kubernetes requests/limits, Docker --memory, systemd MemoryMax가 memcg 인터페이스에 어떻게 매핑(Mapping)되는지 확인합니다.
mm/memcontrol.c (핵심 memcg 로직), mm/vmscan.c (per-memcg 회수), mm/slab.h (kmem charge), include/linux/memcontrol.h (API 선언).
Documentation/admin-guide/cgroup-v2.rst의 "Memory" 섹션이 공식 사용자 문서입니다.
종합 목록은 참고자료 -- 표준 & 규격 섹션을 참고하세요.
Memory Cgroup 개요
Memory Cgroup(memcg)은 리눅스 커널의 cgroup 서브시스템 중 하나로, 프로세스 그룹별로 메모리 사용량을 추적하고 제한합니다. cgroup v1에서는 memory 컨트롤러로, cgroup v2에서는 통합 계층 구조의 일부로 동작합니다.
cgroup v1 vs v2 비교
| 특성 | cgroup v1 (memory) | cgroup v2 (memory) |
|---|---|---|
| 계층 구조 | 컨트롤러별 독립 트리 | 단일 통합 트리 |
| 제한 인터페이스 | memory.limit_in_bytes | memory.max, memory.high, memory.low, memory.min |
| 소프트 제한 | memory.soft_limit_in_bytes (비효율적) | memory.high (쓰로틀링), memory.low (보호) |
| kmem 계정 | 별도 활성화 (memory.kmem.limit_in_bytes) | 항상 활성 (memory.max에 통합) |
| Swap 제어 | memory.memsw.limit_in_bytes | memory.swap.max (독립 제어) |
| OOM 제어 | memory.oom_control (비활성화 가능) | memory.oom.group (그룹 단위 OOM) |
| 이벤트 알림 | eventfd (memory.oom_control) | memory.events + inotify/poll |
| NUMA 통계 | memory.numa_stat | memory.numa_stat (개선) |
| PSI 지원 | 없음 | memory.pressure (per-cgroup PSI) |
| 프로세스 소속 | 다중 계층 가능 | 프로세스는 리프 cgroup에만 소속 |
memory.soft_limit_in_bytes는 알려진 성능 문제로 사용이 권장되지 않습니다. 모든 주요 컨테이너 런타임(containerd, CRI-O)은 cgroup v2를 기본으로 사용합니다.
커널 소스 위치
| 파일 | 역할 | 주요 함수/구조체(Struct) |
|---|---|---|
mm/memcontrol.c | memcg 핵심 로직 | mem_cgroup_charge(), try_charge_memcg(), memory_stat_show() |
include/linux/memcontrol.h | 헤더/인라인 함수(Inline Function) | struct mem_cgroup, mem_cgroup_from_task() |
mm/vmscan.c | 회수 경로 | shrink_lruvec(), try_to_free_mem_cgroup_pages() |
mm/slab.h | Slab charge | memcg_slab_post_alloc_hook() |
mm/page_counter.c | 계층적 카운터 | page_counter_try_charge(), page_counter_uncharge() |
kernel/cgroup/cgroup.c | cgroup 프레임워크 | cgroup_add_dfl_cftypes() |
cgroup v2 계층 구조
cgroup v2에서 메모리 컨트롤러는 단일 통합 계층(unified hierarchy)에서 동작합니다. 모든 cgroup은 하나의 트리를 공유하며, 메모리 제한은 부모에서 자식으로 자동 전파됩니다.
계층 규칙
- No Internal Process -- cgroup v2에서 프로세스는 리프(leaf) cgroup에만 소속할 수 있습니다. 내부 노드에 직접 프로세스를 배치할 수 없습니다 (단, root는 예외).
- Subtree Control -- 부모 cgroup의
cgroup.subtree_control에+memory를 쓰면 자식 cgroup에서 메모리 컨트롤러가 활성화됩니다. - 계층적 과금 -- 자식 cgroup의 메모리 사용량은 부모에게 자동으로 합산됩니다.
page_counter_try_charge()가 root까지 각 레벨의 제한을 체크합니다. - 유효 제한 --
memory.max.effective는 자신의 max와 모든 조상의 max 중 최솟값입니다.
# cgroup v2 메모리 컨트롤러 활성화
mount -t cgroup2 none /sys/fs/cgroup
# 자식 cgroup에서 메모리 컨트롤러 활성화
echo "+memory" > /sys/fs/cgroup/cgroup.subtree_control
# 워크로드 cgroup 생성 및 제한 설정
mkdir /sys/fs/cgroup/workload
echo "16G" > /sys/fs/cgroup/workload/memory.max
echo "2G" > /sys/fs/cgroup/workload/memory.min
# 자식 cgroup 생성
echo "+memory" > /sys/fs/cgroup/workload/cgroup.subtree_control
mkdir /sys/fs/cgroup/workload/pod-a
echo "4G" > /sys/fs/cgroup/workload/pod-a/memory.max
# 프로세스 배치
echo $$ > /sys/fs/cgroup/workload/pod-a/cgroup.procs
메모리 제한 인터페이스
cgroup v2 메모리 컨트롤러는 4단계 제한 모델을 제공합니다. 이 모델은 단순한 경성 제한을 넘어, 메모리 압박(memory pressure) 상황에서 세밀한 정책 표현을 가능하게 합니다.
각 인터페이스 상세
| 파일 | 기본값 | 의미 | 초과 시 동작 |
|---|---|---|---|
memory.min | 0 | 절대 보호 하한 | 글로벌 회수에서도 이 양까지 보호 |
memory.low | 0 | 최선 보호 하한 | 다른 회수 대상이 없을 때만 회수 |
memory.high | max | 쓰로틀링 임계 | 할당 시 직접 회수 강제 (느려짐) |
memory.max | max | 경성 상한 | OOM kill 발생 |
memory.current | (읽기전용) | 현재 사용량 | -- |
memory.peak | (읽기전용) | 역대 최대 사용량 | -- |
/* mm/memcontrol.c -- try_charge_memcg() 제한 체크 흐름 (간략화) */
static int try_charge_memcg(struct mem_cgroup *memcg,
gfp_t gfp_mask,
unsigned int nr_pages)
{
struct page_counter *counter;
unsigned long nr_reclaimed;
/* 1단계: page_counter로 계층적 charge 시도 */
if (page_counter_try_charge(&memcg->memory, nr_pages, &counter))
goto done; /* 성공: 모든 조상의 max 이내 */
/* 2단계: charge 실패 -> 직접 회수 시도 */
nr_reclaimed = try_to_free_mem_cgroup_pages(
memcg, nr_pages, gfp_mask, MEMCG_RECLAIM_MAY_SWAP);
if (nr_reclaimed >= nr_pages)
goto retry; /* 회수 성공 -> 재시도 */
/* 3단계: 회수 실패 -> OOM 경로 */
if (nr_retries-- == 0) {
mem_cgroup_oom(memcg, gfp_mask, get_order(nr_pages));
return -ENOMEM;
}
done:
/* memory.high 체크: 초과 시 쓰로틀링 */
if (page_counter_read(&memcg->memory) >
READ_ONCE(memcg->memory.high))
memcg_memory_event(memcg, MEMCG_HIGH);
return 0;
}
설명
try_charge_memcg()는 memcg에 페이지를 과금하는 핵심 함수입니다. page_counter_try_charge()가 root까지 각 레벨의 max를 체크하며, 하나라도 실패하면 직접 회수를 시도합니다. 회수도 실패하면 OOM 경로로 진입합니다. charge 성공 후에도 memory.high 초과 여부를 체크하여 쓰로틀링 이벤트를 발생시킵니다.
Charge/Uncharge 메커니즘
Memory Cgroup의 핵심 동작은 charge(과금)와 uncharge(환불)입니다. 페이지가 할당될 때 해당 memcg에 사용량을 기록하고, 해제될 때 차감합니다.
Charge 대상
| 메모리 유형 | Charge 함수 | Uncharge 함수 | 비고 |
|---|---|---|---|
| 익명 페이지 (anon) | mem_cgroup_charge() | mem_cgroup_uncharge() | 페이지 폴트 시 charge |
| 파일 캐시 (file) | mem_cgroup_charge() | mem_cgroup_uncharge() | read/write 시 charge |
| Slab 오브젝트 | memcg_slab_post_alloc_hook() | memcg_slab_free_hook() | __GFP_ACCOUNT 플래그 |
| 커널 스택 | memcg_account_kmem() | 자동 | 프로세스 생성 시 |
| 소켓(Socket) 버퍼(Buffer) | mem_cgroup_charge_skmem() | mem_cgroup_uncharge_skmem() | TCP/UDP 메모리 |
| 페이지 테이블(Page Table) | __pte_alloc() 경로 | 자동 | v6.1+ 기본 활성 |
| vmalloc | memcg_account_kmem() | 자동 | __GFP_ACCOUNT 필요 |
/* mm/memcontrol.c -- commit_charge(): folio에 memcg 소유권 기록 */
static void commit_charge(struct folio *folio,
struct mem_cgroup *memcg)
{
/*
* folio->memcg_data에 memcg 포인터를 저장
* 하위 비트는 플래그로 사용 (KMEM, OBJCGS 등)
*/
folio->memcg_data = (unsigned long)memcg;
}
/* 사용량 확인: folio가 어떤 memcg에 속하는지 */
struct mem_cgroup *folio_memcg(struct folio *folio)
{
return (struct mem_cgroup *)
(folio->memcg_data & ~MEMCG_DATA_FLAGS_MASK);
}
코드 설명
- commit_charge()
folio->memcg_data에mem_cgroup포인터를 직접 저장하여 해당 folio의 소유 memcg를 기록합니다.memcg_data의 하위 비트는KMEM,OBJCGS등의 플래그로 사용되므로 포인터는 정렬된 상위 비트에 저장됩니다. 이 함수는mm/memcontrol.c의mem_cgroup_charge()성공 경로에서 호출됩니다. - folio_memcg()
folio->memcg_data에서MEMCG_DATA_FLAGS_MASK를 마스킹하여 순수mem_cgroup포인터를 추출합니다. 회수, uncharge, 통계 조회 등 folio의 소속 memcg를 알아야 하는 모든 경로에서 사용됩니다.include/linux/memcontrol.h에 인라인으로 정의되어 있습니다.
Uncharge 경로
Uncharge는 folio가 해제될 때 자동으로 수행됩니다. mem_cgroup_uncharge()는 folio의 memcg_data에서 memcg를 가져와 page_counter_uncharge()로 계층적으로 사용량을 차감합니다.
/* mm/memcontrol.c -- uncharge 핵심 흐름 */
void mem_cgroup_uncharge(struct folio *folio)
{
struct mem_cgroup *memcg;
memcg = folio_memcg(folio);
if (!memcg)
return;
/* page_counter에서 계층적 차감 */
page_counter_uncharge(&memcg->memory, folio_nr_pages(folio));
if (folio_memcg_kmem(folio))
page_counter_uncharge(&memcg->kmem, folio_nr_pages(folio));
/* 통계 갱신 */
memcg_uncharge_folio(folio, memcg);
/* memcg 소유권 제거 */
folio->memcg_data = 0;
/* CSS 참조 해제 */
css_put(&memcg->css);
}
코드 설명
- folio_memcg(folio)해제할 folio가 어떤 memcg에 charge되어 있는지 조회합니다. memcg가
NULL이면 root cgroup 소속이므로 uncharge가 불필요합니다. - page_counter_uncharge(&memcg->memory, ...)
mm/page_counter.c의 함수로, 현재 memcg부터 root까지 각 레벨의usage를folio_nr_pages()만큼 atomic하게 차감합니다. charge와 반대 방향의 계층적 갱신입니다. - page_counter_uncharge(&memcg->kmem, ...)
folio_memcg_kmem()이 참이면 커널 메모리(slab, vmalloc 등)이므로kmem카운터도 별도로 차감합니다. - folio->memcg_data = 0folio의 memcg 소유권을 제거합니다. 이후 이 folio는 어떤 memcg에도 속하지 않습니다.
- css_put(&memcg->css)cgroup subsystem state의 참조 카운터를 감소시킵니다. 모든 folio가 uncharge되어 참조 카운터가 0이 되면 좀비(zombie) memcg 구조체가 최종 해제됩니다.
memcg 회수 경로
memcg의 메모리 사용량이 제한에 도달하면, 글로벌 회수(kswapd)가 아닌 per-memcg 회수가 수행됩니다. 이 메커니즘은 다른 cgroup에 영향을 주지 않으면서 특정 cgroup의 메모리만 회수합니다.
memory.low / memory.min 보호 메커니즘
회수 경로에서 mem_cgroup_protection()은 각 memcg의 보호 수준을 계산합니다:
/* mm/vmscan.c -- 보호 수준에 따른 회수 결정 */
static unsigned long mem_cgroup_protection(
struct mem_cgroup *root,
struct mem_cgroup *memcg)
{
unsigned long usage = page_counter_read(&memcg->memory);
/* memory.min: 절대 보호 -- 글로벌 회수에서도 보호 */
if (usage <= memcg->memory.min)
return MEMCG_PROT_MIN;
/* memory.low: 최선 보호 -- 다른 회수 대상이 없을 때만 */
if (usage <= memcg->memory.low)
return MEMCG_PROT_LOW;
/* 보호 없음 */
return MEMCG_PROT_NONE;
}
코드 설명
- page_counter_read(&memcg->memory)현재 memcg의 메모리 사용량(페이지 수)을 atomic하게 읽습니다. 이 값을
memory.min,memory.low와 비교하여 보호 수준을 결정합니다. - MEMCG_PROT_MIN사용량이
memory.min이하이면 절대 보호를 반환합니다. 이 보호 수준의 memcg는 글로벌 메모리 압박(kswapd)에서도 회수 대상에서 완전히 제외됩니다. Kubernetes의resources.requests가 이 값에 매핑됩니다. - MEMCG_PROT_LOW사용량이
memory.low이하이면 최선 보호(best-effort)를 반환합니다. 다른 cgroup에서 회수할 메모리가 남아있는 한 이 memcg는 회수되지 않으며, 다른 대상이 모두 소진된 경우에만 회수 대상이 됩니다. - MEMCG_PROT_NONE두 보호 임계값 모두 초과하면 보호 없음을 반환합니다.
mm/vmscan.c의shrink_lruvec()에서 이 값에 따라 스캔 비율을 조절합니다.
lru_gen_folio 구조체가 각 memcg의 세대별 folio 리스트를 관리하며, evict_folios()가 가장 오래된 세대부터 회수합니다. 이는 기존 LRU 스캔 대비 memcg 회수 효율을 크게 개선합니다.
memcg OOM 처리
memcg의 memory.max 초과 시, 글로벌 OOM과 별도로 cgroup-scoped OOM kill이 발생합니다. 이 OOM은 해당 memcg 내의 프로세스만 대상으로 합니다.
# memory.oom.group 설정: cgroup 전체 단위 OOM kill
echo 1 > /sys/fs/cgroup/workload/pod-a/memory.oom.group
# OOM 이벤트 모니터링
cat /sys/fs/cgroup/workload/pod-a/memory.events
# low 0
# high 12
# max 3
# oom 2
# oom_kill 2
# oom_group_kill 1
memory.oom_control은 OOM kill을 완전히 비활성화할 수 있었는데, 이는 cgroup이 영원히 멈추는(hung) 문제를 유발했습니다. v2에서는 OOM kill 비활성화가 제거되고, 대신 memory.oom.group으로 kill 단위(개별 프로세스 vs 전체 그룹)만 제어합니다.
memory.stat 파일 해석
memory.stat은 memcg의 상세 메모리 사용 현황을 보여주는 핵심 모니터링 인터페이스입니다.
cat /sys/fs/cgroup/workload/pod-a/memory.stat
# anon 1073741824 # 익명 페이지 (1GB)
# file 536870912 # 파일 캐시 (512MB)
# kernel 67108864 # 커널 메모리 (64MB)
# kernel_stack 8388608 # 커널 스택 (8MB)
# pagetables 16777216 # 페이지 테이블 (16MB)
# sec_pagetables 0 # 보조 페이지 테이블
# percpu 4194304 # per-CPU 메모리
# sock 2097152 # 소켓 버퍼 (2MB)
# vmalloc 33554432 # vmalloc (32MB)
# shmem 134217728 # 공유 메모리 (128MB)
# zswap 0 # zswap 압축 메모리
# zswapped 0 # zswap 원본 크기
# file_mapped 268435456 # mmap된 파일 (256MB)
# file_dirty 4194304 # dirty 파일 페이지 (4MB)
# file_writeback 0 # writeback 중
# swapcached 0 # swap cache
# anon_thp 536870912 # THP 익명 (512MB)
# file_thp 0 # THP 파일
# shmem_thp 0 # THP 공유 메모리
# inactive_anon 268435456 # 비활성 익명
# active_anon 805306368 # 활성 익명
# inactive_file 134217728 # 비활성 파일
# active_file 402653184 # 활성 파일
# unevictable 0 # 회수 불가
# slab_reclaimable 33554432 # 회수 가능 slab
# slab_unreclaimable 16777216 # 회수 불가 slab
# slab 50331648 # 전체 slab
# workingset_refault_anon 1024 # 워킹셋 재폴트 (익명)
# workingset_refault_file 8192 # 워킹셋 재폴트 (파일)
# workingset_activate_anon 512 # 워킹셋 활성화 (익명)
# workingset_activate_file 4096 # 워킹셋 활성화 (파일)
# workingset_restore_anon 256 # 워킹셋 복원 (익명)
# workingset_restore_file 2048 # 워킹셋 복원 (파일)
# workingset_nodereclaim 128 # 노드 회수
# pgscan 65536 # 스캔된 페이지 수
# pgsteal 32768 # 회수된 페이지 수
# pgscan_kswapd 0 # kswapd 스캔 (memcg에선 보통 0)
# pgscan_direct 65536 # 직접 회수 스캔
# pgfault 1048576 # 페이지 폴트
# pgmajfault 128 # 메이저 폴트
# pgrefill 8192 # 활성->비활성 전환
# pgactivate 16384 # 비활성->활성 승격
# pgdeactivate 4096 # 비활성화
# pglazyfree 0 # lazy free
# pglazyfreed 0 # lazy freed
# thp_fault_alloc 1024 # THP 할당 성공
# thp_collapse_alloc 64 # THP collapse 성공
| 카테고리 | 주요 필드 | 해석 포인트 |
|---|---|---|
| 사용량 | anon, file, kernel, slab | 어떤 유형이 메모리를 점유하는지 파악 |
| 워킹셋 | workingset_refault_* | 높으면 메모리 부족으로 재폴트 빈번 -- 제한 증가 고려 |
| 회수 효율 | pgscan, pgsteal | pgsteal/pgscan 비율이 낮으면 회수 비효율 -- 워킹셋이 큼 |
| 폴트 | pgfault, pgmajfault | pgmajfault 높으면 디스크 I/O 대기 -- swap이나 파일 읽기 |
| THP | anon_thp, thp_fault_alloc | THP 비율로 대형 페이지 활용도 파악 |
memory.events 파일
memory.events는 memcg에서 발생한 중요 이벤트의 누적 카운터를 제공합니다. inotify나 poll()로 모니터링하여 실시간(Real-time) 알림을 받을 수 있습니다.
| 이벤트 | 트리거 조건 | 대응 전략 |
|---|---|---|
low | memory.low 이하 사용 중 회수 발생 | memory.low 값 증가 또는 다른 cgroup 제한 조정 |
high | memory.high 초과로 쓰로틀링 발생 | 빈번하면 memory.high 증가 또는 워크로드 최적화 |
max | memory.max 도달로 직접 회수 강제 | OOM 직전 -- 즉시 대응 필요 |
oom | OOM 조건 발생 (kill 전) | memory.max 증가 또는 워크로드 분산 |
oom_kill | 실제 OOM kill 실행 | 원인 분석 (memory.stat 확인) |
oom_group_kill | memory.oom.group에 의한 그룹 kill | memory.oom.group 설정 재검토 |
# memory.events 실시간 모니터링 (inotifywait 사용)
inotifywait -m /sys/fs/cgroup/workload/pod-a/memory.events
# 또는 Python으로 poll() 사용
python3 -c "
import select
f = open('/sys/fs/cgroup/workload/pod-a/memory.events', 'r')
p = select.poll()
p.register(f, select.POLLPRI)
while True:
events = p.poll()
f.seek(0)
print(f.read())
"
# memory.events.local: 자식 cgroup 이벤트 제외, 자신만의 이벤트
cat /sys/fs/cgroup/workload/memory.events.local
Swap 제어
cgroup v2의 Swap 제어는 memory.swap.max로 독립적으로 설정합니다. v1의 memory.memsw.limit_in_bytes(메모리+스왑(Swap) 합산)과 달리, v2에서는 메모리와 스왑 제한이 분리되어 있습니다.
| 파일 | 설명 | 기본값 |
|---|---|---|
memory.swap.max | 최대 스왑 사용량 | max (무제한) |
memory.swap.current | 현재 스왑 사용량 (읽기전용) | -- |
memory.swap.high | 스왑 쓰로틀링 (v6.3+) | max |
memory.swap.peak | 역대 최대 스왑 사용량 | -- |
memory.swap.events | 스왑 이벤트 카운터 | -- |
memory.zswap.current | zswap 사용량 | -- |
memory.zswap.max | zswap 최대 사용량 (v6.5+) | max |
# 스왑 사용량 제한: 2GB까지만 swap-out 허용
echo "2G" > /sys/fs/cgroup/workload/pod-a/memory.swap.max
# 스왑 완전 비활성화 (swap-out 금지)
echo "0" > /sys/fs/cgroup/workload/pod-a/memory.swap.max
# zswap 사용량 제한 (v6.5+)
echo "512M" > /sys/fs/cgroup/workload/pod-a/memory.zswap.max
NodeSwap feature gate를 활성화하면 memory.swap.max를 통해 Pod별 스왑을 제어할 수 있습니다. swapBehavior: LimitedSwap은 Burstable QoS Pod에만 스왑을 허용합니다.
v1 vs v2 Swap 제어 차이
cgroup v1의 memory.memsw.limit_in_bytes는 메모리+스왑 합산 제한이라 직관적이지 않았습니다. 예를 들어 메모리 1GB + 스왑 1GB를 허용하려면 memory.limit_in_bytes=1G, memory.memsw.limit_in_bytes=2G로 설정해야 했습니다.
/* v1: 혼란스러운 memsw 체크 */
/* charge 시 두 카운터를 별도로 체크:
* res_counter_charge(&mem.res, ...) -- 메모리 제한
* res_counter_charge(&mem.memsw, ...) -- 메모리+스왑 합산 제한
*
* 사용자 혼란: "memsw가 2G인데 왜 1G밖에 못 쓰지?"
* -> mem=1G이면 swap 가능량 = memsw - mem = 1G
*/
/* v2: 명확한 분리 */
/* charge 시:
* page_counter_try_charge(&memcg->memory, ...) -- 메모리만
* page_counter_try_charge(&memcg->swap, ...) -- 스왑만
*
* memory.max = 1G, memory.swap.max = 1G
* -> 메모리 최대 1G, 스왑 최대 1G (명확!)
*/
zswap과 memcg
zswap은 스왑 아웃될 페이지를 메모리 내에서 압축하여 보관하는 메커니즘입니다. 커널 6.5+에서 per-memcg zswap 제한이 지원됩니다.
| 인터페이스 | 설명 | 커널 버전 |
|---|---|---|
memory.zswap.current | 현재 zswap 사용량 (압축 후) | v5.19+ |
memory.zswap.max | zswap 최대 사용량 | v6.5+ |
memory.stat: zswap | zswap 사용량 (stat에 포함) | v6.1+ |
memory.stat: zswapped | zswap 압축 전 원본 크기 | v6.1+ |
# zswap 활성화 확인
cat /sys/module/zswap/parameters/enabled
# Y
# per-memcg zswap 제한 설정
echo "256M" > ${CGROUP}/memory.zswap.max
# zswap 압축률 확인
ZSWAP=$(awk '/^zswap /{print $2}' ${CGROUP}/memory.stat)
ZSWAPPED=$(awk '/^zswapped /{print $2}' ${CGROUP}/memory.stat)
echo "압축률: $(echo "scale=2; $ZSWAPPED / ($ZSWAP + 1)" | bc)x"
PSI (Pressure Stall Information) 연동
cgroup v2의 memory.pressure 파일은 해당 cgroup 내의 메모리 압박 상태를 실시간으로 제공합니다. PSI는 메모리 부족이 워크로드 성능에 미치는 영향을 정량적으로 측정합니다.
cat /sys/fs/cgroup/workload/pod-a/memory.pressure
# some avg10=5.23 avg60=2.15 avg300=1.07 total=1234567
# full avg10=0.45 avg60=0.12 avg300=0.03 total=56789
# some: cgroup 내 일부 태스크가 메모리 대기 (%)
# full: cgroup 내 모든 태스크가 메모리 대기 (%)
# avg10/60/300: 최근 10초/60초/300초 이동 평균
| 지표 | 의미 | 임계값 가이드 |
|---|---|---|
| some avg10 | 최근 10초간 일부 태스크(Task)가 메모리 대기한 비율 | >10%: 경고, >25%: 심각 |
| full avg10 | 최근 10초간 모든 태스크가 메모리 대기한 비율 | >5%: 경고, >10%: 심각 |
PSI 트리거 설정
/* PSI 트리거 등록: full 10초 평균이 5% 초과 시 알림 */
int fd = open("/sys/fs/cgroup/workload/pod-a/memory.pressure",
O_RDWR | O_NONBLOCK);
struct psi_trigger trigger = {
.some_or_full = PSI_FULL,
.threshold_us = 50000, /* 50ms (=5% of 1s) */
.window_us = 1000000, /* 1초 윈도우 */
};
write(fd, "full 50000 1000000", 19);
/* poll()로 트리거 이벤트 대기 */
struct pollfd pfd = { .fd = fd, .events = POLLPRI };
poll(&pfd, 1, -1); /* 트리거 발생 시 반환 */
memory.pressure의 일정 임계값을 초과하면 OOM kill보다 먼저 가장 많은 메모리를 사용하는 cgroup을 종료합니다. 이는 커널 OOM Killer보다 빠르고 정교한 대응이 가능합니다.
커널 메모리 계정
cgroup v2에서 커널 메모리(kmem)는 memory.max에 통합 계정됩니다. Slab, vmalloc, 커널 스택, 페이지 테이블, 소켓 버퍼 등 커널이 프로세스를 대신하여 사용하는 메모리가 해당 memcg에 과금됩니다.
| 커널 메모리 유형 | Charge 조건 | memory.stat 필드 |
|---|---|---|
| Slab (dentry, inode 등) | __GFP_ACCOUNT 플래그 | slab_reclaimable, slab_unreclaimable |
| 커널 스택 | fork() 시 자동 | kernel_stack |
| 페이지 테이블 | pte_alloc 시 자동 (v6.1+) | pagetables |
| 소켓 버퍼 | TCP/UDP 송수신 | sock |
| vmalloc | __GFP_ACCOUNT 플래그 | vmalloc |
| per-CPU 할당 | 자동 | percpu |
/* Slab 오브젝트의 memcg charge: __GFP_ACCOUNT가 핵심 */
struct dentry *d = kmem_cache_alloc(dentry_cache,
GFP_KERNEL | __GFP_ACCOUNT);
/* -> memcg_slab_post_alloc_hook()에서 현재 태스크의 memcg에 charge */
/* 커널 내부에서 __GFP_ACCOUNT가 자동 설정되는 주요 캐시 */
/* - dentry_cache (디렉토리 엔트리) */
/* - inode_cache (아이노드) */
/* - signal_cache (시그널 구조체) */
/* - task_struct 캐시 */
/* - mm_struct 캐시 */
/* - vm_area_struct 캐시 */
memory.stat의 slab 값을 모니터링하세요.
memcg LRU 리스트
각 memcg는 노드별로 독립된 lruvec 구조체를 유지합니다. 이 per-memcg LRU는 해당 cgroup에 속한 folio만 관리하며, memcg 회수 시 이 리스트를 스캔합니다.
/* include/linux/memcontrol.h -- per-memcg lruvec 접근 */
static inline struct lruvec *
mem_cgroup_lruvec(struct mem_cgroup *memcg,
struct pglist_data *pgdat)
{
struct mem_cgroup_per_node *mz;
mz = memcg->nodeinfo[pgdat->node_id];
return &mz->lruvec;
}
/* folio가 속한 memcg의 lruvec 반환 */
struct lruvec *folio_lruvec(struct folio *folio)
{
struct mem_cgroup *memcg = folio_memcg(folio);
struct pglist_data *pgdat = folio_pgdat(folio);
if (!memcg)
return &pgdat->__lruvec; /* 글로벌 lruvec */
return mem_cgroup_lruvec(memcg, pgdat);
}
코드 설명
- mem_cgroup_lruvec(memcg, pgdat)
include/linux/memcontrol.h의 인라인 함수로,memcg->nodeinfo[node_id]배열에서 해당 NUMA 노드의mem_cgroup_per_node를 가져와 그 안의lruvec을 반환합니다. 각 memcg는 NUMA 노드별로 독립된 LRU 리스트를 유지합니다. - folio_lruvec(folio)folio가 속한 memcg의 per-node lruvec을 반환하는 헬퍼 함수입니다.
folio_memcg()로 소속 memcg를 찾고,folio_pgdat()로 NUMA 노드를 확인한 뒤mem_cgroup_lruvec()를 호출합니다. - pgdat->__lruvec 폴백memcg가
NULL(root cgroup 또는 CONFIG_MEMCG 비활성)이면 노드의 글로벌__lruvec을 반환합니다. 이 분기 덕분에 memcg 비활성 환경에서도 동일한 회수 코드를 사용할 수 있습니다.
Dirty Page Writeback 제어
cgroup v2의 메모리 컨트롤러는 per-memcg dirty page writeback을 지원합니다. 각 memcg는 독립된 dirty 비율 제한을 가지며, cgroup writeback을 통해 해당 cgroup의 dirty page만 디스크에 기록합니다.
cgroup writeback 메커니즘
/* per-memcg dirty 비율 계산: mm/page-writeback.c */
/*
* memcg별 dirty 비율 = memcg_dirty_pages / memcg_nr_reclaimable
* 글로벌 dirty_ratio와 별개로, memcg 내에서 dirty 비율을 제어
*
* wb_over_bg_thresh(): writeback 시작 임계값 체크
* - 글로벌 dirty_background_ratio + per-memcg 비율 모두 확인
* - 둘 중 하나라도 초과하면 flusher 스레드 깨움
*/
/* inode는 하나의 memcg에만 연관 */
/* inode->i_wb: 이 inode의 writeback을 담당하는 bdi_writeback */
/* bdi_writeback: per-memcg writeback 컨텍스트 */
inode-memcg 연관
파일의 dirty page writeback을 올바른 memcg에 귀속시키기 위해, 각 inode는 하나의 bdi_writeback에 연관됩니다. 이 연관은 inode에 처음으로 dirty page가 생길 때 결정됩니다.
/* fs/fs-writeback.c -- inode writeback 연관 */
/*
* inode_attach_wb(): inode에 bdi_writeback 연결
*
* 1. folio를 dirty로 마킹할 때 호출
* 2. folio의 memcg를 확인 (folio_memcg())
* 3. 해당 memcg의 bdi_writeback을 찾거나 생성
* 4. inode->i_wb = 해당 bdi_writeback
*
* 문제: inode가 여러 memcg에서 dirty되면?
* -> 주기적으로 inode의 memcg 연관을 재평가
* (가장 많은 dirty page를 가진 memcg로 전환)
* -> inode_switch_wbs(): 비동기 writeback 전환
*/
struct bdi_writeback {
struct backing_dev_info *bdi;
unsigned long state;
struct list_head b_dirty; /* dirty inode 리스트 */
struct list_head b_io; /* writeback 대상 */
struct list_head b_more_io; /* 추가 writeback */
/* cgroup writeback 관련 */
struct mem_cgroup *memcg; /* 연관된 memcg */
struct list_head memcg_node; /* memcg의 wb 리스트 */
struct cgroup_subsys_state *blkcg_css; /* blkcg 연관 */
};
| 파일시스템 | cgroup writeback 지원 | 커널 버전 |
|---|---|---|
| ext4 | 지원 | v4.2+ |
| btrfs | 지원 | v4.3+ |
| xfs | 지원 | v5.15+ |
| f2fs | 지원 | v5.18+ |
| NFS | 미지원 | -- |
| CIFS | 미지원 | -- |
| tmpfs | 해당 없음 | (RAM 기반) |
per-memcg dirty 쓰로틀링
/* mm/page-writeback.c -- per-memcg dirty 쓰로틀링 흐름 */
/*
* balance_dirty_pages():
* 1. 글로벌 dirty_ratio 체크
* 2. per-memcg dirty 비율 체크 (memcg writeback 활성 시)
* 3. 둘 중 하나라도 초과 -> throttle_vm_writeout()
* 4. flusher 스레드 깨워 writeback 촉진
*
* per-memcg dirty 비율 계산:
* memcg_dirty = 해당 memcg의 dirty + writeback 페이지
* memcg_total = 해당 memcg의 전체 reclaimable 페이지
* dirty_ratio = memcg_dirty / memcg_total
*
* 이 비율이 글로벌 dirty_ratio를 초과하면 해당 memcg의
* 프로세스만 writeback 대기 (다른 memcg 불영향)
*/
blkio 컨트롤러도 함께 설정하면 I/O 대역폭(Bandwidth)까지 격리할 수 있습니다.
THP와 memcg
Transparent Huge Pages(THP)와 memcg의 상호작용은 charge 단위와 관련됩니다. THP folio(2MB)가 할당되면 512페이지 단위로 한 번에 charge됩니다.
| 시나리오 | Charge 단위 | memory.stat 반영 |
|---|---|---|
| THP 할당 성공 | 512 pages (2MB) 일괄 charge | anon_thp += 2MB |
| THP fallback (4KB) | 1 page 개별 charge | anon += 4KB |
| THP split | 512 -> 512개 개별 folio | anon_thp -= 2MB, anon += 2MB |
| THP collapse | 512개 -> 1개 THP | anon -= 2MB, anon_thp += 2MB |
| memcg 간 THP 이동 | 전체 512페이지 단위 | 원본 uncharge, 대상 charge |
memory.stat의 thp_fault_alloc 대비 thp_fault_fallback 비율을 모니터링하세요. THP fallback이 빈번하면 memory.max 여유를 확보하거나 THP를 비활성화하는 것을 고려하세요.
Kubernetes 연동
Kubernetes는 Pod의 resources.requests와 resources.limits를 cgroup v2 메모리 인터페이스에 매핑합니다.
# Kubernetes Pod Spec 예시
apiVersion: v1
kind: Pod
metadata:
name: memory-demo
spec:
containers:
- name: app
image: nginx
resources:
requests:
memory: "512Mi" # -> memory.min=512M (MemoryQoS)
limits:
memory: "1Gi" # -> memory.max=1G
# -> memory.high=~900M (MemoryQoS)
kubectl top pod는 memory.current를 읽고, kubectl describe pod의 "Last State: OOMKilled"는 memory.events의 oom_kill 카운터에 대응합니다. 보다 상세한 분석은 노드에서 cat /sys/fs/cgroup/kubepods.slice/.../memory.stat을 직접 확인하세요.
Docker/containerd 연동
Docker와 containerd는 컨테이너 생성 시 cgroup 메모리 인터페이스를 직접 설정합니다.
| Docker 옵션 | cgroup v2 파일 | 설명 |
|---|---|---|
--memory 1g | memory.max = 1073741824 | 경성 메모리 제한 |
--memory-reservation 512m | memory.low = 536870912 | 소프트 메모리 보호 |
--memory-swap 2g | memory.swap.max = 1073741824 | 스왑 제한 (메모리 제한 차감) |
--memory-swappiness 60 | (v1 전용, v2 미지원) | v2에서는 글로벌 설정만 |
--oom-kill-disable | (v1 전용, v2 미지원) | v2에서는 OOM kill 비활성화 불가 |
--kernel-memory | (v1 전용, v2 통합) | v2에서는 memory.max에 통합 |
# Docker 컨테이너 메모리 제한 예시
docker run -d \
--name myapp \
--memory 1g \
--memory-reservation 512m \
--memory-swap 2g \
nginx
# 실제 cgroup 설정 확인 (cgroup v2)
CONTAINER_ID=$(docker inspect myapp --format '{{.Id}}')
CGROUP_PATH="/sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope"
cat ${CGROUP_PATH}/memory.max # 1073741824
cat ${CGROUP_PATH}/memory.low # 536870912
cat ${CGROUP_PATH}/memory.swap.max # 1073741824
cat ${CGROUP_PATH}/memory.current # 현재 사용량
systemd 서비스 메모리 제한
systemd는 서비스(유닛) 단위로 cgroup을 생성하며, 메모리 제한 지시어를 cgroup v2 인터페이스에 매핑합니다.
| systemd 지시어 | cgroup v2 파일 | 설명 |
|---|---|---|
MemoryMax=1G | memory.max | 경성 상한 |
MemoryHigh=800M | memory.high | 쓰로틀링 임계 |
MemoryLow=256M | memory.low | 최선 보호 |
MemoryMin=128M | memory.min | 절대 보호 |
MemorySwapMax=512M | memory.swap.max | 스왑 상한 |
MemoryZSwapMax=256M | memory.zswap.max | zswap 상한 |
ManagedOOMMemoryPressure=kill | systemd-oomd PSI | PSI 기반 선제적 kill |
ManagedOOMMemoryPressureLimit=80% | PSI 임계값 | 메모리 압박 임계 (some avg) |
# /etc/systemd/system/myapp.service
[Service]
ExecStart=/usr/bin/myapp
MemoryMax=1G
MemoryHigh=800M
MemoryLow=256M
MemorySwapMax=0
ManagedOOMMemoryPressure=kill
# 런타임에 메모리 제한 변경
# systemctl set-property myapp.service MemoryMax=2G
systemd Slice 계층
systemd는 .slice 단위로 cgroup 계층을 조직합니다:
# systemd cgroup 계층 구조
systemd-cgls --no-pager
# Control Group /:
# -.slice
# ├─user.slice
# │ └─user-1000.slice
# │ └─session-1.scope
# │ └─ ... (사용자 프로세스들)
# ├─system.slice
# │ ├─nginx.service
# │ │ └─12345 nginx: master process
# │ ├─sshd.service
# │ │ └─12346 sshd: /usr/sbin/sshd
# │ └─myapp.service
# │ └─12347 /usr/bin/myapp
# └─init.scope
# └─1 /sbin/init
# Slice 수준 메모리 제한 (system.slice 전체)
systemctl set-property system.slice MemoryMax=8G
# 런타임 메모리 상태 확인
systemctl show myapp.service -p MemoryCurrent,MemoryPeak,MemoryAvailable
# MemoryCurrent=268435456
# MemoryPeak=536870912
# MemoryAvailable=805306368
# systemd-oomd 상태 확인
oomctl
# Monitored cgroup: system.slice/myapp.service
# Memory pressure: some avg10=2.34 avg60=1.12 avg300=0.56
# Memory min: 256M, max: 1G, current: 512M
systemd-oomd 설정
# /etc/systemd/oomd.conf
[OOM]
SwapUsedLimit=90%
DefaultMemoryPressureDurationSec=30s
# 서비스 개별 설정
# [Service]
# ManagedOOMSwap=kill
# ManagedOOMMemoryPressure=kill
# ManagedOOMMemoryPressureLimit=80%
# ManagedOOMPreference=avoid (또는 omit)
NUMA와 memcg
memory.numa_stat은 memcg의 메모리 사용량을 NUMA 노드별로 분류하여 보여줍니다.
cat /sys/fs/cgroup/workload/pod-a/memory.numa_stat
# anon N0=536870912 N1=536870912
# file N0=268435456 N1=268435456
# kernel_stack N0=4194304 N1=4194304
# pagetables N0=8388608 N1=8388608
# shmem N0=67108864 N1=67108864
# file_mapped N0=134217728 N1=134217728
# file_dirty N0=2097152 N1=2097152
# file_writeback N0=0 N1=0
# swapcached N0=0 N1=0
# anon_thp N0=268435456 N1=268435456
# file_thp N0=0 N1=0
# shmem_thp N0=0 N1=0
# inactive_anon N0=134217728 N1=134217728
# active_anon N0=402653184 N1=402653184
# inactive_file N0=67108864 N1=67108864
# active_file N0=201326592 N1=201326592
# unevictable N0=0 N1=0
memory.numa_stat에서 노드 간 사용량 차이가 크면 numactl --cpunodebind 또는 cpuset.mems로 NUMA 친화도(Affinity)를 설정하세요.
NUMA와 memcg 상호작용
per-memcg LRU는 NUMA 노드별로 독립적인 lruvec을 유지합니다. 이는 memcg 회수 시 특정 NUMA 노드의 메모리만 타겟할 수 있게 합니다.
/* mem_cgroup_per_node: NUMA 노드별 memcg 정보 */
struct mem_cgroup_per_node {
struct lruvec lruvec; /* 이 노드의 LRU 리스트 */
/* MGLRU 지원 */
struct lru_gen_folio lrugen; /* 세대 기반 LRU (v6.1+) */
/* NUMA 통계 */
unsigned long lru_zone_size[MAX_NR_ZONES][NR_LRU_LISTS];
struct mem_cgroup *memcg; /* 역참조 포인터 */
};
/* 회수 시 NUMA 노드 선택:
* 1. memcg charge 실패 -> try_to_free_mem_cgroup_pages()
* 2. 각 NUMA 노드의 per-memcg lruvec를 순회
* 3. 각 노드에서 LRU 스캔 + 회수
* 4. NUMA 거리가 가까운 노드부터 우선 회수 (zonelist 순서)
*/
코드 설명
- struct mem_cgroup_per_node각 memcg의 NUMA 노드별 정보를 담는 구조체입니다.
mem_cgroup->nodeinfo[]배열로 접근하며, 노드 수만큼 할당됩니다.include/linux/memcontrol.h에 정의되어 있습니다. - lruvec이 노드에서 해당 memcg에 속하는 folio들의 LRU 리스트(INACTIVE_ANON, ACTIVE_ANON, INACTIVE_FILE, ACTIVE_FILE, UNEVICTABLE)를 관리합니다. per-memcg 회수 시 이 lruvec만 스캔하여 다른 cgroup에 영향을 주지 않습니다.
- lru_gen_folio lrugen커널 6.1+의 MGLRU(Multi-Gen LRU) 지원 필드입니다. 세대(generation) 기반으로 folio를 분류하여 기존 active/inactive 2단계보다 더 정밀한 회수 우선순위를 제공합니다.
- 회수 시 NUMA 노드 선택memcg charge 실패 시
try_to_free_mem_cgroup_pages()가 zonelist 순서(NUMA 거리 기반)로 각 노드의 per-memcg lruvec을 순회하며 회수합니다. 가까운 노드의 메모리를 먼저 회수하여 NUMA 지역성을 유지합니다.
cpuset과 memcg 연합
# NUMA 바인딩 + 메모리 제한 동시 적용
# cpuset으로 NUMA 노드 0에만 바인딩
echo "+cpuset +memory" > /sys/fs/cgroup/cgroup.subtree_control
mkdir /sys/fs/cgroup/numa-workload
echo "0" > /sys/fs/cgroup/numa-workload/cpuset.mems
echo "0-7" > /sys/fs/cgroup/numa-workload/cpuset.cpus
# 메모리 제한 (노드 0의 물리 메모리 이내)
echo "8G" > /sys/fs/cgroup/numa-workload/memory.max
# 자식 cgroup 생성
echo "+cpuset +memory" > /sys/fs/cgroup/numa-workload/cgroup.subtree_control
mkdir /sys/fs/cgroup/numa-workload/app
echo "4G" > /sys/fs/cgroup/numa-workload/app/memory.max
# 이 설정은 app이 NUMA 노드 0에서만 메모리를 사용하고
# 최대 4GB까지 제한됨을 보장합니다
v1에서 v2 마이그레이션 가이드
cgroup v1에서 v2로 마이그레이션할 때 메모리 컨트롤러의 인터페이스가 크게 변경됩니다.
| cgroup v1 | cgroup v2 | 마이그레이션 노트 |
|---|---|---|
memory.limit_in_bytes | memory.max | 바이트 -> 바이트 또는 접미사(K/M/G) |
memory.soft_limit_in_bytes | memory.high | 의미 변경: 소프트 제한 -> 쓰로틀링 |
memory.memsw.limit_in_bytes | memory.swap.max | mem+swap -> swap만 (분리) |
memory.kmem.limit_in_bytes | (삭제) | memory.max에 통합, 별도 제한 불가 |
memory.oom_control | memory.oom.group | OOM 비활성화 불가, 그룹 kill만 제어 |
memory.usage_in_bytes | memory.current | 동일 의미 |
memory.max_usage_in_bytes | memory.peak | 동일 의미 |
memory.failcnt | memory.events max 필드 | 단일 카운터 -> 다중 이벤트 |
memory.stat | memory.stat | 필드명 변경 (rss -> anon 등) |
memory.force_empty | (삭제) | cgroup 삭제 시 자동 처리 |
memory.swappiness | (삭제) | per-cgroup swappiness 미지원 (글로벌만) |
| (없음) | memory.min | v2 신규: 절대 보호 |
| (없음) | memory.low | v2 신규: 최선 보호 |
| (없음) | memory.pressure | v2 신규: per-cgroup PSI |
memory.memsw.limit_in_bytes는 메모리+스왑 합산 제한이었지만, v2의 memory.swap.max는 스왑만의 제한입니다.
v1에서 memory.limit_in_bytes=1G, memory.memsw.limit_in_bytes=2G이면, v2에서는 memory.max=1G, memory.swap.max=1G로 변환해야 합니다 (memsw - mem = swap).
마이그레이션 체크리스트
- 커널 버전 확인: cgroup v2 메모리 컨트롤러는 커널 4.5+, PSI는 4.20+, memory.high는 4.12+ 필요
- 하이브리드 모드:
systemd.unified_cgroup_hierarchy=1부트 파라미터로 순수 v2 모드 활성화 - 인터페이스 변환: 위 매핑 테이블 참조하여 스크립트/설정 업데이트
- 모니터링 도구: cAdvisor, Prometheus node_exporter가 v2를 지원하는지 확인
- swappiness 제거: per-cgroup swappiness 대신 memory.high로 간접 제어
- oom_control 제거: OOM kill 비활성화 로직을 memory.oom.group으로 대체
모니터링 도구
| 도구 | 데이터 소스 | 특징 |
|---|---|---|
systemd-cgtop | memory.current, memory.swap.current | 시스템 전체 cgroup 메모리 사용량 실시간 |
cAdvisor | memory.stat, memory.events | 컨테이너 메모리 상세 메트릭, Prometheus 연동 |
Prometheus + node_exporter | cgroup 파일시스템 | 시계열 저장, 알림, 대시보드 |
kubectl top | metrics-server (memory.current) | K8s Pod/Node 메모리 사용량 |
docker stats | memory.current, memory.max | 컨테이너별 실시간 통계 |
| 직접 읽기 | cgroup 파일시스템 | 가장 정확, 스크립트 자동화 |
# systemd-cgtop: 시스템 전체 cgroup 메모리 사용량
systemd-cgtop -m
# 특정 cgroup 상세 모니터링 스크립트
while true; do
CGROUP="/sys/fs/cgroup/workload/pod-a"
CURRENT=$(cat ${CGROUP}/memory.current)
MAX=$(cat ${CGROUP}/memory.max)
HIGH=$(cat ${CGROUP}/memory.high)
SWAP=$(cat ${CGROUP}/memory.swap.current)
PSI=$(cat ${CGROUP}/memory.pressure | head -1)
echo "$(date) current=$((CURRENT/1048576))M max=$((MAX/1048576))M swap=$((SWAP/1048576))M psi=${PSI}"
sleep 5
done
# Prometheus 쿼리 예시 (cAdvisor 메트릭)
# container_memory_usage_bytes{pod="myapp"} / container_spec_memory_limit_bytes{pod="myapp"}
# rate(container_memory_failcnt{pod="myapp"}[5m]) # OOM 빈도
튜닝 가이드
memory.high vs memory.max 전략
| 워크로드 유형 | memory.max | memory.high | memory.low | memory.min |
|---|---|---|---|---|
| 지연 민감 (DB, 캐시) | 물리 메모리 70% | max의 85% | 워킹셋 크기 | 워킹셋의 50% |
| 배치 작업 (CI/CD) | 필요량의 120% | max의 80% | 0 | 0 |
| 웹 서버 | 예상 피크의 150% | max의 90% | 기본 RSS | 0 |
| 개발 환경 | 넉넉하게 | 설정 안함 | 0 | 0 |
실전 튜닝 팁
- memory.peak 활용: 먼저 제한 없이 실행한 뒤
memory.peak으로 실제 최대 사용량을 확인하고, 이를 기준으로 memory.max를 설정합니다. - workingset_refault 모니터링:
memory.stat의workingset_refault_*가 지속적으로 증가하면 워킹셋이 memory.max보다 큼 -- 제한 증가 필요 - pgscan/pgsteal 비율:
pgsteal/pgscan < 0.5이면 회수 효율 저하 -- 워킹셋이 크거나 unevictable 메모리가 많음 - memory.high 쓰로틀링 모니터링:
memory.events의high카운터가 빠르게 증가하면 memory.high를 올리거나 워크로드 최적화 - 커널 메모리(slab) 주의: dentry/inode cache가 예상 외로 크면
vfs_cache_pressuresysctl을 높여 회수 촉진 - memory.reclaim 활용 (v6.1+): OOM kill 전에
echo "100M" > memory.reclaim으로 선제적 회수하면 워킹셋을 보호하면서 캐시만 정리 가능 - PSI 기반 자동 조정:
memory.pressure의 some avg10이 지속적으로 10% 이상이면 memory.max 증가 고려. 0%이면 memory.max를 줄여 자원 효율화
제한값 결정 트리
- 워킹셋 크기 측정: 제한 없이 실행 후
memory.peak확인 -- 이것이 최소 memory.max 기준 - memory.max 결정: peak의 1.2~1.5배 (THP, slab, 스파이크 여유)
- memory.high 결정: memory.max의 80~90% (쓰로틀링 구간 확보)
- memory.low 결정: 정상 동작 시 RSS (워킹셋 보호)
- memory.min 결정: 서비스 최소 기능 유지에 필수적인 메모리 (없으면 0)
- memory.swap.max 결정: 지연 민감 -> 0, 배치 -> memory.max의 50~100%
- 검증: 부하 테스트 후 memory.events의 high/max/oom 카운터와 PSI 확인
핵심 모니터링 지표
| 지표 | 소스 | 정상 범위 | 이상 시 대응 |
|---|---|---|---|
| 사용률 (current/max) | memory.current / memory.max | <80% | 80% 이상이면 memory.max 증가 검토 |
| 회수 효율 (steal/scan) | memory.stat pgsteal/pgscan | >0.5 | 낮으면 워킹셋이 큼 -- 제한 증가 |
| 재폴트율 | memory.stat workingset_refault | 낮음(단조) | 빠르게 증가하면 메모리 부족 |
| PSI some avg10 | memory.pressure | <5% | 10% 이상이면 성능 저하 시작 |
| PSI full avg10 | memory.pressure | <1% | 5% 이상이면 심각한 병목(Bottleneck) |
| high 이벤트 빈도 | memory.events high | 낮음 | 빈번하면 memory.high 증가 |
| oom_kill 카운터 | memory.events oom_kill | 0 | 0이 아니면 즉시 원인 분석 |
| slab 비율 | memory.stat slab/current | <20% | 높으면 vfs_cache_pressure 증가 |
커널 빌드 옵션
| 옵션 | 기본 | 설명 |
|---|---|---|
CONFIG_MEMCG | y | Memory Cgroup 컨트롤러 활성화 |
CONFIG_CGROUP_WRITEBACK | y | per-memcg dirty writeback |
CONFIG_SLUB_MEMCG_SYSFS_ON | n | Slab memcg sysfs 인터페이스 |
CONFIG_PSI | y | Pressure Stall Information |
CONFIG_SWAP | y | 스왑 지원 (memcg swap 제어 전제) |
CONFIG_ZSWAP | y | zswap (memcg zswap 제어 전제) |
CONFIG_TRANSPARENT_HUGEPAGE | y | THP (memcg THP charge) |
CONFIG_LRU_GEN | y | MGLRU (per-memcg 세대 기반 회수) |
CONFIG_CGROUPS | y | cgroup 프레임워크 (전제 조건) |
CONFIG_CGROUP_V1_MEM_WARN | n | v1 memory 사용 시 경고 출력 |
# 현재 커널의 memcg 관련 설정 확인
zcat /proc/config.gz | grep -E "MEMCG|CGROUP|PSI|ZSWAP|LRU_GEN"
# 또는
grep -E "MEMCG|CGROUP|PSI|ZSWAP|LRU_GEN" /boot/config-$(uname -r)
# cgroup v2 마운트 확인
mount | grep cgroup2
# cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime)
memcg 문제 진단 플레이북
시나리오 1: 컨테이너 OOM Kill 반복
# 1. OOM kill 이벤트 확인
cat ${CGROUP}/memory.events | grep oom_kill
# oom_kill 5 -> 5회 OOM kill 발생
# 2. 현재 사용량과 제한 비교
echo "current: $(($(cat ${CGROUP}/memory.current)/1048576))M"
echo "max: $(($(cat ${CGROUP}/memory.max)/1048576))M"
echo "peak: $(($(cat ${CGROUP}/memory.peak)/1048576))M"
# 3. 메모리 구성 분석 (어디서 메모리를 소비하는지)
cat ${CGROUP}/memory.stat | grep -E "^(anon|file|kernel|slab|sock|pagetables) "
# 4. 워킹셋 분석
cat ${CGROUP}/memory.stat | grep workingset_refault
# 높으면 -> 메모리 부족으로 워킹셋이 계속 재적재
# 5. PSI 확인 (메모리 압박 수준)
cat ${CGROUP}/memory.pressure
# 6. 대응
# - memory.max 증가
# - memory.high 설정으로 사전 쓰로틀링
# - 애플리케이션 메모리 최적화
시나리오 2: 컨테이너 성능 저하 (쓰로틀링)
# 1. high 이벤트 확인
cat ${CGROUP}/memory.events | grep high
# high 1234 -> 쓰로틀링 1234회 발생
# 2. 현재 사용량 vs memory.high
echo "current: $(($(cat ${CGROUP}/memory.current)/1048576))M"
echo "high: $(($(cat ${CGROUP}/memory.high)/1048576))M"
# 3. PSI full 확인 (모든 태스크가 대기하는 비율)
cat ${CGROUP}/memory.pressure | grep full
# 4. 회수 효율 확인
cat ${CGROUP}/memory.stat | grep -E "pgscan|pgsteal"
# pgsteal/pgscan 비율이 낮으면 -> 회수가 비효율적
# 5. 대응
# - memory.high 증가 (또는 제거)
# - 파일 캐시가 크면: 불필요한 파일 접근 제거
# - slab이 크면: vfs_cache_pressure 증가
시나리오 3: 메모리 누수 의심
# 1. 사용량 추이 관찰 (주기적 기록)
while true; do
echo "$(date +%H:%M:%S) $(($(cat ${CGROUP}/memory.current)/1048576))M"
sleep 60
done
# 2. 메모리 유형별 추이 확인
cat ${CGROUP}/memory.stat | grep -E "^(anon|file|slab|kernel) "
# anon이 계속 증가 -> 애플리케이션 메모리 누수
# slab이 계속 증가 -> 커널 오브젝트 누수 (dentry/inode)
# file이 크지만 안정 -> 파일 캐시 (정상)
# 3. 프로세스별 RSS 확인
for pid in $(cat ${CGROUP}/cgroup.procs); do
rss=$(awk '/VmRSS/{print $2}' /proc/$pid/status)
comm=$(cat /proc/$pid/comm)
echo "$pid $comm ${rss}kB"
done | sort -k3 -n -r | head -10
내부 자료구조
memcg의 핵심 자료구조를 이해하면 커널 소스를 읽고 디버깅(Debugging)하는 데 도움이 됩니다.
/* include/linux/memcontrol.h -- struct mem_cgroup 주요 필드 (간략화) */
struct mem_cgroup {
struct cgroup_subsys_state css;
/* 계층적 페이지 카운터 */
struct page_counter memory; /* 전체 메모리 (anon+file+kmem) */
struct page_counter swap; /* 스왑 사용량 */
struct page_counter kmem; /* 커널 메모리 */
struct page_counter tcpmem; /* TCP 메모리 */
/* 제한 및 설정 */
unsigned long high; /* memory.high 값 */
bool oom_group; /* memory.oom.group */
/* 이벤트 카운터 */
atomic_long_t events[MEMCG_NR_EVENTS];
/* NUMA 노드별 정보 (lruvec 포함) */
struct mem_cgroup_per_node *nodeinfo[];
};
/* mm/page_counter.c -- 계층적 charge */
bool page_counter_try_charge(
struct page_counter *counter,
unsigned long nr_pages,
struct page_counter **fail)
{
struct page_counter *c;
for (c = counter; c; c = c->parent) {
long new = atomic_long_add_return(
nr_pages, &c->usage);
if (new > c->max) {
atomic_long_sub(nr_pages, &c->usage);
/* 이미 증가시킨 하위 레벨 undo */
page_counter_cancel(counter, c, nr_pages);
*fail = c;
return false;
}
}
return true;
}
page_counter 상세 설명
page_counter는 memcg 계층적 과금의 핵심 자료구조입니다. page_counter_try_charge()는 현재 카운터부터 root까지 올라가며 각 레벨의 usage를 증가시키고, max를 초과하는 레벨이 있으면 이미 증가시킨 모든 하위 레벨을 롤백(Rollback)합니다. 이 원자적(Atomic) 계층 체크 덕분에 어떤 조상의 제한도 빠짐없이 적용됩니다.
Charge 배치와 Stock 캐시
매번 folio를 charge할 때마다 page_counter_try_charge()를 호출하면 atomic 연산이 계층 깊이만큼 반복되어 성능이 저하됩니다. 이를 최적화하기 위해 memcg는 per-CPU stock 캐시를 사용합니다. stock 캐시는 동일 memcg에 대한 연속 charge를 배치 처리하여 atomic 연산 횟수를 대폭 줄입니다.
charge 배치 최적화의 핵심 아이디어는 다음과 같습니다:
- 선불 charge:
MEMCG_CHARGE_BATCH(32페이지) 단위로 미리 charge하여 per-CPU stock에 저장 - 즉시 소비: 이후 같은 memcg에 대한 charge는 stock에서 즉시 차감 (atomic 연산 불필요)
- stock 갱신: stock이 소진되면 다시 배치 charge
- stock 드레인: memcg 삭제, CPU 오프라인, 다른 memcg로 전환 시 stock을 uncharge로 반환
per-CPU Stock 캐시
/* mm/memcontrol.c -- per-CPU stock 캐시 구조 */
struct memcg_stock_pcp {
local_lock_t stock_lock;
struct mem_cgroup *cached; /* 현재 캐시된 memcg */
unsigned int nr_pages; /* 잔여 크레딧 (페이지 수) */
struct obj_cgroup *cached_objcg;
struct pglist_data *cached_pgdat;
unsigned int nr_bytes;
int nr_slab_reclaimable_b;
int nr_slab_unreclaimable_b;
};
/*
* consume_stock(): stock에서 charge 소비 (빠른 경로)
* - cached memcg가 같고 nr_pages가 충분하면
* atomic 연산 없이 즉시 차감
*
* refill_stock(): charge 성공 후 여분을 stock에 저장
* - 예: 32페이지 charge 후 1페이지만 필요 -> 31페이지를 stock에
*
* drain_stock(): stock 비우기 (memcg 삭제, CPU offline 등)
*/
static bool consume_stock(struct mem_cgroup *memcg,
unsigned int nr_pages)
{
struct memcg_stock_pcp *stock;
bool ret = false;
stock = this_cpu_ptr(&memcg_stock);
local_lock(&stock->stock_lock);
if (memcg == stock->cached &&
stock->nr_pages >= nr_pages) {
stock->nr_pages -= nr_pages;
ret = true; /* atomic 연산 없이 즉시 성공! */
}
local_unlock(&stock->stock_lock);
return ret;
}
설명
per-CPU stock 캐시는 같은 memcg에 대한 연속 charge를 최적화합니다. 한 번 page_counter_try_charge()로 배치 크기만큼 charge한 뒤, 실제 필요한 양만 사용하고 나머지를 stock에 보관합니다. 이후 같은 memcg에 대한 charge는 stock에서 즉시 차감하여 계층적 atomic 연산을 건너뜁니다. 이는 특히 동일 컨테이너 내에서 대량 페이지 폴트가 발생할 때 성능을 크게 개선합니다.
배치 크기와 임계값
| 상수 | 값 | 용도 |
|---|---|---|
MEMCG_CHARGE_BATCH | 32 pages (128KB) | 한 번에 charge하는 최소 배치 크기 |
MEMCG_DELAY_PRECISION_SHIFT | 20 | 쓰로틀링 지연 시간 정밀도 |
MEMCG_MAX_RECLAIM_LOOPS | 3 | charge 실패 시 회수 재시도 횟수 |
MEMCG_MAX_HIGH_DELAY_JIFFIES | 2초 분량 | memory.high 쓰로틀링 최대 지연 |
memory.high 쓰로틀링 상세
memory.high 초과 시 프로세스는 할당 경로에서 강제로 sleep합니다. 이 쓰로틀링은 OOM kill 없이 자연스러운 백프레셔를 제공합니다.
/* mm/memcontrol.c -- memory.high 쓰로틀링 로직 */
void mem_cgroup_handle_over_high(gfp_t gfp_mask)
{
unsigned long usage, high, clamped_high;
unsigned long penalty_jiffies;
unsigned long pflags;
/* 현재 memcg 찾기 */
struct mem_cgroup *memcg = mem_cgroup_from_task(current);
if (!memcg)
return;
usage = page_counter_read(&memcg->memory);
high = READ_ONCE(memcg->memory.high);
if (usage <= high)
return;
/*
* 쓰로틀링 계산: 초과량에 비례하는 지연
* penalty = (usage - high) / high * max_penalty
* 최대 2초까지 sleep
*/
penalty_jiffies = calculate_high_delay(
memcg, usage - high);
penalty_jiffies = min(penalty_jiffies,
MEMCG_MAX_HIGH_DELAY_JIFFIES);
/* 프로세스를 interruptible sleep */
psi_memstall_enter(&pflags);
set_current_state(TASK_KILLABLE);
schedule_timeout(penalty_jiffies);
psi_memstall_leave(&pflags);
/* 직접 회수도 시도 */
try_to_free_mem_cgroup_pages(
memcg, nr_pages, gfp_mask,
MEMCG_RECLAIM_MAY_SWAP);
/* 이벤트 기록 */
memcg_memory_event(memcg, MEMCG_HIGH);
}
코드 설명
- mem_cgroup_from_task(current)현재 실행 중인 태스크가 속한
mem_cgroup을 반환합니다.task_struct->cgroups를 통해 cgroup subsystem state에 접근합니다. - usage <= high 체크
page_counter_read()로 현재 사용량을 읽고READ_ONCE()로memory.high값을 안전하게 읽습니다. 사용량이 high 이하이면 쓰로틀링 없이 즉시 반환합니다. - calculate_high_delay()초과량
(usage - high)에 비례하는 지연 시간을 jiffies 단위로 계산합니다. 초과 비율이 클수록 지연이 길어지며,MEMCG_MAX_HIGH_DELAY_JIFFIES(약 2초)로 상한이 제한됩니다. - psi_memstall_enter() / leave()PSI(Pressure Stall Information) 메모리 stall 구간을 기록합니다. 이 구간은
memory.pressure의 some/full 비율에 반영됩니다. - set_current_state(TASK_KILLABLE) + schedule_timeout()프로세스를
TASK_KILLABLE상태로 전환하고 계산된 시간만큼 sleep합니다.TASK_KILLABLE이므로 SIGKILL 시그널로 깨울 수 있어 OOM kill이 즉시 처리됩니다. - try_to_free_mem_cgroup_pages()sleep과 함께 해당 memcg의 per-memcg LRU에서 직접 회수를 시도합니다. 쓰로틀링 지연 동안 메모리를 확보하여 사용량을
memory.high아래로 되돌리려는 시도입니다.
- 지연 허용 워크로드 (배치, CI/CD): memory.high를 적극 활용 -- 느려지지만 죽지 않음
- 지연 민감 워크로드 (DB, 캐시): memory.high를 memory.max에 가깝게 설정하거나 미설정 -- 쓰로틀링에 의한 지연 증가 방지
- 하이브리드: memory.high = max의 90%, memory.max = 경성 상한. 10% 버퍼 내에서 쓰로틀링 후 OOM
쓰로틀링 지연 시간 계산
초과량에 비례하여 지연 시간이 증가하며, 최대 2초까지 sleep합니다:
| 초과 비율 (usage-high)/high | 대략적 지연 | 효과 |
|---|---|---|
| 1~5% | 수 밀리초 | 거의 감지 불가 |
| 5~20% | 수십 밀리초 | 약간의 응답 지연 |
| 20~50% | 수백 밀리초 | 눈에 띄는 성능 저하 |
| 50% 이상 | 1~2초 | 심각한 쓰로틀링 (최대값 제한) |
Cgroup-aware OOM Kill 상세
커널 4.19+에서 도입된 cgroup-aware OOM kill은 글로벌 메모리 부족 시에도 cgroup 단위로 OOM 대상을 선택할 수 있습니다.
cgroup 단위 OOM 선정
/* mm/oom_kill.c -- cgroup-aware OOM: 가장 많은 메모리를 사용하는 cgroup 선정 */
/*
* 글로벌 OOM 발생 시:
* 1. cgroup 트리를 순회하며 각 memcg의 사용량을 비교
* 2. oom_group=1인 cgroup은 그 안의 모든 프로세스를 단위로 평가
* 3. 가장 큰 badness 점수를 가진 cgroup/프로세스를 선택
*
* memcg OOM 발생 시:
* 1. 해당 memcg의 서브트리만 스캔
* 2. 서브트리 내 프로세스들의 oom_badness() 비교
* 3. oom_group=1이면 해당 cgroup 전체 kill
*/
static void mem_cgroup_oom_notify(struct mem_cgroup *memcg)
{
struct mem_cgroup *iter;
/* 서브트리의 모든 자식에게 이벤트 전파 */
for_each_mem_cgroup_tree(iter, memcg)
memcg_memory_event(iter, MEMCG_OOM);
}
코드 설명
- cgroup-aware OOM 주석글로벌 OOM과 memcg OOM의 두 가지 경로를 설명합니다. 글로벌 OOM은 전체 cgroup 트리를 순회하며
oom_badness()로 최대 메모리 소비 cgroup/프로세스를 선정하고, memcg OOM은memory.max를 초과한 memcg의 서브트리만 스캔합니다.mm/oom_kill.c에 구현되어 있습니다. - for_each_mem_cgroup_tree(iter, memcg)OOM이 발생한 memcg와 그 모든 자식 memcg를 DFS(깊이 우선 탐색)로 순회하는 매크로입니다. 각 자식 memcg에
MEMCG_OOM이벤트를 전파하여memory.events의oom카운터를 증가시킵니다. - memcg_memory_event(iter, MEMCG_OOM)OOM 이벤트를 기록하고
memory.events파일에 대한 inotify/poll 알림을 트리거합니다. 모니터링 도구(Prometheus, systemd-oomd 등)는 이 이벤트를 감지하여 대응할 수 있습니다.
memory.oom.group 동작 상세
| 설정 | OOM 발생 시 동작 | 적용 범위 |
|---|---|---|
| oom.group=0 (기본) | cgroup 내 개별 프로세스 중 최고 badness kill | 해당 memcg 범위 |
| oom.group=1 (리프) | 해당 cgroup의 모든 프로세스 일괄 SIGKILL | 리프 cgroup 전체 |
| oom.group=1 (부모) | 부모와 모든 자식 cgroup의 프로세스 일괄 SIGKILL | 서브트리 전체 |
| oom.group=1 (계층적) | 가장 가까운 oom.group=1 조상을 찾아 해당 범위 kill | 조상 cgroup 범위 |
memory.oom.group=1을 설정하면, OOM 시 Pod 내 모든 컨테이너(sidecar 포함)가 동시에 종료됩니다. 이는 사이드카 컨테이너가 메인 컨테이너 없이 고아 상태로 남는 것을 방지합니다. kubelet의 --cgroups-per-qos와 함께 사용하세요.
Charge 마이그레이션
프로세스가 한 cgroup에서 다른 cgroup으로 이동하면(cgroup.procs에 PID 쓰기), 해당 프로세스가 사용하는 메모리의 charge가 이동해야 합니다.
cgroup v2 charge 이동 정책
| 메모리 유형 | charge 이동 여부 | 설명 |
|---|---|---|
| 익명 페이지 | 이동 안 함 (기본) | 기존 memcg에 charge 유지, 새 할당만 새 memcg에 charge |
| 파일 캐시 | 이동 안 함 | 최초 charge한 memcg에 유지 |
| Slab 오브젝트 | 이동 안 함 | 할당 시점의 memcg에 고정 |
| 신규 할당 | 새 memcg에 charge | 이동 후 발생하는 페이지 폴트 |
memory.move_charge_at_immigrate을 설정하여 프로세스 이동 시 기존 페이지의 charge를 함께 이동시킬 수 있었습니다. 그러나 이 기능은 대량 페이지 스캔이 필요하여 성능이 매우 나빴고, cgroup v2에서는 제거되었습니다. v2에서는 프로세스 이동 시 기존 페이지는 원래 memcg에 남고, 새 할당만 새 memcg에 charge됩니다.
Memcg Reparenting (cgroup 삭제 시)
cgroup이 삭제되면(rmdir) 해당 memcg의 charge는 부모 memcg로 reparent됩니다. cgroup 디렉토리가 삭제되어도 charge된 페이지가 모두 해제될 때까지 mem_cgroup 구조체는 내부적으로 유지됩니다.
/* cgroup 삭제 시 reparenting 흐름 */
/*
* 1. rmdir /sys/fs/cgroup/workload/pod-a
* 2. css_offline() 호출 -- 새 charge 차단
* 3. 기존 folio의 memcg_data는 여전히 이 memcg를 가리킴
* 4. folio가 회수/해제될 때 uncharge -> 부모에게 자동 전파
* 5. 모든 folio가 해제되면 mem_cgroup 구조체 최종 해제
*
* "zombie memcg": 디렉토리는 삭제되었지만 charge가 남아있는 상태
* /proc/cgroups의 num_cgroups로 zombie 수 확인 가능
*/
좀비 memcg 문제
컨테이너가 빈번하게 생성/삭제되는 환경에서 좀비 memcg가 누적될 수 있습니다. 파일 캐시나 slab 오브젝트가 오래 유지되면 해당 memcg의 charge가 해제되지 않아 메모리 구조체가 쌓입니다.
# 좀비 memcg 수 확인
grep memory /proc/cgroups
# memory 0 342 1
# ^ ^^^ 현재 cgroup 수 (zombie 포함)
# slab에서 좀비 memcg를 유지하는 오브젝트 확인
cat /proc/slabinfo | head -2
cat /sys/kernel/slab/*/objects
# 좀비 memcg 강제 정리: 파일 캐시 드롭
echo 3 > /proc/sys/vm/drop_caches
# slab reclaim 강제
echo 2 > /proc/sys/vm/drop_caches
# vfs_cache_pressure 증가로 dentry/inode 회수 촉진
echo 200 > /proc/sys/vm/vfs_cache_pressure
mem_cgroup 구조체(~수 KB) + per-node lruvec + per-CPU stock을 유지합니다. NUMA 시스템에서 수천 개의 좀비 memcg가 누적되면 수십~수백 MB의 커널 메모리가 낭비됩니다. Kubernetes 환경에서는 --eviction-hard 외에도 주기적인 drop_caches나 적절한 vfs_cache_pressure 설정이 필요합니다.
memory.reclaim 인터페이스 (v6.1+)
커널 6.1에서 도입된 memory.reclaim은 유저스페이스에서 특정 memcg의 메모리를 능동적으로 회수할 수 있는 인터페이스입니다.
# 특정 cgroup에서 100MB 회수 요청
echo "100M" > /sys/fs/cgroup/workload/pod-a/memory.reclaim
# 스왑 가능 옵션 (v6.7+)
echo "100M swappiness=0" > /sys/fs/cgroup/workload/pod-a/memory.reclaim
# swappiness=0: 파일 캐시만 회수 (swap out 금지)
# 활용 예: 프로액티브 회수 데몬
# PSI some avg10 > 20% 일 때 자동으로 memory.reclaim 트리거
while true; do
PSI=$(awk '/some/{print $2}' ${CGROUP}/memory.pressure | cut -d= -f2)
if (( $(echo "$PSI > 20" | bc -l) )); then
echo "50M" > ${CGROUP}/memory.reclaim
fi
sleep 5
done
- 프로액티브 메모리 관리: OOM kill 전에 미리 캐시를 정리하여 워킹셋 유지
- VM 라이브 마이그레이션: 마이그레이션 전에 dirty page를 줄여 다운타임 최소화
- 빈 패킹(bin packing): 유휴 컨테이너의 캐시를 회수하여 다른 컨테이너에 할당
- NUMA 리밸런싱: 특정 노드의 memcg 메모리를 회수하여 다른 노드에 재할당
Slab Charge 상세
커널 5.9+에서 memcg slab charge는 objcg(Object Cgroup) 기반으로 전환되었습니다. 이전에는 slab 페이지 단위로 charge했지만, 이제는 오브젝트 단위로 세밀하게 charge합니다.
objcg (Object Cgroup) 메커니즘
/* mm/slab.h -- objcg 기반 slab charge */
/*
* 기존 (v5.8 이전): slab 페이지 전체를 하나의 memcg에 charge
* - 한 slab 페이지에 여러 memcg의 오브젝트가 혼재되면
* 첫 번째 할당자의 memcg에만 charge -> 불공정
*
* 현재 (v5.9+): objcg 기반 per-object charge
* - 각 오브젝트의 크기만큼 해당 memcg에 정확히 charge
* - folio->memcg_data의 OBJCGS 비트로 objcg 배열 참조
*/
struct obj_cgroup {
struct percpu_ref refcnt; /* 참조 카운터 */
struct mem_cgroup *memcg; /* 소속 memcg */
atomic_t nr_charged_bytes; /* charge된 바이트 */
union {
struct list_head list;
struct rcu_head rcu;
};
};
/* slab 할당 시 memcg charge 흐름 */
/* kmem_cache_alloc(cache, GFP_KERNEL | __GFP_ACCOUNT)
* -> post_alloc_hook()
* -> memcg_slab_post_alloc_hook()
* -> obj_cgroup_charge(objcg, size)
* -> page_counter_try_charge(&memcg->kmem, ...)
*/
코드 설명
- obj_cgroup (v5.9+)기존에는 slab 페이지 전체를 첫 번째 할당자의 memcg에 charge하여 불공정한 과금이 발생했습니다.
obj_cgroup기반으로 전환하여 각 오브젝트 크기만큼 해당 memcg에 정확히 charge합니다.mm/slab.h에 정의되어 있습니다. - percpu_ref refcnt
obj_cgroup의 참조 카운터입니다. per-CPU 참조 카운팅으로 빈번한 slab 할당/해제 시 cacheline 경합을 최소화합니다. - nr_charged_bytes이
obj_cgroup을 통해 charge된 총 바이트 수를 추적합니다. 페이지 단위가 아닌 바이트 단위로 정밀한 커널 메모리 계정을 수행합니다. - slab charge 호출 체인
kmem_cache_alloc()에__GFP_ACCOUNT플래그가 설정되면memcg_slab_post_alloc_hook()이 호출되고,obj_cgroup_charge()를 거쳐page_counter_try_charge(&memcg->kmem, ...)로 kmem 카운터에 charge됩니다.
| __GFP_ACCOUNT가 설정된 주요 캐시 | 오브젝트 크기 | 영향 |
|---|---|---|
| dentry_cache (디렉토리 엔트리) | ~192 bytes | 파일 시스템 탐색 시 대량 생성 |
| inode_cache (아이노드(Inode)) | ~600 bytes | 파일 메타데이터 |
| signal_cache (시그널(Signal) 구조체) | ~1.2 KB | 프로세스 생성 시 |
| task_struct_cachep | ~6 KB | 프로세스당 1개 |
| vm_area_struct 캐시 | ~200 bytes | mmap당 1개 |
| radix_tree_node | ~576 bytes | 페이지 캐시(Page Cache) 인덱스 |
| bio/bio_vec 캐시 | 가변 | I/O 요청당 |
| sk_buff 캐시 | ~256 bytes | 네트워크 패킷(Packet)당 |
Watermark 기반 memcg 회수
memcg의 메모리 사용량이 특정 워터마크(Watermark)에 도달하면 다양한 회수 메커니즘이 트리거됩니다. 이 섹션에서는 각 트리거 지점과 회수 동작을 상세히 분석합니다.
Cgroup v2 위임 (Delegation)
cgroup v2는 비특권(unprivileged) 사용자에게 cgroup 서브트리의 관리를 위임할 수 있습니다. 이는 컨테이너 런타임이 rootless 모드에서 memcg를 관리하는 기반입니다.
# cgroup v2 위임 설정 (systemd 기반)
# systemd는 user@.service를 통해 자동으로 위임
systemctl show user@1000.service | grep Delegate
# Delegate=yes
# 수동 위임: 특정 cgroup의 소유권 변경
mkdir /sys/fs/cgroup/user.slice/user-1000.slice/workload
chown -R 1000:1000 /sys/fs/cgroup/user.slice/user-1000.slice/workload
# 위임받은 사용자는 서브트리 내에서:
# - cgroup 생성/삭제 가능
# - memory.max/high/low/min 설정 가능 (부모 제한 이내)
# - 프로세스 배치 가능
# - memory.stat/events/pressure 읽기 가능
# Rootless Podman 예시
podman --cgroup-manager=systemd run -d \
--memory 512m \
--name rootless-app \
nginx
# -> user cgroup 서브트리 내에 컨테이너 cgroup 생성
memory.min의 합이 부모의 memory.min을 초과하면 비례 배분됩니다. 따라서 한 사용자가 다른 사용자의 보호를 침해할 수 없습니다.
memcg 성능 오버헤드(Overhead)
memcg를 활성화하면 모든 페이지 할당/해제 경로에 charge/uncharge 로직이 추가됩니다. 이 오버헤드를 이해하고 최적화하는 것이 중요합니다.
오버헤드 원인
| 원인 | 영향 정도 | 최적화 |
|---|---|---|
| page_counter atomic 연산 | 중간 | per-CPU stock 캐시로 경감 |
| 계층적 charge (depth) | 깊이에 비례 | cgroup 트리 깊이 최소화 (3~4 레벨 권장) |
| per-memcg LRU 유지 | 낮음 | MGLRU로 스캔 효율 개선 |
| folio->memcg_data 접근 | 캐시라인 오염 | 커널이 자동 최적화 |
| zombie memcg 누적 | 메모리 낭비 | 주기적 cache drop, vfs_cache_pressure |
| stat 갱신 (per-CPU counters) | 낮음 | 배치 갱신 |
벤치마크 참고
| 워크로드 | memcg 비활성 | memcg 활성 | 오버헤드 |
|---|---|---|---|
| 페이지 폴트 지연 (단일) | ~1.2us | ~1.5us | ~25% |
| fork() 지연 | ~150us | ~170us | ~13% |
| 파일 I/O (4KB 순차) | 기준 | 기준+1~3% | 1~3% |
| 네트워크 처리량(Throughput) | 기준 | 기준-1~2% | 1~2% |
| Slab 할당 빈도 높은 워크로드 | 기준 | 기준+2~5% | 2~5% |
ftrace를 이용한 memcg 디버깅
# memcg 관련 tracepoint 확인
ls /sys/kernel/tracing/events/memcg/
# mem_cgroup_mark_inactive_chain
# mem_cgroup_try_charge
# ... (커널 버전별 상이)
# vmscan (회수) tracepoint로 memcg 회수 추적
echo 1 > /sys/kernel/tracing/events/vmscan/mm_vmscan_memcg_reclaim_begin/enable
echo 1 > /sys/kernel/tracing/events/vmscan/mm_vmscan_memcg_reclaim_end/enable
# OOM tracepoint
echo 1 > /sys/kernel/tracing/events/oom/mark_victim/enable
echo 1 > /sys/kernel/tracing/events/oom/oom_score_adj_update/enable
# 트레이스 확인
cat /sys/kernel/tracing/trace
# 특정 cgroup의 charge 이벤트만 필터링 (bpftrace)
bpftrace -e '
kprobe:try_charge_memcg {
@charges[comm] = count();
}
interval:s:5 {
print(@charges);
clear(@charges);
}
'
# memcg OOM 발생 시 커널 로그
dmesg | grep -A 20 "memory cgroup out of memory"
# Memory cgroup out of memory: Killed process 12345 (myapp)
# total_pgfault 1234567
# ...
# Memory cgroup stats for /workload/pod-a:
# anon 1073741824 file 536870912 kernel 67108864 ...
고급 활용 패턴
패턴 1: 계층적 메모리 등급제
# 3등급 메모리 관리: 보장 / 탄력 / 최선
mkdir -p /sys/fs/cgroup/tier-{guaranteed,burstable,besteffort}
# Guaranteed: 절대 보호, 제한 엄격
echo "+memory" > /sys/fs/cgroup/cgroup.subtree_control
echo "4G" > /sys/fs/cgroup/tier-guaranteed/memory.max
echo "4G" > /sys/fs/cgroup/tier-guaranteed/memory.min
# min=max: 정확히 4GB 보장 및 제한
# Burstable: 기본 보호 + 버스트 허용
echo "8G" > /sys/fs/cgroup/tier-burstable/memory.max
echo "6G" > /sys/fs/cgroup/tier-burstable/memory.high
echo "2G" > /sys/fs/cgroup/tier-burstable/memory.low
# 2GB 보호, 6GB까지 자유, 8GB까지 쓰로틀링 허용
# BestEffort: 보호 없음, 최대 제한만
echo "4G" > /sys/fs/cgroup/tier-besteffort/memory.max
# 보호 없이 4GB까지 사용 가능. 메모리 압박 시 가장 먼저 회수
패턴 2: 의도적 오버커밋
# 물리 메모리: 16GB
# 자식 cgroup 합: 32GB (오버커밋 200%)
# -> 실제 사용량이 16GB를 초과하지 않으면 정상 동작
echo "16G" > /sys/fs/cgroup/workload/memory.max # 부모: 물리 메모리
echo "+memory" > /sys/fs/cgroup/workload/cgroup.subtree_control
# 각 서비스에 넉넉한 개별 제한 (합계 > 부모)
for svc in app-{1..8}; do
mkdir /sys/fs/cgroup/workload/$svc
echo "4G" > /sys/fs/cgroup/workload/$svc/memory.max
echo "3G" > /sys/fs/cgroup/workload/$svc/memory.high
done
# 8 * 4G = 32G > 16G 부모 제한
# 부모의 memory.max가 전체 사용량을 16G로 제한하므로 안전
패턴 3: 버스(Bus)트 컨테이너
# 평상시 512MB, 피크 시 2GB까지 허용
echo "2G" > ${CGROUP}/memory.max # 절대 상한
echo "512M" > ${CGROUP}/memory.high # 평상시 제한
echo "256M" > ${CGROUP}/memory.low # 최소 보호
# 결과:
# - 256MB 이하: 절대 보호
# - 256MB~512MB: 정상 동작
# - 512MB~2GB: 쓰로틀링 (느려지지만 허용)
# - 2GB 초과: OOM kill
관련 sysctl 매개변수
| sysctl | 기본값 | memcg 영향 |
|---|---|---|
vm.swappiness | 60 | 글로벌 스왑 경향. memcg 회수 시에도 적용 (v2에서 per-memcg 불가) |
vm.vfs_cache_pressure | 100 | dentry/inode 회수 강도. 높으면 memcg slab charge 빠르게 해제 |
vm.min_free_kbytes | 자동 | 글로벌 watermark. memcg 회수와는 독립이지만 글로벌 회수에 영향 |
vm.overcommit_memory | 0 | memcg memory.max와 별도. 가상 메모리(Virtual Memory) 할당 정책 |
vm.dirty_ratio | 20 | 글로벌 dirty 비율. cgroup writeback의 기준선 |
vm.dirty_background_ratio | 10 | flusher 시작 기준. per-memcg dirty와 함께 체크 |
kernel.panic_on_oom | 0 | memcg OOM에는 미적용 (글로벌 OOM에만) |
cgroup v2 최신 기능 (v6.0+)
| 커널 버전 | 기능 | 설명 |
|---|---|---|
| v5.9 | objcg 기반 slab charge | 오브젝트 단위 정밀 charge |
| v5.18 | memory.reclaim (기본) | 유저스페이스 능동 회수 |
| v6.0 | memory.peak | 역대 최대 사용량 조회 |
| v6.1 | memory.reclaim (정식) | swappiness 옵션 추가 |
| v6.1 | 페이지 테이블 charge | pagetables를 memcg에 자동 계정 |
| v6.3 | memory.swap.high | 스왑 쓰로틀링 임계 |
| v6.5 | memory.zswap.max | per-memcg zswap 제한 |
| v6.7 | memory.reclaim swappiness | 회수 시 스왑 경향 지정 |
| v6.8 | memory.stat sec_pagetables | 보조 페이지 테이블 통계 |
| v6.9 | memory.high charge throttle | charge 경로에서 직접 sleep |
ls /sys/fs/cgroup/<cgroup>/memory.*로 사용 가능한 인터페이스 파일을 확인하세요.
존재하지 않는 파일은 해당 커널에서 미지원입니다.
v1 vs v2 내부 구현 차이
v1 soft_limit의 근본 문제
cgroup v1의 memory.soft_limit_in_bytes는 글로벌 회수 시에만 효과가 있었습니다. kswapd가 회수할 때 soft limit을 초과한 모든 memcg를 글로벌 트리에서 순회하며 초과분을 회수하는 방식이었는데, 이는 cgroup 수가 증가하면 O(n) 스캔 비용이 급증하는 확장성 문제를 가졌습니다.
/* cgroup v1의 soft_limit 회수 (mm/memcontrol.c -- 구버전) */
/*
* mem_cgroup_soft_reclaim() -- 삭제됨 (v2에서는 미사용)
*
* 문제 1: 글로벌 rb-tree에서 soft_limit 초과 memcg를 찾음
* -> 수천 개 cgroup에서 rb-tree 관리 비용 증가
*
* 문제 2: kswapd 경로에서만 호출
* -> 직접 회수(charge 경로)에서는 soft_limit 무시
* -> soft_limit 초과해도 즉시 효과 없음
*
* 문제 3: 회수량이 불예측적
* -> soft_limit 초과 memcg들 간 공정성 보장 안 됨
*
* v2의 memory.high가 이 모든 문제를 해결:
* -> 로컬 charge 경로에서 즉시 쓰로틀링 (O(1))
* -> 초과량에 비례하는 예측 가능한 지연
* -> kswapd 의존 없이 자체 백프레셔
*/
res_counter vs page_counter
| 특성 | v1 res_counter | v2 page_counter |
|---|---|---|
| 카운터 단위 | 바이트 | 페이지 (성능 최적화) |
| 동기화 | spinlock (전체 잠금(Lock)) | atomic_long_t (lock-free) |
| 배치 최적화 | 없음 | per-CPU stock 캐시 |
| 보호 메커니즘 | 없음 | min/low 계층적 보호 |
| 오버플로 체크 | 바이트 기반 오버플로 위험 | 페이지 기반 안전 |
마이그레이션 함정과 주의사항
함정 1: per-cgroup swappiness 제거
cgroup v1의 memory.swappiness는 각 cgroup의 swap 경향을 개별 제어했지만, v2에서는 제거되었습니다. 이는 의도적 설계입니다:
- 이유: per-cgroup swappiness는 글로벌 회수와 memcg 회수 간 불일치를 유발했습니다. swappiness=0인 cgroup에서 회수 시 파일 캐시만 회수하려 하지만, 파일 캐시가 부족하면 OOM kill로 이어집니다.
- 대안:
memory.swap.max=0으로 swap-out 자체를 금지하거나,memory.reclaim의swappiness옵션(v6.7+)을 사용하세요.
함정 2: OOM kill 비활성화 제거
v1의 memory.oom_control로 OOM kill을 비활성화하면, charge에 실패한 프로세스가 영원히 대기(sleep)하면서 cgroup 전체가 응답 불가(hung) 상태에 빠지는 문제가 빈번했습니다. v2에서는 이 기능을 완전히 제거하고 memory.oom.group으로 kill 단위만 제어합니다.
함정 3: No Internal Process 규칙
cgroup v2에서 프로세스는 리프 cgroup에만 배치할 수 있습니다. 내부 노드에 프로세스를 넣으면 에러가 발생합니다:
# 잘못된 구조 (v2에서 에러)
/sys/fs/cgroup/workload/
cgroup.procs # <- 여기에 PID 쓰면 에러!
child-a/
cgroup.procs # <- 프로세스는 여기에
child-b/
cgroup.procs # <- 또는 여기에
# 에러 메시지
echo $$ > /sys/fs/cgroup/workload/cgroup.procs
# -bash: echo: write error: Device or resource busy
# 해결: 프로세스를 리프 cgroup으로 이동
mkdir /sys/fs/cgroup/workload/default
echo $$ > /sys/fs/cgroup/workload/default/cgroup.procs
함정 4: memsw -> swap 변환 계산
# v1 설정
# memory.limit_in_bytes = 1G
# memory.memsw.limit_in_bytes = 3G
# -> 최대 swap 사용량 = 3G - 1G = 2G
# v2 변환
echo "1G" > memory.max # 메모리 제한
echo "2G" > memory.swap.max # 스왑 제한 (memsw - mem)
# 주의: v1에서 memsw = mem인 경우 -> v2에서 swap.max = 0
# v1에서 memsw 미설정 -> v2에서 swap.max = max (무제한)
실전 환경별 설정 예시
데이터베이스 (PostgreSQL/MySQL)
# PostgreSQL: 워킹셋(shared_buffers) 보호가 핵심
CGROUP=/sys/fs/cgroup/database/postgresql
# shared_buffers = 8GB 가정
echo "16G" > ${CGROUP}/memory.max # 전체 상한
echo "14G" > ${CGROUP}/memory.high # 쓰로틀 시작
echo "10G" > ${CGROUP}/memory.low # shared_buffers + 약간 여유
echo "8G" > ${CGROUP}/memory.min # shared_buffers 절대 보호
echo "0" > ${CGROUP}/memory.swap.max # DB는 swap 금지
echo "1" > ${CGROUP}/memory.oom.group # DB 프로세스 전체 종료
Java 애플리케이션 (JVM)
# JVM: -Xmx와 memcg memory.max 조화 필수
# JVM은 -XX:+UseContainerSupport로 cgroup 제한 인식
CGROUP=/sys/fs/cgroup/app/java-service
# Xmx=4G 가정 -> native memory(thread stacks, JNI 등) 고려
echo "6G" > ${CGROUP}/memory.max # Xmx + 2G (native overhead)
echo "5.5G" > ${CGROUP}/memory.high # GC 촉진
echo "2G" > ${CGROUP}/memory.low # 최소 힙 보호
echo "0" > ${CGROUP}/memory.swap.max # GC 성능을 위해 swap 금지
# JVM 옵션
# java -XX:+UseContainerSupport \
# -XX:MaxRAMPercentage=75.0 \ # memory.max의 75% = Xmx
# -XX:+UseG1GC \
# -jar myapp.jar
-XX:+UseContainerSupport(JDK 10+, 기본 활성)로 cgroup의 memory.max를 인식하여 힙 크기를 자동 조정합니다. 그러나 native memory(스레드(Thread) 스택, JNI, 코드 캐시, 메타스페이스)는 힙 외 추가 메모리를 사용하므로, memory.max는 -Xmx보다 최소 1.5~2배 크게 설정해야 합니다. 그렇지 않으면 native OOM이 발생합니다.
Redis / Memcached (인메모리 캐시)
# Redis: maxmemory와 memcg 조화
CGROUP=/sys/fs/cgroup/cache/redis
# Redis maxmemory = 4GB
echo "5G" > ${CGROUP}/memory.max # maxmemory + 1G (fork overhead)
echo "4.5G" > ${CGROUP}/memory.high # fork 경고 임계
echo "4G" > ${CGROUP}/memory.min # maxmemory 절대 보호
echo "0" > ${CGROUP}/memory.swap.max # swap 금지 (지연 방지)
# 주의: Redis BGSAVE(fork)는 COW로 최대 2배 메모리 사용
# memory.max는 maxmemory * 2 이상이 안전 (데이터 변경 많을 때)
빌드 시스템 (CI/CD)
# CI/CD: 버스트 허용, slab 주의
CGROUP=/sys/fs/cgroup/ci/build-job
echo "8G" > ${CGROUP}/memory.max # 빌드 최대 메모리
echo "6G" > ${CGROUP}/memory.high # 쓰로틀 허용 (느려도 OK)
# memory.low/min: 설정 안 함 (보호 불필요)
echo "2G" > ${CGROUP}/memory.swap.max # 약간의 swap 허용
# 빌드 완료 후 캐시 정리 (좀비 memcg 방지)
echo "1G" > ${CGROUP}/memory.reclaim # v6.1+
ML 훈련 워크로드
# ML 훈련: 대용량 데이터 로딩, GPU 메모리 별도
CGROUP=/sys/fs/cgroup/ml/training-job
echo "64G" > ${CGROUP}/memory.max # CPU 메모리만 (GPU VRAM 별도)
echo "56G" > ${CGROUP}/memory.high # 데이터 로더 쓰로틀
echo "32G" > ${CGROUP}/memory.low # 모델 파라미터 보호
echo "16G" > ${CGROUP}/memory.min # 최소 워킹셋
echo "32G" > ${CGROUP}/memory.swap.max # 데이터셋 오버플로 허용
# 데이터 프리로드 최적화: 훈련 전 메모리 워밍업
# vmtouch -t /data/training-set/ # 파일 캐시 프리로드
eBPF를 이용한 memcg 관찰성
eBPF/bpftrace를 사용하면 memcg 내부 동작을 실시간으로 관찰할 수 있습니다.
# bpftrace: memcg charge 실패 추적
bpftrace -e '
kretprobe:try_charge_memcg /retval != 0/ {
printf("charge FAIL: comm=%s pid=%d ret=%d\n",
comm, pid, retval);
@fails[comm] = count();
}
'
# bpftrace: memcg OOM 트리거 추적
bpftrace -e '
kprobe:mem_cgroup_oom {
printf("OOM: comm=%s pid=%d memcg=%p\n",
comm, pid, arg0);
@stack = kstack;
}
'
# bpftrace: memory.high 쓰로틀링 지연 측정
bpftrace -e '
kprobe:mem_cgroup_handle_over_high {
@start[tid] = nsecs;
}
kretprobe:mem_cgroup_handle_over_high /@start[tid]/ {
@delay_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}
'
# BCC: memcg 회수 효율 분석
# /usr/share/bcc/tools/funccount 'try_to_free_mem_cgroup_pages'
# Prometheus + cAdvisor 메트릭 예시
# container_memory_working_set_bytes{pod="myapp"}
# container_memory_rss{pod="myapp"}
# container_memory_cache{pod="myapp"}
# container_memory_failcnt{pod="myapp"}
# rate(container_memory_oom_events_total{pod="myapp"}[5m])
memory.stat, memory.events)은 폴링(Polling) 기반이라 초 단위 관찰에 적합합니다. 밀리초 단위 세밀한 관찰이 필요하면 eBPF/bpftrace를 사용하세요. 특히 charge 실패 빈도, 쓰로틀링 지연 시간 분포, 회수 효율 같은 내부 동작은 eBPF로만 정확히 측정할 수 있습니다.
부트 파라미터와 커널 명령줄
| 파라미터 | 기본값 | 설명 |
|---|---|---|
cgroup_no_v1=memory | 미설정 | v1 memory 컨트롤러 비활성화 (v2만 사용) |
systemd.unified_cgroup_hierarchy=1 | 시스템 의존 | 순수 cgroup v2 모드 강제 |
cgroup.memory=nokmem | 미설정 | 커널 메모리 계정 비활성화 (v4.x 하위 호환) |
swapaccount=0 | 1 | 스왑 계정 비활성화 |
cgroup_memory=on | on | memcg 컨트롤러 활성화/비활성화 |
psi=1 | 1 | PSI 활성화 (memcg PSI 전제) |
# GRUB에서 cgroup v2 순수 모드 설정
# /etc/default/grub
GRUB_CMDLINE_LINUX="systemd.unified_cgroup_hierarchy=1 cgroup_no_v1=all"
# update-grub && reboot
# 현재 cgroup 모드 확인
cat /proc/filesystems | grep cgroup
# nodev cgroup
# nodev cgroup2
mount | grep cgroup
# cgroup2 on /sys/fs/cgroup type cgroup2 (...) -> v2 모드
memcg 테스트 및 검증
# 기본 memcg 기능 테스트
# 1. cgroup 생성 및 제한 설정
mkdir /sys/fs/cgroup/test-memcg
echo "100M" > /sys/fs/cgroup/test-memcg/memory.max
echo "80M" > /sys/fs/cgroup/test-memcg/memory.high
# 2. 프로세스 배치 및 메모리 할당 테스트
echo $$ > /sys/fs/cgroup/test-memcg/cgroup.procs
stress-ng --vm 1 --vm-bytes 50M --timeout 10s
cat /sys/fs/cgroup/test-memcg/memory.current
cat /sys/fs/cgroup/test-memcg/memory.events
# 3. OOM 테스트 (주의: 프로세스가 kill됨)
stress-ng --vm 1 --vm-bytes 200M --timeout 10s
# -> OOM kill 발생
cat /sys/fs/cgroup/test-memcg/memory.events | grep oom_kill
# 4. memory.high 쓰로틀링 테스트
stress-ng --vm 1 --vm-bytes 90M --timeout 10s
cat /sys/fs/cgroup/test-memcg/memory.events | grep high
# 5. PSI 확인
cat /sys/fs/cgroup/test-memcg/memory.pressure
# 6. 정리
echo $$ > /sys/fs/cgroup/cgroup.procs
rmdir /sys/fs/cgroup/test-memcg
# 커널 selftests: memcg 자동화 테스트
cd /usr/src/linux/tools/testing/selftests/cgroup
make
./test_memcontrol
memcg 테스트 도구
| 도구 | 용도 | 설치 |
|---|---|---|
stress-ng | 메모리 부하 생성 (--vm, --vm-bytes) | apt install stress-ng |
memhog | 지정된 크기의 메모리 할당 유지 | apt install numactl |
cgroup-tools | cgcreate, cgexec, cgset | apt install cgroup-tools |
tools/testing/selftests/cgroup/ | 커널 공식 memcg 테스트 | 커널 소스 트리 |
ltp (Linux Test Project) | memcg 통합 테스트 슈트 | github.com/linux-test-project/ltp |
자주 발생하는 문제와 해결
문제 1: 예상치 못한 OOM Kill
| 원인 | 진단 | 해결 |
|---|---|---|
| 커널 메모리(slab) 과다 | memory.stat의 slab 확인 | memory.max 증가 또는 vfs_cache_pressure 증가 |
| tmpfs/shmem 사용 | memory.stat의 shmem 확인 | /dev/shm 크기 제한, tmpfs noswap |
| 페이지 테이블 증가 | memory.stat의 pagetables 확인 | 많은 VMA 매핑 제한, THP 활용 |
| 소켓 버퍼 | memory.stat의 sock 확인 | net.core.rmem_max/wmem_max 조정 |
| 부모 cgroup 제한 도달 | 부모의 memory.events 확인 | 부모 memory.max 증가 |
문제 2: 좀비 memcg 누적
# 진단: cgroup 수 추적
while true; do
awk '/memory/{print $4}' /proc/cgroups
sleep 60
done
# 해결: 정기적 캐시 드롭
echo 3 > /proc/sys/vm/drop_caches
# 장기 해결: vfs_cache_pressure 증가
echo 200 > /proc/sys/vm/vfs_cache_pressure
문제 3: 과도한 쓰로틀링
# 진단: high 이벤트 빈도
watch -n1 "cat ${CGROUP}/memory.events | grep high"
# 해결: memory.high 조정 또는 제거
echo "max" > ${CGROUP}/memory.high # 쓰로틀링 비활성화
문제 4: 페이지 테이블 메모리 과다
# 진단: 페이지 테이블 사용량 확인
awk '/^pagetables /{printf "PageTables: %dMB\n", $2/1048576}' ${CGROUP}/memory.stat
# 원인: 수천 개의 VMA (mmap 과다), 프로세스 수 많음
# 진단: 프로세스별 VMA 수 확인
for pid in $(cat ${CGROUP}/cgroup.procs); do
vma=$(wc -l < /proc/$pid/maps 2>/dev/null || echo 0)
echo "$pid $vma VMAs"
done | sort -k2 -n -r | head -5
# 해결: THP 활성화 (512 PTE -> 1 PMD), vm.max_map_count 조정
echo "always" > /sys/kernel/mm/transparent_hugepage/enabled
# 또는 memory.max 증가 (페이지 테이블도 memory.max에 포함)
문제 5: 소켓 버퍼 메모리 누적
# 진단: 소켓 메모리 확인
awk '/^sock /{printf "Socket buffers: %dMB\n", $2/1048576}' ${CGROUP}/memory.stat
# 원인: 대량 TCP 연결, 큰 수신/송신 버퍼
# 해결: 소켓 버퍼 크기 제한
sysctl -w net.core.rmem_max=1048576
sysctl -w net.core.wmem_max=1048576
sysctl -w net.ipv4.tcp_rmem="4096 87380 1048576"
sysctl -w net.ipv4.tcp_wmem="4096 65536 1048576"
참고자료
커널 문서
- cgroup v2 memory 컨트롤러 — Linux Kernel Documentation
- 메모리 관리 개념 — Linux Kernel Documentation
- memcg v1 문서 — Linux Kernel Documentation
- cgroups(7) — Linux man page
LWN 기사
- LWN: Memcg kernel memory accounting (2013) — 커널 메모리 계정 메커니즘을 다룹니다
- LWN: Memory cgroup soft limits (2012) — memcg 소프트 한도 동작 방식을 다룹니다
- LWN: Memory.high for cgroup v2 (2019) — cgroup v2의 memory.high 인터페이스를 다룹니다
- LWN: Zswap and cgroup v2 (2023) — zswap과 cgroup v2 통합을 다룹니다
커널 소스
- mm/memcontrol.c — memcg 핵심 구현 소스입니다
- include/linux/memcontrol.h — memcg API 헤더입니다
- mm/page_counter.c — 페이지 카운터 구현 소스입니다