Skip to Content

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~500MB126MB로 고정
③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은 자기가 몇 개를 가졌다고 답할까요. 반 개라고 답할 수는 없을 텐데요.

▶ 14장. CPU 제한과 스레드 풀 — CFS quota 스로틀링

Last updated on