Kuma's Curious Paradise
JVM 옵션 설정하기 본문
이번에 한 서버에서 메모리와 디스크 사용량 관련 문제가 발생해 설정 변경을 진행했다.
그런데 막상 설정을 바꾸려다 보니, Dockerfile 안의 JVM 옵션들이 정확히 어떤 의미인지 제대로 이해하지 못하고 있다는 생각이 들었다. 그래서 이번 기회에 Dockerfile의 각 설정이 어떤 의미인지, 그리고 왜 JVM 메모리 설정을 조정해야 하는지 정리해보려 한다.
우선 이번 작업이 메모리 문제의 근본 원인을 완전히 해결한 것은 아니다. 다만 JVM이 컨테이너 안에서 사용할 수 있는 메모리 범위를 더 안전하게 제한해, 컨테이너가 갑자기 OOMKilled 되는 위험을 줄이는 방향으로 설정을 조정했다.
수정 대상 Dockerfile은 다음과 같다.
FROM amazoncorretto:17-alpine
ENV TZ=Asia/Seoul
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
RUN mkdir -p /logs
EXPOSE 80
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:InitialRAMPercentage=50.0", \
"-XX:MaxRAMPercentage=50.0", \
"-XX:MaxDirectMemorySize=256m", \
"-XX:+HeapDumpOnOutOfMemoryError", \
"-XX:HeapDumpPath=/logs/dump.hprof", \
"-XX:+ExitOnOutOfMemoryError", \
"-Djava.awt.headless=true", \
"-jar", "/app.jar"]
그럼 한 줄씩 살펴보자.
FROM amazoncorretto:17-alpine
이 줄은 컨테이너 이미지의 기반 이미지를 지정한다.
여기서 amazoncorretto는 Amazon에서 제공하는 OpenJDK 배포판인 Corretto 이미지다. 17-alpine은 그 이미지의 태그다.
즉, 전체 의미는 다음과 같다. Amazon Corretto JDK 17을 Alpine Linux 기반 이미지로 사용하겠다.
처음에는 amazoncorretto:17과 amazoncorretto:17-alpine이 비슷해 보일 수 있다. 하지만 둘은 기반 OS가 다르다.
amazoncorretto:17 → 일반 Linux 기반 이미지
amazoncorretto:17-alpine → Alpine Linux 기반 이미지
Alpine Linux는 매우 가벼운 리눅스 배포판이다. 그래서 17-alpine 이미지는 보통 이미지 크기가 작다. 이미지가 작으면 빌드, 푸시, 풀 시간이 줄어들 수 있다.
이번 서버는 단순한 Spring Boot API 서버에 가깝기 때문에 amazoncorretto:17-alpine을 사용해도 큰 문제가 없다고 판단했다.
다만 무조건 Alpine이 좋은 것은 아니다.
PDF 변환, 이미지 리사이징, 폰트 렌더링, APM 에이전트, gRPC/Netty Native, OpenCV 같은 기능이 들어가면 Alpine은 쓰지 않는 것이 좋다. 이런 기능들은 Java 코드처럼 보여도 실제로는 OS 레벨의 네이티브 라이브러리에 의존하는 경우가 많기 때문이다.
여기서 네이티브 의존성이란 JVM 내부에서만 끝나는 것이 아니라, 컨테이너 OS에 설치된 C/C++ 기반 라이브러리나 시스템 라이브러리를 함께 사용하는 의존성을 말한다. 정확하게 설명이 들어가려면 공부를 많이 진행해야 할 것 같아서, 이 정도로 설명하고 넘어간다.
따라서 기준은 이렇게 잡으면 된다.
단순 Spring Boot API 서버 → amazoncorretto:17-alpine 가능
PDF / 이미지 / 폰트 / 네이티브 라이브러리 사용 → amazoncorretto:17
---
COPY ${JAR_FILE} app.jar
위에서 지정한 JAR_FILE 경로에, 생성된 jar파일을 복사한 후 이름을 app.jar로 붙인다.
예를 들어 로컬 빌드 결과물이 다음과 같다면:
build/libs/auth-0.0.1-SNAPSHOT.jar
컨테이너 안에서는 다음 이름으로 들어간다.
/app.jar
이렇게 이름을 고정해두면 ENTRYPOINT에서 실행 파일명을 단순하게 쓸 수 있다.
RUN mkdir -p /logs
-p는 parents 옵션인 것을 이번에 처음 알았다.
해당 옵션은 logs 폴더가 이미 만들어져 있어도 에러를 뱉지 않는다.
따라서 빌드할 때 오류가 나지 않도록 -p 옵션을 꼭 붙이는 것이 안전하다.
또한 mkdir -p a/b/c라고 하면, a에서 c까지 이르는 중간 폴더를 모두 만들어 준다.
---
EXPOSE 80
이 줄은 “이 컨테이너는 80번 포트를 사용할 예정”이라는 선언이다.
중요한 점은 EXPOSE가 실제로 포트를 열어주는 명령은 아니라는 것이다.
만약 Spring Boot가 컨테이너 내부에서 8080으로 떠 있다면 다음처럼 연결해야 한다.
docker run -p 80:8080 my-app
외부 80번 포트 → 컨테이너 내부 8080번 포트로 연결하겠다는 의미이다.
Spring Boot의 기본 포트는 보통 8080이다. 반면 HTTP의 기본 포트는 80이다.
그래서 다음 세 가지가 서로 맞아야 한다.
1. Spring Boot가 실제로 뜨는 포트
2. Dockerfile의 EXPOSE 포트
3. docker run -p 또는 Kubernetes Service의 targetPort
만약 Spring Boot가 실제로 8080으로 뜨는데 Dockerfile에 EXPOSE 80이라고 적혀 있다면, 문서상으로는 헷갈릴 수 있다.
운영자가 Dockerfile만 보고 “이 애플리케이션은 컨테이너 내부에서 80번으로 뜨는구나”라고 오해할 수 있기 때문이다.
따라서 Spring Boot가 8080으로 뜬다면 보통은 다음이 더 자연스럽다.
EXPOSE 8080
반대로 애플리케이션 설정에서 실제로 80번 포트를 사용하도록 되어 있다면 EXPOSE 80이 맞다.
---
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:InitialRAMPercentage=70.0", \
"-XX:MaxRAMPercentage=70.0", \
"-XX:HeapDumpOnOutOfMemoryError", \
"-XX:HeapDumpPath=/logs/dump.hprof", \
"-XX:MaxDirectMemorySize=512m", \
"-Djava.awt.headless=true", \
"-jar", "/app.jar"]
ENTRYPOINT는 컨테이너가 시작될 때 이 명령어를 실행하겠다는 의미다.
크게 보면 다음 명령어를 실행하는 것이다.
java [JVM 옵션들] -jar /app.jar
java는 JVM을 실행하는 명령어이며, -jar는 그중 뒤에 오는 파일을 실행 가능한 JAR 파일로 보겠다는 의미이다.
따라서 루트 디렉토리 아래에 있는 /app.jar 파일을 JAR파일로 보고 JVM을 실행한다는 의미가 된다.
-XX는 JVM 고급 옵션 영역임을 의미하며, +는 해당 옵션을 켜겠다는 뜻이다.
Java 실행 옵션은 대략 세 종류로 나눠서 보면 이해하기 쉽다.
-jar → 무엇을 실행할지 지정하는 일반 옵션
-D → 애플리케이션에서 읽을 수 있는 시스템 프로퍼티
-XX → JVM 자체의 동작을 제어하는 고급 옵션
예를 들어:
-Dspring.profiles.active=prod
는 Spring Boot 애플리케이션에 prod 프로필을 넘기는 설정에 가깝다.
반면:
-XX:MaxRAMPercentage=50.0
는 Spring Boot가 아니라 JVM 자체의 메모리 계산 방식을 조정하는 설정이다.
아무튼 UseContainerSupport는 컨테이너의 CPU/메모리 제한을 jvm이 인식하겠다는 뜻이다.
Java 17에서는 컨테이너 지원이 기본적으로 켜져 있다. 따라서 이 옵션을 명시하지 않아도 보통은 동작한다.
---
이후 메모리 관련 설정은 다음을 따른다.
InitialRAMPercentage은 초기 heap 크기를 컨테이너 메모리의 70%로 잡는다.
MaxRAMPercentage는 최대 heap 크기를 컨테이너 메모리의 70%로 잡는다.
MaxDirectMemorySize는 512MB로 제한한다. netty, nio, webclient 같은 heap 아닌 메모리 사용과 관련이 있다.
하나만 자세히 살펴보자.
-XX:MaxRAMPercentage=50.0
JVM이 사용할 수 있는 최대 heap 크기를 컨테이너 메모리 기준 50%로 설정한다.
예를 들어 컨테이너 limit이 1GiB라면 최대 heap은 대략 512MiB 정도가 된다.
이 옵션이 중요한 이유는 컨테이너 메모리 전체가 Java heap으로만 쓰이는 것이 아니기 때문이다.
JVM 프로세스의 메모리는 크게 다음처럼 나뉜다. (오랜만에 본다....)
JVM 프로세스 전체 메모리
├── Heap
├── Metaspace
├── Thread Stack
├── Code Cache
├── Direct Buffer
├── JVM 내부 native memory
└── 기타 OS/JNI/native library 사용량
즉 heap을 컨테이너 limit에 너무 가깝게 잡으면 non-heap 영역이 사용할 공간이 부족해진다.
---
우리는 왜 Docker를 쓰는가?
우리는 서버를 docker로 띄운다. 왜 그럴까?
docker는 가상의 실행환경을 만들어주는 컨테이너로, '환경을 동일하게 재현'한다.
예를 들어 서버에 설치된 java 버전이 다르거나, 환경변수가 달라도 동일하게 실행된다.
따라서 우리는 이미지만 띄우면 되지, 해당 서버에 jdk를 설치하거나 다른 의존성을 설치하거나 하는 등 서버 세팅을 할 필요가 없다.
운영 서버가 늘어나거나 배포 환경이 많아질수록 이 장점은 커진다.
Kubernetes와 Docker의 관계
사내에서는 Kubernetes도 사용하고 있다.
내가 이해한 Kubernetes는 컨테이너 여러 개를 관리하는 관리자다.
예를 들어 replicas: 3으로 설정하면 Kubernetes는 해당 애플리케이션 컨테이너를 3개 유지하려고 한다.
만약 컨테이너 하나가 죽으면 Kubernetes는 새 컨테이너를 띄워 다시 3개를 맞춘다.
Kubernetes가 해주는 일은 대략 다음과 같다.
컨테이너 자동 복구
스케일링
로드 밸런싱
배포 자동화
리소스 제한
헬스 체크
롤링 업데이트
그래서 Kubernetes 환경에서는 Docker 이미지가 사실상 배포 단위가 된다.
우리는 왜 그럼 그 안에서 또 jvm 설정을 해야 하는가?
여기서 중요한 질문이 생긴다.
Kubernetes가 컨테이너를 관리해주는데, 왜 JVM 메모리 설정을 따로 해야 할까?
이유는 Kubernetes가 컨테이너 바깥의 관리자이지, 컨테이너 안에서 실행되는 JVM의 세부 메모리 사용까지 자동으로 안전하게 조정해주는 것은 아니기 때문이다.
Kubernetes는 컨테이너에 대해 다음과 같은 제한을 둘 수 있다.
resources:
requests:
memory: 512Mi
limits:
memory: 1Gi
여기서 limit은 컨테이너가 사용할 수 있는 최대 메모리다.
만약 jvm이 메모리 사용을 초과하면, kubernetes가 정한 컨테이너의 메모리 Limit을 넘게 되고
그러면 os가 jvm 프로세스를 강제 종료(oom killed)하게 된다.
따라서 jvm이 컨테이너 메모리 제한을 초과하지 않도록 설정하는 것이 중요하다. 지금 계속 간당간당하다고 알림이 오는 상황이기 때문이다.
기존 설정의 문제
현재 타겟 컨테이너에 할당되어 있는 메모리는 1Gbi다.
지금 위에 적은 메모리 설정이 문제가 되는 이유는
heap과 direct 메모리 설정이 별도로 잡혀 있고, native 메모리까지 생각하면
jvm oom 전에 pod가 먼저 oom killed될 가능성이 있기 때문이다.
다시 말하면,
컨테이너 limit = 1Gi 상황에서
JVM 메모리 최대 한도는
heap = 700MB
direct = 512MB
native 대략 200MB => 이미 1.4GB로 limit을 초과해버린다.
JVM 입장에서는 아직 여유가 있다고 생각하지만 (oom 발생 x)
Kubernetes가 보기에는 limit을 넘었기 때문에 oom killed가 발생한다.
이러면 jvm에 적용해놓은 heap dump 설정도 소용이 없다.
heap dump는 heap이 꽉 차서 jvm이 스스로 oom을 발생시킬 때 작동하는데, jvm이 그런 판단을 내리기 전에 os에 의해 Killed되기 때문이다.
따라서 선택지는 2가지가 남는다.
1번
limit = 1.5Gi (늘리기)
heap = 60~70%
direct = 256~512MB
2번
limit = 1Gi 유지
heap = 50%
direct = 128MB
이제 grafana에 가서 해당 서버의 1달 간의 상태를 살펴보았다.
내용은 claude가 잘 정리하도록 한다.
2. 메트릭 해석
CPU — ⚠️ 살짝 빡빡함
- Limit 0.5 core, Throttling 2.02% 지속
- Throttling이 0이 아니라는 건 CPU 한도에 닿아서 컨테이너가 의도적으로 멈춰지는 순간이 있다는 뜻. 2%면 치명적이진 않지만 응답
지연(p99 spike)으로 체감될 수 있음
- 👉 Limit을 1 core로 올리거나 제거 권장. Auth 서버는 토큰 검증/암호화 연산 때문에 의외로 CPU spike가 큽니다
메모리 — 🟢 여유 있음 (오히려 과할당)
- Usage 337 MiB, Request 1 GiB, Limit의 32.8% 사용 (Limit ≈ 1 GiB)
- 컨테이너 메모리 330~450 MiB 변동도 정상 범위
- 👉 Request를 512 MiB로 낮춰도 안전. K8s 스케줄링 효율이 좋아짐. Limit은 1 GiB 유지(JVM heap + non-heap + 여유)
- ⚠️ 단, jvm_memory_used_bytes 가 수집되기 전엔 heap/non-heap 분포를 모르니 위 1번 작업 끝나고 1주일 관찰 후 결정
네트워크 — 🟢 정상
- RX 86.8 KB/s, TX 144 KB/s, dropped 0 p/s
- 인증 요청 트래픽으로는 평범한 수준, 패킷 드롭 없음 = 건강
디스크 I/O — 🟢 거의 없음
- Read 0, Write 121 KB/s, IOPS 1
- Auth 서버는 stateless니까 정상. 로그 쓰기 정도
---
우선순위 액션
1. Prometheus 엔드포인트 노출 + scrape 설정 → JVM heap, GC, thread pool 가시화
2. CPU limit 0.5 → 1.0 으로 상향 (throttling 해소)
3. JVM 메트릭 1주 관찰 후 → 메모리 request 0.5 GiB로 다운사이징 검토
ec2로 떠 있는 같은 서버를 확인한 결과 메모리 제한 1gb에 450mb 정도를 쓰고 있었다.
따라서 최종 권장된 설정은 다음과 같다.
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:InitialRAMPercentage=50.0", \
"-XX:MaxRAMPercentage=50.0", \
"-XX:MaxDirectMemorySize=128m", \
"-XX:+HeapDumpOnOutOfMemoryError", \
"-XX:HeapDumpPath=/logs/dump.hprof", \
"-XX:+ExitOnOutOfMemoryError", \
"-Djava.awt.headless=true", \
"-jar", "/app.jar"]
이 설정을 기준으로 컨테이너 limit이 1GiB라면 대략 다음과 같이 볼 수 있다.
| Heap | 약 512MiB | 컨테이너 limit의 50% |
| Direct Memory | 128MiB | 기존 512MiB에서 축소 |
| Native Memory | 약 200MiB | metaspace, thread stack, code cache 등 |
| 총합 | 약 840MiB | 1GiB limit 안에 거의 들어오는 수준 |
다만 남은 문제
1) Grafana에서 JVM 메모리 지표가 수집되고 있지 않다. 따라서 heap, non-heap, direct memory가 각각 얼마나 쓰이는지는 알 수 없다.
2) CPU throttling이 2% 수준이면 치명적이라고 보기는 어렵지만, 인증 서버처럼 토큰 검증, 암호화 연산이 있는 서버에서는 p99 응답 지연으로 나타날 수 있다.
3) 여러 서버가 EKS auth를 더 많이 사용하게 되면 트래픽이 증가할 수 있다. 따라서 지금의 피크 450MiB가 앞으로도 유지된다고 단정하면 안 된다.
4) Grafana 알림 기준이 불명확하다. 무엇을 기준으로 85%를 넘었다는 건지 알 수 없다.
85%는 다음 중 하나일 수 있다.
컨테이너 메모리 사용량 / limit
컨테이너 메모리 사용량 / request
JVM heap used / max
JVM non-heap used / max
노드 메모리 사용률
디스크 사용률
CPU 사용률
각 기준은 의미가 완전히 다르므로, 알림 기준을 확인하지 않고 “메모리가 위험하다”고 판단하면 안 된다.
알림 쿼리를 확인해서 분모와 분자가 무엇인지 먼저 봐야 한다.