메모리 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) 기반으로 분석합니다.

전제 조건: 메모리 관리(Memory Management) 개요OOM Killer 문서를 먼저 읽으세요. Memory Cgroup은 커널 메모리 관리 위에서 프로세스(Process) 그룹별 자원 제한을 수행하므로 기본 메모리 구조를 이해해야 합니다.
일상 비유: Memory Cgroup은 아파트 단지의 세대별 수도 계량기와 비슷합니다. 전체 수도(물리 메모리(Physical Memory))는 단지가 공유하지만, 각 세대(cgroup)에 계량기(charge)를 설치하여 사용량을 추적하고, 과도한 사용(memory.max) 시 수압을 낮추거나(throttle) 차단(OOM kill)합니다. memory.low/min은 "최소 보장 수량"처럼 회수 대상에서 보호합니다.

핵심 요약

  • 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 전체를 단위로 종료합니다.

단계별 이해

  1. cgroup v2 기본 구조 이해
    cgroup v2 파일시스템(Filesystem)의 계층 구조와 메모리 컨트롤러 활성화 방법을 파악합니다.
  2. 4단계 제한 인터페이스 학습
    memory.min/low/high/max 각각의 의미와 상호작용을 이해합니다.
  3. Charge/Uncharge 흐름 추적
    페이지 폴트 → mem_cgroup_charge() → 제한 체크 → 회수/OOM 경로를 따라갑니다.
  4. 모니터링 인터페이스 활용
    memory.stat, memory.events, memory.pressure로 상태를 진단합니다.
  5. 컨테이너 런타임 연동
    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_bytesmemory.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_bytesmemory.swap.max (독립 제어)
OOM 제어memory.oom_control (비활성화 가능)memory.oom.group (그룹 단위 OOM)
이벤트 알림eventfd (memory.oom_control)memory.events + inotify/poll
NUMA 통계memory.numa_statmemory.numa_stat (개선)
PSI 지원없음memory.pressure (per-cgroup PSI)
프로세스 소속다중 계층 가능프로세스는 리프 cgroup에만 소속
v1 폐기 경로: cgroup v1 memory 컨트롤러는 커널 6.x에서 유지보수 모드에 진입했습니다. 새 기능은 v2에만 추가되며, memory.soft_limit_in_bytes는 알려진 성능 문제로 사용이 권장되지 않습니다. 모든 주요 컨테이너 런타임(containerd, CRI-O)은 cgroup v2를 기본으로 사용합니다.

커널 소스 위치

파일역할주요 함수/구조체(Struct)
mm/memcontrol.cmemcg 핵심 로직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.hSlab chargememcg_slab_post_alloc_hook()
mm/page_counter.c계층적 카운터page_counter_try_charge(), page_counter_uncharge()
kernel/cgroup/cgroup.ccgroup 프레임워크cgroup_add_dfl_cftypes()

cgroup v2 계층 구조

cgroup v2에서 메모리 컨트롤러는 단일 통합 계층(unified hierarchy)에서 동작합니다. 모든 cgroup은 하나의 트리를 공유하며, 메모리 제한은 부모에서 자식으로 자동 전파됩니다.

cgroup v2 메모리 계층 구조 / (root cgroup) memory.max = max (무제한) system.slice memory.max = 4G memory.low = 1G (보호) user.slice memory.max = 8G memory.high = 6G (쓰로틀) workload memory.max = 16G memory.min = 2G (절대 보호) sshd.service memory.max = 256M nginx.service memory.max = 2G pod-a memory.max = 4G pod-b memory.max = 8G 유효 제한 (Effective Limit) 계산 nginx.service 유효 max = min(256M[자체], 4G[system.slice], max[root]) = 256M pod-a 유효 max = min(4G[자체], 16G[workload], max[root]) = 4G page_counter 계층적 과금 nginx가 100MB charge -> system.slice에도 100MB 전파 -> root에도 전파 자식의 사용량은 항상 부모의 사용량에 포함됩니다 page_counter_try_charge()가 root까지 역순으로 체크하며 하나라도 실패하면 전체 실패 mm/page_counter.c: page_counter_try_charge() -> 부모 chain을 거슬러 올라가며 각 레벨의 max와 비교
cgroup v2 메모리 계층 구조. 제한은 부모에서 자식으로 전파되며, 유효 제한은 경로 상 최솟값입니다.

계층 규칙

# 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) 상황에서 세밀한 정책 표현을 가능하게 합니다.

memcg 4단계 제한 모델 사용량 memory.min 절대 보호 영역: 이 범위의 메모리는 절대 회수하지 않음 글로벌 메모리 압박에서도 보호. 자식의 합이 부모 min을 초과하면 비례 배분 용도: 워크로드에 필수적인 최소 메모리 보장 (K8s requests 매핑) memory.low 최선 보호 (best-effort): 다른 cgroup에서 회수할 메모리가 없을 때만 회수 memory.low 이하 사용량은 회수 우선순위가 매우 낮음 용도: 중요 서비스의 캐시 보호, 워킹셋 유지 memory.high 쓰로틀링 영역: 초과 시 할당 경로에서 직접 회수를 강제 프로세스가 느려지지만 OOM kill되지 않음. 자연스러운 백프레셔 memory.high 초과 시 memory.events의 'high' 카운터 증가 용도: 버스트 트래픽 허용하면서 점진적 제어 (memory.max 도달 방지) v6.9+: memory.high 초과 시 MEMCG_CHARGE_THROTTLE로 charge 경로에서 sleep memory.max 경성 제한 (hard limit): 초과 불가. 초과 시도 시 OOM kill 발생 try_charge_memcg() -> page_counter_try_charge() 실패 -> 직접 회수 -> OOM memory.events의 'max'/'oom'/'oom_kill' 카운터 증가 용도: 절대적 메모리 상한 (K8s limits, Docker --memory 매핑) min low high max OOM
memory.min(절대 보호) < memory.low(최선 보호) < memory.high(쓰로틀링) < memory.max(경성 제한) 4단계 워터폴 모델

각 인터페이스 상세

파일기본값의미초과 시 동작
memory.min0절대 보호 하한글로벌 회수에서도 이 양까지 보호
memory.low0최선 보호 하한다른 회수 대상이 없을 때만 회수
memory.highmax쓰로틀링 임계할당 시 직접 회수 강제 (느려짐)
memory.maxmax경성 상한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 초과 여부를 체크하여 쓰로틀링 이벤트를 발생시킵니다.

