EC2上で大容量データを扱うElasticsearchクラスターを構築する

# 背景
日あたり1億レコード、2千変数を超える（セミ）スキーマレスなデータをニアリアルタイムで投入・集計したいというニーズがあったときに、EC2上でどのように構築するのがいいか？
X-Packを使いたかったのでElasticsearch Serviceが使えず、Security Groupやチューニング余地、コスト等々の都合でElastic社のマネージドサービスも選択せず、なおかつX-Packのライセンス料を最小にしたかったのでノード数も少なくしたかった。

# 概要
- Elasticsearch 6.4以降を想定（5.x でもほぼ共通だがノード間通信の暗号化が必須か否か、G1GCが使えるか否か、が違う）
- EC2インスタンスはメモリーが多いもの、256GB以上を選ぶ
- EBSではなく、SSD（所謂エフェメラルストレージ/揮発性のNVMe）が付いてくるインスタンス、ストレージが2台以上、できれば4台くらいあるものを選択し、RAIDでストライピングする
- ノード間の通信を暗号化するのに証明書を入れるが、手間を少なくするために一度Dockerイメージを作ってECRに入れる
- ノードあたりのヒープサイズは128GBとか、「JVMの32GB制限」を気にせず、G1GCを有効化することで大容量メモリーと性能をバランスする
- データノードの他に、独立したコーディネーターノードを建てる

# 構築手順

## 前提
- インスタンスタイプはi3.8xlarge を想定
	- m5d、c5d、r5d等のNitro世代のディスク付きでも同じだが、ルートのEBSがNVMe扱いなのでデバイスの番号がずれるのでRAID構成時に注意する
- OSは初期状態のAmazon Linux
	- ElasticsearchそのものはDocker上で動かすのであまり拘らなくて良いかもしれない

## ホストマシンのチューニング
基本的な手順は公式ドキュメントと変わらないが、RAID構成をする

### アップデートしてDockerをインストール
OS等の更新を適用し、Dockerをインストールしておく

```sh
yum -y update
yum -y install docker
```

### limits.confに設定を追加
ファイルディスクリプタの上限を緩和、メモリーロックを無制限にする。
シェルで以下のように実行すれば追記される（くれぐれもlimits.confにコピペしない）

```sh
bash -c 'echo root soft nofile 1048576 >> /etc/security/limits.conf'
bash -c 'echo root hard nofile 1048576 >> /etc/security/limits.conf'
bash -c 'echo * soft nofile 1048576 >> /etc/security/limits.conf'
bash -c 'echo * hard nofile 1048576 >> /etc/security/limits.conf'
bash -c 'echo * soft memlock unlimited >> /etc/security/limits.conf'
bash -c 'echo * hard memlock unlimited >> /etc/security/limits.conf'
```

### sysctl.confに設定を追加
ネットワークとスワップの設定をする。こちらもシェルに貼り付ければ追記できる。

```sh
bash -c 'echo net.ipv4.tcp_tw_reuse = 1 >> /etc/sysctl.conf'
bash -c 'echo net.ipv4.tcp_fin_timeout = 30 >> /etc/sysctl.conf'
bash -c 'echo net.ipv4.ip_local_port_range = 16384 65535 >> /etc/sysctl.conf'
bash -c 'echo vm.max_map_count = 262144 >> /etc/sysctl.conf'
bash -c 'echo vm.swappiness = 1 >> /etc/sysctl.conf'
```

### /etc/rc.local に追記
`/etc/rc.local` を開いて以下を追記する。
ホスト名はAWSのあまり知られていない（？） `169.254.169.254` をたたいてインスタンスIDを得る。
RAIDの構成をここに書くのは、エフェメラルストレージだとシャットダウン後にデータが消えてRAIDの構成も失われるので、RAIDが構成されていなかったら構成しなおすため。
この例では i3.8xlarge を想定しているのでRAIDを組む対象デバイスが `/dev/nvme0` から `/dev/nvme3` の4台になっている。Nitro世代だと `/dev/nvme1` から始まる。
`/data` をElasticsearchのデータディレクトリにするのでこうしているが、パーミッションはちゃんと考えた方がよさそう。

```sh
# Set Hostname
hostname "$(curl -s http://169.254.169.254/latest/meta-data/instance-id |sed 's/\./-/g')"

# Init RAID
if [ ! -d /data ]
then
mkdir /data  
fi

if [ ! -d /dev/md0 ]
then

# Initialize the RAID with four NVMe drives
mdadm --create --verbose --level=0 /dev/md0 --name=DATA --raid-devices=4 /dev/nvme0n1 /dev/nvme1n1 /dev/nvme2n1 /dev/nvme3n1
mkfs.ext4 /dev/md0
mdadm --detail --scan | tee -a /etc/mdadm.conf

# Just in case, Update the kernel option
dracut -H -f /boot/initramfs-$(uname -r).img $(uname -r)

# Mount the RAID
mount -a

fi

chmod 777 /data
```

