컨테이너가 "메모리 한도"에 닿았을 때 무슨 일이 벌어지느냐고 물으면, 나는 오랫동안 "OOM으로 죽는다" 한마디로 답했다. 그런데 운영하다 보면 죽지는 않는데 특정 순간마다 요청이 멈칫거리는 컨테이너가 있다. 로그에는 OOMKilled가 없는데 지연만 생긴다. 이게 왜 벌어지는지 뭉뚱그려 알던 걸 커널 문서와 소스까지 따라가며 정리했다.
핵심은 이거였다. cgroup v2에서 "메모리 한도"는 하나가 아니다. memory.min·memory.low·memory.high·memory.max 네 개의 서로 다른 축으로 나뉘고, 그중 OOM 킬러를 부르는 건 오직 memory.max 하나다. memory.high는 아무리 넘겨도 프로세스를 죽이지 않는다 — 대신 느리게 만든다. 이 글은 "죽이느냐 / 느리게 하느냐"로 갈리는 두 벽의 정체를 정리한 것이다.
먼저 흔한 오해부터 걷어내자. cgroup v1에는 memory.limit_in_bytes 하나가 하드 리밋이었지만, cgroup v2의 메모리 컨트롤러는 usage를 네 값으로 규율한다. 아래로 갈수록 강한 제약이다.
memory.min ── 하드 보호. 이 밑으로는 어떤 상황에서도 회수(reclaim) 안 함.
memory.low ── 베스트에포트 보호. 다른 곳에 회수할 게 없을 때만 회수.
─────────────── (usage가 여기를 넘어가기 시작)
memory.high ── 소프트 상한. 넘으면 heavy reclaim + 할당자 스로틀. OOM 안 냄.
memory.max ── 하드 상한. 회수 실패 시 memcg OOM 킬러 호출.
여기서 가장 헷갈리는 지점: memory.min과 memory.low는 상한이 아니라 하한 보호선이다. Linux Kernel Documentation의 cgroup-v2 Memory 섹션에 따르면, 이 둘은 usage가 그 값을 넘는 걸 막는 게 아니라, 시스템이 메모리 압박을 받아 페이지를 회수할 때 이 그룹의 페이지를 얼마나 늦게 뺏을지를 정한다.
memory.min: 이 밑으로는 절대 회수하지 않는다. 이 그룹의 보장된 몫이다.memory.low: "정말 다른 데서 뺏을 게 없을 때만" 회수한다. 베스트에포트 보호다.즉 min/low는 "얼마까지 쓸 수 있나"가 아니라 "압박이 왔을 때 누구 걸 먼저 뺏나"의 우선순위 값이다. 실제 상한은 high/max 둘뿐이고, 이 글의 핵심은 그 둘의 차이다.
| 파일 | 성격 | 넘으면 | OOM 유발 |
|---|---|---|---|
| memory.min | 하한 보호(하드) | 회수 대상에서 완전 제외 | 아니오 |
| memory.low | 하한 보호(소프트) | 최후에만 회수 | 아니오 |
| memory.high | 상한(소프트) | 강제 회수 + 할당자 스로틀 | 아니오 |
| memory.max | 상한(하드) | 회수 실패 시 memcg OOM | 예 |
한도가 언제 검사되는지 알려면 charge 경로를 봐야 한다. 프로세스가 페이지를 얻을 때마다 mm/memcontrol.c의 try_charge()가 호출되어, 해당 cgroup부터 루트까지 조상 각각의 한도를 확인하며 usage를 누적(charge)한다.
어느 조상에서 한도에 걸리면 그 그룹 범위 안에서 회수를 시도한다 — 페이지 캐시, 회수 가능한 slab, (스왑이 있으면) 익명 페이지가 대상이다. 여기서 실무에서 자주 밟는 함정 하나:
익명 페이지(힙)는 스왑 없이는 회수할 수 없다.
memory.swap.max=0인 컨테이너에서 힙이 커지면 회수할 대상이 파일 캐시밖에 없어, 곧장 OOM으로 직행하기 쉽다.
또 하나 중요한 함의는, charge가 조상까지 올라간다는 점이다. 개별 컨테이너는 자기 한도 안이어도, 상위 그룹(쿠버네티스라면 Pod 슬라이스나 노드 슬라이스) 한도에서 걸려 스로틀이나 OOM이 날 수 있다. "내 컨테이너 limit은 넉넉한데 왜 죽지?"의 답이 여기 있는 경우가 있다.
memory.high를 초과하면 커널은 세 단계로 대응한다.
mem_cgroup_handle_over_high()가 유저스페이스 복귀 직전에 schedule_timeout_killable()로 재운다. 이 페널티는 상한이 있어(대략 최대 2초, MEMCG_MAX_HIGH_DELAY_JIFFIES) 한 번에 무한정 재우진 않지만, 초과가 지속되면 반복 부과된다. 애플리케이션 입장에선 "간헐적 지연/멈칫"으로 체감된다.즉 high는 "죽이지 않고 압력으로 눌러 스스로 줄이게 만드는" 소프트 상한이다. 도입부에서 말한 "죽지는 않는데 멈칫거리는 컨테이너"의 정체가 바로 이 high 스로틀이다. memory.events의 high 카운터가 스로틀이 걸린 횟수다.
memory.max에 usage가 닿고, 회수로도 못 줄이면 그 cgroup 안에서 OOM 킬러(mem_cgroup_out_of_memory())가 발동한다. 이게 memcg OOM이며, 시스템 전체를 대상으로 하는 global OOM과 구분된다 — 다른 컨테이너는 멀쩡한데 한도를 넘긴 그룹 안에서만 프로세스가 죽는다.
한 가지 알아둘 예외: memory.max를 O_NONBLOCK으로 열어 쓰면 동기 reclaim과 oom-kill이 바이패스된다. 덕분에 관리 프로세스가 한도를 낮출 때 자기 자신을 트리거로 죽이지 않고 값을 조정할 수 있다.
memcg OOM에서 누구를 죽일지는 mm/oom_kill.c의 oom_badness()가 프로세스별 점수로 정한다. 공식은 대략 이렇다.
score = rss_anon + rss_file + rss_shmem + swapents + pgtables_pages
+ (oom_score_adj * totalpages / 1000)
oom_score_adj(-1000..+1000)로 편향을 준다. -1000이면 점수가 음수로 눌려 사실상 면제된다.totalpages는 시스템 전체 RAM이 아니라 그 그룹의 memory.max(또는 그에 준하는 값)다. 그래서 "전체 대비 몇 %"가 아니라 "이 컨테이너 한도 대비 몇 %"로 점수가 매겨진다. 128GB 머신이라도, 512MB 한도 컨테이너 안에서 죽을 프로세스는 512MB 기준으로 점수화된다.프로세스 단위 kill이 문제가 되는 경우 — 워커 절반만 죽고 나머지가 좀비처럼 남는 상황 — 는 memory.oom.group=1로 막는다. 켜면 개별 프로세스가 아니라 그룹 전체를 하나의 단위로 죽인다.
지금까지를 하나의 흐름으로 붙여 보자. high 512M, max 600M인 컨테이너를 가정한다.
memory.high = 512M
memory.max = 600M
usage 480M → 정상. 스로틀 없음.
usage 520M → high 초과. direct reclaim 시작 + 페널티 지연 부과.
회수로 490M까지 떨어지면 스로틀 해제. (프로세스는 안 죽음)
usage 601M → max 초과. reclaim 시도.
회수 성공해 580M 되면 계속 실행.
회수 실패로 못 줄이면 → 그룹 내 oom_badness 최고 점수 프로세스 kill.
memory.events: oom_kill += 1
사후 진단은 memory.events 파일이 답이다. high/max/oom/oom_kill 카운터를 읽으면, 이 그룹이 "스로틀만 당했는지"(high↑, oom_kill=0)인지 "실제로 죽었는지"(oom_kill↑)인지를 구분할 수 있다.
그리고 죽기 전에 알고 싶다면 memory.pressure(PSI)를 본다. PSI는 "메모리 부족으로 작업이 지연된 시간 비율"을 some/full 지표로 준다. OOM에 이르기 전 압력을 조기 감지해, 오케스트레이터가 미리 스케일아웃하거나 트래픽을 빼는 신호로 쓸 수 있다.
memory.max다.memory.high는 넘어도 절대 죽이지 않는다. 회수 압력과 할당자 스로틀로 눌러 스스로 줄이게 한다. "안 죽는데 이상하게 느린" 컨테이너는 high 스로틀을 의심하자.oom_badness)는 시스템 RAM이 아니라 그 그룹의 한도를 분모로 매겨진다.컨테이너가 메모리 벽에 닿을 때 죽느냐 느려지느냐는, 그 벽이
memory.max냐memory.high냐로 갈린다.
더 파고들 만한 것: global OOM vs memcg OOM의 트리거·희생자 선정 차이와 oom_score_adj가 kubelet의 QoS 클래스(Guaranteed/Burstable/BestEffort)로 어떻게 매핑되는지, 그리고 memory.high 페널티 지연의 정확한 계산식(초과분 ↔ jiffies)이 다음 주제다.
memory.min/low/high/max/events, memory.oom.group)mm/memcontrol.c — try_charge, mem_cgroup_handle_over_high, mem_cgroup_out_of_memorymm/oom_kill.c — oom_badness