memory.high vs memory.max 전략: memory.high를 memory.max의 80~90%로 설정하면, OOM kill 없이 자연스러운 백프레셔(throttling)로 메모리 사용을 제어할 수 있습니다. 이는 컨테이너 환경에서 갑작스러운 OOM kill을 방지하는 권장 패턴입니다.

Charge/Uncharge 메커니즘

Memory Cgroup의 핵심 동작은 charge(과금)uncharge(환불)입니다. 페이지가 할당될 때 해당 memcg에 사용량을 기록하고, 해제될 때 차감합니다.

memcg Charge 흐름 (페이지 폴트 경로) Page Fault (handle_mm_fault) folio = folio_alloc() (물리 페이지 할당) mem_cgroup_charge(folio, mm, gfp) memcg = get_mem_cgroup_from_mm(mm) try_charge_memcg(memcg, gfp, nr_pages) 성공 실패 commit_charge(folio, memcg) folio->memcg_data = memcg (소유권 기록) 페이지 폴트 처리 완료 try_to_free_mem_cgroup_pages() 회수 성공? YES -> 재시도 / NO -> OOM mem_cgroup_oom() -> OOM kill
페이지 폴트 시 memcg charge 흐름. 성공 시 folio->memcg_data에 소유권을 기록하고, 실패 시 회수 후 재시도 또는 OOM kill

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+ 기본 활성
vmallocmemcg_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_datamem_cgroup 포인터를 직접 저장하여 해당 folio의 소유 memcg를 기록합니다. memcg_data의 하위 비트는 KMEM, OBJCGS 등의 플래그로 사용되므로 포인터는 정렬된 상위 비트에 저장됩니다. 이 함수는 mm/memcontrol.cmem_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까지 각 레벨의 usagefolio_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의 메모리만 회수합니다.

memcg 회수 경로 (try_to_free_mem_cgroup_pages) memory.max 초과 (charge 실패) try_to_free_mem_cgroup_pages(memcg, nr_pages, gfp) shrink_node_memcgs(pgdat, sc, target_memcg) shrink_lruvec(lruvec) -- per-memcg LRU 스캔 익명 페이지 회수 LRU_INACTIVE_ANON 스캔 -> swap out (memory.swap.max 이내) 파일 캐시 회수 LRU_INACTIVE_FILE 스캔 -> clean drop / dirty writeback memory.low / memory.min 보호 체크 mem_cgroup_protection(): 사용량 < memory.min -> MEMCG_PROT_MIN (회수 금지) 사용량 < memory.low -> MEMCG_PROT_LOW (다른 회수 대상 우선)
memcg charge 실패 시 per-memcg LRU를 스캔하여 회수. memory.min/low 보호 체크가 회수 우선순위(Priority)를 결정합니다.

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.cshrink_lruvec()에서 이 값에 따라 스캔 비율을 조절합니다.
MGLRU와 memcg: Multi-Gen LRU(MGLRU, v6.1+)는 per-memcg lruvec에서도 세대(generation) 기반 회수를 수행합니다. lru_gen_folio 구조체가 각 memcg의 세대별 folio 리스트를 관리하며, evict_folios()가 가장 오래된 세대부터 회수합니다. 이는 기존 LRU 스캔 대비 memcg 회수 효율을 크게 개선합니다.

memcg OOM 처리

memcg의 memory.max 초과 시, 글로벌 OOM과 별도로 cgroup-scoped OOM kill이 발생합니다. 이 OOM은 해당 memcg 내의 프로세스만 대상으로 합니다.

memcg OOM 결정 트리 mem_cgroup_oom(memcg, gfp, order) memory.oom.group == 1? YES 그룹 OOM Kill cgroup 내 모든 프로세스에 SIGKILL memory.events: oom_group_kill++ NO 프로세스별 oom_badness() 평가 최고 점수 프로세스 SIGKILL memcg 내부만 스캔 (다른 cgroup 불영향) 계층적 OOM (Hierarchical OOM) 자식 memcg에서 charge 실패 시, 부모 memcg의 max를 초과한 것이면 부모 범위에서 OOM 발생 가장 가까운 조상의 memory.oom.group=1이 설정된 cgroup 전체가 kill 단위 memory.oom.group 미설정 시 해당 범위 내 개별 프로세스 OOM kill 글로벌 OOM vs memcg OOM 차이 글로벌: 시스템 전체 메모리 부족 -> 전체 프로세스 대상 oom_badness() memcg: 특정 cgroup의 memory.max 초과 -> 해당 cgroup 범위 내에서만 oom_badness()
memcg OOM 결정 트리. memory.oom.group=1이면 전체 cgroup kill, 아니면 개별 프로세스 kill
# 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
cgroup v1과의 차이: v1의 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, pgstealpgsteal/pgscan 비율이 낮으면 회수 비효율 -- 워킹셋이 큼
폴트pgfault, pgmajfaultpgmajfault 높으면 디스크 I/O 대기 -- swap이나 파일 읽기
THPanon_thp, thp_fault_allocTHP 비율로 대형 페이지 활용도 파악

memory.events 파일

memory.events는 memcg에서 발생한 중요 이벤트의 누적 카운터를 제공합니다. inotifypoll()로 모니터링하여 실시간(Real-time) 알림을 받을 수 있습니다.

memory.events 이벤트 트리거와 대응 전략 사용량↑ min low high max OOM low: 보호 영역 침범 회수 → memory.low 증가 또는 다른 cgroup 조정 high: 쓰로틀링 발생 → 빈번하면 memory.high↑ 또는 최적화 max: 경성 제한 도달 → 직접 회수 강제. OOM 직전 경고 oom / oom_kill / oom_group_kill → 즉시 원인 분석 (memory.stat 확인) → memory.max 증가 또는 워크로드 분산 모니터링 방법 inotify (파일 변경 감지) inotifywait -m memory.events 카운터 변경 시 즉시 알림 poll() / epoll (커널 이벤트) POLLPRI 이벤트 대기 프로그래밍 방식 실시간 모니터링 memory.events.local 자식 cgroup 이벤트 제외 자신만의 이벤트만 추적 memory.events: 자식 포함 누적 memory.events.local: 자신만
memory.events의 6가지 이벤트 카운터와 트리거 조건. inotify 또는 poll()로 실시간 모니터링합니다.
이벤트트리거 조건대응 전략
lowmemory.low 이하 사용 중 회수 발생memory.low 값 증가 또는 다른 cgroup 제한 조정
highmemory.high 초과로 쓰로틀링 발생빈번하면 memory.high 증가 또는 워크로드 최적화
maxmemory.max 도달로 직접 회수 강제OOM 직전 -- 즉시 대응 필요
oomOOM 조건 발생 (kill 전)memory.max 증가 또는 워크로드 분산
oom_kill실제 OOM kill 실행원인 분석 (memory.stat 확인)
oom_group_killmemory.oom.group에 의한 그룹 killmemory.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에서는 메모리와 스왑 제한이 분리되어 있습니다.

