컨테이너가 메모리 한도에 닿으면 죽는가, 느려지는가 — cgroup v2 memory.high와 memory.max의 경계

seonwoo_jung·3일 전

1. 왜 이걸 파게 됐나

컨테이너가 "메모리 한도"에 닿았을 때 무슨 일이 벌어지느냐고 물으면, 나는 오랫동안 "OOM으로 죽는다" 한마디로 답했다. 그런데 운영하다 보면 죽지는 않는데 특정 순간마다 요청이 멈칫거리는 컨테이너가 있다. 로그에는 OOMKilled가 없는데 지연만 생긴다. 이게 왜 벌어지는지 뭉뚱그려 알던 걸 커널 문서와 소스까지 따라가며 정리했다.

핵심은 이거였다. cgroup v2에서 "메모리 한도"는 하나가 아니다. memory.min·memory.low·memory.high·memory.max 네 개의 서로 다른 축으로 나뉘고, 그중 OOM 킬러를 부르는 건 오직 memory.max 하나다. memory.high는 아무리 넘겨도 프로세스를 죽이지 않는다 — 대신 느리게 만든다. 이 글은 "죽이느냐 / 느리게 하느냐"로 갈리는 두 벽의 정체를 정리한 것이다.

2. 한도가 아니라 네 개의 축

먼저 흔한 오해부터 걷어내자. 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.minmemory.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

3. charge 경로 — 모든 할당은 조상 트리로 올라간다

한도가 언제 검사되는지 알려면 charge 경로를 봐야 한다. 프로세스가 페이지를 얻을 때마다 mm/memcontrol.ctry_charge()가 호출되어, 해당 cgroup부터 루트까지 조상 각각의 한도를 확인하며 usage를 누적(charge)한다.

어느 조상에서 한도에 걸리면 그 그룹 범위 안에서 회수를 시도한다 — 페이지 캐시, 회수 가능한 slab, (스왑이 있으면) 익명 페이지가 대상이다. 여기서 실무에서 자주 밟는 함정 하나:

익명 페이지(힙)는 스왑 없이는 회수할 수 없다. memory.swap.max=0인 컨테이너에서 힙이 커지면 회수할 대상이 파일 캐시밖에 없어, 곧장 OOM으로 직행하기 쉽다.

또 하나 중요한 함의는, charge가 조상까지 올라간다는 점이다. 개별 컨테이너는 자기 한도 안이어도, 상위 그룹(쿠버네티스라면 Pod 슬라이스나 노드 슬라이스) 한도에서 걸려 스로틀이나 OOM이 날 수 있다. "내 컨테이너 limit은 넉넉한데 왜 죽지?"의 답이 여기 있는 경우가 있다.

4. memory.high — "넘어도 안 죽는다, 대신 느려진다"

memory.high를 초과하면 커널은 세 단계로 대응한다.

  1. 그룹의 프로세스들이 heavy reclaim pressure 아래 놓인다. 할당 시 direct reclaim을 직접 수행하게 된다.
  2. 그래도 usage가 high 위에 머물면, 커널은 할당자 스로틀(penalty stall)을 건다. 초과분에 비례해 계산된 지연을 mem_cgroup_handle_over_high()가 유저스페이스 복귀 직전에 schedule_timeout_killable()로 재운다. 이 페널티는 상한이 있어(대략 최대 2초, MEMCG_MAX_HIGH_DELAY_JIFFIES) 한 번에 무한정 재우진 않지만, 초과가 지속되면 반복 부과된다. 애플리케이션 입장에선 "간헐적 지연/멈칫"으로 체감된다.
  3. 절대 OOM 킬러를 부르지 않는다. 극단적 상황에선 high를 넘어서까지 usage가 breach되는 것도 허용한다.

즉 high는 "죽이지 않고 압력으로 눌러 스스로 줄이게 만드는" 소프트 상한이다. 도입부에서 말한 "죽지는 않는데 멈칫거리는 컨테이너"의 정체가 바로 이 high 스로틀이다. memory.eventshigh 카운터가 스로틀이 걸린 횟수다.