### fstabに追記
以下をシェルで実行して、構成したRAIDを起動時に `/data` にマウントするようにする。

```sh
bash -c 'echo /dev/md0 /data ext4 defaults,nofail,noatime,discard 0 2 >> /etc/fstab'
```

### ホストを再起動

```sh
reboot
```

## Elasticsearchコンテナを起動する

### Dockerfileを作る
基本的にはElasticの公式イメージをベースとし、必要なプラグインを入れたりする。最近はX-Packは最初から入ってたりするので、あまり必要になるケースは多くないかもしれない。
ノード間の通信を暗号化することが必須になっているので、p12ファイルを生成してイメージに組み込んだ。（くれぐれもパブリックなリポジトリに入れないこと）

```Dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:6.6.2

#### 形態素解析のプラグイン等インストールする場合はここに列挙する
RUN \
elasticsearch-plugin install --batch analysis-icu && \
elasticsearch-plugin install --batch analysis-kuromoji

#### ノード間の通信の暗号化に使うp12ファイルを作っておいて、よしなに入れてやる
RUN mkdir /usr/share/elasticsearch/config/certs
ADD elastic-certificates.p12 /usr/share/elasticsearch/config/certs/elastic-certificates.p12
ADD elastic-stack-ca.p12 /usr/share/elasticsearch/config/certs/elastic-stack-ca.p12
RUN chown -R elasticsearch /usr/share/elasticsearch/config/certs
RUN chgrp -R root /usr/share/elasticsearch/config/certs
RUN chmod o-rx /usr/share/elasticsearch/config/certs
RUN chmod 640 /usr/share/elasticsearch/config/certs/elastic-stack-ca.p12
RUN chmod 640 /usr/share/elasticsearch/config/certs/elastic-certificates.p12
```

### ビルドする
ビルドする。プラグインや証明書が入ったイメージができる。
ECRに入れやすいタグを付けておく。

```sh
docker build -t 0000000000.dkr.ecr.ap-northeast-1.amazonaws.com/es/high-performance-es:6.6.2 .
```

### ECRに入れて置く
ECRにログインして、それからプッシュ。

```sh
aws ecr get-login --no-include-email --profile hoge | sudo /bin/bash
docker push 0000000000.dkr.ecr.ap-northeast-1.amazonaws.com/es/high-performance-es:6.6.2
```

### Elasticsearchを起動する
個々のノードで以下のようにElasticsearchコンテナを立ち上げる。
- `Xms` と `Xmx` はホストのメモリーの50%くらいにする。JVMの32GB（アドレス空間が32ビットか64ビットか問題）は気にしない。EC2だと31GBくらいで64ビットになった気がするけどマシンの性能が高いしG1GCを使うようにしてからなおさら気にならない。
- `discovery.zen.ping.unicast.hosts` にクラスターを構成するマシンのIPアドレスを列挙する
- データを格納するディレクトリは、コンテナ内で場所は変更できなかった
- `ELASTIC_PASSWORD` 環境変数で、デフォルトユーザーのパスワードを指定する

#### データノード（データを格納したり、集計処理をする）

- データノードは `node.master` 、 `node.data=true` 、 `node.ingest` が全て `true`

```sh
docker pull 0000000000.dkr.ecr.ap-northeast-1.amazonaws.com/es/high-performance-es:6.6.2
docker run -d --net=host -v /data:/usr/share/elasticsearch/data \
-e "node.master=true" \
-e "node.data=true" \
-e "node.ingest=true" \
-e "ELASTIC_PASSWORD=P4ssW0rd" \
-e "node.name=$(curl -s http://169.254.169.254/latest/meta-data/instance-id |sed 's/\./-/g')" \
-e "ES_JAVA_OPTS=-Xms128G -Xmx128G -XX:-UseConcMarkSweepGC -XX:-UseCMSInitiatingOccupancyOnly -XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=75" \
-e "network.host=0.0.0.0" \
-e "bootstrap.memory_lock=true" \
-e "discovery.zen.ping.unicast.hosts=3.0.0.1,3.0.0.2,3.0.0.3" \
-e "discovery.zen.minimum_master_nodes=1" \
-e "xpack.security.transport.ssl.enabled=true" \
-e "xpack.security.transport.ssl.verification_mode=certificate" \
-e "xpack.security.transport.ssl.keystore.path=/usr/share/elasticsearch/config/certs/elastic-stack-ca.p12" \
-e "xpack.security.transport.ssl.truststore.path=/usr/share/elasticsearch/config/certs/elastic-certificates.p12" \
-e "thread_pool.bulk.queue_size=1000" \
-e "cluster.name=high-performance-es" \
--ulimit nofile=524288:524288 --ulimit memlock=-1:-1 \
--privileged \
--name elasticsearch \
0000000000.dkr.ecr.ap-northeast-1.amazonaws.com/es/high-performance-es:6.6.2
```