cgroup v1 vs v2 Swap 제어 모델 cgroup v1 (혼란스러운 모델) limit_in_bytes 메모리 1GB memsw.limit 메모리 스왑 2GB (합산) 스왑 가능량 = memsw - mem = 2GB - 1GB = 1GB 직관적이지 않음! "memsw가 2G인데 왜 1G밖에?" cgroup v2 (명확한 분리) memory.max 메모리만 1GB swap.max 스왑만 1GB 메모리 최대 1GB 스왑 최대 1GB (독립) 명확하고 직관적! page_counter: memory, swap 별도 카운터
v1의 memsw는 메모리+스왑 합산으로 혼란스럽지만, v2는 memory.max와 memory.swap.max를 완전히 분리하여 명확합니다.
파일설명기본값
memory.swap.max최대 스왑 사용량max (무제한)
memory.swap.current현재 스왑 사용량 (읽기전용)--
memory.swap.high스왑 쓰로틀링 (v6.3+)max
memory.swap.peak역대 최대 스왑 사용량--
memory.swap.events스왑 이벤트 카운터--
memory.zswap.currentzswap 사용량--
memory.zswap.maxzswap 최대 사용량 (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
K8s와 Swap: Kubernetes v1.28+에서 cgroup v2 swap 지원이 Beta로 도입되었습니다. 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.maxzswap 최대 사용량v6.5+
memory.stat: zswapzswap 사용량 (stat에 포함)v6.1+
memory.stat: zswappedzswap 압축 전 원본 크기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는 메모리 부족이 워크로드 성능에 미치는 영향을 정량적으로 측정합니다.

per-memcg PSI (Pressure Stall Information) PSI 압박 수준 some cgroup 내 일부 태스크가 메모리 대기 (%) 직접 회수, compaction, swap-in 등 full cgroup 내 모든 태스크가 메모리 대기 (%) 워크로드 완전 정지 상태 (심각) avg10 / avg60 / avg300 / total (이동 평균) psi_memstall_enter/leave()가 stall 시간 기록 임계값 가이드 some avg10 0-10% 10-25% >25% 심각 full avg10 0-5% 5-10% >10% 심각 정상: some < 5%, full < 1% 경고: some 10-25%, full 5-10% 심각: some > 25%, full > 10% → memory.max 증가 또는 워크로드 최적화 PSI 활용 에코시스템 PSI 트리거 write(fd, "full 50000 1000000") → poll() 대기 systemd-oomd PSI 임계 초과 시 선제적 cgroup kill 커스텀 모니터링 PSI some > 20% 시 memory.reclaim 트리거 epoll/poll 이벤트 → 자동 스케일링 SIGKILL 전송 → OOM 방지 선제적 회수 → 캐시 정리 글로벌 PSI: /proc/pressure/memory | per-cgroup PSI: memory.pressure (cgroup v2 전용)
per-memcg PSI: some(일부 태스크 대기)과 full(전체 대기) 수준으로 메모리 압박을 정량화합니다. systemd-oomd, 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);  /* 트리거 발생 시 반환 */
systemd-oomd와 PSI: systemd-oomd는 cgroup PSI를 기반으로 선제적 OOM kill을 수행합니다. memory.pressure의 일정 임계값을 초과하면 OOM kill보다 먼저 가장 많은 메모리를 사용하는 cgroup을 종료합니다. 이는 커널 OOM Killer보다 빠르고 정교한 대응이 가능합니다.

커널 메모리 계정

cgroup v2에서 커널 메모리(kmem)는 memory.max에 통합 계정됩니다. Slab, vmalloc, 커널 스택, 페이지 테이블, 소켓 버퍼 등 커널이 프로세스를 대신하여 사용하는 메모리가 해당 memcg에 과금됩니다.

커널 메모리 (kmem) 계정 구조 memory.max (통합 제한) user + kernel 합산 사용자 메모리 memory.stat: anon + file + shmem mem_cgroup_charge() 경로 커널 메모리 (kmem) memory.stat: kernel = slab + 스택 + PT + ... page_counter: memcg->kmem 카운터 Slab dentry inode task 커널 스택 fork() Page Table v6.1+ 소켓 버퍼 TCP/UDP vmalloc percpu ACCOUNT 커널 메모리 Charge 조건과 영향 __GFP_ACCOUNT 플래그: Slab, vmalloc 할당 시 이 플래그가 있으면 현재 태스크의 memcg에 charge 자동 charge: 커널 스택(fork), 페이지 테이블(pte_alloc), 소켓 버퍼(tcp_sendmsg) → 별도 플래그 불필요 주의: dentry/inode cache 폭증 → memory.max 도달 가능 (빌드 시스템, find 등) 모니터링: memory.stat의 kernel, slab, pagetables, sock, vmalloc, kernel_stack 필드
cgroup v2에서 커널 메모리는 memory.max에 통합 계정됩니다. Slab, 커널 스택, 페이지 테이블, 소켓 버퍼, vmalloc이 주요 charge 대상입니다.
커널 메모리 유형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 캐시 */
커널 메모리와 OOM: 커널 메모리(slab, pagetables)는 memory.max에 포함되므로, 수많은 파일을 여는 워크로드(예: 빌드 시스템(Build System))는 dentry/inode cache만으로 memory.max에 도달할 수 있습니다. memory.statslab 값을 모니터링하세요.

memcg LRU 리스트

각 memcg는 노드별로 독립된 lruvec 구조체를 유지합니다. 이 per-memcg LRU는 해당 cgroup에 속한 folio만 관리하며, memcg 회수 시 이 리스트를 스캔합니다.