5. memory.max — "회수해도 못 줄이면 죽인다"

memory.max에 usage가 닿고, 회수로도 못 줄이면 그 cgroup 안에서 OOM 킬러(mem_cgroup_out_of_memory())가 발동한다. 이게 memcg OOM이며, 시스템 전체를 대상으로 하는 global OOM과 구분된다 — 다른 컨테이너는 멀쩡한데 한도를 넘긴 그룹 안에서만 프로세스가 죽는다.

한 가지 알아둘 예외: memory.maxO_NONBLOCK으로 열어 쓰면 동기 reclaim과 oom-kill이 바이패스된다. 덕분에 관리 프로세스가 한도를 낮출 때 자기 자신을 트리거로 죽이지 않고 값을 조정할 수 있다.

누구를 죽이나 — oom_badness()

memcg OOM에서 누구를 죽일지는 mm/oom_kill.coom_badness()가 프로세스별 점수로 정한다. 공식은 대략 이렇다.

score = rss_anon + rss_file + rss_shmem + swapents + pgtables_pages
        + (oom_score_adj * totalpages / 1000)
  • 기본 원칙: 메모리를 많이 쓰는 놈이 높은 점수 → 먼저 죽는다.
  • oom_score_adj(-1000..+1000)로 편향을 준다. -1000이면 점수가 음수로 눌려 사실상 면제된다.
  • 결정적 포인트: memcg OOM에서 totalpages시스템 전체 RAM이 아니라 그 그룹의 memory.max(또는 그에 준하는 값)다. 그래서 "전체 대비 몇 %"가 아니라 "이 컨테이너 한도 대비 몇 %"로 점수가 매겨진다. 128GB 머신이라도, 512MB 한도 컨테이너 안에서 죽을 프로세스는 512MB 기준으로 점수화된다.

프로세스 단위 kill이 문제가 되는 경우 — 워커 절반만 죽고 나머지가 좀비처럼 남는 상황 — 는 memory.oom.group=1로 막는다. 켜면 개별 프로세스가 아니라 그룹 전체를 하나의 단위로 죽인다.

6. 시나리오로 정리 + PSI로 미리 보기

지금까지를 하나의 흐름으로 붙여 보자. 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에 이르기 전 압력을 조기 감지해, 오케스트레이터가 미리 스케일아웃하거나 트래픽을 빼는 신호로 쓸 수 있다.

7. 정리

  • cgroup v2의 메모리 "한도"는 min/low(하한 보호) + high/max(상한) 네 축이고, OOM을 부르는 건 오직 memory.max다.
  • memory.high는 넘어도 절대 죽이지 않는다. 회수 압력과 할당자 스로틀로 눌러 스스로 줄이게 한다. "안 죽는데 이상하게 느린" 컨테이너는 high 스로틀을 의심하자.
  • memcg OOM의 희생자 점수(oom_badness)는 시스템 RAM이 아니라 그 그룹의 한도를 분모로 매겨진다.

컨테이너가 메모리 벽에 닿을 때 죽느냐 느려지느냐는, 그 벽이 memory.maxmemory.high냐로 갈린다.

더 파고들 만한 것: global OOM vs memcg OOM의 트리거·희생자 선정 차이와 oom_score_adj가 kubelet의 QoS 클래스(Guaranteed/Burstable/BestEffort)로 어떻게 매핑되는지, 그리고 memory.high 페널티 지연의 정확한 계산식(초과분 ↔ jiffies)이 다음 주제다.

참고 자료

  • Linux Kernel Documentation — Control Group v2, Memory Controller (memory.min/low/high/max/events, memory.oom.group)
  • mm/memcontrol.ctry_charge, mem_cgroup_handle_over_high, mem_cgroup_out_of_memory
  • mm/oom_kill.coom_badness

0개의 댓글