ElasticSearch
ElasticSearch (이하 ES) 클러스터를 운영하면서 가장 어려움을 겪는 문제 중에 하나는 역시 GC 모니터링이 아닐까 합니다. 저희도 클러스터를 운영하면서 힙 메모리에 대한 이슈를 해결하기 위해 Task Cancelling (https://brunch.co.kr/@alden/40), G1 GC 적용 (https://brunch.co.kr/@alden/45) 등등 많은 노력을 했습니다. 이번 글 역시 GC와 관련된 글이며, 기본으로 사용하는 CMS GC를 유지한 상태로 튜닝했던 사례를 이야기해 볼까 합니다. G1 GC로 변경하는 게 아직 걱정되시는 분들에게는 도움이 될 수도 있을 것 같습니다. 참고로 이 튜닝은 함께 업무를 하고 있는 카카오 시스템 엔지니어 정송화 님이 며칠간의 추적과 리서치 끝에 적용한 건입니다. (단지 제가 브런치를 쓰고 있어서 허락을 받고 정리만 해 봅니다. ^^)
우선 어떤 문제를 겪고 있었는지에 대해 살펴보겠습니다. 운영하고 있는 여러 ES 클러스터 중 하나에서 잦은 Old GC와 간혹 발생하는 초 단위의 Pause Time이 확인되었습니다. 이로 인해 클러스터의 성능에 이슈가 있는 상태였습니다.
위 그림에서 볼 수 있듯이 힙 메모리 영역의 사용률이 급격하게 늘었다가 줄어드는 형태를 보이고 있었으며 하루에도 몇 번씩 Old GC가 발생했습니다. 또한 간헐적으로 초 단위의 Pause Time이 발생하는 경우도 있었습니다. 보통 이런 경우에는 GC 방식을 변경해 본다거나 힙 메모리 증설 혹은 데이터 노드의 증설 등을 통해서 문제를 해결해 왔습니다. 하지만, 뭔가 다른 이슈가 있을 것 같다는 엔지니어의 촉(?)이 발동하게 되었고 이 이슈에 대해 조금 더 깊게 살펴보기로 했습니다.
본격적인 이야기를 하기 전에 Young 영역과 Old 영역에 대한 이야기를 해보겠습니다. 자바의 힙 메모리는 아래와 같이 영역이 나뉘어 있습니다.
위 그림에서 볼 수 있듯이 최초 생성된 객체는 Eden 영역에 생성되며 수차례의 GC가 발생하는 동안에 Survivor 영역을 이동하고, 끝까지 살아남은 객체는 Old 영역으로 이동합니다. 그리고 Old 영역에 있는 객체들을 지우는 과정이 Old GC이며 가장 큰 Pause Time을 발생시킵니다. 우리가 위에서 문제로 제기했던 것이 바로 이 Old 영역에 대한 Old GC가 너무 자주 발생하는 것이었습니다.
GC에 대한 조금 더 자세한 이야기는 https://mirinae312.github.io/develop/2018/06/04/jvm_gc.html 을 참고하시기 바랍니다.
그래서 jstatd와 VisualVM을 이용해서 GC가 발생하는 과정을 조금 더 시각화해 보기로 했습니다. 아래는 VisualVM의 플러그인 중 하나인 Visual GC로 확인한 GC 과정입니다.
위 그림을 잘 보시면 Eden 영역이 아주 빠르게 차오르고 Survivor 영역들의 사용량도 꽤 높다는 것을 볼 수 있습니다. Young 영역에 대한 GC 과정만 조금 더 자세히 살펴보겠습니다.
Survivor 영역의 사용률을 보면 재미있는 사실을 확인할 수 있는데요, 사용률이 거의 100%에 육박하는 것을 볼 수 있습니다.
이것은 Eden에서 Survivor로 넘어가야 하는 객체들이 Survivor 영역의 용량이 부족해서 Survivor를 거치지 않고 Old 영역으로 바로 넘어갈 수 있다는 것을 의미합니다.
즉, 굳이 Old 영역으로 넘어가지 않아도 되는 객체들이 Survivor 영역의 용량 부족으로 인해 넘어가게 되고 이로 인해 불필요하게 Old 영역의 사용률이 증가될 수 있습니다.
그리고 잦은 Old GC로 인해 Old 영역의 메모리 단편화가 심해지고 간헐적으로 Compaction이 발생하게 됩니다. CMS GC에서 Compaction 은 굉장히 시간이 많이 소요되는 무거운 작업이며 이로 인해서 간헐적으로 초 단위의 Pause Time이 발생하게 됩니다.
즉 문제의 원인은 불필요하게 Old 영역에 객체가 저장되는 것이었습니다. 그럼 어떻게 Old 영역으로의 이동을 최소화할 수 있을까요?
CMS GC는 튜닝을 하지 않으면 Young 영역과 Old 영역이 1:9로 설정됩니다. 만약 30GB의 힙 메모리를 사용한다면 Young 영역은 3GB, Old 영역은 27GB가 됩니다. 그리고 Young 영역은 다시 Eden과 Survivor0, 1 이렇게 3개의 영역으로 나뉘며 역시 별다른 튜닝을 하지 않으면 8:1:1로 설정됩니다. 그래서 Eden은 2.4GB, Survivor0과 1은 각각 0.3GB를 나눠 가집니다. 30GB라는 큰 힙 메모리를 잡아도 Survivor 영역이 0.3GB 밖에 안되기 때문에 만약 Young GC 발생 시 Survivor로 넘어와야 하는 객체의 총 양이 0.3GB를 넘게 된다면 바로 Old 영역으로 넘어가버립니다. 따라서 NewRatio와 SurvivorRatio를 조절해서 Young 영역을 조금 더 늘린다면 Old 영역으로 넘어오는 객체의 양을 줄일 수 있습니다.
아래는 튜닝에 적용한 값입니다.
-XX:NewRatio=2
-XX:SurvivorRatio=6
-XX:CMSInitiatingOccupancyFraction=80 #기본값은 75
이렇게 튜닝하면 Young 영역과 Old 영역은 1:2가 되고 Young 영역 중에서 Eden과 Survivor의 비율은 6:1:1이 됩니다. 만약 30GB의 힙 메모리를 사용한다면 Eden은 7.5GB Survivor는 각각 1.25GB씩, 마지막으로 Old 영역은 20GB가 됩니다.
CMSInitiatingOccupancyFraction 이 값은 CMS GC에서 Old 영역의 사용률이 어느 정도 되었을 때 Old GC를 수행할 것인지를 결정하는 값이며 백분율을 의미합니다. ES에서 사용하는 기본값은 75%입니다. 우리는 NewRatio를 통해 Old 영역을 줄였기 때문에 이 값도 함께 변경해 주어야 합니다. 그래서 이 값을 80으로 변경했습니다.
이 값들을 종합적으로 정리하면 아래와 같습니다.
Eden 영역의 크기 : 7.5GB
Survivor 0 영역의 크기 : 1.25GB
Survivor 1 영역의 크기 : 1.25GB
Old 영역의 크기 : 20GB
Old GC가 발생하는 타이밍 : Old 영역의 사용량이 16GB가 되었을 때
위 값은 저희가 운영 중인 클러스터에 어울리는 값이며 워크로드에 따라 NewRatio를 더 늘려야 할 수도 있습니다. 가장 좋은 방법은 값을 조금씩 바꿔보면서 GC 과정을 확인해 보는 게 가장 좋습니다.
그래서 위와 같이 튜닝하게 된 후 어떤 효과가 있었을까요? 아래 그림은 튜닝 후 GC 모니터링 화면입니다.
기존과 비교해 봤을 때 Old GC의 발생 빈도가 현저하게 줄었으며, 힙 메모리의 사용 패턴도 훨씬 보기 좋아졌습니다. 위 클러스터는 특히 로그의 크기 자체가 크기도 했기 때문에 Young GC 발생 시 이동해야 할 객체의 크기가 컸고, Young 영역을 늘린 효과가 컸습니다.
결국 문제의 해결은 Young 영역을 늘려서 Old 영역으로 객체의 이동을 최소화하는 것이었습니다.
GC 문제가 발생했을 경우 CMS GC에 대한 튜닝으로도 충분히 좋은 결과를 얻을 수 있다는 교훈을 얻었습니다.
이 글이 GC로 인해 어려움을 겪고 계신 분들에게 도움이 되었으면 좋겠습니다.
마지막으로 문제를 해결하고 글을 쓸 수 있도록 허락해 주신 정송화 님에게 무한한 감사의 인사를 드립니다.
https://mirinae312.github.io/develop/2018/06/04/jvm_gc.html
https://d2.naver.com/helloworld/37111