아티클

[Kubernetes/JVM] 쿠버네티스 팟 안에서 동작하는 자바 프로세스 메모리 설정

호키포키장 2025. 2. 18. 01:26

쿠버네티스 안에서 동작하는 자바 컨테이너에서 자꾸 OOM kill이 발생한다. 

알아서 잘해주겠지 라는 마음이 있었는데 막상 문제가 발생하니 디버깅에 어려움이 있어서,, 공부공부해보자.

영어로 된 아티클을 잘 안읽게 되다보니 눈이 침침해져서 해석하면서!

 

 

요약

- WSS/RSS는 JVM이 사용하는 실제 heap / non-heap 사용량의 합이 아니라 committed heap memory 를 기준으로 측정된다.

- G1 GC는 Full GC / concurrenct cycle이 아니면 메모리를 반환(*committed memory)하지 않기 때문에 실제 사용하는 heap/non-heap 양보다 더 높은 WSS/RSS 가 측정될 수 있다.

- heap 과 non - heap 사이의 적절한 비율을 설정해야한다.  (모니터링의 중요성)

 

 

오늘의 아티클 (두둥)

Memory settings for Java process running in Kubernetes pod

"쿠버네티스 팟 안에서 동작하는 자바 프로세스를 위한 메모리 설정들"

 

 

쿠버네티스 안에서 동작하는 자바 프로세스의 메모리를 관리하는건 생각보다 더 힘든 일이다.

적절한 JVM 메모리 설정을 설정했음에도, OOMKilled 이슈는 여전히 발생하고 또 궁금하게 한다.

 

 

개요;

자바 프로세스는 여러가지 요인에 의해 달라질 수 있는 non-heap 메모리가 아니라, heap 사이즈 제한에만 신경쓰기 때문에 전체 메모리의 한계를 보장할 수 있는 방법은 없다. 힙에 75%의 비율을 주면서, 메모리가 어떻게 동작하는지 지켜보자. 만약 상황이 통제되지 않으면, 팟의 메모리 제한을 조정하거나, 힙과 논힙 사이의 비율을 조정해서 OOMKilled 사고를 피해보자.

 

맥락;

우리는 쿠버네티스에서 돌아가는 운영 환경 자바 어플리케이션의 반복적인 OOMKilled와 재시작 문제를 겪었다.  POD과 JVM 레벨에서 메모리 설정을 정의해두었음에도 불구하고, 팟의 전체 모리는 계속 출렁이면서 빈번한 재시작으로 이끌었다.

 

- Pod level 설정; 

팟의 메모리 제한은 2Gi 로 설정

resources:
  requests:
    memory: "2Gi"
    cpu: "4"
  limits:
    memory: "2Gi"
    cpu: "4"

 

- JVM level 설정;

JVM이 그 환경에 맞게 사용할 수 있도록 시스템 메모리 비율 설정

-XX:MaxRAMPercentage=80.0

 

MaxRamPercentage는 자바 프로세스가 사용할 수 있는 전체 메모리를 뜻하는 것이 아님에 주의해야한다.

이건 JVM heap size를 의미하고, 이 heap은 어플리케이션이 접근하고 또 사용할 수 있는 유일한 메모리다.

이런 설정을 기반으로 팟은 시스템 메모리의 2Gi를 가진 채로 1.6Gi는 heap이, 나머지 0.4Gi는 non-heap이 사용할 수 있게된다.

(모니터링 메트릭은 대시보드에 GB단위로 표기하지만 2Gi는 2*1024*1024*1024 = 2.15GB 임에 유의하자.)

 

문제 해결을 위한 초기 시도;

OOMKilled 이슈를 줄이기 위해, 우리는 팟 메모리를 2Gi -> 4Gi로 올렸다. 이 방법은 문제를 어느정도 해결해주었지만, 여전히 질문이 남아있는 부분이 있다.

 

1. jvm heap과 non-heap 사용량이 낮았음에도 불구하고, 왜 WSS(container memory wss)와 RSS(container memory rss)는 100%에 가까워졌을까? 

 

2. 한 팟에 자바 프로세스만 구동중임에도 불구하고, 왜 WSS와 RSS는 JVM의 전체 메모리(heap+non-heap)보다 더 많은 메모리를 사용했을까?

3. 프로세스의 메모리는 왜 팟의 메모리 제한까지 도달하며 100% 에 가까운 사용률을 보였을까?

분석;

 

1. 자바 메모리 사용량은 왜 시스템 전체 메모리 사용량보다 훨씬 낮았을까?

 

우리는 WSS와 RSS의 증가가 committed heap memory가 maximum heap size에 도달할 때 멈춘다는걸 발견했다.

 

1; committed JVM Heap 이 heap limit에 도달하면 멈춘다

2,3; 1의 증가분이 멈추면 WSS, RSS의 증가량도 멈춘다.

 

