← 문제로

15. 메모리 오버커밋과 OOM Killer, 컨테이너(cgroup) 메모리 제한

난이도 중
내 답안
모범답안

해설 — 메모리 오버커밋과 OOM Killer, 컨테이너(cgroup) 메모리 제한

난이도: 중상

1. 메모리 오버커밋(overcommit)

리눅스는 프로세스가 malloc/mmap 등으로 메모리를 "요청"할 때, 그 요청을 즉시 물리 메모리에 대응시키지 않는다. 대부분의 할당은 가상 주소 공간만 예약하고, 실제 물리 페이지는 그 주소에 처음 쓰기(또는 읽기)가 일어나는 순간(page fault)에야 배정된다(디맨드 페이징, os_memory/problem13 참고). 이 덕분에 커널은 "실제 물리 메모리+스왑의 총합보다 많은 가상 메모리 할당 요청"을 허용할 수 있는데, 이를 어느 정도까지 허용할지가 vm.overcommit_memory 정책이다.

  • 0 (heuristic, 기본값): 커널이 휴리스틱으로 판단한다. 명백히 말이 안 되는 큰 요청(사용 가능한 메모리보다 터무니없이 큰 단일 할당)은 거부하지만, 일반적인 경우는 대체로 허용한다. 대부분의 리눅스 배포판 기본 동작.
  • 1 (always): 사실상 모든 할당 요청을 항상 허용한다(용량 검사를 사실상 생략). 대규모 sparse 메모리를 다루는 특수 워크로드(예: 일부 과학 연산, redis의 BGSAVE fork)에서 명시적으로 쓰기도 하지만, 실제 물리 메모리가 부족해지면 나중에 OOM Killer가 개입할 위험이 커진다.
  • 2 (never): 커널이 정해진 한도를 넘는 할당 요청을 즉시 거부한다(malloc이 실패하고 ENOMEM 반환). 이때 한도는 스왑 크기 + 물리 메모리 × vm.overcommit_ratio(기본 50%)로 계산된다. overcommit_ratio를 조정해 "실제 커밋 가능한 총량"을 튜닝할 수 있다. 예측 가능성이 중요한 서버(메모리 요청 실패를 명확히 알고 싶은 경우)에서 선호되기도 하지만, 정상적인 프로그램도 실사용량보다 넉넉히 예약하는 경향이 있어 지나치게 보수적으로 실패할 수 있다.

2. 오버커밋이 존재하는 이유

만약 오버커밋이 전혀 없다면, 커널은 모든 가상 메모리 요청에 대해 "물리적으로 확실히 뒷받침할 수 있는가"를 엄격히 검사해야 한다. 이는 두 가지 이유로 비현실적이다.

  • fork() + Copy-on-Write: fork()는 자식 프로세스에게 부모의 전체 주소 공간을 "복사"한 것처럼 보이게 하지만, 실제로는 페이지 테이블만 복제하고 물리 페이지는 부모와 공유하며 쓰기가 일어날 때만 실제로 복제한다(COW, os_memory/problem3 참고). 이때 커널 입장에서는 자식 프로세스가 "부모의 메모리 전체 크기만큼"의 가상 메모리를 새로 요청한 것처럼 보이지만, 실제 물리 메모리 사용은 거의 늘지 않는다(쓰기가 일어나기 전까지). 많은 프로그램(특히 쉘에서 명령 실행하듯 fork 후 바로 exec하는 경우)은 자식이 잠깐 존재하다 곧바로 다른 프로그램 이미지로 대체되므로, 부모의 메모리를 거의 건드리지 않는다. 오버커밋 없이 fork마다 "부모 크기만큼의 실제 메모리"를 예약해야 한다면, 큰 프로세스를 fork할 때마다 불필요하게 막대한 메모리가 필요하다는 모순이 생긴다.
  • Sparse allocation 관행: 많은 프로그램(특히 가비지 컬렉션 런타임, 메모리 풀 할당자)은 실제로 당장 쓸 양보다 훨씬 넉넉하게 가상 주소 공간을 예약해두고, 실제 사용량은 그중 일부에 그치는 경우가 흔하다(예: JVM의 힙 최대 크기 예약, 스택 예약 영역). 이런 "예약은 크게, 실사용은 적게"라는 패턴이 정상적인 프로그램 동작인데, 오버커밋이 없으면 이런 정상 프로그램들도 실행 초기에 할당 실패를 겪게 된다.

