dev/Cloud & Infra

ElasticSearch Heap 사이즈 설정

lugi 2019. 2. 3. 07:13

지난 주는 대용량 데이터를 kafka를 통해 vertica 및 elasticsearch 에 적재하면서 최적의 설정을 하기 위해 몇십 기가씩 쏴보고 heap 사이즈 부족하다고 터지면 설정 조절하고 그런 것의 연속이었다. ElasticSearch 관련해서 설정을 조정하면서 레퍼런스 문서에서 참고한 부분들을 살펴 보려한다. 아마도 JVM 기반의 애플리케이션의 Heap 사이즈를 설정하면서 여러 군데 사용할 수 있는 지식이기도 할 듯 해서 정리 해 둠.


- 배경 지식

Oracle Java Virtual Machine Specification (https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html)에 따르면 JVM은 프로그램 실행시 런타임에서 사용하는 다양한 데이터 영역을 정의해두고 있다. 그 데이터 영역은 JVM 구동시 혹은 스레드 구동시의 라이프 사이클에 의해서 관리된다.


해당 영역은 얼추 다음과 같다

1. pc(program counter) 레지스터

- JVM 은 다중 스레드를 지원하기 때문에, 스레드는 자체적인 pc 레지스터를 가지고 실행 중인 JVM 명령어의 주소를 포함한다. (네이티브 메소드의 경우에는 undefined 를 가진다) 이 영역은 모든 주소를 커버할 정도로 애초에 넓다

2. JVM 스택

- JVM 스레드는 동적 연결, 메소드의 반환 값, 에러처리를 수행하는데 사용하는 데이터 및 부분적인 결과를 포함하는 프레임을 저장하기 위해 비공개 스택을 스레드 생성시에 가지는데, 이 스택은 주로 로컬 변수를 저장하는데 쓰인다

3. 힙(Heap)

- 이 놈을 주로 살펴보려고 다른 놈들까지 따라오게 되었는데, 얘는 스레드 간에 공유가 가능하고, 클래스 인스턴스나 배열에 힙 영역을 할당한다. JVM 생성시에 생성되고, 가비지 컬렉터에 의해서 관리된다. 힙의 사이즈는 사용자가 지정하거나, 동적으로 힙을 제어할 경우 그 최대 최소 범위를 설정할 수도 있다. 관리할 수 있는 힙보다 많은 Heap 사이즈를 요청할 때 OutOfMemory (OOM)을 자주 접할 수 있다. 오늘은 이 부분에 대해서 살펴볼 예정이다.

4. 메소드 영역

- 이 친구도 스레드 간에 공유가 가능한 영역이다. JVM7 까지(Hotspot VM 과 유사한 계열 기준) 클래스나 메소드의 Metadata 및 static, 런타임 상수풀이 위치하는 영역인데, 용량이 64~82메가 정도로 작다. --XX:PermSize 류의 옵션으로 조정하던 영역이 여기에 해당된다. 이 부분은 Permanent 영역이라고 해서 각종 메타 정보+static object 까지 여기에 들어가서 static 남발 or hotdeploy를 할 때 기존 class 정보가 헤제되지 않고 leak이 발생하는 경우 PermGen space OOM을 구경해야 했다. 그래서 Java 8부터는 Metaspace라는 영역이 Native 메모리에 생겨서 메소드나 클래스의 메타정보는 여기에 저장하고, 상수풀이나 static 정보는 Heap 에 저장된다. JVM 스펙 문서에는 논리적으로는 힙의 일부이지만 GC의 마수를 피할 수도 있고, 압축을 안 할 수도 있다. 라고 설명되어 있는데, 뒤에 나오는 설명으로는 메소드 영역의 위치나 정책을 딱히 정해놓지는 않았다고 해서, JVM8부터는 개념적으로 이 영역에 저장되는 데이터의 위치는 실제로는 힙일 수도 있고, 네이티브 메모리일 수도 있을 거 같은데. 더 자세히는 나도 잘 모르겠다.

5. 런타임 상수풀

- 각종 리터럴이나 런타임에 해석되어야 하는 메소드 및 필드 참조 등의 상수를 저장하는 영역이다. 얘도 메소드 영역에 할당된다는데 JVM8부터는 상수풀의 메소드 영역은 힙이라고 하니 얘도 결국 힙으로 간다고 봐야하나? 

6. 네이티브 메소드 스택

- C언어로 작성된 메소드가 실행될 때 데이터 저장하는 곳이다

사실 JVM 설명하려고 했던 것은 아니니... https://docs.oracle.com/javase/specs/jvms/se11/html/jvms-2.html 을 더 자세히 참조하자...


- Elastic Search

아래의 내용은 https://www.elastic.co/guide/en/elasticsearch/reference/current/heap-size.html 의 요약이다.

엘라스틱서치도 물론 Heap을 사용한다. 기본적으로 Min값 Max값 모두 1기가로 설정되어 있다. 해당 옵션은 실행시 최소값은 Xms 최대값은 Xmx 옵션을 통해 조절할 수 있다. 이 때 Elasticsearch가 권장하는 설정은 다음과 같다

- 최소값 최대값은 같게 맞춰라(Xmx 랑 Xmx를 같게)