per-memcg lruvec 구조 pg_data_t (Node 0) node lruvec (글로벌) LRU_INACTIVE_ANON LRU_ACTIVE_ANON LRU_INACTIVE_FILE LRU_ACTIVE_FILE LRU_UNEVICTABLE (모든 memcg의 합계) memcg A lruvec LRU_INACTIVE_ANON: 5000 LRU_ACTIVE_ANON: 12000 LRU_INACTIVE_FILE: 3000 LRU_ACTIVE_FILE: 8000 LRU_UNEVICTABLE: 100 (memcg A 소속 folio만) memcg B lruvec LRU_INACTIVE_ANON: 2000 LRU_ACTIVE_ANON: 6000 LRU_INACTIVE_FILE: 1500 LRU_ACTIVE_FILE: 4000 LRU_UNEVICTABLE: 50 (memcg B 소속 folio만) 자료구조 관계 mem_cgroup -> mem_cgroup_per_node[nid] -> lruvec (per-memcg LRU) folio->memcg_data -> 어떤 memcg에 속하는지 (charge 시 설정) folio_lruvec(folio) -> folio가 속한 memcg의 lruvec 반환 회수 시: shrink_lruvec(target_memcg의 lruvec)만 스캔 -> 다른 memcg 불영향 MGLRU: lru_gen_folio per-memcg -> 세대 기반 회수로 스캔 효율 대폭 개선
각 memcg는 노드별 독립 lruvec을 유지합니다. 회수 시 해당 memcg의 lruvec만 스캔하여 격리(Isolation)를 보장합니다.
/* 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만 디스크에 기록합니다.

per-memcg Cgroup Writeback 아키텍처 memcg A (컨테이너 A) Dirty Pages file_dirty: 100MB file_writeback: 20MB bdi_writeback A memcg = A b_dirty: inode list dirty 비율 = dirty / reclaimable 비율 초과 → A만 throttle balance_dirty_pages() inode->i_wb = bdi_writeback A (첫 dirty 시 연관) memcg B (컨테이너 B) Dirty Pages file_dirty: 10MB file_writeback: 2MB bdi_writeback B memcg = B b_dirty: inode list dirty 비율 정상 → B는 throttle 없음 (격리!) inode_switch_wbs(): 주기적으로 memcg 재평가 per-bdi Flusher Thread wb_workfn() → writeback_sb_inodes() → per-wb dirty inode 처리 memcg A의 wb → A의 dirty inode만 기록 | memcg B의 wb → B만 기록 Block Device (디스크) cgroup writeback 지원 파일시스템 ext4 (v4.2+) | btrfs (v4.3+) | xfs (v5.15+) | f2fs (v5.18+) 미지원: NFS, CIFS → root cgroup wb로 fallback (격리 없음)
per-memcg writeback: 각 memcg는 독립된 bdi_writeback을 통해 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 기반)
cgroup writeback 미지원 시: 파일시스템이 cgroup writeback을 지원하지 않으면 모든 dirty page가 root cgroup의 writeback으로 처리됩니다. 이 경우 한 컨테이너의 대량 dirty page가 다른 컨테이너의 I/O 성능에 영향을 줄 수 있습니다. NFS 마운트(Mount)를 사용하는 컨테이너에서는 이 점을 고려하세요.

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 불영향)
 */
I/O 집약 컨테이너 격리: cgroup writeback을 사용하면 한 컨테이너가 대량의 dirty page를 생성해도 해당 컨테이너만 writeback throttle을 받습니다. 이 격리를 위해 ext4/btrfs/xfs 파일시스템을 사용하고, blkio 컨트롤러도 함께 설정하면 I/O 대역폭(Bandwidth)까지 격리할 수 있습니다.

THP와 memcg

Transparent Huge Pages(THP)와 memcg의 상호작용은 charge 단위와 관련됩니다. THP folio(2MB)가 할당되면 512페이지 단위로 한 번에 charge됩니다.

시나리오Charge 단위memory.stat 반영
THP 할당 성공512 pages (2MB) 일괄 chargeanon_thp += 2MB
THP fallback (4KB)1 page 개별 chargeanon += 4KB
THP split512 -> 512개 개별 folioanon_thp -= 2MB, anon += 2MB
THP collapse512개 -> 1개 THPanon -= 2MB, anon_thp += 2MB
memcg 간 THP 이동전체 512페이지 단위원본 uncharge, 대상 charge
THP와 memory.max: THP 할당은 2MB 단위이므로, memory.max에 가까운 상태에서 THP 할당이 실패하면 4KB fallback이 발생합니다. memory.statthp_fault_alloc 대비 thp_fault_fallback 비율을 모니터링하세요. THP fallback이 빈번하면 memory.max 여유를 확보하거나 THP를 비활성화하는 것을 고려하세요.

Kubernetes 연동

Kubernetes는 Pod의 resources.requestsresources.limits를 cgroup v2 메모리 인터페이스에 매핑합니다.

Kubernetes -> cgroup v2 메모리 매핑 Kubernetes Pod Spec resources.requests.memory 예: 512Mi (최소 보장) resources.limits.memory 예: 1Gi (최대 상한) QoS Class Guaranteed / Burstable / BestEffort QoS -> oom_score_adj 매핑 Guaranteed: -997 (거의 면제) Burstable: 2~999 (requests 비율) BestEffort: 1000 (최우선 kill) kubelet이 /proc/PID/oom_score_adj에 설정 cgroup v2 메모리 memory.min = 512M (requests -> memory.min 매핑) memory.max = 1G (limits -> memory.max 매핑) kubelet MemoryQoS (v1.22+) MemoryQoS feature gate 활성화 시: requests -> memory.min (절대 보호) limits -> memory.max (경성 제한) limits * 0.9 -> memory.high (쓰로틀링) 미설정 시: requests -> memory.low (기존 동작) cgroup v2 전용 (v1에서는 미지원)
Kubernetes Pod 리소스 설정이 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)
Pod 메모리 상태 확인: kubectl top podmemory.current를 읽고, kubectl describe pod의 "Last State: OOMKilled"는 memory.eventsoom_kill 카운터에 대응합니다. 보다 상세한 분석은 노드에서 cat /sys/fs/cgroup/kubepods.slice/.../memory.stat을 직접 확인하세요.

Docker/containerd 연동

Docker와 containerd는 컨테이너 생성 시 cgroup 메모리 인터페이스를 직접 설정합니다.

