• 카테고리

    질문 & 답변
  • 세부 분야

    데브옵스 · 인프라

  • 해결 여부

    미해결

Compressed OOP 조건에 따른 ES Heap Size 제약

24.03.12 15:18 작성 24.03.22 09:20 수정 조회수 155

0

안녕하세요, 대용량 데이터 검색엔진 구축을 위해 Elasticsearch를 도입했습니다.

 

1. 개발환경 및 Spec 설명

  • On-premise Kubernetes 환경에 Helm 배포를 통해 Master, Coordinating, Data Node 각각 4, 4, 10대로 Elasticsearch 클러스터를 구성했습니다.(HA 구성을 위해 Data Node는 모두 다른 Kubernetes Node에 배포되며, statefulSet을 통한 Rolling Update 방식입니다.)

  • 예상되는 클러스터 전체 Data Usage는 50TB 수준이고, primary shard와 replica shard의 개수는 각각 10과 1로 둘 예정입니다. 하나의 shard 용량은 10~20GB 수준으로 유지할 예정입니다. 현재는 초기 적재를 위해 replica shard 개수를 0으로 설정한 상황입니다.)

  • pod container의 limit resource는 8core, 64Gi이며, ES_JAVA_OPTS 값으로는 -Xms30g -Xmx30g 옵션을 통해 Elasticsearch의 Heap Memory로는 30GB를 할당했습니다.

     

  • 32Bit 포인터 관리 방식에서 object 그 자체가 아닌 object의 offset을 참조하는 Compressed OOP 사용을 위해, Elasticsearch의 Heap Size는 32GB를 권장하고 있습니다. 여기에 시작 주소를 0으로 두는 Zero-based 까지 고려하여 보수적으로 30GB를 사용했습니다.

  • 위와는 독립적인 권장 사항인 'JVM의 50%을 ES에 할당하라' 조건까지 고려하여 JVM Heapsize를 64Gi 로 두었습니다.

 

2. issue

  • 데이터 색인(bulk가 아닌 일반적인 PUT API) 중, kibana를 비롯하여 Elasticsearch 클러스터 전체에 503 에러가 발생했고 쿠버네티스 클러스터에 배포된 pod(Master, Coordinating Node 전부, 그리고
    Data Node는 2대를 제외한 나머지 8개)가 restart 없이 죽었습니다. (원인은 CircuitBreaker입니다.)

NAME                                           READY   STATUS    RESTARTS   AGE
edms-p01-srep01-coordinating-0                 0/1     Running   0          37h
edms-p01-srep01-coordinating-1                 0/1     Running   0          37h
edms-p01-srep01-coordinating-2                 0/1     Running   0          37h
edms-p01-srep01-coordinating-3                 0/1     Running   0          37h
edms-p01-srep01-data-0                         0/1     Running   0          37h
edms-p01-srep01-data-1                         0/1     Running   0          37h
edms-p01-srep01-data-2                         1/1     Running   0          37h
edms-p01-srep01-data-3                         0/1     Running   0          37h
edms-p01-srep01-data-4                         0/1     Running   0          37h
edms-p01-srep01-data-5                         0/1     Running   0          37h
edms-p01-srep01-data-6                         1/1     Running   0          37h
edms-p01-srep01-data-7                         0/1     Running   0          37h
edms-p01-srep01-data-8                         0/1     Running   0          26h
edms-p01-srep01-data-9                         0/1     Running   0          37h
edms-p01-srep01-es-exporter-8457b87fb7-wsshd   1/1     Running   0          39h
edms-p01-srep01-kb-84dcb6d7f7-gdhd9            0/1     Running   0          39h
edms-p01-srep01-master-0                       0/1     Running   0          37h
edms-p01-srep01-master-1                       0/1     Running   0          37h
edms-p01-srep01-master-2                       0/1     Running   0          37h
edms-p01-srep01-master-3                       0/1     Running   0          37h

 IMG_4935.png

Data Node의 경우 빈번한 Young GC, 그리고 Old GC가 발생했지만 점차 확보하는 Memory 양이 적어지다가 CircuitBreakingException이 발생했습니다.