MemoryUsage 클래스의 자바 문서에 따르면, 이 메트릭은 여기서 온다:

public long getCommitted​()
Returns the amount of memory in bytes that is committed for the Java virtual machine to use. This amount of memory is guaranteed for the Java virtual machine to use.

Java 가상 머신이 사용할 수 있도록 할당된 바이트 단위의 메모리를 리턴한다.
이 메모리는 자바 가상 머신이 사용할 수 있도록 보장되어있다.

 

이 "할당 메모리"는 운영 체제에서 JVM이 미리 잡아둔(할당 받아놓은) 메모리를 뜻한다. 결과적으로, 컨테이너/팟의 입장에서, WSS/RSS가 높은 것처럼 보여도, JVM 안에서의 heap과 non-heap 메모리 사용량은 낮을 수 있다.

 

이건 또한 팟의 OOMKilled가 나기 전에 OutOfMemory 에러가 나지 않는 이유를 설명할 수 있다. 왜냐하면 heap이나 non-heap은 JVM의 리밋에 도달하지 않았기 때문이다. 그 대신에, JVM에서 OS로부터 쉽게 해제하지 않는 메모리를 미리 할당하고, 예약해둔다. OpenJDK specification은 이를 설명한다 (Motivation 쪽) :

 

G1 only returns memory from the Java heap at either a full GC or during a concurrent cycle.Since G1 tries hard to completely avoid full GCs, and only triggers a concurrent cycle based on Java heap occupancy and allocation activity, it will not return Java heap memory in many cases unless forced to do so externally. This behavior is particularly disadvantageous in container environments where resources are paid by use. Even during phases where the VM only uses a fraction of its assigned memory resources due to inactivity, G1 will retain all of the Java heap. This results in customers paying for all resources all the time, and cloud providers not being able to fully utilize their hardware.

G1은 full GC나 동시 사이클 동안에만 Java heap에서 메모리를 반환합니다. G1은 full GC 를 완전히 피하려고 노력하고, Java heap의 사용률이나 메모리 할당 활동을 할 때에만 동시 사이클을 트리거하기 때문에, 외부에서 강제로 실행하지 않는 이상 Java heap memory를 반환하지 않습니다. 이건 리소스를 사용한 만큼 비용이 부과되는 컨테이너 환경에서는 특히 불리합니다. VM(가상머신)이 비활동상태일 때 할당받은 메모리의 일부만 사용한다 하더라도, G1은 모든 Java heap을 유지합니다. 이는 고객들이 모든 시간동안 모든 리소스에 대해 비용을 지불하게 되고, 클라우드 제공자는 그들의 하드웨어를 완전히 활용하지 못하게 됩니다.

*참고; Java 12 이상부터 G1PeriodicGCInterval 옵션을 통해 주기적인 GC 주기를 지정할 수 있도록 했는데, 이는 옵션값이다. 필요에 따라 설정이 가능하다 (주기적으로 사용하지 않는 메모리를 OS에 반납함으로 committed memory가 줄어들 수 있음.) / 컨테이너 환경은 리소스 사용량만큼 비용을 지불하는게 일반적. 이렇게 heap memory 를 쥐고 있다면 사용하지 않는 메모리까지 할당된 상태를 유지함으로서 불필요한 비용이 발생함.

 

따라서 자바 프로세스가 사용하고 있는 실제 메모리 양은 적을지 몰라도, JVM에 의해 미리 할당된 메모리는 훨씬 높을 수 있고 시스템에 바로바로 반환되지 않을 것이다.

 

정리;

필자는 팟 메모리를 2 GB -> 4GB로 올렸고 MaxRamPercentage는 80%이기 때문에 commmitted heap size는 3.2 GB이다.

WSS와 RSS는 이 committed heap size를 기준으로 계산하기 때문에 WSS, RSS 사용량이 실제 자바 앱이 사용하고 있는 양보다 높게 잡힌 것. 단, 그렇다할지라도 committed 기준 heap(3.2GB) + non-heap(1.23GB) 이 wss limit을 넘지 않는데 100%를 친 이유는 Question 2에서 설명.

 

2. 왜 WSS/RSS 메모리 사용량이 JVM 전체 메모리보다 높을까?

 

이는 시스템 메모리와 JVM 메트릭의 근원을 찾은 이후에도 의문으로 남아있었다.

JVM에서 미리 할당해둔 (commited) 메모리와 RSS의 차이

 

1. WSS : 3.8 GB

2. JVM에 할당된(committed) heap memory : 3.22GB

3. JVM의 전체 할당(committed) 메모리 : 3.42GB

 

