[ES] 기초 검색시스템 구축하기2

뜨뜨미지근한물
14 min readAug 25, 2021

--

AWS ES를 쓰면서, 퍼포먼스 등을 목적으로 내부 설정을 튜닝한 적은 없었다. 그러나 ES 내부는 어떻게 되어있는지 어떻게 모니터링 가능한지 알고 있다면, 이슈에 관해 조금 더 의심의 범위가 넓어지리라 생각해서 루씬부터 클러스터의 대략적인 설정까지 공부한 내용을 정리하기로 했다.

대부분의 내용은 엘라스틱서치 실무가이드를 참고해 작성했다.

1. ES 구성요소 (Top-Down)

앞서 다뤘던 클러스터부터 문서까지의 구성요소와 ES의 핵심인 검색 라이브러리 루씬과 세그먼트까지 훑어본다.

클러스터

노드들의 집합체로, 하나 이상의 물리적 노드로 구성된다.
같은 클러스터 내부의 데이터만 서로 공유가 가능해서, 모든 노드는 하나의 클러스터에 구성원으로 연결되어 있어야 한다.
각 노드에 같은 클러스터명을 설정해서 연결시키고, 클러스터 내에서 노드들은 검색, 수정, 복구 등의 작업을 함께 진행한다.

AWS에서는 특정 버전의 ES부터, 다른 AWS ES 클러스터간의 작업을 지원해준다. (멀티 클러스터 검색, 집계)

노드

실제 엘라스틱서치가 설치되고 실행되는 인스턴스 (물리적 단일 서버).
실행시, 노드 식별 UUID가 할당되며, 한 노드에는 여러 Index 존재할 수 있다.
클러스터 모드에서는 운영 효율을 위해, 단일 책임을 갖는 노드들로 관리한다. (ex, 마스터, 데이터, 인제스트..)

AWS ES 설정시, 보통 1개의 클러스터(도메인) 안에 마스터노드 3개 / 데이터 노드 AZ마다 1개씩 구성한다.

인덱스

유사한 문서(데이터)를 모아둔 컬렉션(Nosql)이다. 클러스트 내에서 고유한 인덱스명을 가져야 하고, 소문자로 작성되어야 한다.

문서

하나의 인덱스에서 관리되는 실제 물리적인 데이터.
JSON으로 표현되고, 샤드라는 복수의 물리적 저장소에 분산저장된다.
( == 복수의 노드에 분산 저장)

샤드**

하나의 인덱스의 데이터를 분산저장함으로써 수평적 확장(Scale-out)을 가능케 하는 물리적 저장소. (샤딩, 파티셔닝)

하나의 인덱스는 기본적으로 5개의 샤드에 데이터를 분산관리하며, 사용자의 요청이 독립된 샤드로 내려가고, 각 샤드에서 처리한 결과를 취합해서 최종 결과를 만들어 리턴하는 식으로 서비스가 이뤄진다.

고가용성, 처리량(성능) 향상 등의 잇점을 가져간 대신, 분산처리와 최종 집계의 방식으로 정확성이 조금 떨어지는 trade-off가 있다.

레플리카: 샤드의 복제본으로, 기본적으로 1개의 레플리카셋(1*샤드수)가 세팅된다. 데이터노드의 장애시 페일오버식으로 대응되는데 활용된다.
일반적인 상황에서는 읽기 작업에 활용되어 검색 요청 분산에 활용되기도 한다.
cf) Failover(페일오버): 서버에 이상이 생겼을 때 예비 시스템으로 자동전환되는 기능

검색 트래픽이 늘어나면, 레플리카 셋을 늘림으로써 대응할 수도 있다. 그렇지만, 색인을 처리해야 하는 샤드가 늘어나면서 색인 성능은 떨어질 수 밖에 없다.

루씬 인덱스: 루씬 라이브러리를 갖는 각 샤드는 최소 단위의 검색 모듈이다.
샤드 내부의 루씬 라이브러리는 여러 클래스로 구성된 검색 lib인데. 검색과 색인에 있어, indexSearcher, indexWriter라는 클래스를 활용해 작업을 수행한다. 각각 검색어를 세그먼트를 통해 서치(역인덱스 검색)하는 기능과 들어온 데이터를 세그먼트로 구성(색인)해 저장하는 기능을 수행한다.