Master와 Coordinating Node는 Old GC 없이 Young GC만으로 heap size가 잘 관리되다가 모든 Data Node 메모리 부하가 심해지니 Pod가 죽었는데, 유추하기로는 처리되지 못한 색인 데이터의 transport가 loop 되다가 Master/Coordinating 메모리에도 영향을 준 것으로 보입니다.

 

CircuitBreakingException이 발생 전의 한 Data Node의 stats은 아래와 같습니다. (GET _nodes/stats)


"mem" : {
  "heap_used_in_bytes" : 28558120848,
  "heap_used_percent" : 88,
  "heap_committed_in_bytes" : 32212254720,
  "heap_max_in_bytes" : 32212254720,
  "non_heap_used_in_bytes" : 191720344,
  "non_heap_committed_in_bytes" : 201515008,
  "pools" : {
    "young" : {
      "used_in_bytes" : 771751936,
      "max_in_bytes" : 0,
      "peak_used_in_bytes" : 19243466752,
      "peak_max_in_bytes" : 0
    },
    "old" : {
      "used_in_bytes" : 27784679400,
      "max_in_bytes" : 32212254720,
      "peak_used_in_bytes" : 32129130920,
      "peak_max_in_bytes" : 32212254720
    },
    "survivor" : {
      "used_in_bytes" : 1689512,
      "max_in_bytes" : 0,
      "peak_used_in_bytes" : 1520169584,
      "peak_max_in_bytes" : 0
    }
  }
},
...
"gc" : {
  "collectors" : {
    "young" : {
      "collection_count" : 226,
      "collection_time_in_millis" : 15603
    },
    "old" : {
      "collection_count" : 1,
      "collection_time_in_millis" : 6322
    }
  }
}

 

3. 의문점

  • Data node 역할을 담당하는 pod가 죽은 것은 그럴 수 있다 쳐도 색인과 관련 없는 Coordinating/Master Node 역할의 pod에까지 영향을 미치는 이유는 무엇인가요?(위의 pod metric을 살펴보아도 OOM과는 전혀 거리가 멀어보이긴 합니다만) Elasticsearch가 분산시스템이지만 위와 같이 Kubernetes 노드에 문제가 없는 상태에서 pod만 죽어버리니 고가용성이 무색해지는군요... 앞서 말씀드린 것처럼 색인 데이터의 transport 내부 동작이 영향을 미쳤을까요?


  • 위와 같은 CircuitBreakingException에 대응하는 방법에는 ES_JAVA_OPT의 Heap Memory 용량을 증설하거나, 클러스터 세팅 indices.breaker.total.limit 값은 이미 95%입니다. (indices.breaker.total.use_real_memory가 true 이므로) 이때, 이미 Heap이 30GB라면, Compressed OOP의 조건인 32GB를 넘는 수준의 ES_JAVA_OPTS 설정을 시도하려면 어느 정도로 높게 하는게 좋을지 고견을 여쭙습니다.


    (Container의 jvm memory 자체에는 큰 제약이 없는 개발환경입니다. 즉, 128GB, 256GB처럼 높은 수준의 resources.limit 설정도 가능합니다.)

     

답변 1

답변을 작성해보세요.

