# Docker

Docker 컨테이너는 512MB 제한인데 왜 261MB에서 죽을까?

@Hong2026-10-01

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
Node.js 컨테이너가 메모리 제한 512MB보다 훨씬 적게 쓰고도 메모리 부족으로 끝나는 이유는?
답
Node.js 24의 힙 한도는 512MB 이하 제한에서 259MB로 고정이라, 512MB 컨테이너에서는 힙 한도에 먼저 닿아 커널이 아니라 Node.js가 JavaScript heap out of memory(종료 코드 139)로 끝난다.
조건
macOS Docker Desktop(Docker Engine 27.5.1, cgroup v2), node:24-alpine, 할당마다 3회

환경

Docker
macOS의 Docker Desktop, Docker Engine 27.5.1
리눅스 VM
cgroup v2, CPU 8개, 스왑 1GB
런타임
Node.js 24(node:24-alpine)
메모리 제한
256MB와 512MB 중심, --memory-swap은 --memory와 같은 값
반복
같은 할당을 3번씩
목차
  1. 실습 준비
  2. 실습 1: Node.js의 힙 한도는 제한보다 클 수 있습니다
  3. 실습 2: 같은 프로그램, 다른 끝
  4. 정리

이번 시간에는 컨테이너가 메모리 때문에 끝나는 두 가지 경로를, Docker에서 Node.js를 같은 프로그램으로 제한만 바꿔 돌려 보며 배워가는 시간입니다. 두 경로는 종료 코드와 OOMKilled로 구분해 볼게요.

먼저 이런 상황을 하나 볼게요. Node.js 컨테이너에 메모리 제한을 512MB로 주고 객체를 계속 만들었더니, 컨테이너 메모리 사용량이 261MB일 때 프로세스가 끝났어요.

컨테이너가 메모리 때문에 끝날 때는 두 갈래가 있습니다. 런타임이 정한 힙 한도에 먼저 닿으면 런타임이 오류를 내고, 컨테이너 메모리 제한에 먼저 닿으면 커널이 끝내요.

실습 준비

이 글의 명령은 모두 실습 코드의 docker-oom/ 디렉터리에서 실행합니다.

  • heap.js: V8 힙에 객체 배열을 쌓는 Node.js 프로그램

docker run --memory 256m으로 준 제한을 실제로 지키는 쪽은 리눅스 커널이에요. Docker는 컨테이너를 만들 때 그 값을 cgroup(프로세스 묶음이 쓰는 CPU와 메모리를 세고 제한하는 커널 기능)에 써 둡니다. cgroup v2에서는 컨테이너 안의 /sys/fs/cgroup에 이런 파일이 보입니다.

  • memory.max 파일: 메모리 제한이에요. 컨테이너 안 프로세스가 쓰는 메모리 합이 이 값을 넘으려 하면 커널은 먼저 회수할 수 있는 메모리를 비우고, 그래도 모자라면 프로세스를 하나 골라 끝냅니다.
  • memory.current 파일: 지금 이 cgroup이 쓰고 있는 메모리예요.
  • memory.events 파일: 제한에 닿은 횟수(max), 회수를 해도 메모리를 확보하지 못한 횟수(oom), 그 때문에 커널이 프로세스를 끝낸 횟수(oom_kill)를 셉니다. OOM은 Out Of Memory, 곧 메모리가 모자란 상태를 말하고, 이때 프로세스를 골라 끝내는 커널 기능을 OOM killer라고 불러요.

Docker 문서에 따르면 --memory-swap을 주지 않으면 컨테이너는 --memory와 같은 크기만큼 스왑을 더 쓸 수 있습니다. 그래서 이 글의 실습은 모두 --memory-swap을 --memory와 같은 값으로 줄게요. 그래야 제한에 닿는 순간 무슨 일이 일어나는지 스왑에 가리지 않고 볼 수 있습니다.

반복해서 쓸 셸 함수를 먼저 만들어 놓을게요. node_run은 메모리 제한과 컨테이너 이름을 받아 node:24-alpine으로 프로그램을 실행하고, show는 종료 코드와 OOMKilled를 출력한 뒤 컨테이너를 지웁니다. 종료 코드와 OOMKilled를 docker inspect로 읽어야 하므로 --rm을 붙이지 않았어요.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

show() {
  docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}}' "$1"
  docker rm "$1" > /dev/null
}

node_run() { local mem=$1 name=$2; shift 2; docker run --name "$name" --memory "$mem" --memory-swap "$mem" -v "$PWD:/app:ro" -w /app node:24-alpine node "$@"; }

같은 할당을 세 번씩 반복했고, 마지막 할당량은 세 번이 같거나 몇 MB 안에서 달랐어요.

실습 1: Node.js의 힙 한도는 제한보다 클 수 있습니다

Node.js는 V8 엔진이 힙을 관리하고, V8도 시작할 때 메모리 크기를 보고 힙 한도를 정해요. 제한을 바꿔 가며 heap_size_limit을 MB 단위로 출력해 볼게요.

for m in 128m 256m 512m 1g 2g 4g; do
  printf "%s " $m
  docker run --rm --memory $m --memory-swap $m node:24-alpine node -e "console.log(v8.getHeapStatistics().heap_size_limit / 1048576)"
