운영
메모리 단편화
마지막 수정
이건 직접적으로 내가 해결한 내용은 아니다. 옆에서 회사 동료가 겪은 문제였고, 같이 의견 내면서 확인해보면서 원인을 찾아가는 과정이 있었다. 정리해두면 좋을 것 같아서 정리해둔다.
아래의 내용은 동료분의 입장에서 각색하여 서술했다.
사건의 발단
우리 회사에는 AI 관련 기능이 모두 모여있는 스프링 서버가 있다. 직접적으로 AI를 돌리는 서버는 아니고, AI에 요청하기 전 단계의 모든 비즈니스 로직을 해당 서버에서 처리한다.
그렇게 잘 운영되나 싶었는데, 어느 날 파드 하나의 메모리가 100%을 사용하고 있다는 인프라 팀의 제보를 받았다(쿠버네티스로 운영중이다). 파드에 문제가 생겨 재기동은 시작되었는데, 진행 중에 무언가 문제가 생겨 그 상태로 멈춰있던 것이었다.
우선 급하게 liveness probe 설정을 진행했다. 주기적으로 파드를 검사해서 문제가 생기면 재기동하도록 설정했다. 이렇게 했더니 우선 쿠버네티스에서 재시작은 정상적으로 되기 시작했다.
그렇다고 끝난 건 아니었다. 어찌되었든 메모리를 100% 먹고 있는 것이 문제였으니 이걸 해결해야했다.
1차 조치
서버의 힙 설정 값이 시스템 메모리의 25%만 사용하도록 되어 있었다. 보통 우리팀은 힙을 시스템 메모리의 75%까지 설정한다.(-XX:MaxRAMPercentage=75.0)
이것이 원인이라고 생각해 75%까지 설정해서 재배포를 했다.
다만 이렇게 하면서도 의문이었던 건, 시스템 메모리의 25%만 사용하는데 왜 파드의 메모리를 100%를 사용하고 있었는지 였다.
2차 조치
역시나 서버는 계속해서 죽었다. 물론 이번에는 다시 잘 살아나고 있었기에 운영상의 큰 문제는 없었다. 75%가 문제인가 싶어서 50%로 줄여봤다.
여전히 서버는 계속해서 죽었다. 솔직히 옆에서 보던 나도 머리가 아픈데, 동료 분은 얼마나 머리 아팠을까...
3차 조치
기존에 그라파나에서 JVM 메모리 대시보드가 있었는데, 정보가 부족해 아예 다른 대시보드로 변경해보았다.
여기서는 힙 뿐만 아니라 논-힙(non-heap) 데이터도 보여주는데 여기서 이상한 것을 보았다.
direct_memory가 계속해서 우상향 되고 있는 것이었다. 해당 영역은 JVM 메모리 관리를 거치지 않고 바로 시스템 메모리를 할당해서 사용하는 메모리이다. 자바가 NIO를 사용할 때 주로 다이렉트 메모리를 사용한다.
해당 서버는 파일을 다루는 과정이 많았기 때문에 필연적으로 NIO를 사용하게 되고, 이게 다이렉트 메모리를 증가 시켰을 거라고 예상했다.
찾아보니, nio는 스레드 별로 다이렉트 메모리의 버퍼를 미리 캐싱해둔다고 한다. 그래서 대용량의 파일이 오면 그 크기만큼 버퍼를 잡아두고, 반환을 하지 않고 다음에도 사용하려고 한다.
버퍼의 최대 크기를 JVM 옵션으로 제한 걸어보았다. -Djdk.nio.maxCachedBufferSize=262144(256KB)
4차 조치
여전히 OOM이 발생했다. 다만 긍정적인 신호는 하루마다 재시작되다가 5일만에 재시작 되었다는 것이다.
이제는 아예 JVM 메모리 덤프까지 떠서 확인해보았다.(JCMD 이용)
근데 이상했다. 힙, 논-힙 모두 정상이었다. JVM 자체는 메모리를 거의 먹지 않고 있는데, 계속해서 외부에서 보는 메모리 사용량(RSS)은 쭉쭉 늘어났다.
이때 동료 분이 이전에 레디스에서 메모리 파편화에 대한 게시글을 본 적이 있었고, 딱 그 내용이 생각났다.
반신반의했지만, 실제로 그 문제가 맞았다. 범인은 glibc 이라는 c의 저수준 라이브러리이다. 자바의 NIO는 c를 이용해서 파일 처리를 진행하는데 이때 메모리를 할당하고 해제하는 과정에서 문제가 있었던 것이다.
glibc은 malloc을 실행하면 바로 OS에 메모리를 반환하는게 아니라, glibc가 메모리를 가지고 있다가 다음 요청 때 재사용하려고 한다. 그리고 OS에 메모리를 반환할 때 낮은 주소에서부터 높은 주소로 스캔하며 반환하는데, 이때 실제 사용하는 메모리가 있으면 그 뒤의 메모리는 반환하지 않는다.
근데 이후에 좀 큰 값를 할당하려고 하면 자신이 가지고 있는 메모리 중에서 하나를 뽑는데, 가지고 있는 메모리보다 큰 메모리라면 새롭게 메모리를 할당한다. 이 과정이 반복되면서 사용하지 않는 메모리는 늘어나고 OS에도 반환되지 않으면서 RSS 가 계속 늘어나는 것이다.
해결방법은 두 가지가 있었다.
- jmalloc 사용하기 : 해당 문제가 없는 glibc을 대체하는 구현체이다.
- MALLOC_ARENA_MAX 설정하기
- glibc는 스레드별로 메모리를 관리하는 범위인 arena를 설정한다.
- arena의 기본 개수는 cpu 코어 수 * 8이다.
- 아레나 별로 많은 파편화가 일어날 수 있다.
- 그래서 MALLOC_ARENA_MAX를 2로 설정했다.
간단하게 MALLOC_ARENA_MAX=2로 설정하고 나서부터 OOM 으로 인한 재시작은 사라졌다.
후기
이번 문제는 오히려 JVM 메모리 관리 방식을 머리 속에 익히는 좋은 기회였던 것 같다.
heap은 오히려 제한 값을 걸어두면 그 제한 내에서 잘 관리되지만, non-heap의 경우 시스템 자원을 많이 먹지 않게 하기 위해서 신경써야 한다는 것을 알았다.
솔직히 NIO가 어떻게 빠른 속도를 내는 지도 몰랐는데 어느 정도 감을 잡게해준 좋은 기회였다고 생각한다.