Docker → containerd → cgroup v2 메모리 매핑 docker run --memory 1g --memory-reservation 512m --memory-swap 2g --oom-kill-disable (v2 미지원 옵션들) containerd/runc OCI spec 생성 cgroup v2 감지 옵션 변환 v1 전용 옵션 무시/경고 cgroup v2 memory.max = 1073741824 memory.low = 536870912 memory.swap.max = 1073741824 swap = --memory-swap - --memory cgroup 경로 /sys/fs/cgroup/system.slice/docker-{CONTAINER_ID}.scope/memory.{max,low,swap.max,...} containerd: /sys/fs/cgroup/system.slice/containerd-{ID}.scope/ | CRI-O: /sys/fs/cgroup/kubepods/... cgroup v2에서 무시되는 Docker 옵션 --oom-kill-disable (v2에서 OOM kill 비활성화 불가) | --memory-swappiness (per-cgroup 미지원) --kernel-memory (v2에서 memory.max에 통합) | v2에서는 memory.oom.group으로 그룹 kill만 제어
Docker의 메모리 옵션이 containerd/runc를 거쳐 cgroup v2 인터페이스에 매핑됩니다. 일부 v1 전용 옵션은 v2에서 무시됩니다.
Docker 옵션cgroup v2 파일설명
--memory 1gmemory.max = 1073741824경성 메모리 제한
--memory-reservation 512mmemory.low = 536870912소프트 메모리 보호
--memory-swap 2gmemory.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 메모리 매핑 systemd Unit 설정 MemoryMin=128M MemoryLow=256M MemoryHigh=800M MemoryMax=1G MemorySwapMax=512M MemoryZSwapMax=256M ManagedOOMMemoryPressure=kill ManagedOOMMemoryPressureLimit=80% cgroup v2 파일 memory.min = 134217728 memory.low = 268435456 memory.high = 838860800 memory.max = 1073741824 memory.swap.max = 536870912 memory.zswap.max = 268435456 systemd-oomd 데몬 memory.pressure PSI 모니터링
systemd의 메모리 지시어가 cgroup v2 인터페이스 파일에 1:1로 매핑됩니다. ManagedOOM은 systemd-oomd 데몬이 처리합니다.
systemd 지시어cgroup v2 파일설명
MemoryMax=1Gmemory.max경성 상한
MemoryHigh=800Mmemory.high쓰로틀링 임계
MemoryLow=256Mmemory.low최선 보호
MemoryMin=128Mmemory.min절대 보호
MemorySwapMax=512Mmemory.swap.max스왑 상한
MemoryZSwapMax=256Mmemory.zswap.maxzswap 상한
ManagedOOMMemoryPressure=killsystemd-oomd PSIPSI 기반 선제적 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)
systemd-oomd vs earlyoom: systemd-oomd는 PSI 기반으로 메모리 압박을 측정하여 선제적 kill을 수행합니다. earlyoom은 전체 시스템의 MemAvailable을 모니터링합니다. systemd 환경에서는 systemd-oomd가 cgroup 통합이 더 자연스럽고, earlyoom은 cgroup을 사용하지 않는 전통적 환경에 적합합니다.

NUMA와 memcg

memory.numa_stat은 memcg의 메모리 사용량을 NUMA 노드별로 분류하여 보여줍니다.

NUMA 노드별 per-memcg LRU 구조 struct mem_cgroup (memcg A) nodeinfo[0] nodeinfo[1] NUMA Node 0 per_node[0].lruvec INACTIVE_ANON: 2000 ACTIVE_ANON: 5000 INACTIVE_FILE: 1500 ACTIVE_FILE: 4000 lru_gen_folio (MGLRU) 물리 메모리 CPU 0-7 로컬 총 64GB memcg A 할당: anon=2GB file=1GB 로컬 접근: ~100ns NUMA Node 1 per_node[1].lruvec INACTIVE_ANON: 3000 ACTIVE_ANON: 7000 INACTIVE_FILE: 2000 ACTIVE_FILE: 3000 lru_gen_folio (MGLRU) 물리 메모리 CPU 8-15 로컬 총 64GB memcg A 할당: anon=3GB file=1.5GB 원격 접근: ~300ns QPI/UPI NUMA-aware memcg 회수와 모니터링 회수 시: shrink_node_memcgs()가 각 NUMA 노드의 memcg lruvec를 개별 스캔 zonelist 순서(가까운 노드 우선)로 회수 → 원격 메모리(Remote Memory)보다 로컬 메모리 우선 보존 memory.numa_stat: 노드별 anon/file/slab 분포 → 불균형 감지 시 cpuset.mems로 바인딩 조정 cpuset.mems + memory.max 조합: 특정 NUMA 노드의 물리 메모리 내에서만 할당하도록 제한
각 memcg는 NUMA 노드별로 독립 lruvec를 유지합니다. 회수 시 노드별로 개별 스캔하며, memory.numa_stat으로 분포를 모니터링합니다.
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
NUMA 불균형 진단: 특정 노드에 메모리가 편중되면 해당 노드의 로컬 메모리가 먼저 소진되어 원격 접근(remote access) 지연(Latency)이 발생합니다. 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 v1cgroup v2마이그레이션 노트
memory.limit_in_bytesmemory.max바이트 -> 바이트 또는 접미사(K/M/G)
memory.soft_limit_in_bytesmemory.high의미 변경: 소프트 제한 -> 쓰로틀링
memory.memsw.limit_in_bytesmemory.swap.maxmem+swap -> swap만 (분리)
memory.kmem.limit_in_bytes(삭제)memory.max에 통합, 별도 제한 불가
memory.oom_controlmemory.oom.groupOOM 비활성화 불가, 그룹 kill만 제어
memory.usage_in_bytesmemory.current동일 의미
memory.max_usage_in_bytesmemory.peak동일 의미
memory.failcntmemory.events max 필드단일 카운터 -> 다중 이벤트
memory.statmemory.stat필드명 변경 (rss -> anon 등)
memory.force_empty(삭제)cgroup 삭제 시 자동 처리
memory.swappiness(삭제)per-cgroup swappiness 미지원 (글로벌만)
(없음)memory.minv2 신규: 절대 보호
(없음)memory.lowv2 신규: 최선 보호
(없음)memory.pressurev2 신규: per-cgroup PSI
주의: memsw -> swap.max 변환: v1의 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).

마이그레이션 체크리스트

  1. 커널 버전 확인: cgroup v2 메모리 컨트롤러는 커널 4.5+, PSI는 4.20+, memory.high는 4.12+ 필요
  2. 하이브리드 모드: systemd.unified_cgroup_hierarchy=1 부트 파라미터로 순수 v2 모드 활성화
  3. 인터페이스 변환: 위 매핑 테이블 참조하여 스크립트/설정 업데이트
  4. 모니터링 도구: cAdvisor, Prometheus node_exporter가 v2를 지원하는지 확인
  5. swappiness 제거: per-cgroup swappiness 대신 memory.high로 간접 제어
  6. oom_control 제거: OOM kill 비활성화 로직을 memory.oom.group으로 대체

모니터링 도구