즉 오버커밋은 "가상 주소 공간 예약"과 "실제 물리 메모리 소비"를 분리함으로써, 위와 같은 정상적인 사용 패턴이 불필요하게 실패하지 않도록 하는 실용적 타협이다. 대가로 "예약은 성공했지만 나중에 실제로 다 쓰려 하니 물리 메모리가 없는" 상황이 생길 수 있고, 이때 OOM Killer가 개입한다.

3. OOM Killer의 동작

오버커밋으로 인해 여러 프로세스가 "가상으로는" 충분한 메모리를 할당받았지만, 실제로 그 메모리에 쓰기 시작하면서 물리 메모리(+스왑)가 바닥나는 순간이 올 수 있다. 이때 커널은 새 페이지를 배정할 수 없는 상황(진짜 OOM)에 처하는데, 단순히 할당 요청을 실패시키는 것만으로는 이미 커널 내부에서 메모리를 기다리는 다른 작업들이 막혀 시스템 전체가 불안정해질 수 있다. 그래서 리눅스는 OOM Killer를 발동해 특정 프로세스를 강제 종료(SIGKILL)함으로써 메모리를 회수한다.

어떤 프로세스를 죽일지는 oom_score(0~1000 범위 점수, 높을수록 희생 우선순위 높음)로 결정한다. 대략적으로 다음 요소가 반영된다.

  • 메모리 사용량 비중: 전체 사용 가능 메모리 대비 그 프로세스(및 자식들)가 차지하는 비율이 클수록 점수가 높아진다(메모리를 많이 먹는 프로세스가 우선 희생 대상).
  • 실행 시간·안정성: 오래 실행된, 안정적으로 동작 중인 프로세스보다 최근에 생성된 프로세스가 상대적으로 덜 "중요하다"고 취급되는 경향(구현 세부는 커널 버전에 따라 다름).
  • 권한(루트 등): 루트 권한으로 실행 중인 프로세스나 시스템 핵심 프로세스는 점수가 낮게(덜 죽게) 보정되는 경향이 있다.
  • oom_score_adj: 관리자가 /proc/<pid>/oom_score_adj(-1000~1000)를 설정해 특정 프로세스의 최종 점수를 인위적으로 낮추거나(보호) 높일 수 있다(-1000은 사실상 OOM Killer 대상에서 제외, 반대로 높이면 우선 희생). 게임서버처럼 절대 죽으면 안 되는 핵심 프로세스에 음수 값을 줘서 보호하는 식으로 활용된다.

4. 컨테이너(cgroup) 메모리 제한과의 차이

컨테이너에 memory.limit_in_bytes(cgroup v1) 또는 memory.max(cgroup v2)로 메모리 상한을 걸면, 그 cgroup(컨테이너) 안의 프로세스들이 사용하는 메모리 합이 이 상한에 도달했을 때 호스트 전체의 메모리 상황과 무관하게 cgroup 메모리 컨트롤러가 개입한다. 이때 발동하는 것이 cgroup-scoped OOM Killer로, 호스트 전역 OOM Killer와는 범위가 다르다.

  • 호스트 전역 OOM: 시스템 전체의 물리 메모리(+스왑)가 고갈됐을 때 발동하며, 호스트에 떠 있는 모든 프로세스(다른 컨테이너 포함) 중에서 oom_score가 가장 높은 것을 희생시킨다. 즉 어느 컨테이너의 프로세스가 죽을지 예측하기 어렵고, "옆집 컨테이너"가 죽을 수도 있다.
  • cgroup 스코프 OOM: 해당 cgroup(컨테이너)의 메모리 사용량이 자신에게 할당된 limit에 도달하면, 그 cgroup 내부의 프로세스 중에서만 희생양을 골라 죽인다. 호스트에 물리 메모리가 아직 넉넉히 남아 있어도(다른 컨테이너나 호스트 프로세스가 여유가 있어도) 이 컨테이너는 자신의 한도 안에서만 운신할 수 있으므로, 그 한도를 넘는 순간 즉시 컨테이너 내부 OOM이 발생한다. 다른 컨테이너·호스트 프로세스는 영향받지 않는다. Kubernetes에서 파드가 OOMKilled 상태로 재시작되는 것이 바로 이 경로다.

