본문으로 건너뛰기
The JVM Index
뒤로 가기
cloud-native

Kubernetes 기초: Spring Boot 컨테이너를 띄운다는 것 (JVM에서 본 클라우드 네이티브 2)

이 글 수정

1부 끝에서 예고한 첫 사고부터 풀자. Spring Boot 앱을 컨테이너로 만들어 올렸다. 메모리는 넉넉하게 1Gi를 줬다. -Xmx는 안 박았으니 JVM이 알아서 하겠거니 했다. 그런데 부하가 좀 붙으니 파드가 죽는다. kubectl get pod을 보면 상태가 이렇다.

NAME                     READY   STATUS      RESTARTS   AGE
my-service-7d9f8-abcde   0/1     OOMKilled   3          7m

OOMKilled. 메모리가 모자라서 커널이 죽였다는 뜻이다. 이상하다. 힙을 1Gi 안에서 쓰는데 왜? 이 질문에 답하려면 Kubernetes가 컨테이너를 어떻게 다루는지, 그리고 JVM이 컨테이너 안에서 자기 한계를 어떻게 인식하는지를 같이 봐야 한다. 그게 이 글이다.

Pod, Deployment, Service: 세 단어부터

JVM 개발자가 Kubernetes에서 외울 오브젝트는 처음엔 셋이면 된다.

이 중 Service가 트래픽을 어느 Pod로 보낼지 정할 때 쓰는 신호가 다음 주제인 probe다.

probe 세 가지, 그리고 Actuator

Kubernetes는 컨테이너 안의 앱이 살아있는지, 트래픽 받을 준비가 됐는지를 스스로 알지 못한다. 프로세스가 떠 있다고 앱이 정상인 건 아니다. 데드락에 걸려도 프로세스는 살아있다. 그래서 앱에게 직접 물어보는 장치가 probe다. 세 종류가 있고, 실패했을 때 Kubernetes의 반응이 각각 다르다.

세 번째가 JVM에게 특히 중요하다. JVM 앱은 기동이 느리다. 클래스 로딩, 빈 초기화, 커넥션 풀 준비까지 수 초에서 수십 초가 걸린다. startup probe 없이 liveness만 걸어두면, 아직 뜨는 중인 앱을 Kubernetes가 “안 살아있다”고 판단해 죽인다. 그리고 다시 뜨는 중에 또 죽인다. 부팅 루프다. startup probe로 기동 예산을 충분히 준 다음에야 liveness가 넘겨받게 해야 한다.

이 probe들을 Spring Boot는 Actuator로 바로 노출한다. 직접 health 엔드포인트를 짤 필요가 없다.

management:
  endpoint:
    health:
      probes:
        enabled: true # Kubernetes 환경이면 자동 활성, 명시도 가능
      show-details: always
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: trueapplication.yml

이러면 두 엔드포인트가 생긴다.

Spring Boot는 이 둘을 ApplicationAvailability라는 내부 상태에서 끌어온다. liveness는 애플리케이션 컨텍스트가 refresh되는 순간 “live”가 되고, readiness는 ApplicationRunner·CommandLineRunner까지 다 돌고 나면 “ready”가 된다. 의미가 명확하다. 앱이 자기 내부 상태로 일할 수 있으면 live, 외부 요청까지 받을 준비가 끝나면 ready다.

Deployment YAML에서는 이렇게 가리킨다.

livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  failureThreshold: 3
  periodSeconds: 10
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  failureThreshold: 3
  periodSeconds: 10
startupProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  failureThreshold: 30
  periodSeconds: 5 # 30 × 5 = 최대 150초 기동 허용deployment.yaml

여기서 흔한 실수 하나. liveness probe가 외부 시스템(DB, 다른 서비스) 상태에 의존하면 안 된다. Spring 문서가 명시적으로 경고하는 지점이다. DB가 잠깐 끊겼다고 liveness가 실패하면 Kubernetes는 앱을 재시작한다. 재시작해도 DB는 여전히 끊겨 있으니 또 죽는다. DB 장애가 앱 재시작 폭풍으로 번진다. 외부 의존성 체크는 readiness에 둬야 한다. 트래픽만 잠깐 안 받으면 되니까.

왜 컨테이너 limit 안의 JVM이 OOMKilled되나

OOMKilled의 원인은 메모리 limit이 컨테이너 전체 RSS에 걸리는데 -Xmx는 힙만 제한하기 때문이다. 둘 사이에 빠진 비힙 메모리가 limit을 넘기면 커널이 컨테이너를 죽인다. 이제 서두의 사고로 돌아간다. 먼저 리소스 선언부터.