루씬 인덱스는 자체적인 데이터(세그먼트) 내에서만 검색이 가능한데.
엘라스틱서치의 샤드는 이를 확장해서, 다수의 샤드가 갖는 모든 세그먼트에 대한 검색이 가능하도록 처리했다.

세그먼트**

문서들을 빠르게 검색할 수 있도록 설계된 특수한 자료구조.

각각의 샤드가 독립적으로 갖는 루씬 라이브러리에서 데이터를 색인하면서 나오는 토큰을 저장하는 자료구조이다. 읽기에 최적화된 자료구조 (역색인구조)로 되어 있다.
여러 데이터가 색인되면서, 여러 세그먼트가 루씬 라이브러리에 종속되어 존재한다.

논리적 저장소, 인덱스의 구조 (출처: 엘라스틱서치 실무가이드)

2. ES의 데이터 색인 과정

색인 과정

  1. 루씬인덱스는 새 데이터를 색인/저장할 때마다 (index Writer) 신규 세그먼트를 만든다. (update X)
    그리고 커밋포인트라는 세그먼트 목록 자료구조에 생성한 세그먼트를 추가 기록한다.
  2. 검색할 때에는 (index Searcher) 커밋 포인트를 통해 모든 세그먼트를 읽어 결과를 제공한다. (old → new 순으로)
    신규 세그먼트가 생성중이면, 얘는 제외하고 검색한다. (커밋포인트에 해당 세그먼트가 추가되면 검색이 가능해진다.)
  3. 백그라운드에서 주기적으로 세그먼트를 병합하는 작업을 한다.(Merge)
    index Writer가 세그먼트를 복제하고, 복제 세그먼트 병합 → 병합 완료후 기존 데이터와 교체 (원본 데이터 삭제)

이는 수정을 사용하지 않는 특성, Immutable 로 인해 만들어진 과정이다.
( update보다, Insert & Delete 의 잇점을 챙긴다!! )

Immutable (불변성)

ES는 segment를 수정 불가능(이뮤터블) 하게 다룬다. 이로 인해 다음과 같은 장점을 갖는다.
1. 동시성 문제 회피 (멀티스레드 lock문제)
2. 시스템 캐시 활용 (업데이트로 인한 추가제거 필요X)
3. 역색인 구조를 만드는 리소스 절감 (CPU, IO, memory 등) ..

반면 다음과 같은 단점이 있다.
1. 문서 일부 수정에도 신규 세그먼트 생성 작업이 필요 (역인덱싱 다시 수행..)
2. 데이터의 실시간 반영이 힘들어 결국 준실시간 (데이터 반영) 검색의 양상

이뮤터블의 특성에 맞춰 그리고 단점을 최소화시키기 위해,
1. 문서의 업데이트 또한 삭제 후 재생성으로 이뤄진다.
2. 문서 삭제의 경우에는 당장에 디비에 영향을 안주고, 특정 문서의 삭제여부 비트 flag 만 수정하는 식이다. (이 flag로 해당 데이터는 검색결과에서 제외) 시간이 지나고 백그라운드에서 세그먼트 병합(Merge)시에 해당 데이터 세그먼트를 삭제한다.

이뮤터블로 인한 이런 과정(기능)들은 더 강력한 검색 성능을 보장하면서도, 수정 또한 실시간에 가까운 적용이 되도록 만들어준다.

루씬의 데이터 배치 작업

루씬은 효율적인 색인 과정을 위해 주기적인 배치작업으로 데이터 CUD를 처리한다. 메모리 버퍼를 큐로 활용하면서, 요청이 일정량이 쌓이면 배치작업으로 한번에 처리하는 것이다.