오버커밋과의 상호작용: 컨테이너 안에서도 오버커밋 정책은 호스트 커널 설정을 그대로 따른다. 즉 컨테이너 프로세스가 실제로 쓰지 않고 "예약"만 해둔 가상 메모리는 cgroup의 사용량 집계(주로 실제 상주 메모리, RSS 기준)에 당장 반영되지 않지만, 이후 실제로 그 메모리에 쓰기 시작해 실사용량이 늘어나는 순간 cgroup limit에 걸릴 수 있다. 즉 "할당(malloc)은 성공했는데 나중에 실제 쓰기 시점에 컨테이너가 OOMKilled 되는" 현상이 오버커밋 + cgroup 제한의 조합에서 흔히 발생한다.

5. 게임서버 운영 관점의 모니터링·방어

  • 메모리 사용량·트렌드 모니터링: 컨테이너 RSS, cgroup의 memory.current(v2)/memory.usage_in_bytes(v1), 호스트 전체 가용 메모리를 시계열로 수집하고, 완만한 증가 추세(메모리 누수 징후)를 조기에 알림으로 잡는다.
  • swap·페이지폴트 관찰: swap 사용량이 늘거나 메이저 페이지폴트가 급증하면 메모리 압박의 선행 신호다(os_memory/problem14 참고). 컨테이너 환경에서는 swap을 비활성화하는 경우가 많아, 이 경우 압박이 오면 바로 cgroup OOM으로 직행하므로 더 민감하게 모니터링해야 한다.
  • limit/request 여유(headroom) 확보: Kubernetes의 resources.requests/limits.memory를 설정할 때 실측 피크 사용량보다 여유를 두어, 순간적인 스파이크(대량 접속, GC 피크)로 인한 OOMKilled를 피한다. 너무 타이트하게 잡으면 정상 부하에서도 재시작이 반복될 수 있다.
  • oom_score_adj 조정: 여러 프로세스가 한 호스트/컨테이너에서 도는 구조라면, 절대 죽으면 안 되는 핵심 게임서버 프로세스는 oom_score_adj를 낮춰 보호하고, 부수적인 배치/로그 수집 프로세스는 상대적으로 먼저 희생되도록 조정한다.
  • 알림 임계치 설정: 메모리 사용률이 limit의 70~80% 등 임계치를 넘으면 사전 경고를 울려, OOM이 실제로 터지기 전에 스케일아웃·재배포·원인 조사를 할 시간을 확보한다.
  • Graceful degradation: 메모리 압박이 감지되면 서버가 스스로 신규 연결을 제한하거나, 캐시 크기를 줄이거나, 비핵심 기능(리플레이 버퍼, 통계 수집)을 축소해 OOM 도달 자체를 회피하는 방어 로직을 둔다. 이는 "죽고 나서 재시작"보다 "미리 낮은 비용으로 성능을 낮추는" 전략으로, 플레이어 경험 단절(연결 끊김)을 줄인다.
  • 사후 분석: OOMKilled가 발생하면 원인 프로세스의 메모리 스냅샷(가능하면 힙 프로파일)과 직전 트래픽·GC 로그를 함께 남겨, 특정 코드 경로의 누수인지 일시적 스파이크인지 구분한다.