- Heap 사이즈가 커지면 캐싱하기에는 유리하다. 근데 Heap 사이즈를 늘릴 수록 Garbage collect에 들어가는 시간도 늘어나고, 그 시간은 시스템의 성능 저하나 Hang을 유발할 수도 있다

- 힙의 최소값을 물리적 RAM 의 50% 이상으로 설정하지 말고, 커널 파일 시스템 캐시에 충분한 RAM이 확보될 수 있도록 해라

- 힙의 최대값을 Compressed OOPS 이상으로 설정하지 마라, 이 값은 시스템마다 다르지만 거의 32기가바이트 근방이다. 다음과 같은 로그로 이 한계 이하인지 확인 가능하다 heap size [1.9gb], compressed ordinary object pointers [true]

- zero based Compressed OOP 이하의 임계치에 머물도록 설정해라. 이 부분도 시스템에 따라 약간씩 다른데 26~30GB 근방이다. -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompressedOopsMode 옵션으로 확인 가능하다.

jvm.options 파일에서 -Xms, -Xmx 옵션을 통해서 설정할 수도 있고, 해당 파일에서는 그 부분을 주석 처리 한 이후에 ES_JAVA_OPTS="-Xms2g -Xmx2g" ./bin/elasticsearch  와 같이 실행시에 환경 변수를 주는 방식으로 설정할 수도 있다.


그럼 여기서 위에 나온 Compressed OOPS 랑 Zero-based가 뭔지 좀 알아보면... 32비트 시스템은 4GB까지 메모리를 쓸 수 있고(2^32비트), 64비트에서는 2^64비트까지(그냥 엄청 크다) 메모리를 쓸 수 있다는 건 널리 알려진 사실이다. JVM은 메모리 주소를 지시하는 포인터를 Ordinary Object Pointer, OOP라고 한다. (https://wiki.openjdk.java.net/display/HotSpot/CompressedOops) 일반적으로는 네이티브에서 말하는 포인터와 같은 사이즈인데, 요즘 컴퓨터들이 다들 64비트라 물리적인 RAM도 4GB 이상을 쓰니까, 64Bit JVM을 쓰면 힙도 32비트의 범위를 넘어설 수 있는데, 이 때 약간 차이가 생긴다. 4GB를 넘어서니까 그냥 64비트로 처리하면 될 것 같지만, 64비트 메모리를 처리하기 위해서는 주소 공간도 2^64-1 개가 필요한데, 굳이 주소를 위해 쓰지도 않을 공간을 할당하는 것은 낭비다. 그래서 64비트 머신에서 32비트 포인터를 사용하는 Compressed OOPS 라는 놈을 만들었다.

64비트 머신에서 JVM의 메모리 관리 포인터는 8바이트 단위이다(0, 8, 16, 24 ..., http://btoddb-java-sizing.blogspot.com/2012/01/object-sizes.html 참조) Compressed OOP는 이 0,,8,16,24... 의 메모리 주소 대신 가상의 오프셋인 0,1,2,3을 사용한다. 이 경우 오프셋을 3번 왼쪽으로 비트 시프트를 시키면 2^3 을 곱해서 실제 메모리 관리 포인터를 지시할 수 있다. 이 경우 동일한 숫자의 관리포인터를 사용할 때 메모리의 공간을 8배로 확장해서 쓸 수 있는 효과가 난다. 그래서 4GB~32GB 의 영역까지는 Compressed OOPS로 처리할 수 있다. 다만 JVM 이 사용하는 각종 영역들의 영향으로 32기가 전체를 사용할 수 없으므로 Compressed OOPS 임계치가 어디까지인지 위에서 설명한 것과 같이 잘 조사해서 써야한다. 이 때 3번의 왼쪽 시프트로 정확한 메모리 포인터를 가리킬 수 있다는 전제는 시작점이 0인 Compressed OOPS 를 사용해야 한다는 것이다. 만약 시작점이 0이 아니라면 시프트 연산 이후에 그 주소만큼의 더하기 연산을 통해 이동을 시켜줘야하기 때문에 성능이 저하되는 원인이 될 수 있다. 그러므로 이 지점도 위에서 말한 임계치를 잘 찾아내는 것이 중요하다.


사실 엘라스틱서치에 32GB 가까운 힙을 사용할 것이 아니라면 이 부분이 그렇게 신경 쓰일 일은 아닌 거 같다. 그래도 메모리나 성능에 관련된 문제가 언제 어디서 발생할지 모르는 거고... 어쩌다보니 엘라스틱서치 이야기보다 JVM 이야기도 많이 쓴 거 같은데... JVM 의 내부적인 구조에 관한 이야기도 내가 정확하게 이해하고 쓴 건지도 사실 잘 모르겠다. 그래서 참고 링크를 토대로 계속 공부는 해야겠다.


이 글을 쓰면서 참고한 링크들은...

https://www.elastic.co/guide/en/elasticsearch/reference/current/heap-size.html

http://btoddb-java-sizing.blogspot.com/2012/01/object-sizes.html

https://www.javacodegeeks.com/2016/05/compressedoops-introduction-compressed-references-java.html

https://blog.codecentric.de/en/2014/02/35gb-heap-less-32gb-java-jvm-memory-oddities/

https://www.slideshare.net/GlobalLogicUkraine/java-memory-management-tricks

https://wiki.openjdk.java.net/display/HotSpot/CompressedOops

위와 같다.