이러한 배치작업은 OS의 디스크에 한번에 반영되지 않는다. 효율적인 쓰기 연산을 위해 다음과 같은 처리 과정을 거친다.

  1. Flush: OS의 커널 캐시상으로만 먼저 작업을한다. (이를 위해 write system call을 사용한다) 커널 캐시에 반영된 데이터는 루씬 인덱스가 읽을 수 있다. 최악의 경우에는 캐시 상태의 데이터가 유실될 수 있다.
  2. Commit: 일정 주기로 OS 커널의 캐시가 디스크에 동기화되는 과정이다. (이를 위해 fsync system call이 사용된다.) 이 때 리소스가 많이 소모된다.
  3. Merge: Commit과 함께 세그먼트를 정리하는 과정이다. 이 과정으로 실제 Disk에 저장되는 세그먼트의 수가 관리된다.

ES의 데이터 배치 작업

단일 검색엔진인 루씬의 이러한 과정을 분산 검색엔진인 엘라스턱 서치에서 확장해서 사용하고 있다. (다수의 샤드를 관리하는 것에 맞춰 기능 개선 및 튜닝 API 제공)

  1. Refresh(Flush): ES 클러스터내에서 일반적으로 모든 샤드는 1초마다 Refresh 작업을 수행해 인메모리 버퍼에서 커널 캐시로 작업을 반영시킨다.
    _setting API를 통해 refresh 주기를 변경할 수도 있다.
  2. Flush(Commit): 보통 5초마다 작업을 수행해, 커널캐시 내용을 디스크에 저장 시킨다. 작업 후, Translog 파일을 정리한다.

    cf) Translog: 샤드 장애 복구를 위한 파일로 데이터 유실을 방지한다.
    변경사항을 이 파일에 기록한다. (인메모리 버퍼 적재 시점)
    이 후, 루씬으로 작업하는데(Refresh..) 작업중 오류가 나면, Translog로 원복이 가능하고.
    디스크 반영 작업이 완료되면 (Flush 시점), 해당 시점이전의 Translog 데이터를 날려, 다시 적절한 사이즈로 업데이트 로그(Translog)를 관리한다.
  3. Optimize API(Merge): 해당 AP로 루씬의 Merge를 강제 동작시킬 수 있다.
    파편화된 세그먼트를 정리해 적은수의 세그먼트로 통합시켜, 읽기 성능을 높인다.

정리

- 검색 흐름
→ 사용자의 엘라스틱 서치 검색 요청
→ 모든 샤드(루씬 인덱스)로 동시 요청
→ 샤드는 커밋 포인트로 세그먼트 순차적 전체 검색 후 결과 전달
→ ES는 모든 샤드로부터 결과를 받아 취합후 사용자에게 전달

- 이 읽기 과정이 빠르도록, 이뮤터블을 기본으로 하며, 업데이트, 삭제 작업 또한 이에 맞는 방식으로 처리함 (Create & Delete ft. flag)

- 분산환경의 대용량 데이터를 효율적으로 관리하기 위해, 단계별 배치로 작업을 수행함 (메모리 버퍼큐, Flush, Commit, Merge)

샤드 최적화

- 샤드는 단일 검색엔진으로, 샤드 확장/축소시 세그먼트의 적절한 분리/병합 (이전을 위한)을 하지 못한다. 그래서 운영중에 샤드 개수의 수정이 원칙적으로 불가능하며, ReIndex (새로운 index를 재구성하라고 함)로 이를 처리한다.

비슷하게 세그먼트의 수정이 일어나는 mapping의 변화도 샤드의 이뮤터블 속성으로 인해 ReIndex로 처리하는 듯 하다.

- 적절한 레플리카 샤드의 수는 어떤 작업이 우선이냐에 따라 달라진다.
색인이 중요한 경우에(insert) 레플리카 샤드가 많으면, 세그먼트 작업을 하는 샤드수가 많아져 불리하고.
검색이 중요한 경우에(search) 레플리카 샤드에 업무가 분산되면서 큰 트래픽을 받을 수 있어 유리하다. 서비스에 맞는 저울질이 필요하다!
(샤드와 달리, 레플리카셋은 언제든 자유롭게 갯수를 설정할 수 있다!)