done
128m 259
256m 259
512m 259
1g 560
2g 1120
4g 2240

1GB 이상에서는 제한에 비례해 커지니, Node.js 24도 컨테이너 제한을 읽는다는 걸 알 수 있어요.

그런데 512MB 이하에서는 259MB로 바닥이 있습니다. 512MB 제한에서는 힙 한도 259MB가 제한보다 작고, 128MB나 256MB 제한에서는 힙 한도가 제한보다 커서 힙이 한도에 닿기 전에 컨테이너 제한에 먼저 닿아요.

실습 2: 같은 프로그램, 다른 끝

heap.js는 객체 25만 개짜리 배열을 계속 쌓으면서, 쌓을 때마다 heapUsed와 memory.current를 MB 단위로 출력합니다. 먼저 512MB 제한에서 돌려 볼게요.

node_run 512m oom-node heap.js 2>&1 | tail -7; show oom-node
heapUsed 244 MB, memory.current 261 MB

<--- Last few GCs --->
nt[1:0xffff7e200000]     2363 ms: Mark-Compact (reduce) 247.6 (249.3) -> 246.8 (249.3) MB, pooled: 0 MB, 403.15 / 0.00 ms  (+ 0.4 ms in 3 steps since start of marking, biggest step 0.3 ms, walltime since start of marking 410 ms) (average mu = 0.224, current[1:0xffff7e200000]     2664 ms: Mark-Compact (reduce) 251.2 (253.6) -> 251.1 (253.6) MB, pooled: 0 MB, 264.24 / 0.00 ms  (+ 0.0 ms in 3 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 268 ms) (average mu = 0.184, current
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----

139 false

GC(가비지 컬렉터, 더 이상 쓰지 않는 객체를 치워 힙을 비우는 기능)를 몇 번 돌려도 힙이 줄지 않자 V8이 JavaScript heap out of memory를 내고 끝냈어요. 글 첫머리의 "512MB 제한인데 261MB에서 끝난" 경우가 바로 이거예요. 커널은 관여하지 않았으므로 OOMKilled는 false입니다.

종료 코드는 139였습니다. V8은 오류를 출력한 뒤 프로세스를 중단하는데, 이 node:24-alpine 이미지에서는 그 과정이 SIGSEGV(11번 신호)로 끝나 128에 11을 더한 139가 세 번 모두 나왔어요. 다른 이미지에서는 다른 값이 나올 수 있으니, 종료 코드가 128보다 크다고 커널이 끝냈다고 단정하지 말고 OOMKilled를 같이 봐 주세요.

이번에는 힙 한도(259MB)가 제한보다 큰 256MB 제한에서 돌려 볼게요.

node_run 256m oom-node heap.js 2>&1 | tail -1; show oom-node
heapUsed 221 MB, memory.current 238 MB
137 true

이번에는 힙 한도에 닿기 전에 컨테이너 제한에 먼저 닿아 커널의 OOM killer가 프로세스를 끝냈어요. 리눅스에서 신호를 받아 끝난 프로세스의 종료 코드는 128에 신호 번호를 더한 값이 되고, OOM killer가 보내는 SIGKILL의 번호가 9라서 137이 나와요.

다만 docker kill도 기본으로 SIGKILL을 보내서 137로 끝나요. 그래서 137만으로는 메모리 때문인지 알 수 없고, OOMKilled가 true인지를 함께 봐야 합니다.

세 번씩 돌린 결과입니다.

  • 512MB 제한, 기본값(힙 한도 259MB): 243~244MB에서 JavaScript heap out of memory, 종료 코드 139, OOMKilled false
  • 256MB 제한, 기본값(힙 한도 259MB): 209~221MB에서 커널이 종료, 종료 코드 137, OOMKilled true

그래서 작은 컨테이너에서 Node.js를 돌린다면, --max-old-space-size를 컨테이너 제한보다 작게, 힙 밖에서 쓸 메모리를 남겨 두고 직접 지정하시는 편이 안전해요. 이 옵션은 V8 힙에서 오래 살아남은 객체가 머무는 영역(old space)의 최대 크기를 MB 단위로 정하고, NODE_OPTIONS=--max-old-space-size=192처럼 환경 변수로 줄 수도 있습니다.

정리

범위: macOS Docker Desktop(Docker Engine 27.5.1, cgroup v2), node:24-alpine, 메모리 제한 256MB와 512MB 중심, 같은 할당을 3번씩.

  • Node.js 24의 힙 한도는 512MB 이하 제한에서 259MB로 고정이었고, 1GB 이상에서는 제한에 비례해 커졌습니다. 512MB 컨테이너에서 Node.js가 261MB쯤에서 끝난다면 힙 한도를 먼저 의심하세요.
  • 512MB 제한에서는 243~244MB에서 JavaScript heap out of memory(종료 코드 139, OOMKilled false)로, 256MB 제한에서는 209~221MB에서 커널이 137, OOMKilled true로 끝냈습니다. 작은 컨테이너에서는 --max-old-space-size를 제한보다 작게 직접 주세요.
  • OOM killer가 끝낸 컨테이너는 137이었지만, docker kill도 SIGKILL을 보내 137로 끝나요. 137만 보고 OOM이라고 판단하지 말고 OOMKilled를 함께 봐 주세요.
내 링크 만들기