resources:
  requests:
    memory: "512Mi"
    cpu: "250m"
  limits:
    memory: "1Gi"
    cpu: "1000m"deployment.yaml

requestslimits는 역할이 다르다.

핵심은 메모리 limit이 **컨테이너 전체가 실제로 점유한 물리 메모리(RSS)**에 걸린다는 점이다. JVM 힙이 아니라. 그런데 -XmxMaxRAMPercentage힙만 제한한다. 둘 사이에 비힙 메모리가 통째로 빠져 있다.

JVM이 실제로 먹는 메모리는 힙만이 아니다.

이걸 다 합친 게 RSS다. 힙을 limit의 90%로 잡으면, 나머지 비힙이 들어갈 자리가 10%밖에 안 남는다. 부하가 붙어 스레드가 늘고 메타스페이스가 차는 순간 RSS가 1Gi를 넘고, 커널이 컨테이너를 죽인다. 그게 OOMKilled, 종료 코드 137이다.

힙은 limit 안에 있었다. 맞다. 그런데 죽었다. 힙만 봤기 때문이다.

container-aware JVM: cgroup 한계를 읽는 JVM

해법의 절반은 JVM이 컨테이너 한계를 제대로 읽게 하는 것이다. 다행히 요즘 JVM은 이걸 알아서 한다.

현재 JDK는 Linux에서 UseContainerSupport가 기본으로 켜져 있다. JVM이 cgroup에 걸린 메모리·CPU 한계를 읽어서, 호스트 전체가 아니라 컨테이너에 할당된 양을 기준으로 자원을 잡는다. JDK 21 기준 java 매뉴얼이 이 동작을 명시한다. 컨테이너 인식이 어떻게 도는지 보고 싶으면 이 옵션이 도움이 된다.

-Xlog:os+container=trace

그 위에서 힙을 잡는 방식이 갈린다.

# 절댓값: limit을 바꿀 때마다 이 숫자도 같이 바꿔야 함
-Xmx768m

# 비율: limit이 바뀌면 힙이 따라 스케일됨
-XX:MaxRAMPercentage=70.0 -XX:InitialRAMPercentage=70.0

컨테이너에서는 비율 쪽이 운영하기 편하다. MaxRAMPercentage는 컨테이너에 할당된 메모리의 몇 %를 힙 최대치로 쓸지를 정한다. limit을 1Gi에서 2Gi로 올려도 매니페스트의 메모리 값만 고치면 힙이 알아서 따라온다. -Xmx는 두 군데(매니페스트와 JVM 옵션)를 매번 동기화해야 한다.

비율을 얼마로 둘지는 비힙이 얼마나 필요한가에 달렸다. 스레드 많고 클래스 많은 전형적인 웹 앱이라면 힙을 limit의 70~75% 선에 두고, 나머지를 비힙에 양보하는 데서 시작해 실제 RSS를 보며 조정하는 게 보통이다. 정답 숫자는 없다. 앱마다 비힙 프로파일이 다르기 때문이다. 그래서 다음 단계가 “추측 말고 측정”이고, 그 측정 도구가 5부·6부의 관측성이다.

Tip

JDK 버전도 점검 대상이다. 2025년 9월에 JDK 25가 LTS로 나왔고, 그 전 LTS는 JDK 21이다. 오래된 JDK일수록 컨테이너 인식이 부실하다. 클라우드 네이티브로 갈 거라면 베이스 이미지의 JDK부터 올리는 게 첫 단추다.

정리

OOMKilled 사고의 정체는 단순하다. Kubernetes는 컨테이너 전체 메모리를 보는데, 나는 힙만 봤다. 그 틈에 비힙이 끼어서 limit을 넘겼다.

그래서 컨테이너의 JVM은 두 가지를 같이 챙겨야 한다. JVM이 cgroup 한계를 읽게 하고(UseContainerSupport, 비율 기반 힙), 힙과 비힙을 합친 RSS가 limit 안에 들어오도록 여유를 남기는 것. 이 여유를 얼마나 줄지는 결국 관측해서 정한다.

다음 글은 이 컨테이너 이미지를 어떻게 만드느냐다. Dockerfile을 손으로 쓰는 것 말고, Jib과 Buildpacks로 Spring 빌드에서 바로 OCI 이미지를 뽑고 Helm으로 패키징하는 길로 간다.

참고


이 글 수정
이 글 공유하기:

이전 글
이미지와 패키징: Jib·Buildpacks·Helm (JVM에서 본 클라우드 네이티브 3)
다음 글
지도: 2026년 CNCF, JVM 개발자의 입장에서 (JVM에서 본 클라우드 네이티브 1)