13장. 512MB 안의 JVM — 컨테이너 인식 JDK와 OOMKilled
--memory 512m을 건 앱이 실제로 쓰는 힙은 36MB였습니다. 그런데 컨테이너가 먹는 메모리는 202MB였어요. 166MB가 힙 바깥이고, -Xmx로는 1바이트도 안 줄어듭니다.
{/* 삽화 자리: 큰 상자 안에 작은 상자 하나가 놓여 있고, 사람은 그 작은 상자만 손가락으로 가리키며 아직 여유롭다는 표정이다. 그런데 큰 상자 바닥에는 작은 상자보다 훨씬 많은 이름표 없는 꾸러미가 빽빽이 깔려 있어 실제로는 절반 가까이 차 있다. 사람의 시선은 꾸러미들을 지나쳐 작은 상자에만 머문다. 프롬프트: docs/book/illustration-prompts-container.md */}
배경
12장에서 여는 글의 137을 풀었습니다. 유예가 끝나고 SIGKILL이 왔던 그 137이요.
그런데 7장에서 같은 숫자를 한 번 더 봤습니다. 메모리 제한을 건 컨테이너가 OOMKilled=true에 exit 137로 죽었죠. 같은 숫자, 다른 이유입니다.
자바 앱에서 이 두 번째 137은 흔한 사고입니다. 그리고 그 원인으로 오래 회자된 설명이 하나 있어요. “JVM이 컨테이너 메모리 제한을 모르고 호스트 전체 메모리를 보기 때문”이라는 거요.
맞는 말이었습니다. 다만 ‘지금’이 언제부터인지가 함정이에요.
이 장은 두 가지를 봅니다. 요즘 JVM이 실제로 무엇을 보는지, 그리고 그런데도 왜 여전히 죽을 수 있는지요.
스토리
먼저 확인부터 했습니다. JVM에게 직접 물어보면 됩니다.
docker run --rm --memory 512m eclipse-temurin:21-jre \
java -XshowSettings:system -version
docker run --rm --memory 512m eclipse-temurin:21-jre \
java -XX:+PrintFlagsFinal -version | grep MaxHeapSize=== 1. JVM 은 컨테이너 제한을 보는가 ===
제한 없음 본 한도=Unlimited MaxHeapSize=2044 MB
--memory 256m 본 한도=256.00M MaxHeapSize=126 MB
--memory 512m 본 한도=512.00M MaxHeapSize=128 MB
--memory 1g 본 한도=1.00G MaxHeapSize=256 MB
--memory 2g 본 한도=2.00G MaxHeapSize=512 MB
기본 비율: MaxRAM%=25% MinRAM%=50%✅ 실측 (Docker Engine 29.8.0 / Docker Desktop 4.91.0 / Temurin 21.0.12 / Apple M1 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch13/jvm-memory.sh)
봅니다. 그것도 정확하게요.
“본 한도” 열이 7장에서 만진 그 memory.max입니다. JVM이 그 파일을 읽어서 자기가 쓸 수 있는 양을 판단해요. 제한이 없으면 Unlimited, 걸면 그 값이 그대로 나옵니다.
그리고 힙은 그 한도의 일부만 가져갑니다. 512MB면 128MB, 1GB면 256MB. 정확히 25%예요.
그러니까 “JVM이 컨테이너 제한을 모른다”는 말은 옛날 이야기입니다. 다만 그 “옛날”이 생각보다 최근이에요.
그리고 표에 이상한 줄이 하나 있습니다. 256MB인데 힙이 126MB예요. 25%면 64MB여야 하는데 절반 가까이 가져갔습니다.
핵심
”옛날”이 언제부터인가
컨테이너 인식은 한 번에 들어온 게 아닙니다. 두 번에 나눠서 들어왔어요. cgroup v1을 읽는 기능이 먼저, v2를 읽는 기능이 한참 뒤에요.
그게 언제인지 이미지를 갈아 끼우며 확인했습니다.
=== 1-3. cgroup v2 인식은 언제 들어왔나 ===
eclipse-temurin:11.0.15_10-jre MaxHeapSize=2044MB UseContainerSupport=true
eclipse-temurin:11.0.16_8-jre MaxHeapSize=128MB UseContainerSupport=true
eclipse-temurin:8u362-b09-jre MaxHeapSize=2044MB UseContainerSupport=true
eclipse-temurin:8u372-b07-jre MaxHeapSize=128MB UseContainerSupport=true✅ 실측 (같은 환경, 같은 소스. 전부 --memory 512m)
세 번째 열을 보세요. 넷 다 UseContainerSupport=true입니다. 컨테이너 인식이 켜져 있어요.
그런데 11.0.15와 8u362는 힙을 2044MB로 잡았습니다. 512MB를 걸었는데 기계 전체를 본 거예요. 기능은 켜져 있는데 v2를 못 읽어서 못 본 겁니다.
경계가 11.0.16과 8u372입니다. 11.0.15는 2022년 4월에 나온 버전이에요.
이게 왜 중요하냐면, 이 책의 실험은 1장부터 전부 cgroup v2이기 때문입니다. Ubuntu 22.04와 RHEL 9부터 리눅스는 v2가 기본이고요. 즉 “JDK 11 쓰는데 왜 OOMKilled가 나지”는 지금도 벌어지는 사고입니다. 버전 끝자리를 봐야 해요.
📄 문서 기반 (cgroup v1 인식은 JDK 10에 들어와 8u191로 백포트, v2 인식은 JDK 15에 들어와 위 두 버전으로 백포트)
작은 컨테이너에서는 구간이 셋입니다
256MB의 50%는 128MB입니다. 126MB가 아니에요. 그러니 “작으면 50%“만으로는 설명이 안 됩니다.
한도를 촘촘히 훑어봤습니다.
=== 1-2. 한도별 힙: 구간이 몇 개인가 ===
--memory 128m 힙 64.0 MB --memory 400m 힙 126.0 MB
--memory 200m 힙 100.0 MB --memory 500m 힙 126.0 MB
--memory 240m 힙 120.0 MB --memory 512m 힙 128.0 MB
--memory 250m 힙 126.0 MB --memory 600m 힙 150.0 MB
--memory 256m 힙 126.0 MB --memory 1g 힙 256.0 MB
--memory 300m 힙 126.0 MB✅ 실측 (같은 환경, 같은 소스)
한도를 250m에서 500m으로 두 배 올렸는데 힙은 126MB에서 126MB가 됐습니다. 1바이트도 안 늘었어요.
구간이 둘이 아니라 셋입니다.
| 구간 | 한도 | 힙 |
|---|---|---|
| ① | 250MB 미만 | 한도 × 50% (MinRAMPercentage) |
| ② | 250~500MB | 126MB로 고정 |
| ③ | 512MB 이상 | 한도 × 25% (MaxRAMPercentage) |
가운데 평지의 정체는 MaxHeapSize의 기본값입니다. 비율과 무관하게 JVM이 들고 있는 기본 힙이 126MB이고, 두 비율로 계산한 값이 그보다 작을 때만 계산값을 씁니다. 그래서 250~500MB 구간은 한도를 올려도 힙이 안 따라와요.
이름도 헷갈리게 지어져 있습니다. MinRAMPercentage는 “최소 비율”이 아니라 “메모리가 적을 때 쓸 비율”이에요. 이름만 보고 반대로 읽기 쉽습니다.
그런데 힙이 전부가 아닙니다
여기까지면 좋은 소식입니다. JVM이 알아서 잘 잡아주니까요.
문제는 다른 데 있습니다. 512MB 컨테이너에 앱을 띄우고 실제 메모리를 재봤어요.
=== 2. 힙이 전부가 아니다 (--memory 512m 으로 앱을 띄우고) ===
자바 프로세스 RSS : 199 MB
cgroup 이 세는 양 : 189 MB
cgroup 한도 : 512 MB✅ 실측 (같은 환경, 같은 소스. RSS는 실행마다 195~215MB 사이로 갈립니다)
힙 최대치가 128MB인데 프로세스는 200MB 가까이 쓰고 있습니다. 아무 요청도 안 받은 상태에서요.
한 가지 짚고 가면, memory.current가 RSS보다 10MB쯤 작습니다. 보통은 반대예요 — cgroup은 페이지 캐시까지 세니까요. 여기선 이미지 레이어의 파일 페이지가 이미 다른 그룹에 과금돼 있어서 이 그룹에는 안 잡힙니다.
차이는 힙 바깥에 있습니다. 자바 프로세스가 쓰는 메모리는 힙만이 아니거든요.
| 어디 | 무엇 |
|---|---|
| 힙 | 우리가 new로 만드는 객체. -Xmx가 정하는 그 영역 |
| 메타스페이스 | 클래스 정보. 스프링은 클래스가 많다 |
| 스레드 스택 | 스레드마다 하나씩. 톰캣은 최대 200개지만 처음엔 10개만 띄운다 |
| 코드 캐시 | JIT이 컴파일한 기계어 |
| GC 구조체 | 카드 테이블·리멤버드 셋 같은 GC 자체 자료구조. 대개 작다 |
| 다이렉트 버퍼 | NIO가 힙 바깥에 잡는 버퍼 |
cgroup은 이 전부를 셉니다. 7장에서 memory.current가 뭘 세는지 봤죠. 프로세스가 실제로 만진 페이지를 세는 거지 “힙”이라는 개념은 모릅니다.
그러니까 -Xmx는 저 표의 첫 줄만 제한합니다. 나머지는 그대로 더해져요.
그 바깥이 얼마인지 재봤습니다
표만으로는 크기 감이 안 옵니다. JVM에게 직접 물어보면 됩니다. NMT(Native Memory Tracking)를 켜면 JVM이 자기가 잡은 메모리를 항목별로 세어 줘요.
=== 2-2. 힙 바깥이 실제로 얼마인가 (NMT) ===
RSS : 202 MB
NMT 합계(committed): 119 MB
그중 Java Heap : 36 MB✅ 실측 (같은 환경, 같은 소스)
힙은 36MB만 쓰고 있었습니다. 최대치 128MB의 4분의 1이에요. 그런데 컨테이너는 202MB를 먹고 있습니다.
그러니까 -Xmx를 아무리 조여도 줄어들 여지가 36MB뿐입니다. 나머지 166MB는 -Xmx가 손댈 수 없는 영역이에요.
NMT 합계가 119MB라는 것도 봐둘 만합니다. RSS 202MB의 절반 조금 넘어요. NMT는 JVM이 스스로 잡은 것만 세거든요. 매핑된 JAR 파일, .so 라이브러리의 코드 페이지, malloc이 따로 잡은 영역은 못 셉니다. NMT로도 절반밖에 설명이 안 됩니다. 그래서 결국 RSS를 봐야 해요.
JRE 이미지에는 jcmd가 없어서 jcmd 1 VM.native_memory는 안 돕니다. 대신 -XX:NativeMemoryTracking=summary -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics를 주면 종료할 때 출력해 줍니다. 위 숫자를 그렇게 뽑았어요.
그래서 -Xmx를 한도까지 밀면 안 됩니다
흔한 실수가 이겁니다. --memory 256m을 걸었으니 -Xmx240m쯤 주면 딱 맞겠다고요.
그러면 힙 바깥 몫이 안 남습니다. 방금 본 표의 나머지 다섯 줄이 갈 곳이 없어요.
실제로 해봤습니다. --memory 256m에 -Xmx240m을 걸고, AlwaysPreTouch로 힙을 시작부터 물리 메모리에 올리게 했어요.
그런데 안 죽었습니다. 두 가지가 틀려 있었어요.
첫째, AlwaysPreTouch가 만지는 건 -Xmx가 아닙니다.
AlwaysPreTouch 가 실제로 만지는 크기:
InitialHeapSize 8 MB
MaxHeapSize 240 MB✅ 실측 (같은 환경, 같은 소스)
AlwaysPreTouch는 커밋된 힙만 미리 만집니다. -Xms를 안 주면 초기 힙이 8MB고, 그래서 8MB만 만져요. 240MB를 올리려면 -Xms240m이 같이 가야 합니다.
둘째, --memory 256m은 256MB를 거는 게 아닙니다.
--memory 256m 이 실제로 거는 것:
memory.max=268435456 memory.swap.max=268435456
(--memory-swap 256m 을 더하면) memory.swap.max=0✅ 실측 (같은 환경, 같은 소스)
memory.swap.max에도 같은 값이 들어갑니다. 스왑을 같은 양만큼 덤으로 준다는 뜻이에요. 그러니까 --memory 256m은 사실상 512MB입니다. 넘친 페이지가 스왑으로 빠져나가니 안 죽었던 거고요.
둘을 고쳐서 2×2로 돌렸습니다.
=== 3. -Xmx 를 한도까지 밀면 정말 죽는가 (2x2) ===
-Xmx240m -XX:+AlwaysPreTouch 스왑 열림 살아 있다
-Xmx240m -XX:+AlwaysPreTouch --memory-swap 256m 살아 있다
-Xms240m -Xmx240m -XX:+AlwaysPreTouch 스왑 열림 살아 있다
-Xms240m -Xmx240m -XX:+AlwaysPreTouch --memory-swap 256m 죽었다 exit 137 OOMKilled=true✅ 실측 (같은 환경, 같은 소스)
마지막 줄이 우리가 찾던 그겁니다. OOMKilled=true에 exit 137. 7장에서 본 그 죽음이에요.
그리고 셋 중 하나만으로는 안 죽는다는 것도 보입니다. 힙을 진짜로 올려야 하고, 스왑 탈출구도 막아야 해요. 둘 중 하나만 하면 살아남습니다.
프로덕션 리눅스 서버는 대개 스왑을 끕니다. 즉 세 번째 줄이 아니라 네 번째 줄이 여러분의 환경이에요. 힙 바깥 166MB에 트래픽이 붙어 스레드와 버퍼가 늘어나면, -Xmx를 한도 가까이 민 앱은 그렇게 죽습니다.
137을 만났을 때 구분하는 법
12장의 137과 이 장의 137은 로그만 보면 똑같이 생겼습니다. 구분할 방법이 있습니다.
docker inspect <컨테이너> --format '{{.State.OOMKilled}} {{.State.ExitCode}}'OOMKilled가 true면 메모리 한도에 걸린 겁니다. false인데 137이면 12장 쪽, 유예가 끝나 SIGKILL이 온 거고요.
7장에서 만진 파일로도 확인됩니다. memory.events의 oom_kill이 0보다 크면 그 그룹에서 누가 죽은 거예요.
docker exec <컨테이너> cat /sys/fs/cgroup/memory.events겉으로는 같은 137이지만, 한쪽은 “종료 신호를 못 들어서”, 다른 쪽은 “메모리를 넘어서”입니다. 고칠 곳이 완전히 다릅니다.
그래서 어떻게 잡나
정리하면 이렇습니다.
| 하면 안 되는 것 | 대신 |
|---|---|
-Xmx를 컨테이너 한도까지 밀기 | 안 건드리고 JVM에게 맡기거나, 한도의 50~70% 선에서 잡기 |
| 힙만 보고 한도 정하기 | RSS를 재서 정하기. 힙 바깥이 얼마인지는 앱마다 다르다 |
한도가 250~500MB인데 MaxRAMPercentage로 조절하기 | 그 구간에서는 무시된다. 힙이 126MB로 고정이다 |
| 숫자를 짐작으로 정하기 | 띄워놓고 memory.current와 RSS를 실제로 읽기 |
JVM이 알아서 잡게 두는 게 대체로 낫습니다. 기본값이 보수적인 이유가 힙 바깥 몫을 남겨두려는 것이거든요.
건드려야 한다면 비율로 조절하는 쪽이 안전합니다. -XX:MaxRAMPercentage=60 같은 식이요. 한도를 바꿔도 따라가니까요. -Xmx로 절대값을 박아두면 컨테이너 한도만 올리고 -Xmx를 안 고치는 실수가 생깁니다.
다만 앞에서 본 평지 구간은 예외입니다. 한도가 250~500MB면 MaxRAMPercentage를 올려도 힙이 126MB에서 안 움직여요. 사이드카나 배치 잡이 딱 그 구간에 많습니다.
정리
- 요즘 JDK는 컨테이너 메모리 제한을 봅니다. 7장에서 만진
memory.max를 읽어요. 실측에서--memory 512m에 힙 128MB,1g에 256MB로 정확히 25%였습니다. - 다만 “요즘”이 최근입니다. 컨테이너 인식은 v1과 v2로 나눠 들어왔고, v2 인식은 11.0.16·8u372부터예요. 실측에서 11.0.15는
UseContainerSupport=true인데도 512MB 컨테이너에서 힙을 2044MB로 잡았습니다. - 힙 구간은 셋입니다. 250MB 미만은 50%, 250~500MB는 126MB 고정, 512MB 이상은 25%. 실측에서 한도를 250m→500m으로 두 배 올려도 힙은 126MB 그대로였어요.
- 힙은 전부가 아닙니다. 메타스페이스·스레드 스택·코드 캐시·GC 구조체·다이렉트 버퍼가 힙 바깥에 있고, cgroup은 그 전부를 셉니다.
- NMT로 재보니 실제 힙은 36MB인데 RSS는 202MB였습니다. 166MB가 힙 바깥이고
-Xmx로는 안 줄어들어요. NMT 합계조차 119MB라 RSS의 절반밖에 설명하지 못합니다. AlwaysPreTouch는-Xmx가 아니라-Xms를 만집니다. 실측에서-Xms없이 쓰면 8MB만 만졌어요.docker run --memory 256m은memory.swap.max에도 256MB를 넣습니다. 스왑을 같은 양 덤으로 주는 거라 사실상 512MB예요.-Xms240m으로 힙을 진짜 올리고--memory-swap 256m으로 스왑을 막았더니 드디어OOMKilled=true, exit 137이 나왔습니다. 둘 중 하나만 하면 안 죽습니다.- 137을 만나면
docker inspect의OOMKilled를 보세요.true면 메모리,false면 12장 쪽입니다.
생각해볼 질문
메모리는 그래도 직관적입니다. 넘으면 죽으니까요.
CPU는 안 죽습니다. 대신 멈췄다 가요.
그런데 컨테이너에 CPU를 0.5개만 줬을 때, JVM은 자기가 몇 개를 가졌다고 답할까요. 반 개라고 답할 수는 없을 텐데요.