도구데이터 소스특징
systemd-cgtopmemory.current, memory.swap.current시스템 전체 cgroup 메모리 사용량 실시간
cAdvisormemory.stat, memory.events컨테이너 메모리 상세 메트릭, Prometheus 연동
Prometheus + node_exportercgroup 파일시스템시계열 저장, 알림, 대시보드
kubectl topmetrics-server (memory.current)K8s Pod/Node 메모리 사용량
docker statsmemory.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.maxmemory.highmemory.lowmemory.min
지연 민감 (DB, 캐시)물리 메모리 70%max의 85%워킹셋 크기워킹셋의 50%
배치 작업 (CI/CD)필요량의 120%max의 80%00
웹 서버예상 피크의 150%max의 90%기본 RSS0
개발 환경넉넉하게설정 안함00

실전 튜닝 팁

제한값 결정 트리

memcg 제한값 결정 흐름 1단계: 워킹셋 측정 제한 없이 실행 → memory.peak 확인 2단계: memory.max = peak × 1.2~1.5 THP, slab, 스파이크 여유 확보 3단계: memory.high = max × 0.8~0.9 OOM 전 쓰로틀링 구간 확보 4단계: memory.low = 정상 시 RSS 워킹셋 보호 (글로벌 회수에서 우선 보호) 5단계: memory.min = 최소 기능 필수량 절대 보호 (K8s requests 매핑) 지연 민감? YES swap.max = 0 NO swap.max = max × 0.5~1.0 7단계: 검증 부하 테스트 실행 memory.events 확인 PSI some/full 확인 workingset_refault 예시 (웹서버) peak: 800MB max: 1200MB high: 1000MB low: 600MB min: 0 swap: 0 (지연 민감) high/max gap: 200MB
memcg 제한값 결정 7단계: 워킹셋 측정 → max → high → low → min → swap → 검증
  1. 워킹셋 크기 측정: 제한 없이 실행 후 memory.peak 확인 -- 이것이 최소 memory.max 기준
  2. memory.max 결정: peak의 1.2~1.5배 (THP, slab, 스파이크 여유)
  3. memory.high 결정: memory.max의 80~90% (쓰로틀링 구간 확보)
  4. memory.low 결정: 정상 동작 시 RSS (워킹셋 보호)
  5. memory.min 결정: 서비스 최소 기능 유지에 필수적인 메모리 (없으면 0)
  6. memory.swap.max 결정: 지연 민감 -> 0, 배치 -> memory.max의 50~100%
  7. 검증: 부하 테스트 후 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 avg10memory.pressure<5%10% 이상이면 성능 저하 시작
PSI full avg10memory.pressure<1%5% 이상이면 심각한 병목(Bottleneck)
high 이벤트 빈도memory.events high낮음빈번하면 memory.high 증가
oom_kill 카운터memory.events oom_kill00이 아니면 즉시 원인 분석
slab 비율memory.stat slab/current<20%높으면 vfs_cache_pressure 증가

커널 빌드 옵션

옵션기본설명
CONFIG_MEMCGyMemory Cgroup 컨트롤러 활성화
CONFIG_CGROUP_WRITEBACKyper-memcg dirty writeback
CONFIG_SLUB_MEMCG_SYSFS_ONnSlab memcg sysfs 인터페이스
CONFIG_PSIyPressure Stall Information
CONFIG_SWAPy스왑 지원 (memcg swap 제어 전제)
CONFIG_ZSWAPyzswap (memcg zswap 제어 전제)
CONFIG_TRANSPARENT_HUGEPAGEyTHP (memcg THP charge)
CONFIG_LRU_GENyMGLRU (per-memcg 세대 기반 회수)
CONFIG_CGROUPSycgroup 프레임워크 (전제 조건)
CONFIG_CGROUP_V1_MEM_WARNnv1 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)하는 데 도움이 됩니다.