- 클러스터에 샤드수가 많으면 모든 샤드를 관리해야 하는 마스터노드의 부하가 크다. (샤드 상태 관리, 데이터 라우팅 처리, 검색 요청 분산..)
관리를 위한 샤드 정보를 메모리에 띄워놓고 처리하는데, 샤드가 많을수록 메모리 이슈로 마스터 노드에 장애 위험이 생긴다.

3. ES의 시스템적 측면

ES의 성능은 하드웨어와 밀접한 관계가 있어, 시스템 설정은 전체 클러스터에 큰 영향을 끼친다.

  • 자바로 만들어진 ES:루씬은 jar형태로 배포되고, ES는 루씬lib을 임포트하는 방식으로 세팅된다. 결국 루씬과 ES 모두 JVM위에서 함께 동작한다.
  • 힙 메모리 관리
    -
    메모리를 많이 사용하는 ES에서 주요 사항 ~ 기본적으로 최적화된 JVM 옵션이 제공된다.
    - 메모리 관리를 하는 JVM은 자체적으로 GC를 통해 메모리를 회수함. GC 옵션으로 회수 주기를 튜닝할 수 있다.
    - 사이즈가 성능에 영향을주는, JVM의 힙 메모리 관리가 중요하다.
    1. OS의 50% 메모리공간 보장 (시스템(커널) 캐시도 고려!)
    2. 32GB내의 메모리 힙 사이즈로 설정 (JAVA8 ~ JVM object pointer)
    그 이상의 리소스는 다수의 ES 인스턴스에 할당하는게 더 좋다.
    - 메모리 사이즈를 넉넉하게 잡아, 메모리 스와핑을 최소화 (비활성화) 해야 한다.
  • 이 외에도 OS 상, ulimit, sysctl 등의명령어를 통해, 프로세스 리소스 제한 / 메모리락 / 가상메모리 / 파일 디스크립터 / 네트워크 등의 설정을 조절해 튜닝이 가능하다.

cf-1) 가상메모리: 실제 물리적 메모리보다 많은 양의 메모리를 사용할 수 있도록 OS가 제공하는 메모리 관리 기술.

기본적으로 JVM에 설정된 메모리 설정을 따른다. 그러나 루씬의 경우 VM을 거치지 않고, 커널에 직접 접근이 가능하게 설계되었다. (Flush: 커널의 파일시스템 캐시 사용 / Commit: 디스크 쓰기) 그래서 100% ES에 메모리를 할당하는 것보다, OS를 위한 메모리를 남겨둬야 한다. (결국 루씬이 씀)

cf-2) 32bit vs 64bit: 최대 4GB(2³²)까지 메모리주소 커버 가능 vs 이론상 18EB (2⁶⁴) 까지 메모리주소 커버 가능
64bit를 채용함으로써 메모리 가용량이 더 커지긴 하지만.
64bit로 메모리주소를 주고받으면서 그만큼 트래픽 대역폭은 커지고, 공간활용성은 떨어지게 된다. (여분이 많아..)
~ 공간 효율성 저하 및 시스템버스(캐시~메모리) 속도 저하

JVM은 Ordinary Object Pointer라는 자료구조에서 포인터 주소를 관리하는데, 위 점을 고려해 32bit Compressed OOP라는 포인터 관리 기법을 도입했다. 64bit를 활용하면서, 잔여공간을 줄이고, 빠른 연산을 위해 포인터 주소를 압축해서 관리하는 것인데. 이를 통해, 2³² * 8의 메모리 공간을 활용할 수 있게 되었다.
32GB 이하로 설정시, Compressed OOP 방식을 사용하고, 그 이상은 일반 OOP로 전환되어, 64bit 상에서 관리 효율이 떨어지게 된다. 또한 큰 힙크기로 인해 FullGC를 수행하는 경우 시스템이 멈춰버리는 시간이 증가한다..