파드 안에서 동작하는 JVM 의 Native Memory Tracking (NMT) 리포트는 자바 프로세스의 메모리 사용 상황을 자세하게 분석할 수 있도록 해준다. 특히 non-heap 메모리 관련해서 말이다. 그 결과는 JVM Heap과 JVM 전체 메트릭과 일치한다.

 

  Native Memory Tracking:
  
  Total: reserved=5066125KB, committed=3585293KB
  -                 Java Heap (reserved=3145728KB, committed=3145728KB)
                              (mmap: reserved=3145728KB, committed=3145728KB) 
  -                     Class (reserved=1150387KB, committed=113419KB)
  -                    Thread (reserved=297402KB, committed=32854KB)
  -                      Code (reserved=253098KB, committed=73782KB)
  -                        GC (reserved=174867KB, committed=174867KB)
  -                  Compiler (reserved=2156KB, committed=2156KB)
  -                  Internal (reserved=11591KB, committed=11591KB)
  -                     Other (reserved=2690KB, committed=2690KB)
  -                    Symbol (reserved=21454KB, committed=21454KB)
  -    Native Memory Tracking (reserved=6275KB, committed=6275KB)
  -               Arena Chunk (reserved=195KB, committed=195KB)
  -                   Logging (reserved=4KB, committed=4KB)
  -                 Arguments (reserved=29KB, committed=29KB)
  -                    Module (reserved=249KB, committed=249KB)

 

시스템의 메모리 사용량인 WSS/RSS는 팟 에서 top command를 통해 RES (프로세스가 사용하고 있는 메모리의 양, resident memory used by the process) 라는 이름의 메모리로 표현된다. 그리고 자바 프로세스는 그 팟안에서 동작하는 단하나의 프로세스였다.

 

*total committed -> 3.5 GB

 

USER   PID    %CPU %MEM  VSZ      RSS      TTY  STAT START TIME   COMMAND
xxx-+      1  7.7  0.4   24751760 3818536  ?    Ssl  Jul28 340:41 /usr/java/jdk-11.0.17/bin/java -XX:MaxRAMPercentage=75.0 -XshowSettings:vm -classpath ...
xxx-+  80559  0.0  0.0   50548    3936     ?    Rs   07:02 0:00   ps -aux

 

* RSS -> 3.8 GB

 

따라서 두 메트릭은 모두 신뢰성이 있다고 볼 수 있지만 여전히 300MB 정도의 갭이 있었다.

 

*이 부분은 여전히 풀리지 않는 의문으로 남아있는듯함 :(

 

3. 팟의 메모리 리밋을 늘렸음에도, 시스템 메모리 사용량은 여전히 100% 가까이 칠까?

 

우선, 시스템의 메모리 사이즈를 결정하는건 resources.requests.memory 가 아니라 resources.limits.memory 라는 것이다. 전자는 그저 쿠버네티스가 그 파드를 실행할 요청된 메모리 크기에 맞는 노드를 찾을때 사용하는 정보입니다.

 

둘째로, 이전에 말했지만, heap 만이 JVM에 의해 타이트하게 컨트롤되고 또 명시될 수 있습니다. non/off-heap 메모리는 그렇지 않습니다. 따라서 시스템 메모리가 증가하면, non/off-heap 메모리도 비례적으로 증가할 수 있습니다.

 

이를 완화하기 위해 heap 메모리 비율을 줄이는건 non/off-heap 가 공간을 더 사용할수 있도록 해줍니다. 여기서 저희가 시도한 다음 옵션이 나오죠 : MaxRAMPercentage 를 80% 에서 75%로 낮췄고, 이는 예상한대로 동작했습니다: WSS/RSS가 줄어들었죠.

 

BEFORE : heap 퍼센테이지줄이기 전

 

1. WSS/RSS가 여전히 파드의 메모리 리밋에 가까움 (4.29GB)

 

AFTER : heap 퍼센테이지 줄인 후

 

2. WSS/RSS가 3.6GB 수준으로 안정화되고, 파드의 메모리 리밋 하의 여유가 생김

 

결론

 

아래 방식들은 Java process의 메모리 사용량에 관한 불확실성을 낮추는 것과 파드의 OOMKilled 이슈를 제거하는데 사용될 수 있습니다.

 

1. MaxRAMPercentage 를 이성적으로 설정하세요, 75%는 좋은 스타팅 포인트가 되곤 합니다.

2. heap 사용량과 시스템 메모리의 WSS/RSS 를 지속적으로 모니터링하세요.

 

- 만약 maxmimum heap 사용량이 높다면 (90% 이상) 당신의 팟 메모리 제한(requests.limits.memory)를 늘리라는 신호입니다.  heap 에 공간이 더 필요하다는 거죠.

 

- 만약 maxmium heap 사용량은 괜찮은데, WSS/RSS 가 프로세스의 리밋까지 높아진다면, non/off-heap 공간을 더 늘려주기 위해 MaxRAMPercentage 를 줄여보세요.

 

- maxmimum WSS/RSS가 파드의 메모리 리밋에 5~10%정도의 마진을 남기도록 하세요. Don't fly too close to the sun!