memcg 내부 자료구조 struct mem_cgroup css: cgroup_subsys_state (cgroup 트리 연결) memory: page_counter (memory.max/current) swap: page_counter (swap.max/current) kmem: page_counter (커널 메모리 카운터) tcpmem: page_counter (TCP 메모리 카운터) high: unsigned long (memory.high 값) oom_group: bool (memory.oom.group) events[]: atomic_long_t (이벤트 카운터) nodeinfo[]: *per_node (NUMA 노드별 정보) cgwb_list: list_head (writeback 리스트) struct page_counter usage: atomic_long_t (현재 값) max: unsigned long (최대 제한) min: unsigned long (절대 보호) low: unsigned long (최선 보호) parent: *page_counter (부모) memory, swap, kmem struct mem_cgroup_per_node lruvec: struct lruvec lists[NR_LRU_LISTS] lru_gen (MGLRU) lru_zone_size[] memcg: *mem_cgroup nodeinfo[] struct folio memcg_data: [63:2] mem_cgroup * [1] KMEM 플래그 [0] OBJCGS 플래그 소유권 page_counter 계층적 과금 메커니즘 page_counter_try_charge(): self->usage += nr_pages; if(usage > max) return -ENOMEM; -> parent->usage += nr_pages; if(parent_usage > parent_max) { undo; return -ENOMEM; } -> ... root까지 반복. 어느 레벨에서든 실패하면 이미 증가시킨 하위 레벨 모두 undo page_counter_uncharge(): self->usage -= nr_pages -> parent->usage -= nr_pages -> ... root
mem_cgroup, page_counter, mem_cgroup_per_node, folio 간의 관계. page_counter가 계층적 과금의 핵심입니다.
/* 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 연산 횟수를 대폭 줄입니다.

per-CPU Stock 캐시 동작 흐름 mem_cgroup_charge(folio, memcg, 1 page) consume_stock(memcg, 1)? YES (빠른 경로) 즉시 완료! atomic 연산 없음 stock.nr_pages-- NO page_counter_try_charge(memcg, 32 pages) 배치 charge (MEMCG_CHARGE_BATCH=32) refill_stock(memcg, 31 pages) 1 page 사용, 31 pages를 stock에 저장 per-CPU memcg_stock_pcp 상태 변화 CPU 0 cached: memcg A nr_pages: 31 → 30 → 29 ... 같은 memcg 연속 charge = 빠름 ✓ atomic 연산 스킵 CPU 1 cached: memcg B nr_pages: 12 다른 memcg charge 시 → drain → 새 배치 charge drain 트리거 • memcg 삭제 (rmdir) • CPU offline (hotplug) • 다른 memcg로 전환 → stock uncharge 반환
per-CPU stock 캐시: 같은 memcg에 대한 연속 charge를 배치 처리하여 계층적 atomic 연산을 건너뜁니다.

charge 배치 최적화의 핵심 아이디어는 다음과 같습니다:

  1. 선불 charge: MEMCG_CHARGE_BATCH(32페이지) 단위로 미리 charge하여 per-CPU stock에 저장
  2. 즉시 소비: 이후 같은 memcg에 대한 charge는 stock에서 즉시 차감 (atomic 연산 불필요)
  3. stock 갱신: stock이 소진되면 다시 배치 charge
  4. 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_BATCH32 pages (128KB)한 번에 charge하는 최소 배치 크기
MEMCG_DELAY_PRECISION_SHIFT20쓰로틀링 지연 시간 정밀도
MEMCG_MAX_RECLAIM_LOOPS3charge 실패 시 회수 재시도 횟수
MEMCG_MAX_HIGH_DELAY_JIFFIES2초 분량memory.high 쓰로틀링 최대 지연

memory.high 쓰로틀링 상세

memory.high 초과 시 프로세스는 할당 경로에서 강제로 sleep합니다. 이 쓰로틀링은 OOM kill 없이 자연스러운 백프레셔를 제공합니다.

memory.high 쓰로틀링 동작 흐름 페이지 폴트 → mem_cgroup_charge() 성공 usage > memory.high ? NO 즉시 반환 YES mem_cgroup_handle_over_high() sleep (TASK_KILLABLE) 초과량 비례 지연 (최대 2초) 직접 회수 시도 try_to_free_mem_cgroup_pages() 이벤트 기록 memory.events: high++ 초과량 대비 지연 시간 곡선 초과 비율 (usage - high) / high → 0% 10% 25% 50% 100%+ 0 1s 2s max 2s 감지불가 약간 지연 눈에 띄는 저하 심각
memory.high 초과 시 초과량에 비례하여 할당 경로에서 강제 sleep. 최대 2초까지 지연되며, 동시에 직접 회수를 시도합니다.
/* 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 아래로 되돌리려는 시도입니다.
쓰로틀링 vs OOM kill 선택:
  • 지연 허용 워크로드 (배치, 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.eventsoom 카운터를 증가시킵니다.
  • 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 범위
K8s Pod와 oom.group: Kubernetes에서 Pod의 컨테이너들은 같은 cgroup 아래에 배치됩니다. Pod 수준 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이동 후 발생하는 페이지 폴트
v1의 move_charge_at_immigrate: cgroup v1에서는 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 구조체는 내부적으로 유지됩니다.

memcg Reparenting 생명주기 1. 활성 상태 parent memcg child memcg (pod-a) folio charge: child rmdir 2. 오프라인 (좀비) parent memcg child memcg (zombie) 디렉토리 삭제됨 기존 folio: child에 유지 folio 해제 3. 최종 해제 parent memcg child 구조체 해제 모든 folio uncharge 완료 css_put() → css_free() Reparenting 상세 흐름 1. rmdir /sys/fs/cgroup/workload/pod-a 2. cgroup_destroy_locked() → css_offline() 호출 → 새 charge 차단 (css_tryget_online 실패) → 새 프로세스의 folio는 parent memcg에 charge 3. 기존 folio->memcg_data는 여전히 zombie child를 가리킴 4. folio 회수/해제 시 mem_cgroup_uncharge() → page_counter 계층적 차감 5. 모든 folio 해제 → css 참조 카운터 0 → mem_cgroup 구조체 최종 해제 좀비 memcg 누적 원인 • dentry/inode cache가 오래 유지 • tmpfs/shmem 페이지 미회수 • per-CPU stock 캐시 미드레인
cgroup 삭제 시 memcg reparenting 3단계: 활성 → 좀비(오프라인) → 최종 해제. 좀비 상태에서 folio가 남아있으면 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
좀비 memcg의 실제 영향: 각 좀비 memcg는 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
memory.reclaim 활용 시나리오:
  • 프로액티브 메모리 관리: OOM kill 전에 미리 캐시를 정리하여 워킹셋 유지
  • VM 라이브 마이그레이션: 마이그레이션 전에 dirty page를 줄여 다운타임 최소화
  • 빈 패킹(bin packing): 유휴 컨테이너의 캐시를 회수하여 다른 컨테이너에 할당
  • NUMA 리밸런싱: 특정 노드의 memcg 메모리를 회수하여 다른 노드에 재할당

Slab Charge 상세

커널 5.9+에서 memcg slab charge는 objcg(Object Cgroup) 기반으로 전환되었습니다. 이전에는 slab 페이지 단위로 charge했지만, 이제는 오브젝트 단위로 세밀하게 charge합니다.

objcg 기반 Slab Charge 메커니즘 (v5.9+) v5.8 이전: slab 페이지 단위 charge Slab Page (4KB) memcg A memcg B memcg A memcg C 전체 페이지를 첫 할당자(memcg A)에만 charge → memcg B, C의 사용량 누락 (불공정) → 회수 시 정확한 대상 선정 불가 folio->memcg_data = memcg A (페이지 전체) v5.9+: objcg 기반 per-object charge Slab Page (4KB) + objcg 배열 objcg A objcg B objcg A objcg C 각 오브젝트 크기만큼 해당 memcg에 정확히 charge → A: 192B×2, B: 192B×1, C: 192B×1 → 공정한 계정, 정확한 회수 대상 선정 folio->memcg_data = objcg[] (OBJCGS 비트 설정) objcg Charge 흐름 kmem_cache_alloc(cache, __GFP_ACCOUNT) memcg_slab_post_alloc_hook() obj_cgroup_charge(objcg, obj_size) page_counter_try_charge(&memcg->kmem, size) memory.stat: slab += obj_size, kernel += obj_size objcg = current->memcg->objcg nr_charged_bytes += obj_size PAGE_SIZE 누적 시 batch charge
v5.8 이전은 slab 페이지 전체를 하나의 memcg에 charge하여 불공정했지만, v5.9+의 objcg는 오브젝트별로 정확히 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 refcntobj_cgroup의 참조 카운터입니다. per-CPU 참조 카운팅으로 빈번한 slab 할당/해제 시 cacheline 경합을 최소화합니다.
  • nr_charged_bytesobj_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 bytesmmap당 1개
radix_tree_node~576 bytes페이지 캐시(Page Cache) 인덱스
bio/bio_vec 캐시가변I/O 요청당
sk_buff 캐시~256 bytes네트워크 패킷(Packet)당

Watermark 기반 memcg 회수

memcg의 메모리 사용량이 특정 워터마크(Watermark)에 도달하면 다양한 회수 메커니즘이 트리거됩니다. 이 섹션에서는 각 트리거 지점과 회수 동작을 상세히 분석합니다.

memcg 메모리 사용 단계별 동작 보호 영역 정상 영역 쓰로틀 영역 직접 회수 OOM memory.min memory.low memory.high memory.max min 이하 글로벌 회수에서 보호 memcg 내부 회수도 보호 PSI: 없음 min~low 최선 보호 (best-effort) 다른 대상 없을 때만 회수 PSI: 없음~낮음 high 초과 할당 경로에서 sleep 직접 회수 강제 PSI: 중간~높음 max 도달 직접 회수 -> 재시도 실패 시 OOM kill PSI: 매우 높음 회수 경로 비교 글로벌 kswapd 회수: 전체 시스템 watermark 기반. memcg 제한과 독립적으로 동작. -> shrink_node()에서 모든 memcg의 lruvec을 순회하되, memory.min/low 보호 적용 memcg 직접 회수: try_charge_memcg() 실패 시 해당 memcg의 lruvec만 타겟 -> try_to_free_mem_cgroup_pages(target_memcg) -> shrink_lruvec(target lruvec) memory.reclaim (유저 요청): echo "100M" > memory.reclaim -> 동일 회수 경로
memcg 메모리 사용 단계별 동작: 보호 -> 정상 -> 쓰로틀링 -> 직접 회수 -> OOM

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 생성
위임의 안전성: cgroup v2 위임은 안전합니다. 위임받은 사용자는 부모의 제한을 초과하는 값을 설정할 수 없으며, 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%
실무 결론: memcg의 오버헤드는 대부분의 워크로드에서 1~5% 수준으로, 격리와 관리 이점이 오버헤드를 크게 상회합니다. 오버헤드가 문제되는 초고성능 워크로드에서는 cgroup 트리 깊이를 최소화하고, CONFIG_MEMCG=n으로 빌드하는 것은 일반적으로 권장되지 않습니다.

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.swappiness60글로벌 스왑 경향. memcg 회수 시에도 적용 (v2에서 per-memcg 불가)
vm.vfs_cache_pressure100dentry/inode 회수 강도. 높으면 memcg slab charge 빠르게 해제
vm.min_free_kbytes자동글로벌 watermark. memcg 회수와는 독립이지만 글로벌 회수에 영향
vm.overcommit_memory0memcg memory.max와 별도. 가상 메모리(Virtual Memory) 할당 정책
vm.dirty_ratio20글로벌 dirty 비율. cgroup writeback의 기준선
vm.dirty_background_ratio10flusher 시작 기준. per-memcg dirty와 함께 체크
kernel.panic_on_oom0memcg OOM에는 미적용 (글로벌 OOM에만)

cgroup v2 최신 기능 (v6.0+)

커널 버전기능설명
v5.9objcg 기반 slab charge오브젝트 단위 정밀 charge
v5.18memory.reclaim (기본)유저스페이스 능동 회수
v6.0memory.peak역대 최대 사용량 조회
v6.1memory.reclaim (정식)swappiness 옵션 추가
v6.1페이지 테이블 chargepagetables를 memcg에 자동 계정
v6.3memory.swap.high스왑 쓰로틀링 임계
v6.5memory.zswap.maxper-memcg zswap 제한
v6.7memory.reclaim swappiness회수 시 스왑 경향 지정
v6.8memory.stat sec_pagetables보조 페이지 테이블 통계
v6.9memory.high charge throttlecharge 경로에서 직접 sleep
커널 버전 확인: 사용 중인 커널 버전에서 어떤 memcg 기능이 지원되는지 확인하려면: ls /sys/fs/cgroup/<cgroup>/memory.*로 사용 가능한 인터페이스 파일을 확인하세요. 존재하지 않는 파일은 해당 커널에서 미지원입니다.

v1 vs v2 내부 구현 차이

cgroup v1 vs v2 메모리 아키텍처 비교 cgroup v1 (레거시) memory hierarchy memory.limit_in_bytes memory.memsw.limit memory.kmem.limit cpuset hierarchy (별도 트리) v1 문제점 1. 컨트롤러별 독립 트리 -> 일관성 부재 2. soft_limit: 비효율적 글로벌 리스트 스캔 3. memsw: mem+swap 합산 제한 (혼란) 4. oom_control: OOM 비활성 -> cgroup hang 5. slab: 페이지 단위 charge (불공정) 6. move_charge: 대량 스캔 성능 저하 v1 Charge 경로 mem_cgroup_charge_common() -> res_counter_charge() (deprecated) -> 별도 kmem limit 체크 -> 별도 memsw limit 체크 v1 Reclaim soft_limit 글로벌 트리 스캔: 모든 memcg 순회 -> soft limit 초과 memcg 회수 O(n) 복잡도 -> 수천 cgroup에서 심각한 지연 cgroup v2 (현재) 단일 통합 트리 (Unified Hierarchy) memory + cpu + io + pids ... 모든 컨트롤러 공존 memory.max / high / low / min (4단계 모델) v2 개선사항 1. 단일 트리 -> 일관된 계층 구조 2. memory.high: 로컬 쓰로틀링 (O(1)) 3. swap.max: 분리된 독립 제한 4. oom.group: kill 단위만 제어 (안전) 5. objcg: 오브젝트 단위 slab charge 6. PSI per-cgroup: 정량적 압박 측정 v2 Charge 경로 try_charge_memcg() -> page_counter_try_charge() (통합 카운터) -> per-CPU stock 배치 최적화 -> memory.high 쓰로틀링 체크 v2 Reclaim per-memcg lruvec 타겟 회수: -> shrink_lruvec(target lruvec) -- O(lru_size) memory.min/low 보호로 우선순위 결정
cgroup v1과 v2의 메모리 아키텍처 비교. v2는 통합 계층, 4단계 제한, 로컬 쓰로틀링, PSI 등으로 근본적으로 개선되었습니다.

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_counterv2 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에서는 제거되었습니다. 이는 의도적 설계입니다:

함정 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
JVM과 memcg 주의사항: JVM은 -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])
cgroup 파일 vs eBPF: cgroup 파일(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=01스왑 계정 비활성화
cgroup_memory=ononmemcg 컨트롤러 활성화/비활성화
psi=11PSI 활성화 (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-toolscgcreate, cgexec, cgsetapt 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"

참고자료

커널 문서

LWN 기사

커널 소스