cf-3) 메모리 스와핑: 효율적인 메모리 관리를 위해 메모리와 디스크간 데이터를 교환하는 스와핑 작업.
부족한 메모리를 멀티스레드가 공유하기 위한 장치 로, 안쓰는 데이터는 디스크로(swap-in) / 사용할 데이터는 메모리로(swap-out)로 교환해서 제한적 공간을 활용한다.
시스템 리소스를 많이 요구하는 작업으로, ES에서는 메모리를 크게 잡고, 아예 안쓰는게 낫다. (스와핑중 노드 장애 확률이 큼..) ~ GC를 통한 메모리관리로 대응하자.

→ OS 레벨에서 스와핑 비활성화 / 스와핑 최소화를 할 수 있고.
ES의 memorylock 기능을 통해, ( mlockall() 시스템콜로 해당 프로세스의 메모리 페이징 막음) 앱레벨에서 스와핑을 비활성화할 수 있다.

노드 부트스트랩

노드 최초 실행시, 동작할 환경을 체크하는 작업 (운영시 발생가능한 다양한 문제점 미연에 방지 위해!)
OS에서 살펴볼 측면을 몇개 고르자면,

1) 힙 크기 체크: JVM 힙사이즈 체크
기본 힙과 최대 힙 크기 체크! ~ 다르면 그 차이만큼은 memory lock이 안걸려 의도치 않게 메모리 스왑핑이 일어날 수 있다.

2) 파일디스크립터 체크: 모든게 파일로 처리되는 리눅스(FS)에서, ES는 소켓 / 시스템 콜 / 역색인 데이터 등에 사용될 충분한 파일이 필요하다.

3) 메모리락 체크: GC가 힙메모리를 정리할 때, 메모리스왑된 페이지가 있다면, Swap-In이 강제되어 ES에 부담을 준다. 따라서 메모리와 디스크사이 스왑을 막았는지 체크한다.

4) 최대 스레드 수 체크: ES의 모듈은 각자의 스레드풀로 요청을 처리한다. 각 모듈이 여유롭게 스레드를 생성/관리할 수 있도록 최소 4096개 이상 생성 가능한 것이 좋다.

5) 최대 가상 메모리 크기 체크: 루씬은 mmap을 통해,리눅스 커널로 바로 메모리 맵핑을 하는데(JVM 건너뛰고). mmap의 효율적 메모리 맵핑을 위해 가상 메모리 크기가 클수록 좋다. (무제한 세팅)

6) 최대 파일 크기 체크: 리눅스에서 기본적으로 가질 수 있는 최대 파일수가 제한되어 있다. 이 때문에 세그먼트나 Translog 파일이 더 커지면서, 데이터가 유실되거나 오작동할 수 있어, 무제한 설정하는게 좋다.

7) mmap 카운트 체크: 생성가능한 메모리맵 최대 영역 검사

8) JAVA관련: JVM, GC, 퍼미션 등 체크..

4. ES 모니터링 API

앞서 언급한 것들은 클러스터 모니터링/관리 API를 통해, 보거나 수정할 수 있다. 클러스터 레벨, 노드 레벨, 인덱스 레벨의 세세한 API가 존재하는데.

대표적인 모니터링 API를 살펴보면 다음과 같다.

  1. GET _cluster/health : 클러스터 메타정보 및 헬스 체크
    GET _cluster/health?level=shards : 각 인덱스 샤드별 메타정보 및 헬스 체크
    GET _cluster/health/인덱스명 : 인덱스 헬스 체크
  2. GET _cluster/state
    GET _nodes : 기본적인 count, OS, JVM, process 등 정보 파악 가능
    - OS: 메모리, 스왑, cpu, cgroup (네임스페이스에 할당된 리소스) 등
    - process: 파일 디스크립터(open 파일(소켓,파일,장치..)수) / cpu, mem 등
    - fs는 파일시스템 데이터 종류, io_stats 등 확인 가능
    - 이 외 docs / indexes / translog/ merge /refresh / flush 등 작업 수행 여부도 파악 가능
  3. GET _cluster/stats : 클러스터레벨의 각종 지표
    GET _nodes/stats : 노드 레벨의 각종 지표 (seearch, merge, flush 작업수행시간..)

--

--