메모리가 남는데 OOM으로 죽는 이유: 오버커밋, cgroup, OOM 킬러
모니터링 그래프에는 메모리 여유가 있는데 프로세스가 죽어 있고, dmesg에는 OOM 킬러의 흔적이 남아 있습니다. “메모리가 남는데 왜 OOM이지?“는 리눅스 운영에서 가장 자주 만나는 역설이고, 답은 세 겹입니다. 리눅스는 메모리를 약속만 하고 나중에 지급하며(오버커밋), 죽음의 기준은 시스템 전체가 아니라 cgroup 한도일 수 있고, 그래프의 “남은 메모리"는 애초에 잘못 읽은 숫자일 수 있습니다. 하나씩 풀어 갑니다.
전제: malloc은 메모리를 주지 않습니다 #
첫 번째 열쇠는 오버커밋(overcommit)입니다. 프로세스가 malloc으로 1GB를 요청하면, 커널은 물리 메모리를 확보하지 않고 가상 주소 공간만 예약해 줍니다. 실제 물리 페이지는 프로세스가 그 주소에 처음 쓰는 순간 할당됩니다. 그래서 리눅스는 물리 메모리보다 많은 양을 태연하게 약속할 수 있고, 대부분의 경우 이것은 잘 동작합니다. 프로세스들이 요청한 만큼 다 쓰는 일이 드물기 때문입니다. fork가 copy-on-write로 동작하는 것과 같은 철학입니다.
문제는 약속이 한꺼번에 이행을 요구받을 때입니다. 여러 프로세스가 약속받은 메모리에 실제로 쓰기 시작해 물리 메모리가 진짜로 바닥나면, 커널은 선택지가 없습니다. 이미 약속한 메모리라 거절할 수 없고, 그렇다고 시스템 전체를 멈출 수도 없습니다. 이때 등장하는 것이 OOM 킬러입니다. 누군가를 죽여서 메모리를 회수하는, 커널의 최후 수단입니다.
vm.overcommit_memory 설정으로 이 동작을 바꿀 수 있지만(2로 설정하면 엄격 모드), 데스크톱·일반 서버 기본값은 휴리스틱 오버커밋이고, 대부분의 환경은 이 전제 위에서 운영됩니다.
free의 남은 메모리는 무엇이 남았다는 뜻인가 #
두 번째 열쇠는 지표 읽기입니다. free 출력에서 봐야 할 것은 free 컬럼이 아니라 available 컬럼입니다.
free: 정말 아무 용도로도 안 쓰는 메모리입니다. 리눅스는 남는 메모리를 페이지 캐시(디스크 내용의 캐시)로 쓰기 때문에, 건강한 시스템일수록 이 값은 작습니다.available: 페이지 캐시 중 회수 가능한 부분까지 포함해, “새 프로세스가 실제로 쓸 수 있는” 추정치입니다.
“free가 작다"는 정상이고, “available이 바닥"이 위험 신호입니다. 그런데 available이 넉넉해 보여도 죽는 경우가 있습니다. 그것이 세 번째 열쇠입니다.
컨테이너 시대의 OOM: 기준은 노드가 아니라 cgroup입니다 #
현대 환경에서 만나는 OOM의 다수는 시스템 전체의 메모리 고갈이 아니라 cgroup 한도 초과입니다. 컨테이너(Docker, Kubernetes)는 cgroup으로 메모리 상한을 걸고, 컨테이너 안 프로세스들의 사용량이 그 상한에 닿으면 노드에 메모리가 아무리 남아 있어도 cgroup 범위의 OOM 킬이 발생합니다.
이것이 “노드 그래프에는 여유가 있는데 파드가 OOMKilled"의 정체입니다. 확인 위치도 다릅니다.
# 시스템 전체 OOM: 커널 로그
dmesg -T | grep -i "out of memory"
journalctl -k | grep -i oom
# Kubernetes: 종료 코드 137, 사유 OOMKilled
kubectl describe pod mypod # Last State: Terminated, Reason: OOMKilled종료 코드 137(128 + SIGKILL의 9)은 OOM의 대표 시그니처입니다. 커널 로그에는 어느 cgroup에서, 어떤 프로세스가, 당시 메모리 상태가 어땠는지가 함께 기록되므로, OOM 조사의 첫 명령은 항상 dmesg입니다.
JVM·컨테이너 조합의 고전 사고도 여기서 나옵니다. 힙 최대치를 컨테이너 한도와 같게 잡으면, 힙 밖 메모리(메타스페이스, 스레드 스택, 네이티브)가 더해지는 순간 한도를 넘어 죽습니다. 컨테이너 한도는 프로세스의 전체 메모리 사용량 기준이고, 애플리케이션 설정의 힙 한도보다 여유 있게 잡아야 합니다.
희생자는 어떻게 고르는가: oom_score #
OOM 킬러는 무작위로 죽이지 않습니다. 프로세스마다 oom_score(대략 메모리 사용 비중 기반)를 계산해 가장 높은 것을 죽입니다. 여기에 oom_score_adj(-1000〜1000)로 가중치를 걸 수 있습니다.
-1000은 면제입니다. sshd 같은 필수 데몬이 낮은 값을 갖는 이유이고, 반대로 죽어도 되는 캐시성 프로세스에 양수를 주면 먼저 희생되게 할 수 있습니다.- Kubernetes는 이 장치를 QoS 등급으로 씁니다. requests와 limits가 같은 파드(Guaranteed)는 보호받고, requests 없이 뜬 파드(BestEffort)가 먼저 죽습니다. “중요한 서비스인데 자꾸 먼저 죽는다"면 requests·limits 설정, 즉 QoS 등급부터 확인해야 합니다.
대응: 순서가 있습니다 #
- 사인 확인: dmesg·kubectl로 시스템 OOM인지 cgroup OOM인지, 누가 죽었고 당시 누가 메모리를 들고 있었는지 확인합니다. 여기를 건너뛰고 “메모리 늘리기"로 가면 같은 사고가 반복됩니다.
- 한도가 문제인가, 사용량이 문제인가: 정상 부하에서 필요한 메모리가 한도보다 크면 한도(limits)를 올리는 것이 맞고, 사용량이 계속 우상향한다면 메모리 릭 조사가 맞습니다. 파이썬이라면 py-spy 같은 프로파일링이 도구가 됩니다.
- 스왑과 압박 지표: 스왑이 없는 노드는 메모리 압박이 곧바로 OOM으로 직행합니다. 죽기 전에 신호를 보려면 PSI(pressure stall information)나 working set 추이를 모니터링에 넣는 것이 순서입니다. 시스템이 느려지는 다른 원인들은 Load Average의 정체에서 다뤘습니다.
정리 #
- 리눅스는 오버커밋으로 메모리를 약속만 하고, 물리 페이지는 쓰는 순간 할당합니다. 약속이 한꺼번에 이행을 요구받으면 OOM 킬러가 나섭니다.
- free가 아니라 available을 봅니다. 페이지 캐시 때문에 free가 작은 것은 정상입니다.
- 현대의 OOM 다수는 노드가 아니라 cgroup 한도 초과입니다. 노드에 메모리가 남아도 컨테이너는 죽습니다. 종료 코드 137과 dmesg가 확인 수단입니다.
- 희생자는 oom_score로 선정되고, Kubernetes에서는 requests·limits가 만드는 QoS 등급이 그 순위를 결정합니다.
- 대응 순서는 사인 확인 → 한도 문제와 릭 문제의 구분 → 압박 지표 모니터링 추가입니다. 확인 없는 “메모리 증설"은 재발로 이어집니다.