#### コーディネーターノード（入出力の窓口のみを担う）
- コーディネーターはクラスターのエンドポイントの役割を担い、入出力をデータノードに割り振る役割
- `node.master` 、 `node.data=true` 、 `node.ingest` が全て `false`
- インスタンスサイズは小さくて大丈夫だが、EC2で小さいインスタンスを使う場合、ネットワーク帯域が制限されるか、クレジットの枯渇でパフォーマンスが落ちることがあるので、そこそこ大きめのインスタンスがオススメ
- コーディネーターノードはゆとりがあるので、ここでKibanaを動かすといい

```sh
docker pull 0000000000.dkr.ecr.ap-northeast-1.amazonaws.com/es/high-performance-es:6.6.2
docker run -d --net=host -v /data:/usr/share/elasticsearch/data \
-e "node.master=false" \
-e "node.data=false" \
-e "node.ingest=false" \
-e "node.name=Coordinator" \
-e "ELASTIC_PASSWORD=P4ssW0rd" \
-e "ES_JAVA_OPTS=-Xms30G -Xmx30G -XX:-UseConcMarkSweepGC -XX:-UseCMSInitiatingOccupancyOnly -XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=75" \
-e "network.host=0.0.0.0" \
-e "bootstrap.memory_lock=true" \
-e "discovery.zen.ping.unicast.hosts=3.0.0.1,3.0.0.2,3.0.0.3" \
-e "discovery.zen.minimum_master_nodes=1" \
-e "xpack.security.transport.ssl.enabled=true" \
-e "xpack.security.transport.ssl.verification_mode=certificate" \
-e "xpack.security.transport.ssl.keystore.path=/usr/share/elasticsearch/config/certs/elastic-stack-ca.p12" \
-e "xpack.security.transport.ssl.truststore.path=/usr/share/elasticsearch/config/certs/elastic-certificates.p12" \
-e "thread_pool.bulk.queue_size=1000" \
-e "cluster.name=high-performance-es" \
--ulimit nofile=524288:524288 --ulimit memlock=-1:-1 \
--privileged \
--name elasticsearch \
0000000000.dkr.ecr.ap-northeast-1.amazonaws.com/es/high-performance-es:6.6.2
```


## インデックス設定の調整
インデックスの設定はドキュメントのサイズ、入出力の頻度、性能要件、可用性等と相談する必要がある。特にエフェメラルストレージを使って性能を引き出している場合、ハードウェア障害が起こるとデータが失われるので、レプリカを持つのかEBSにバックアップを作るのかとか、恒久的なデータ保管は別のDBで担いElasticsearchは柔軟性と速度を重視するのかとか、選択と集中が必要。
性能に影響する設定としては以下のものが重要だと考えた。

- refresh_interval
	- 投入したデータが反映されるまでの時間に影響する。長くすれば書き込み処理が粗くなるので軽くなるが、投入から集計までの時差=Latencyが延びる
- number_of_shards
	- インデックスをクラスター内でいくつに分割するかの値。多すぎでも少なすぎでもよくないので難しいが、ノード数の4倍にした。インデックスあたりのドキュメント数や容量でも考える必要があるし、インデックスをどういう単位で分割するかにもよる。
- codec
	- 圧縮設定。 `best_compression` でいいと思う。圧縮と伸張はCPUを使うが、ディスクIOの方が問題になるので、書き込みと読み出しの時点でのサイズを最小にする方が早い印象。
- number_of_replicas
	- レプリカを作ればデータの保全はしやすいが、データを複製・同期するので遅くなる。最速を目指すならレプリカは作らない。
- ドキュメントに対する制約をいじって使わないデータを扱わないようにする
	- max_docvalue_fields_search
	- total_fields
	- depth
	- nested_fields
- analyzer
	- 変数ごとに有効・無効を切り替えられるが、形態素解析をしないような変数では基本的に無効にしておく。

## 終わりに
- Dockerにしておくとアップデートの時に作業しやすい
- Kibanaの設定は人間の工数がかかっているデータなので、S3にバックアップする方が良い。Kibanaの設定も当然クラスター内にあるので、誤ったシャットダウンや障害、作業ミスで消えると困る。
- なにか色々はしょってる気がする
- 誤りや更新が必要なところはコメントください
- すでに7.0が出ている… プラグイン周りが変わったと思うけど他は使えるはず