1

  1. _bulk 인덱싱 할 때 데이터 노드들에게만 REST API로 요청을 하셨을까요? ES는 클러스터 이기 때문에 마스터 노드, 코디네이팅 노드에게 인덱싱을 해도 내부에서 데이터 노드로 라우팅이 되는 구조이기 때문에 명시적으로 데이터 노드를 통해서만 인덱싱을 하는 게 좋습니다. 쿠버네티스로 구성 하셨다고 하니 인덱싱을 위해 생성한 엔드포인트 URL 이 데이터 노드들만 가리키는지를 확인해 볼 필요가 있습니다.

  2. 데이터 노드 파드들이 죽을 때 OOM으로 죽은 걸까요? 파드가 죽을 때 죽는 이유가 나올 텐데 그 이유가 명확하게 어떤 게 나오는지 궁금합니다.

  3. 위에 말씀해 주신 제약 조건 이면 데이터 노드들의 힙 메모리로 30GB 정도 주는 것은 충분할 것 같습니다. 서킷브레이킹 리밋을 줄이는 건 임시 방편이고, 메모리가 제대로 비워지지 않는 이유를 확인해야 합니다. Old GC가 발생해도 Old 영역에 메모리가 비워지지 않는 이유를 봐야 하는데요 필드 데이터 때문에 이런 경우가 발생할 수 있으니 텍스트 타입의 필드들에 필드 데이터가 켜져 있는지 살펴 보시면 좋을 것 같습니다. (https://www.elastic.co/guide/en/elasticsearch/reference/current/text.html#fielddata-mapping-param)

  1. Endpoint url이 존재합니다. (https://[ES클러스터명].[K8s클러스터명].[회사도메인].com) 혹시 data node만 가리키는지 확인은 ES API를 통해 가능한가요? ingress.hosts에 위 url을 명시하는 방식으로 endpoint를 만들었고, 지금까지 _search, _mapping 등의 API는 role(m, d, c, i 등)에 따른 node를 명시하지 않고 그냥 호출하였습니다.



    2-1. Data Node 파드(***-data-1)가 [Ready: 0/1] 상태인 컨테이너의 로그입니다. 대부분의 파드가 죽은 상황이라 ***-data-1에서 ***-data-4로의 shard 이동을 수행하려 해도, data-4의 addEstimateBytesAndMaybeBreak 메서드에서 heap memory 부족으로 CircuitBreakingException을 터뜨리면서 파드가 죽은 것 같습니다.

     


    2-2. Data Node 파드(***-data-4)가 [Ready: 1/1] 상태인 컨테이너의 로그입니다. 반복 수행되는 young GC가 유의미하게 heap memory를 복구해주지는 않고 있으며, old GC가 동작한다 하더라도 거의 10초 가량의 시간이 걸려 100mb 수준의 memory만 확보되었습니다.


  1. Legacy Index Template 상에서 text 타입의 fielddata 옵션을 모두 disable 한 상태에서 index를 구성하고 있습니다. 추후에도 집계 등으로는 사용하지 않을 예정이라 disable을 유지할 예정입니다.

     

  1. ES API를 통해서는 사실 불가능하고 엔드포인트 URL을 만드는 방법을 기반으로 유추할 수 밖에 없습니다. AWS ALB를 사용한다면 그 하단에 연결된 파드들을 보면 될텐데, 어떻게 만드셨는지 알 수가 없어서 이 자리에서 답변 드리기는 어려울 것 같습니다.

  2. 저에게 공유해 주시는 이미지가 전부 엑박 입니다. ^^;; 주소가 https://cdn.skhynix.com/img/thumb/0x0/F3D082AE66590FF7D2310D8AFA06713F 이렇게 나오는데, 사내 이미지 CDN 주소를 공유해 주셔서 그런 게 아닐까 싶습니다.

  3. 템플릿이 인덱스에 잘 반영 되었는지 생성된 인덱스의 매핑 정보를 다시 한 번 확인 하셔서 필드 데이터가 false로 되어 있는지 확인이 필요할 것 같습니다.

  1. AWS와 같은 Public Cloud 환경이 아니라 On-premise인데, 혹시 말씀하신 부분에 대한 확인이 가능한 환경일까요? (7.10.2 버전의 elastic 공식 Helm Chart로 설치 후 ArgoCD로 운영/배포 중입니다)

 

  1. 그렇군요 ㅎㅎ 사내 CDN이 외부 호스팅이 안되어 이해가 아닌, 오히려 혼동만 드린 것 같습니다...

 

[색인 데이터 특성]

  • 문서 하나의 용량이 큽니다. (사내 문서함에 저장되는 파일 중 20MB 이하의 pdf, ppt, txt, xlsx, doc를 색인합니다.)

  • 부하를 우려하여 ngram이 아닌 edgengram_analyzer(min:2, max:10)을 사용하고 있습니다.

  • 문서 수정을 대비하여 수정일자를 version으로 두고 있습니다.(version_type=external) 같은 버전 문서 색인으로 인한 409 에러가 종종 발생하기도 합니다.

  • fielddata는 Index Template에서도 비활성화하였으며, 실제로 생성된 Index의 mapping에서도 비활성화 상태입니다.

 

https://discuss.elastic.co/t/circuitbreakingexception-parent-data-too-large-in-es-7-x/192801/13?fbclid=IwAR17ROa4nNGMldmaioCpYrtvaEI_CRILc60uegyOVxs5qtDbJ4RFaHrayPQ

위 링크가 저와 유사한 상황입니다. G1GC 하에서 Young/Survivor/Humongous Region에서의 청소는 계속 수행(But, 높은 LocalMinimum 유지)하지만, Old Region에 대한 수거가 이뤄지지 않아 JVM Heap Size가 계속 상승하는 패턴입니다.
(운영 중인 다른 ES 클러스터에서는 위 패턴이 발견되지 않습니다.)

image

 

CircuitBreakingException이 발생하는 시점에서야 FullGC가 발생하지만 Old Region의 메모리 확보가 미미하고, 결국 다른 Data Node에 부하가 몰려 전체 클러스터가 CircuitBreakingException에 빠집니다.

[2024-03-20T03:49:20.677+0000][7][gc,start] GC(1013) Pause Full (G1 Evacuation Pause)
[2024-03-20T03:49:20.701+0000][7][gc,phases,start] GC(1013) Phase 1: Mark live objects
[2024-03-20T03:49:25.065+0000][7][gc,phases      ] GC(1013) Phase 1: Mark live objects 4364.230ms
[2024-03-20T03:49:25.065+0000][7][gc,phases,start] GC(1013) Phase 2: Prepare for compaction
[2024-03-20T03:49:26.596+0000][7][gc,phases      ] GC(1013) Phase 2: Prepare for compaction 1531.206ms
[2024-03-20T03:49:26.596+0000][7][gc,phases,start] GC(1013) Phase 3: Adjust pointers
[2024-03-20T03:49:28.592+0000][7][gc,phases      ] GC(1013) Phase 3: Adjust pointers 1995.496ms
[2024-03-20T03:49:28.592+0000][7][gc,phases,start] GC(1013) Phase 4: Compact heap
[2024-03-20T03:49:31.054+0000][7][gc,phases      ] GC(1013) Phase 4: Compact heap 2462.276ms
[2024-03-20T03:49:31.073+0000][7][gc,heap        ] GC(1013) Eden regions: 0->0(102)
[2024-03-20T03:49:31.073+0000][7][gc,heap        ] GC(1013) Survivor regions: 0->0(0)
[2024-03-20T03:49:31.073+0000][7][gc,heap        ] GC(1013) Old regions: 2018->1761
[2024-03-20T03:49:31.073+0000][7][gc,heap        ] GC(1013) Archive regions: 0->0
[2024-03-20T03:49:31.073+0000][7][gc,heap        ] GC(1013) Humongous regions: 30->30
[2024-03-20T03:49:31.073+0000][7][gc,metaspace   ] GC(1013) Metaspace: 116817K(121836K)->116817K(121836K) NonClass: 102767K(106484K)->102767K(106484K) Class: 14049K(15352K)->14049K(15352K)
[2024-03-20T03:49:31.073+0000][7][gc             ] GC(1013) Pause Full (G1 Evacuation Pause) 31535M->28161M(32536M) 10396.100ms
[2024-03-20T03:49:31.073+0000][7][gc,cpu         ] GC(1013) User=80.94s Sys=0.21s Real=10.40s

 

아래 명령어처럼 jdk/bin의 java 명령어(./java -XX:+PrintFlagsFinal -version)로 확인한 JVM Flag는 G1ReservePercent 10%, InitiatingHeapOccupancyPercent 45%입니다. 다만 2019.09에 elastic에 Merge된 내용에 따라 각각 25%, 30%로 오버라이딩되었습니다. (저는 jdk 15, Elasticsearch 7.10.2 버전이라 해당 PR이 적용된 환경입니다.)

## G1GC Configuration
# NOTE: G1 GC is only supported on JDK version 10 or later
# to use G1GC, uncomment the next two lines and update the version on the
# following three lines to your version of the JDK
# 10-13:-XX:-UseConcMarkSweepGC
# 10-13:-XX:-UseCMSInitiatingOccupancyOnly
14-:-XX:+UseG1GC
14-:-XX:G1ReservePercent=25
14-:-XX:InitiatingHeapOccupancyPercent=30

 

요지는, 위의 PR에 도움을 받은 케이스가 있지만, 제 상황의 경우 긴 STW를 유발하는 FullGC를 감수하고서라도 Old Region을 비워낼 필요가 있어보입니다. 물론 근본적으로는 GC가 발생함에도 JVM Heap이 MaxHeapSize까지 상승추세를 그리는 MemoryLeak의 원인을 찾아야겠습니다.. 아래 Oracle 문서에 따르면 색인 데이터 하나의 용량이 10MB 이상으로 큰 경우 라이브 객체로 덩치가 큰 것들이 쌓이는건가 싶습니다.(가령 Humongous Region에 너무 빨리 쌓이는 등)

The reason that a Full GC occurs is because the application allocates too many objects that can't be reclaimed quickly enough. Often concurrent marking has not been able to complete in time to start a space-reclamation phase. The probability to run into a Full GC can be compounded by the allocation of many humongous objects. Due to the way these objects are allocated in G1, they may take up much more memory than expected.

 

ES 내부의 객체 참조 상태를 확인하지 못해서 어렵군요 ㅎㅎ 색인 관점에서는 Lucene segment에 disk flush 성공하면 곧바로 객체 참조를 해제해도 될 것 같은데 말이지요.

 

이런 경우, 시도해볼만한 GC Tuning이 있을까요? (G1PeriodicGCInterval, G1PeriodicGCInvokesConcurrent,G1ReservePercent, G1UseAdaptiveIHOP=false, InitiatingHeapOccupancyPercent 를 고려 중입니다.) ZGC 테스트까지도 고려해보고자 합니다. (다만 ZGC는 CompressedOOP가 안된다는 말이 있군요.)

  1. 온프레미스 환경 이니까 아마 L4 장비를 사용하게 될텐데, 장비 담당자에게 어떤 노드들을 바인딩 했는지를 물어 보시면 어떨까 싶습니다. 만약 그게 여의치 않을 경우에는 엔드포인트 URL로 GET 메소드를 이용해서 / 로 요청을 보내보면 엔드포인트에 바인딩 되어 있는 노드들이 돌아가면서 응답을 줄겁니다. 그 때 어떤 노드들이 응답을 주는지를 잘 살펴보면 엔드포인트에 바인딩 되어 있는 노드들을 파악할 수 있을 겁니다.

  2. 이번 이슈의 경우 핵심은 Full GC가 발생해도 왜 힙 메모리가 비워지지 않는가 인데요, 제 지난 경험 상 비슷한 이슈가 있었을 때 Scroll API 때문에 발생했던 경우가 있었습니다. 혹시 검색 할 때 scroll을 사용하진 않는지 아니면 비슷한 기능을 사용하진 않는지 확인해 보시겠어요? (https://www.elastic.co/guide/en/elasticsearch/reference/current/scroll-api.html) 참고로 Scroll API는 검색 결과에 대한 컨텍스트를 힙 메모리에 유지 시키기 때문에 지금처럼 Full GC가 발생해도 힙 메모리가 비워지지 않는 이슈가 있었습니다. 비슷한 이슈가 확인된 것도 있는 것 같습니다. (https://discuss.elastic.co/t/using-scroll-api-causes-out-of-memory-errors/283388/9)

  3. 만약 Scroll API를 사용하지 않는다면 https://brunch.co.kr/@alden/47 에 있는 것처럼 jststd와 VisualVM 을 사용해서 힙 메모리 사용 현황을 시각화 해서 분석해 볼 필요가 있습니다.