JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까
8코어 노드에 컨테이너를 띄웠는데
ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내부 메커니즘으로 풀어내는 글이에요. Java/JVM과 Docker를 함께 쓰는 분들을 대상으로 해요.
처음 자바 애플리케이션을 컨테이너로 올렸을 때, 저는 컨테이너가 "작은 가상 머신"이라고 막연히 생각했어요. CPU를 2개로 제한하면 그 안의 프로세스는 2코어 머신처럼 보일 거라고요.
그런데 컨테이너 안에서 Runtime.getRuntime().availableProcessors()를 찍어보면 호스트의 코어 수가 그대로 나오는 경우가 있었어요. 반대로 어떤 환경에서는 갑자기 1이 나오기도 했고요. 같은 코드가 환경에 따라 스레드 풀 크기도, GC 종류도, 힙 크기도 다르게 잡혔어요.
이 모든 혼란의 출발점은 하나의 사실이에요. 컨테이너에는 "CPU 개수"나 "메모리 용량"이라는 게 따로 없어요. 컨테이너는 그냥 호스트 커널 위에서 격리된 프로세스일 뿐이에요. 그럼 JVM은 도대체 무엇을 보고 "내가 쓸 수 있는 자원"을 판단하는 걸까요. 그 답이 바로 cgroup이에요.
컨테이너는 가상 머신이 아니에요
먼저 오해부터 풀어볼게요. 컨테이너는 호스트 커널을 그대로 공유해요. 가상화된 하드웨어가 따로 있는 게 아니에요. 컨테이너가 "격리"되어 보이는 건 리눅스 커널의 두 가지 기능 덕분이에요.
- namespace — "무엇을 볼 수 있는가"를 격리해요. PID, 네트워크, 마운트 같은 걸 컨테이너마다 분리된 것처럼 보여줘요.
- cgroup (control group) — "얼마나 쓸 수 있는가"를 제한해요. CPU 시간, 메모리, I/O에 상한을 걸어요.
문제는 전통적인 자원 조회 API들이 namespace는 신경 쓰지만 cgroup 제한은 모른다는 점이에요. 예를 들어 컨테이너 안에서 코어 수를 알아보려고 흔히 쓰는 방법들을 보면 이래요.
# /proc/cpuinfo 는 호스트의 모든 코어를 그대로 보여줘요
grep -c processor /proc/cpuinfo # 호스트가 16코어면 16
# nproc 도 cgroup 제한을 모르고 호스트 기준으로 답해요
nproc # 역시 16
/proc/cpuinfo는 호스트 커널이 노출하는 물리적인 정보예요. 컨테이너에 --cpus=2를 줬어도 이 파일은 여전히 16을 보여줘요. 메모리도 마찬가지예요. /proc/meminfo는 호스트 전체 메모리를 보여주지, "이 컨테이너에 허용된 메모리"를 보여주지 않아요.
그래서 JVM이 /proc/cpuinfo나 /proc/meminfo만 믿고 힙 크기와 스레드 풀을 정하면, 컨테이너 제한을 한참 넘는 자원을 가정하게 돼요. 그 결과가 바로 OOMKilled이고, 과도하게 큰 스레드 풀이에요.
진짜 경계선은 cgroup 파일 안에 적혀 있어요. 그래서 JVM은 cgroup을 직접 읽도록 진화해 왔어요.
UseContainerSupport — JVM이 cgroup을 읽기 시작한 순간
JVM이 처음부터 컨테이너를 알았던 건 아니에요. 컨테이너 인식 기능은 단계적으로 들어왔어요.
초기에는 Java 8과 9에 -XX:+UseCGroupMemoryLimitForHeap 같은 실험적 플래그가 있었어요. 이름 그대로 메모리 한계만 일부 반영하는 수준이었고, CPU는 다루지 못했어요.
본격적인 컨테이너 인식은 JDK-8146115("Improve docker container detection and resource configuration usage") 작업으로 들어왔어요. 이때 -XX:+UseContainerSupport 플래그가 Java 10에 도입되고, 기본값이 켜짐(on) 으로 설정됐어요. 이 변경은 널리 쓰이는 Java 8 갱신판에도 백포트됐어요. 동시에 예전의 -XX:+UseCGroupMemoryLimitForHeap은 deprecated 처리되며 UseContainerSupport로 대체됐어요.
UseContainerSupport가 켜져 있으면 JVM은 부팅 시 cgroup 파일을 읽어서, CPU 개수와 메모리 한계를 컨테이너 제한 기준으로 다시 계산해요. 이게 이 글의 핵심 메커니즘이에요.
여기서 한 가지 더 큰 갈래가 있어요. 리눅스 cgroup에는 v1과 v2(unified hierarchy) 두 가지 버전이 있어요. cgroup v2 인식은 Java 15 에서 JDK-8230305로 들어왔고, 이후 장기 지원 버전인 11.0.16+ 와 8u372+ 로 백포트됐어요. 즉, 아주 오래된 JDK 11(11.0.16 미만)이나 Java 8(8u372 미만)을 cgroup v2만 있는 시스템에서 돌리면 컨테이너 인식이 아예 동작하지 않을 수 있어요. 이건 최신 리눅스 배포판이 대부분 cgroup v2로 넘어간 지금 특히 조심해야 할 부분이에요.
cgroup v1과 v2 — JVM은 어떤 파일을 읽나
JVM은 부팅 시 자기가 컨테이너 안에 있는지, cgroup 버전이 무엇인지부터 판별해요. 이 판별과 파일 읽기는 HotSpot의 리눅스 전용 소스에 들어 있어요.
src/hotspot/os/linux/osContainer_linux.cpp # 컨테이너 감지 진입점
src/hotspot/os/linux/cgroupSubsystem_linux.cpp # 버전 무관 공통 로직
src/hotspot/os/linux/cgroupV1Subsystem_linux.cpp # cgroup v1 파일 해석
src/hotspot/os/linux/cgroupV2Subsystem_linux.cpp # cgroup v2 파일 해석
src/hotspot/os/linux/cgroupUtil_linux.cpp # CPU 개수 계산 유틸
감지 과정은 대략 이래요. /proc/cgroups와 /proc/self/mountinfo, /proc/self/cgroup을 읽어서 cgroup 버전과 각 컨트롤러(cpu, memory)의 마운트 경로를 알아내요. 그 경로 아래의 한계 파일들을 읽어서 실제 제한값을 구해요.
버전별로 읽는 파일이 다른 게 핵심이에요.
| 자원 | cgroup v1 | cgroup v2 (unified) |
|---|---|---|
| CPU 쿼터 | cpu.cfs_quota_us |
cpu.max (첫 값) |
| CPU 주기 | cpu.cfs_period_us |
cpu.max (둘째 값) |
| CPU 가중치(share) | cpu.shares |
cpu.weight |
| CPU 핀(affinity) | cpuset.cpus |
cpuset.cpus.effective |
| 메모리 한계 | memory.limit_in_bytes |
memory.max |
| 메모리 사용량 | memory.usage_in_bytes |
memory.current |
cgroup v2에서는 CPU 쿼터와 주기가 cpu.max 한 파일에 "<quota> <period>" 형태로 같이 들어가 있어요. 쿼터 자리에 max가 적혀 있으면 "제한 없음"을 뜻해요. v1에서는 두 값이 별도 파일로 나뉘어 있고, cpu.cfs_quota_us가 -1이면 제한 없음이에요.
이제 이 파일들을 읽어서 JVM이 실제로 CPU 개수와 힙 크기를 어떻게 정하는지 하나씩 따라가 볼게요.
JVM은 사실 "컨테이너"를 모른다
여기서 자주 오해하는 지점을 하나 짚고 갈게요. UseContainerSupport라는 이름 때문에 "JVM이 자기가 Docker 안인지 판별한다"고 생각하기 쉬운데, 정확히는 그렇지 않아요. JVM이 보는 건 cgroup 한계 파일 이지 "내가 컨테이너인가"라는 명제가 아니에요.
이게 왜 중요하냐면, cgroup은 컨테이너만의 것이 아니거든요. 요즘 리눅스의 systemd는 각 서비스를 cgroup으로 감싸요. 그래서 컨테이너가 전혀 없는 베어메탈 호스트에서도, systemd 유닛에 MemoryMax=나 CPUQuota=를 걸어 두면 JVM은 그 값을 컨테이너 한계처럼 똑같이 읽어요.
# 컨테이너가 아니어도, systemd 유닛의 이 설정이 JVM에 그대로 반영돼요
[Service]
MemoryMax=2G
CPUQuota=150%
CPUQuota=150%는 내부적으로 cgroup의 quota/period로 번역되니, JVM은 ceil(1.5) = 2코어로 인식해요. MemoryMax=2G는 메모리 한계로 읽혀서 기본 최대 힙이 약 512MB(2GB의 25%)로 잡히고요. "도커를 안 쓰는데 왜 힙이 작게 잡히지?"의 답이 여기 있을 수 있어요.
정리하면 UseContainerSupport는 "컨테이너 감지기"가 아니라 "cgroup 한계 반영기"에 가까워요. 그래서 컨테이너든 systemd 슬라이스든, cgroup으로 자원을 제한하는 모든 환경에서 같은 메커니즘이 작동한다고 이해하는 게 정확해요.
CPU 개수는 어떻게 정해질까
JVM에게 "CPU 개수"는 단순한 숫자가 아니에요. 이 값 하나가 스레드 풀, GC 스레드, JIT 컴파일러 스레드, 그리고 ForkJoinPool 병렬도까지 다 흔들어요. 그래서 이 계산이 가장 중요해요.
cgroup에는 CPU를 제한하는 방식이 두 가지 있어요.
- 쿼터(quota) / 주기(period) — "100ms마다 최대 200ms만큼의 CPU 시간을 써라" 같은 절대적인 상한이에요. Docker의
--cpus=2가 여기에 해당해요. - share(가중치) — "다른 컨테이너 대비 상대적으로 이만큼 비율을 가져라"는 상대값이에요. Docker의
--cpu-shares가 여기에 해당해요.
JVM은 이 두 값에서 각각 "CPU 개수처럼 보이는 숫자"를 뽑아냈어요.
quota_count = ceil(cpu_quota / cpu_period) # quota > 0, period > 0 일 때
share_count = ceil(cpu_shares / 1024) # PER_CPU_SHARES = 1024
예를 들어 Docker에서 --cpus=2를 주면 보통 cpu.cfs_quota_us=200000, cpu.cfs_period_us=100000이 돼서 quota_count = 200000 / 100000 = 2가 나와요. 깔끔하죠.
share는 이야기가 달라요. cpu.shares의 기준값이 1024라서, 예전 JVM은 shares / 1024를 코어 수처럼 해석했어요. --cpu-shares=512를 주면 share_count = ceil(512 / 1024) = 1이 되는 식이에요.
여기서 그 유명한 함정이 생겨요. share는 상대적 비율이지 절대적인 코어 수가 아니에요. A에 1024, B에 2048을 줬다고 해서 A가 1코어, B가 2코어라는 뜻이 전혀 아니에요. 16코어 머신에 컨테이너가 A 하나뿐이면 A는 16코어를 다 써요. 그런데도 JVM은 1024 / 1024 = 1이라며 "넌 1코어짜리야"라고 판단해 버렸어요.
두 값이 모두 잡혀 있을 때 어느 쪽을 따를지는 -XX:+PreferContainerQuotaForCPUCount 플래그가 결정했어요. 기본값이 켜짐이라, 쿼터가 있으면 쿼터를 우선했어요. 그래서 흐름을 그림으로 보면 이래요.
flowchart TD
A[Start: compute active processor count] --> B{UseContainerSupport on<br/>and inside a container?}
B -->|No| H[Use host CPU count]
B -->|Yes| C[Read cgroup cpu files]
C --> D{quota and period<br/>both set?}
D -->|Yes| E["quota_count = ceil(quota / period)"]
D -->|No| F[quota_count not available]
C --> G{cpu shares set?}
G -->|Yes| I["share_count = ceil(shares / 1024)"]
G -->|No| J[share_count not available]
E --> K{PreferContainerQuotaForCPUCount?}
I --> K
F --> K
J --> K
K -->|true, prefer quota| L[Pick quota_count if present]
K -->|false| M["Pick min(quota_count, share_count)"]
L --> N["result = min(picked, host CPU count)"]
M --> N
N --> O["Lower bound: at least 1 CPU"]
이 share 기반 계산이 너무 많은 사람을 괴롭혔어요. 그래서 JDK-8281181("Do not use CPU Shares to compute active processor count") 작업으로 동작이 바뀌었어요. JDK 19부터 JVM은 기본적으로 CPU share를 CPU 개수 계산에 쓰지 않아요. 즉, 쿼터만 보고 판단하고, 쿼터가 없으면 호스트 코어 수를 써요.
옛 동작이 필요한 경우를 위해 -XX:+UseContainerCpuShares 플래그가 추가됐는데, 이건 도입과 동시에 deprecated로 표시된 임시 호환 장치예요. 함께 -XX:+PreferContainerQuotaForCPUCount도 JDK 19에서 deprecated, JDK 20에서 obsolete, JDK 21에서 expire되는 일정으로 정리됐어요. 정리하면 이래요.
- JDK 18 이하 — 쿼터와 share를 모두 보고, 기본적으로 쿼터 우선. share만 있으면 share로 코어 수를 추정.
- JDK 19 이상 — share는 기본적으로 무시. 쿼터가 있으면 쿼터, 없으면 호스트 코어 수.
마지막으로 어떤 경우든 결과는 호스트의 물리 코어 수를 넘지 않고, 최소 1로 바닥을 깔아요. 그리고 이 모든 자동 판단을 무시하고 직접 못 박고 싶으면 -XX:ActiveProcessorCount=N을 주면 돼요. 이 값이 있으면 JVM은 cgroup 계산을 건너뛰고 N을 그대로 써요.
메모리(힙)는 어떻게 정해질까
메모리는 CPU보다 단순하지만, 잘못 알면 OOMKilled로 직결되는 부분이에요.
UseContainerSupport가 켜져 있으면 JVM은 cgroup의 메모리 한계 파일을 읽어요. v1이면 memory.limit_in_bytes, v2면 memory.max예요. 이 값이 있으면 JVM은 "내가 쓸 수 있는 물리 메모리"를 호스트 전체가 아니라 이 컨테이너 한계 로 간주해요.
한 가지 주의할 게 "제한 없음"의 표현이에요. cgroup v1의 memory.limit_in_bytes는 제한이 없을 때 엄청나게 큰 수(예: 9223372036854771712 근처)가 적혀 있어요. v2의 memory.max는 max라는 문자열이 적혀요. JVM은 이런 값을 "한계 없음"으로 해석하고, 그럴 때는 호스트의 실제 물리 메모리로 폴백해요. 그래서 메모리 limit을 안 걸면 컨테이너 안인데도 호스트 전체 메모리를 가정하게 돼요.
기본 힙 크기는 여기서 결정돼요. 컨테이너 환경에서 핵심이 되는 플래그는 이거예요.
| 플래그 | 기본값 | 의미 |
|---|---|---|
-XX:MaxRAMPercentage |
25.0 |
최대 힙을 (감지된 메모리)의 몇 %로 잡을지 |
-XX:InitialRAMPercentage |
1.5625 |
초기 힙 비율 |
-XX:MinRAMPercentage |
50.0 |
감지된 메모리가 아주 작을 때 적용되는 비율 |
여기서 자주 헷갈리는 부분을 짚을게요. MaxRAMPercentage의 기본값이 25.0이에요. 즉, 별도 설정이 없으면 최대 힙은 컨테이너 메모리 한계의 25% 로 잡혀요. 컨테이너에 메모리 1GB를 줬다면 기본 최대 힙은 약 256MB라는 뜻이에요. "1GB나 줬는데 왜 힙이 256MB밖에 안 잡히지?"의 답이 바로 이거예요.
이름이 헷갈리는 MinRAMPercentage는 "최소 힙"이 아니에요. 감지된 메모리가 아주 작을 때(작은 컨테이너) 적용되는 비율이에요. 작은 컨테이너에서는 25%가 너무 빠듯할 수 있으니, 작은 메모리 구간에서는 더 큰 비율을 허용하려는 의도예요.
물론 -Xmx로 직접 지정하면 이 비율 계산은 무시돼요. 컨테이너에서는 -Xmx 같은 절대값보다 -XX:MaxRAMPercentage로 비율을 잡는 게 권장돼요. 컨테이너 메모리 limit을 바꿔도 힙이 비율 따라 같이 움직여서, 설정을 한 곳(쿠버네티스 매니페스트)에서만 관리할 수 있거든요.
숫자로 따라가는 한 바퀴
말로만 보면 추상적이니, 구체적인 명령 하나로 처음부터 끝까지 따라가 볼게요. cgroup v2를 쓰는 최신 리눅스 호스트(물리 16코어, 64GB)에서 이렇게 실행했다고 해볼게요.
docker run --cpus=1.5 --memory=1g eclipse-temurin:21 \
java -Xlog:os+container=trace -version
먼저 Docker가 이 옵션을 cgroup 파일로 번역해요. cgroup v2 기준이에요.
cpu.max -> "150000 100000" # quota=150000, period=100000
memory.max -> 1073741824 # 1 GiB in bytes
--cpus=1.5는 "주기 100ms마다 150ms어치 CPU 시간"으로 번역돼요. --memory=1g는 memory.max에 1GiB가 그대로 적혀요.
이제 JVM이 부팅하면서 이 값을 읽고 계산해요.
1. 컨테이너 감지 -> /proc/self/mountinfo 등에서 cgroup v2 확인
2. CPU 개수
quota_count = ceil(150000 / 100000) = ceil(1.5) = 2
share 없음 -> share_count 미사용
result = min(2, host 16) = 2
3. 메모리 한계 -> memory.max = 1 GiB (제한 있음으로 인식)
4. 최대 힙
MaxRAMPercentage = 25.0 (기본값)
MaxHeapSize ≈ 1 GiB * 0.25 ≈ 256 MB
여기서 눈여겨볼 게 두 가지 있어요.
첫째, --cpus=1.5인데 availableProcessors()는 2 가 나와요. ceil(1.5) = 2라서 그래요. JVM은 코어 수를 정수로 올림해요. "1.5코어를 줬으니 1.5겠지"가 아니라는 점을 기억해 두면 좋아요.
둘째, CPU는 2개로 잡혔지만 메모리가 1GiB라서 1792MB 기준에 못 미쳐요. 그래서 이 컨테이너의 기본 GC는 G1이 아니라 Serial GC가 돼요. CPU 조건(2개 이상)은 만족했는데 메모리 조건에서 걸린 거예요. 두 조건은 AND라서 하나만 못 넘어도 Serial로 떨어진다는 걸 이 예제가 잘 보여줘요.
-Xlog:os+container=trace 로그를 보면 위 1~4단계가 거의 그대로 한 줄씩 찍혀 나와요. 그래서 "내 계산이 맞나"를 머리로 추론하지 말고 로그로 확인하라는 거예요.
도미노 — availableProcessors 하나가 흔드는 것들
CPU 개수 계산이 왜 그렇게 중요한지, 그 파급 효과를 보면 실감이 나요. Runtime.availableProcessors()가 반환하는 이 값은 단순한 정보가 아니라 JVM 곳곳의 기본값을 결정하는 입력값이에요.
flowchart TD
A["availableProcessors() value from cgroup"] --> B["ForkJoinPool.commonPool parallelism = N - 1"]
A --> C["Parallel Stream uses common pool"]
A --> D["GC parallel and concurrent thread counts"]
A --> E["JIT C1/C2 compiler thread counts"]
A --> F["Thread pools sized by Runtime in libraries"]
A --> G{"GC ergonomics: 2+ CPUs and 1792+ MB memory?"}
G -->|Yes| H["Default to G1 GC"]
G -->|No| I["Default to Serial GC"]
특히 두 가지가 운영에서 자주 사고를 일으켜요.
첫째, ForkJoinPool의 공용 풀. ForkJoinPool.commonPool()의 병렬도는 기본적으로 availableProcessors() - 1이에요. 병렬 스트림(parallelStream())도 이 공용 풀을 써요. 컨테이너 인식이 잘못돼서 CPU가 1로 잡히면 1 - 1 = 0이 되고, 공용 풀은 사실상 직렬로 동작해요. 8코어 노드인데 병렬 스트림이 하나도 병렬화되지 않는 황당한 상황이 이렇게 생겨요.
둘째, GC 자동 선택. JVM은 부팅 시 "이 환경이 서버급인가"를 판단해요. 기준은 CPU가 2개 이상이고, 사용 가능한 메모리가 1792MB 이상인지예요. 이 조건을 만족하면 기본 GC로 G1을 고르고, 아니면 Serial GC를 골라요. 컨테이너 메모리를 1.5GB로 빠듯하게 줬다면 1792MB에 못 미쳐서 조용히 Serial GC가 선택돼요. "운영 환경인데 왜 GC 멈춤이 길지?"의 원인이 여기 숨어 있을 수 있어요.
요점은 이거예요. cgroup 한계가 CPU 개수와 메모리 크기를 바꾸고, 그 두 숫자가 다시 스레드 풀과 GC를 바꿔요. 컨테이너 자원 설정은 단순히 "얼마나 줄까"가 아니라 JVM의 런타임 성격 자체를 바꾸는 스위치 인 셈이에요.
쿠버네티스에서 특히 조심할 점
쿠버네티스를 쓰면 cgroup 값을 직접 만지지 않고 requests/limits로 선언해요. 이게 cgroup으로 어떻게 번역되는지 알아야 위 함정들이 보여요.
resources:
requests:
cpu: "500m" # cpu.shares 로 번역됨 (대략 millicore * 1024 / 1000)
memory: "512Mi" # 스케줄링 힌트에 가깝고 cgroup 한계는 limits 가 결정
limits:
cpu: "2" # cpu.cfs_quota_us (period 100000 기준 quota=200000)
memory: "1Gi" # memory.limit_in_bytes / memory.max
requests.cpu는cpu.shares로 번역돼요. 즉 상대적 가중치예요.limits.cpu는cpu.cfs_quota_us로 번역돼요. 즉 절대적 쿼터예요.limits.memory는memory.limit_in_bytes(v1) /memory.max(v2) 로 번역돼요. 이게 JVM이 읽는 메모리 한계예요.
여기서 두 가지 시나리오가 사고를 일으켜요.
시나리오 1 — requests.cpu만 주고 limits.cpu는 안 준 경우 (JDK 18 이하). 쿼터가 없으니 JVM은 share를 봐요. requests.cpu: "500m"은 대략 cpu.shares=512로 번역되고, ceil(512/1024) = 1이 돼요. 8코어 노드인데 JVM은 자기가 1코어짜리라고 믿어요. 스레드 풀이 쪼그라들고 처리량이 바닥을 쳐요. 이게 컨테이너 시대 초기에 가장 흔했던 사고예요.
시나리오 2 — limits.cpu를 안 준 경우 (JDK 19 이상). 이번엔 반대 문제예요. JDK 19부터는 share를 무시하니, 쿼터가 없으면 호스트 코어 수를 그대로 써요. 64코어 노드에 떠 있으면 JVM은 64코어 기준으로 GC 스레드와 스레드 풀을 잡아요. 컨테이너에 실제로는 0.5코어어치 시간만 허용돼 있는데도요. 결과는 과도한 컨텍스트 스위칭과 메모리 낭비예요.
그래서 권장은 명확해요. CPU limits를 명시적으로 설정하세요. 그래야 JDK 버전과 무관하게 쿼터 기반으로 일관되게 계산돼요. 그래도 불안하면 -XX:ActiveProcessorCount=N으로 직접 못 박는 게 가장 확실해요.
직접 확인하는 법
추측 대신 JVM이 실제로 무엇을 봤는지 눈으로 확인하는 게 제일 좋아요. 가장 강력한 도구는 컨테이너 관련 통합 로깅이에요.
# JVM이 cgroup에서 무엇을 읽었는지 그대로 출력해요
java -Xlog:os+container=trace -version
이 로그를 켜면 감지한 cgroup 버전, 읽은 쿼터/주기/share, 계산된 CPU 개수, 메모리 한계가 한 줄씩 찍혀요. "내가 의도한 값이 들어갔나"를 가장 빠르게 검증할 수 있어요.
확정된 최종 플래그 값을 보고 싶으면 이렇게 해요.
# 최종적으로 적용된 힙/CPU 관련 플래그 확인
java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|MaxRAMPercentage|ActiveProcessorCount|UseContainerSupport"
MaxHeapSize가 컨테이너 메모리 한계의 25% 근처인지, ActiveProcessorCount가 의도한 값인지 바로 보여요.
코드 안에서 확인하고 싶다면 표준 MXBean을 쓰면 돼요.
import com.sun.management.OperatingSystemMXBean;
import java.lang.management.ManagementFactory;
OperatingSystemMXBean os =
(OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
System.out.println("availableProcessors = " + Runtime.getRuntime().availableProcessors());
System.out.println("maxMemory(MB) = " + (Runtime.getRuntime().maxMemory() >> 20));
availableProcessors()가 cgroup 인식 결과를 그대로 반영하고, maxMemory()가 컨테이너 한계 기반으로 계산된 최대 힙을 보여줘요. 컨테이너에 올린 직후 이 두 줄을 로그로 한 번 찍어두면, 환경이 바뀔 때마다 디버깅이 훨씬 수월해져요.
자주 부딪히는 함정 정리
지금까지 따라온 메커니즘에서 실제로 사고를 일으키는 지점만 모으면 이래요. 증상과 원인, 그리고 대응을 한 표로 정리해 둘게요.
| 증상 | 진짜 원인 | 대응 |
|---|---|---|
| 컨테이너가 자꾸 OOMKilled | MaxRAMPercentage 기본 25%인데 힙 외 메모리(메타스페이스, 스레드 스택, 네이티브)까지 합치면 한계 초과 |
비율을 낮추거나 컨테이너 limits.memory를 늘려요. 힙만 한계가 아니라는 걸 기억해요 |
| 8코어 노드인데 처리량이 바닥 | JDK 18 이하 + requests.cpu만 설정 → share 기반 추정으로 CPU=1 |
CPU limits를 명시하거나 -XX:ActiveProcessorCount 지정 |
| 64코어 노드에서 스레드 폭증 | JDK 19 이상 + limits.cpu 미설정 → 호스트 코어 수 그대로 사용 |
CPU limits를 반드시 설정 |
| 운영인데 GC 멈춤이 길다 | 메모리 1792MB 미만 또는 CPU 1개라 Serial GC가 자동 선택됨 | 메모리·CPU를 기준 이상으로 주거나 -XX:+UseG1GC 명시 |
| 병렬 스트림이 병렬화 안 됨 | CPU=1로 잡혀 commonPool 병렬도가 1-1=0 |
CPU 인식 값을 먼저 바로잡아요 |
| 아주 오래된 JDK에서 컨테이너 인식 안 됨 | 11.0.16 미만 / 8u372 미만을 cgroup v2 전용 호스트에서 실행 | JDK를 백포트 포함 버전으로 올려요 |
공통적으로 보이는 패턴이 있어요. 대부분의 사고는 "JVM이 본 값"과 "내가 의도한 값"이 어긋났을 때 생겨요. 그리고 그 어긋남은 거의 항상 requests/limits를 어떻게 줬는지, JDK 버전이 몇인지에서 비롯돼요. 그래서 컨테이너에 자바를 올릴 때 체크리스트는 짧아요. CPU limits를 명시했는가, 메모리 비율을 의도대로 잡았는가, 그리고 -Xlog:os+container=trace로 실제 값을 한 번 확인했는가. 이 셋이면 위 표의 거의 모든 줄을 예방할 수 있어요.
마무리
컨테이너 속 JVM이 이상하게 행동하는 거의 모든 사례는 결국 한 문장으로 정리돼요. 컨테이너에는 CPU 개수도 메모리 용량도 없고, JVM은 cgroup 파일을 읽어 그것을 추정한다.
이 글에서 따라간 메커니즘을 정리하면 이래요.
- 컨테이너는 가상 머신이 아니에요 —
/proc/cpuinfo,/proc/meminfo는 호스트를 보여줘요. 진짜 경계는 cgroup이에요. - UseContainerSupport — Java 10에서 기본 켜짐으로 도입(JDK-8146115), Java 8u로 백포트. cgroup v2 인식은 Java 15(JDK-8230305)부터, 11.0.16+ / 8u372+로 백포트.
- CPU 개수 —
ceil(quota/period)가 핵심. share 기반 추정은 함정이 많아 JDK 19부터 기본 비활성(JDK-8281181).-XX:ActiveProcessorCount로 못 박을 수 있어요. - 힙 크기 —
MaxRAMPercentage기본값 25%. 컨테이너 메모리 한계의 1/4이 기본 최대 힙. limit을 안 걸면 호스트 메모리로 폴백. - 도미노 — CPU 개수가
ForkJoinPool공용 풀과 GC 자동 선택(2코어·1792MB 기준 G1 vs Serial)을 흔들어요. - 쿠버네티스 —
requests.cpu는 share,limits.cpu는 쿼터. CPUlimits를 명시하는 게 가장 안전해요.
컨테이너 자원 설정은 "얼마나 줄까"의 문제가 아니라, JVM이 자기 자신을 어떻게 인식할지를 정하는 설정이에요. 한 번 -Xlog:os+container=trace로 JVM의 눈을 직접 들여다보면, 그동안 이상하게 느껴졌던 동작들이 대부분 설명돼요.
참고자료
- Ergonomics — Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide (Oracle) — 서버급 머신 판정(2코어·1792MB)과 GC 자동 선택 규칙
- The java Command — JDK Tool Specifications (Oracle) —
UseContainerSupport,MaxRAMPercentage,ActiveProcessorCount등 플래그 정의 - OpenJDK 소스 — cgroupSubsystem_linux.cpp — CPU 개수 계산과 cgroup 공통 로직
- JDK-8146115 — Improve docker container detection and resource configuration usage — UseContainerSupport 도입
- JDK-8230305 — Cgroups v2: Container awareness — cgroup v2 인식 도입
- JDK-8281181 — Do not use CPU Shares to compute active processor count — JDK 19의 share 기반 계산 비활성화
- Java 17: What's new in OpenJDK's container awareness (Red Hat Developer) — 버전별 컨테이너 인식 정리
- OpenJDK 8u372 to feature cgroup v2 support (Red Hat Developer) — cgroup v2 백포트 범위
- Control Group v2 — The Linux Kernel documentation —
cpu.max,cpu.weight,memory.max파일 정의 - Resource Management for Pods and Containers (Kubernetes) — requests/limits가 cgroup으로 번역되는 방식

