본문 바로가기

Programming/Java & JSP & Spring

JVM 메모리 누수 트러블슈팅 (Native memory leak)

현재 팀에서는 예전부터 꾸준히 서버의 메모리 사용량이 우상향하는 현상이 있었다.

우리는 java application의 서버를 운용중이고, 거대한 모놀리스 시스템으로 많은 트래픽과 동시에 많은 데이터를 처리하고 있다.

 

서버를 물리장비에서 컨테이너로 전환한 뒤에, 모니터링하면서 메모리 사용량이 지속적으로 증가하는 것을 알 수 있었다.

JVM의 Max Heap은 컨테이너의 50% 수준으로 설정해놓았고, 그 외 Metaspace, native memory, cache 등의 용량을 생각해봐도 이해할 수 없는 수치로 계속해서 올라갔고, 어디선가 메모리 누수가 발생하고 있음이 분명했다.

 

# Heap & Metaspace 영역 체크

사실 Java 개발자는 직접적으로 작업해서 관여할 수 있는 메모리 영역이 주로 JVM heap 영역일 것이다.

 

우리는 pinpoint(https://github.com/pinpoint-apm/pinpoint)을 연동하고 있으므로, 손쉽게 Heap메모리와 GC를 모니터링할 수 있었다.

pinpoint 상으로는 문제가 되는 부분은 안보였고, GC도 정상적으로 동작하고 있었다.

Pinpoint heap memory usage

 

pinpoint외에도 jstat, visual vm 등으로도 더블체크 해봤지만, heap memory에서의 누수는 발견되지 않았다.

위의 pinpoint, jstat, visual vm 툴에서 metaspace에 대한 영역도 확인이 되므로, metaspace 영역 또한 누수가 없다는 것을 확인할 수 있었다.

 

무엇보다 우리는 Java application을 실행시킬 때, Heap 영역과 Metaspace 영역에 대한 Max값을 설정했기 때문에, 그 값에 도달하면 java OOM이 먼저 발생했어야 했다.

 

하지만, heap & metaspace max값으로 설정 그 이상으로 java app이 죽지않고, jvm 프로세스의 메모리가 계속해서 상승했기 때문에, 용의선상에서 heap과 metaspace는 제외를 시켰다.

 

# Native memory tracking

JVM에서 Native 메모리를 추적할 수 있는 옵션을 제공한다.

(참고: https://www.baeldung.com/native-memory-tracking-in-jvm)

 

예를 들어 아래와 같이 java를 실행시킬 때 NMT 옵션을 추가한다.

java -XX:NativeMemoryTracking=summary

 

그 후에, 비교 기준이 되는 baseline을 따고 비교를 한다.

$ jcmd [PID] VM.native_memory baseline
$ jcmd [PID] VM.native_memory summary.diff

 

 

> 주의할 점은 해당 옵션을 넣으면, JVM의 성능이 5~10% 정도 저하된다고 한다.

 

NMT옵션으로 무언가의 단서를 얻을 수 있을줄 알았지만, 미비한 메모리 증가만 보일 뿐, 유의미한 증가량은 보이지 않았다.

native memory 영역도 아니라는걸까?

 

조금 더 리서치해보니, NMT 옵션이 모든 native memory의 사용을 추적하는 것은 아니였다.

그렇다면, NMT옵션도 추적못하는 어디선가 누수가 발생하고 있음을 뜻했다.

 

# async-profiler

프로세스의 메모리 사용량을 구체적으로 알 수 있는 방법으로, pmap & smaps & gdb 리눅스 명령어를 통해 확인하는 방법도 존재한다.

하지만, 위 방법으로 추적했을 때에도 특정 메모리 영역이 증가하는 것이 아니라, 여러 메모리 영역에서 동시다발적으로 상승되어서, 원인을 파악하기는 어려웠다.

 

좀 더 가시성이 있고 편리한 툴을 알아보았다.

 

https://github.com/async-profiler/async-profiler

 

GitHub - async-profiler/async-profiler: Sampling CPU and HEAP profiler for Java featuring AsyncGetCallTrace + perf_events

Sampling CPU and HEAP profiler for Java featuring AsyncGetCallTrace + perf_events - async-profiler/async-profiler

github.com

 

Java 영역에서는 프로파일링하는 도구로 꽤나 유명한것 같고 괜찮아보였다.

 

async-profiler에서는 다양한 이벤트(malloc, mprotect, mmap 등)를 추적할 수 있는 옵션을 제공하는데, 조금 더 리서치를 해보니 native memory 영역을 프로파일링하는 전용옵션이 존재했다.

(참고: https://github.com/async-profiler/async-profiler/discussions/491#discussioncomment-1590216)

 

아직 정식버전은 아닌 것 같지만, 프로파일링하는데에는 문제가 없을 듯해서, 컨테이너 환경에 적용해봤다.

 

Dockerfile

RUN curl -sLo async-profiler https://profiler.tools/builds/async-profiler-2.1-malloc-linux-x64.tar.gz \ && tar -xvzf async-profiler

ENV LD_PRELOAD='/home1/irteam/apps/async-profiler-2.1-malloc-linux-x64/build/libasyncProfiler.so'

 

java 서버 실행

java -agentpath:/home/apps/async-profiler-2.1-malloc-linux-x64/build/libasyncProfiler.so=start,event=nativemem,file=malloc.jfr

 

덤프 종료 및 jfr 파일받기

$ ./profiler.sh stop [PID]

 

java 실행 파일 경로에서 파일 확인하고, converting

$ java -cp converter.jar MallocReport malloc.jfr malloc.html

 

이렇게 받은 파일은 아래와 같은 결과물을 보여준다.

 

응용 레벨에서는 몰랐지만, ES, Netty, 컴파일러 등등 정말 다양한 곳에서 Native memory를 사용하고 있었다.

나는 1일, 3일, 5일 간격으로 다양하게 덤프를 받아서 어떤 영역이 증가하는지 확인해봤다.

 

하지만 이렇게 자세히 어떤 라이브러리에서 native memory를 사용하는지를 보여주는데도, 특정 라이브러리에서 증가하는 곳은 찾지 못했다.

그냥 전체적으로 증가할 뿐이였다.

 

남은 방법은, JVM 메모리 우상향의 원인으로 꼽히는 대표적인 이슈들을 모두 소거하는 방법 뿐이였다.

 

# Netty 메모리 누수?

일단 Netty 내부에서도 Native memory를 사용하는 것으로 확인되었고, 공식문서나 다른 글에서도 쉽지않게 native memory 누수 관련 내용을 확인할 수 있었다.

- https://projectreactor.io/docs/netty/snapshot/reference/appendices.html#faq.memory-leaks 

- https://stackoverflow.com/questions/67068818/oom-killed-jvm-with-320-x-16mb-netty-directbytebuffer-objects

 

Netty leak 로깅과 netty가 직접적으로 사용하는 direct buffer를 사용하지 못하도록 변경했다.

java -Dio.netty.leakDetection.level=paranoid -Dio.netty.noPreferDirect=true -Dio.netty.maxDirectMemory=0

 

하지만, 효과는 없었고 아예 DirectByteBuffer 메모리 사용량도 제한해봤지만 효과는 없었다.

java -XX:MaxDirectMemorySize=512m

(DirectByteBuffer가 원인이였다면 애초에 NMT에서도 추적이 가능하기 때문에 위에서 발견됐을 것이다)

 

# JNI & JNA

C언어 구현체로 되어있어서 JVM에서는 메모리를 추적하지 못해 누수가 발생할 수도 있다고 한다.

하지만, JNI & JNA를 사용하는 부분은 async-profiler에서 포착되지 않았고, 코드 내에 존재하지 않았다.

 

# JIT Compiler

토스 레퍼런스(https://youtu.be/w4fWgLgop5U)에서 JIT Compiler에서 메모리 누수가 있었음을 소개했다.

우리의 프로파일링 결과에서 Compiler쪽의 특이 사항은 없었지만, 지푸라기 잡는 심정으로 레퍼런스를 참고해 확인해봤다.

- C2 Compiler -> C1 Compiler 로 변경

- Graal Compiler 적용

 

두가지 적용을 해봤지만, 역시나 별다른 효과없이 메모리 우상향 현상이 지속되었다.

 

# RestClientHighLevel

async-profiler에서 그나마 눈에 띈 부분이 RestClientHighLevel 를 이용해서 ES를 호출하는 부분이였다.

RestClientHighLevel을 사용하는 API만 격리시켜 보았다.

 

그 결과, RestClientHighLevel을 사용하는 API의 메모리 우상향 속도가 조금 더 빨랐다.

하지만, 여전히 사용하지 않는 쪽도 우상향 현상이 남아있는 것으로 보아, ES만의 문제는 아니였다.

 

# Native Memory Fragmentation

위의 RestClientHighLevel을 사용하는 API를 격리시키니, 우상향 현상이 조금 완화되었는데, 이것이 조금의 단서가 됐다.

우리 서버에서 무거운 요청에 해당하는 API들을 하나씩 계속 격리시켜보았다.

 

그 결과, 무거운 API를 하나씩 옮길 때마다 메모리 우상향 현상이 계속해서 완화되었다. (!!)

 

격리시키는 API들의 공통점은 아무리 찾아봐도, 요청이 무겁다라는 것외에는 보이지 않았다.

또한, 앞서 정리한대로 메모리가 급증할때엔 Heap & CPU 사용량이 급증했었다.

 

그렇다면 메모리 파편화 현상이 발생하는것은 아닐까?

 

이러한 가설을 토대로 다시 한번 리서치해봤는데, 꽤나 많은 사례가 존재했다. 

- https://blog.arkey.fr/drafts/2021/01/22/native-memory-fragmentation-with-glibc/

- https://www.linkedin.com/blog/engineering/infrastructure/taming-memory-fragmentation-in-venice-with-jemalloc

 

위에서 언급한 내용을 정리하면 아래와 같다.

JVM NMT옵션이나, Heap dump, metaspace, direct buffer에서도 아무런 leak 현상을 발견하지 못했다.

(우리와 상황이 같네?)

 

glibc malloc의 특징을 살펴보았고, 그로인해 메모리 파편화가 발생하는 것을 확인했다.

  • glibc malloc의 특징
    • glibc malloc은 mutex경합이 감지되면, glibc는 추가적인 arena 영역을 할당한다.
    • glibc malloc은 사용한 메모리를 OS에 반환을 쉽게하지 않는 특징을 가지고 있다.

메모리 파편화를 해결하는데 특화된 jemalloc 또는 tcmalloc 할당자 변경으로 해결하였다.

 

사실 Linkedin 기술블로그에서도 말했듯이, 우리는 자바 개발자이기 때문에 로우 레벨의 리눅스 동작방식을 이해하는 것은 어려웠다.

완벽히 이해했더라면, 기본 glibc malloc 할당자를 튜닝하는 방법도 있을 것이다.

 

그래도 우선 glibc malloc에 대한 작동방식을 최대한 이해해보고, jemalloc이라는 할당자로 변경해보았다.

 

# jemalloc 할당자 변경

Linux 기본 메모리 할당자는 glibc의 malloc이다.

JVM은 다른 프로세스와 마찬가지로 OS에서 메모리를 할당한 다음 malloc 함수를 호출한다.

jemalloc은 또다른 malloc 구현이며, malloc을 호출하는 것을 시각적으로 추적할 수 있는 jeprof라는 도구도 존재한다.

 

Dockerfile (jemalloc 적용)

RUN yum upgrade -y; yum group install -y "Development Tools"; \ yum install -y wget autoconf automake gcc make tcl which zlib-devel git docbook-xsl libxslt graphviz; \ yum clean all 
RUN git clone https://github.com/jemalloc/jemalloc.git 

WORKDIR ${APPS_PATH}/jemalloc 

RUN ./autogen.sh --enable-prof 
RUN make dist 
RUN make 
RUN make install 

ENV MALLOC_CONF="prof_leak:true,prof:true,lg_prof_interval:28,lg_prof_sample:18,prof_prefix:${LOGS_PATH}/jeprof" 
ENV LD_PRELOAD=${APPS_PATH}/jemalloc/lib/libjemalloc.so.2


jemalloc 적용 여부 확인

$ MALLOC_CONF=abort_conf:true,invalid_option:foo java -version

 

# jemalloc 적용 결과

메모리 할당자를 jemalloc으로 변경해주니, 메모리 우상향이 더 이상 발생하지 않았다. (!!)

메모리가 약간 상향하다가도, 반환하는 현상도 나타났다.

 

jemalloc 메모리 할당자로 인해, 우려됐던 것은 라이브러리 충돌이나, Response 응답이 느려진다거나, CPU 사용량이 높아지는 부분을 우려했었는데, 한동안 모니터링해봤을땐 별다른 문제는 없었다.

 

# 그렇다면 왜 메모리 파편화가 발생했는가?

우리의 서버도 jemalloc을 적용하니, 위의 결과처럼 메모리 우상향이 해결되는 현상을 관측할 수 있었다.

따라서, 우리의 서버도 메모리 파편화가 발생하고 있었다는 것을 간접적으로 확인한 셈이다.

 

메모리 파편화가 정확히 얼마나 발생했는지를 수치화를 하면 좋았을텐데, 우리의 인프라 여건 상 수치를 구하는 것은 어려웠다.

(인프라 여건이 받쳐준다 하더라도, 메모리 파편화의 수치를 정확히 산출하는건 힘들다고 한다)

 

다만, 정황 상 메모리 파편화가 원인인 것은 거의 99퍼 확실해 보인다.

  • NMT 옵션으로 추적되지 않음 -> JVM 자체에서 사용하는 메모리 문제는 아니라는 뜻
  • jemalloc 적용으로 해결됨
  • 무거운 요청들을 routing했을 때, 무거운 요청을 받는 서버의 메모리 상향 속도가 확연히 빠름

 

그렇다면 왜 메모리 파편화가 발생하는건가? 모든 Java app이 이런것일까?

 

메모리 파편화가 발생하는 원인을 대략적으로 추정해봤다.

  • 잦은 경합으로 인한 arena 영역 증가
    • 무겁거나 많은 요청이 들어오는 경우 또는 많은 스레드가 동시에 메모리를 할당하고 해제하는 경우, 다양한 크기의 arena영역이 생성되고 해제되면서 단편화를 유발할 수 있다.
    • 즉, 큰 객체를 자주 할당하고 해제하거나 OR 다양한 크기의 객체를 빈번히 할당하거나
  • 메모리 할당 알고리즘
    • ptmalloc2의 메모리 할당 알고리즘, First-Fit 전략은 메모리 할당 요청을 빠르게 처리하지만, 이는 단편화의 주요 원인이 될 수 있음
  • 서버 스펙
    • 메모리나 CPU 코어 수가 충분하지 않은 경우, 많은 요청을 처리하는 과정에서 메모리 할당과 해제가 빈번히 일어나며, 이는 단편화를 증가시킬 수 있음
  • 긴 실행시간
    • 위와 같은 원인들로 메모리 단편화가 발생하면, 빠르게 반환하면 해결될 수도 있지만, ptmalloc2는 이러한 환경에서 메모리를 OS에 쉽게 반환하지 않기 때문에 문제가 계속해서 누적된다.

 

메모리 단편화 현상은 메모리 사용량이 충분함에도 불구하고, 메모리 할당을 못하는 현상을 말하는 것이 아니였나?

 

  1. 메모리 할당자의 특성
    • ptmalloc2는 메모리 단편화가 발생할 때, 메모리를 OS에 반환하는 데 제한적일 수 있다.
    • 이는 특히 멀티스레드 환경에서 여러 arena를 사용하는 경우 더욱 심각해진다.
    • 각각의 arena가 메모리를 할당하고 해제하면서 외부 단편화를 일으키고, 이러한 단편화된 메모리는 재사용되지 않고, 새로운 메모리 할당이 필요해지므로 전체 메모리 사용량이 증가할 수 있다.
  2. 메모리 회수
    • 단편화된 메모리는 특정 크기의 연속된 메모리 블록을 할당하기 어려운 경우가 많다.
    • 예를 들어, 4KB의 블록이 여러 개 존재하더라도, 8KB의 연속된 블록이 필요할 때 할당하지 못해 새로운 메모리 할당이 필요해지고, 이는 전체적인 메모리 사용량을 증가시킨다.

 

# 결론

만약, 어떠한 툴을 사용해도 특정 영역에 대한 Leak 단서가 나오지 않고, 우리의 서버와 비슷한 상황에 놓여있다면, 메모리 할당자를 jemalloc 또는 tcmalloc으로 변경해보자.

 

이번 계기로, 내가 사용하는 꽤나 많은 라이브러리(Netty, ES client 등)가 직접적으로 Native memory를 할당받고 해제하고 있었음을 깨달았고, OS레벨에서의 메모리 할당에 대해서도 많은 공부를 했었다.

 

* jemllaoc, tcmalloc에 관한 글: https://blog.malt.engineering/java-in-k8s-how-weve-reduced-memory-usage-without-changing-any-code-cbef5d740ad