「ElasticsearchのMasterノードって偶数台で構成してもいいんですか？」 (2020年版)

## はじめに
本記事は[Elasticsearchアドベントカレンダー(2020年)](https://qiita.com/advent-calendar/2020/elasticsearch)の8日目の記事です。
もともと23日目に書こうと思っていたのですが、この日が空いてたのでエントリを移動させていただきました。

## 謝辞
本トピックは、今年執筆した[「Elastic Stack実践ガイド[Elasticsearch/Kibana編]」](https://www.amazon.co.jp/dp/4295009776/)のレビューをしていただいた大谷さん(@johtani)から助言いただいた内容をもとにしたものです。
この場を借りてあらためてお礼を申し上げます。

## この記事の内容
Elasticsearchは複数ノードでクラスタ構成を組むことで高い可用性と拡張性を実現できます。通常、この手の分散システムでは複数ノードのふるまいを適切に調停して状態管理することは非常に高度な設計が必要で、さまざまな不測の事態が大規模なサービス停止やクラスタ不整合といった障害を引き起こすこともありますが、Elasticsearchは簡単な設定のみでクラスタを組める点が大きな特長です。

Elasticsearchではクラスタ管理を行う役割としてMasterノードがありますが、クラスタ内でMasterノードとして動作するのは1台のみであり、クラスタ構成時にはあらかじめ複数のMaster-eligibleノードを割り当てておき、調停によりその中から1台がMasterに選出される仕組みになっています。

Elasticsearchバージョン6までは、クラスタコーディネーションの仕組みとしてzen discoveryという機構があり、Master-eligibleノードを3台以上の奇数台で組むことで、そのうちの過半数ノードの合意をもって新たなMasterの選出やクラスタ構成変更を確定する、というアーキテクチャになっていました。

このとき、合意に至る定足数（"Quorum"と呼ばれる）としての「過半数」の値は、設定ファイルに"discovery.zen.minimum_master_nodes"というパラメータで明示的に指定する必要があり、Master-eligibleノード数/2（小数点切り下げ）+1の値を求めてelasticsearch.ymlに設定しなければならず、設定漏れ等による不慮の事故のもととなっていました。

また、Master-eligibleノードを偶数台にした場合、ネットワーク障害等が原因でクラスタが２つに分割する（いわゆる「スプリットブレイン」）と、分断されたそれぞれのクラスタで更新が起きたりして、クラスタ状態の不整合やデータ損失のリスクにもつながりますが、上記のパラメータで過半数の値を設定しておけばどちらの側においても過半数に満たないために、こうした事故は回避できます。一方で、過半数に満たないノードグループはクラスタに参加できずどちらの側もクラスタとして機能できないために、結果的にはクラスタが全断することになることから、Master-eligibleノードの偶数台構成は避けるべき、とも言われていました。

Elasticsearch7.xからは、このzenの仕組みが改善されて、新たなクラスタコーディネーションが再設計、実装されました。この新しいクラスタコーディネーションでは、"discovery.zen.minimum_master_nodes"パラメータは廃止となり、過半数の計算はElasticsearch自身が自動で判別してくれるようになっています。

前置きが長くなりましたが、以下ではこの新しいクラスタコーディネーションのQuorumの仕組みと、その結果としてMaster-eligibleノードが偶数台でも構成できるようになったというお話を紹介したいと思います。



## Voting Configurationの仕組み
バージョン7以降のElasticsearchクラスタは、内部状態として「voting configuration（以下、voting config）」というノードリストを保持しています。

**Elasticsearch Reference: Voting Configurations**
https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-discovery-voting.html

これはMaster-elibigleノードのリストのサブセットであり、Masterノードの選出やクラスタ状態の更新に必要となる合意＝多数決に投票するための投票者リストのようなものです。このリストに記載があるノードは多数決に参加（投票）することができ、定足数である過半数(Quorum)をもって合意される仕組みです。

たとえばクラスタ内にMaster-eligibleノードが3台いれば、voting configには3ノードすべてが含まれ、過半数にあたる2ノード分の投票数でQuorumを獲得します。この過半数に影響しない範囲、すなわち1ノードまでの障害には耐えうることになります。
voting configはMaster-eligibleノードの追加、削除に伴って自動で変更されますので、冒頭で触れたように設定ファイルに手動で過半数の値を指定しなくても良くなりました。
Elasticsearchは、常にクラスタが最も耐障害性が高くなるようにvoting configの構成をインテリジェントに決定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51832/beb2644c-5656-1c04-8211-c478a72d1e16.png)

## Master-eligibleノードが偶数のときのvoting config
単純に考えると、投票権でもあるvoting configはすべてのMaster-eligibleノードが記載されるのが当たり前に感じるかもしれませんが、実は、Master-eligibleノードが偶数の場合、Elasticsearchは「意図的に」voting configのノード数を1つ減らすというふるまいをします。それでも、これにより耐障害性は低下することはありません。

例として、4ノードのMaster-eligibleノードを含むクラスタを考えます。voting configが4ノードだとすると過半数は4/2+1=3となるため、許容されるノード障害は1ノードまでとなり、3ノードクラスタと耐障害性は変わらないことになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51832/fc5aaef4-bae2-55c3-0f91-2dd6628ca61d.png)

ただし、voting configに4ノードが記載されていると2ノードずつのスプリットブレインが起きると良くありません。分断された両サイドともに2ノードしかないために過半数のQuorumを獲得できずクラスタ停止を招くためです。

ここでvoting configをあえて3ノードに調整しておけば、過半数は2ノードとなり、2ノードずつのスプリットブレインが起きても片側だけはQuorumを獲得できるため、分断したどちらか一方だけでクラスタ機能を維持することができるのです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51832/705fb120-d817-dd34-5d23-35d8dbea6b4c.png)

このとき、投票権を1ノード分しか持っていない側（反対側）ではクラスタの機能を失います。このように、偶数のMaster-eligibleノードを持つクラスタでスプリットブレインが起きると、片側のみのノードグループでクラスタが生存するという動きをします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51832/db3fdbc2-8bf4-b986-3d2e-4aab10025803.png)

## voting configの確認方法
クラスタ内のvoting configの状態を確認するには、以下のREST APIコマンドを実行します。出力JSONフォーマットの"last_committed_config"に対して、ノード情報が配列で示されます。

```
GET /_cluster/state?filter_path=metadata.cluster_coordination.last_committed_config
```

3ノードの場合は以下のように3ノードすべてが含まれています。voting configの内容はノード名の配列ではなくノードIDの配列となっている点に注意してください。

```json
{
  "metadata" : {
    "cluster_coordination" : {
      "last_committed_config" : [
        "xPWHs5B6SgKZYiesfBUQ3A",
        "C9H9OPX6SwGT5ABUCn4xog",
        "Donl9OS8THSJhlnk2brrhg"
      ]
    }
  }
}
```

一方、4ノードの場合は以下のように4ノードすべてが含まれることはなく、奇数になるよう3ノードのみ投票権(vote)を持つように調整されていることがわかります。

```json
{
  "metadata" : {
    "cluster_coordination" : {
      "last_committed_config" : [
        "xPWHs5B6SgKZYiesfBUQ3A",
        "C9H9OPX6SwGT5ABUCn4xog",
        "Donl9OS8THSJhlnk2brrhg"
      ]
    }
  }
}
```

見た目からは3ノードの場合と4ノードの場合で同じなのでわかりにくいかもしれません。試しにvoteを持つ3ノードのうち1つ("C9H9OPX6SwGT5ABUCn4xog")を停止してみると、voteを持っていなかったノード("-aZpSwG2TEimF8KT6m8xJQ")が代わりにvoting configに含まれるようになりました。このように常に構成に応じて自動的にvoting configの中身は調整されます。

```json
{
  "metadata" : {
    "cluster_coordination" : {
      "last_committed_config" : [
        "xPWHs5B6SgKZYiesfBUQ3A",
        "Donl9OS8THSJhlnk2brrhg",
        "-aZpSwG2TEimF8KT6m8xJQ"
      ]
    }
  }
}
```

ちなみに、仮にもう1ノード拡張して5ノードにした場合は、voting configは奇数である5ノード分すべてが含まれるようになります。つまり、常にvote数が奇数になるように自動調整されているということです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51832/bcfebe7f-5192-11ad-7d2e-c2b061a9a960.png)

## 偶数ノード環境でスプリットブレインを起こしてみる
実際に4ノード環境を構築して、スプリットブレインを模擬することで上記のふるまいを確認してみます。

### 利用環境
| product | version |
|---------|---------|
| CentOS | 7.9 |
| Elasticsearch | 7.10.0 | 
| Kibana | 7.10.0 | 

クラウド環境（今回はAzure）の仮想ネットワーク上に2つのサブネットを構成します。オンプレ環境であれば、ラック障害に備えて2ラックにまたがって構成することがあるかと思いますが、ここではそれと同じイメージだと思ってください。それぞれのサブネット上に2台ずつElasticsearchノードを起動し、合計4台でクラスタを構成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51832/94e7c4c0-321d-bba2-8c50-0de39287b5cd.png)

elasticsearch.ymlファイルの設定は以下のような内容となります。（デフォルト設定ファイルに追記した部分のみ抜粋）

*/etc/elasticsearch/elasticsearch.ymlファイル（追記部分のみ抜粋）*

```yaml
cluster.name: "cluster01"
network.host: 0.0.0.0
discovery.zen.ping.unicast.hosts: ["vm01","vm02","vm03","vm04"]
cluster.initial_master_nodes:  ["vm01","vm02","vm03","vm04"]
```

4ノードクラスタの起動後にノード情報を出力すると以下のような表示となります。
vm01とvm02はサブネット1(172.16.0.0/24)に属しており、vm03とvm04はサブネット2(172.16.1.0/24)に属しています。この時点では、お互いのサブネット間は疎通が可能です。

```
GET _cat/nodes?v

ip         heap.percent ram.percent cpu load_1m load_5m load_15m node.role  master name
172.16.0.4           58          64   3    0.89    0.31     0.28 cdhilmrstw *      vm01
172.16.0.5           54          53   9    1.14    0.30     0.17 cdhilmrstw -      vm02
172.16.1.4           10          49   1    0.17    0.13     0.10 cdhilmrstw -      vm03
172.16.1.6           42          49   2    0.52    0.39     0.25 cdhilmrstw -      vm04
```


なお、この環境でのノード名とノードIDは以下のようになっていました。
vm01がMaster、Voteはvm01,vm02,vm03が持っている状況です。

|ノード名|ノードID|Master|Vote|
|---|---|---|---|
|vm01|Donl9OS8THSJhlnk2brrhg|*|x|
|vm02|xPWHs5B6SgKZYiesfBUQ3A| |x|
|vm03|C9H9OPX6SwGT5ABUCn4xog| |x|
|vm04|-aZpSwG2TEimF8KT6m8xJQ| | |


## 疑似障害（ネットワーク分断）を起こす
ネットワーク分断によるスプリットブレインをエミュレートしてみたいと思います。

Azureでは、各サブネットにルーティングテーブルをカスタム定義して割り当てることができます。これを利用して、お互いのサブネットへの疎通をdropする（「次ホップの種類」を「なし」に設定する）ようなルールを構成して、これをそれぞれのサブネットに適用するとお互いのサブネット同士が疎通できなくなります。

*サブネット1(172.16.0.0/24)からサブネット2(172.16.1.0/24)へのカスタムルート定義*
<img width="70%" alt="customroute1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51832/f24b19ec-f76b-c2d0-b7a5-472dd63f7bb1.png">


*サブネット2(172.16.1.0/24)からサブネット1(172.16.0.0/24)へのカスタムルート定義*
<img width="70%" alt="customroute2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51832/3c707ebb-1ae3-1c32-46b3-06d2ee263d7f.png">

このルーティングテーブルを適用するまでは正常にクラスタが動作していましたが、適用後しばらくすると、片方のサブネットでのみElasticsearchクラスタが動作して、反対側では応答しなくなりました。_cat/nodesでノード情報を見ても2ノードしか表示されないこともわかります。

```
GET _cat/nodes?v

ip         heap.percent ram.percent cpu load_1m load_5m load_15m node.role  master name
172.16.0.4           20          64   6    0.04    0.12     0.20 cdhilmrstw *      vm01
172.16.0.5           14          53  14    0.00    0.09     0.12 cdhilmrstw -      vm02
```

さて、このときクラスタから除外された「反対側」のノードのログを見てみるとどうなっているでしょうか。vm04の/var/log/elasticsearch/cluster01.logを見てみます。（見やすさのため途中で改行を入れています）

```
[2020-12-06T08:34:25,272][WARN ][o.e.c.c.ClusterFormationFailureHelper] [vm04] master not discovered 
or elected yet, an election requires at least 2 nodes with ids from [xPWHs5B6SgKZYiesfBUQ3A, 
C9H9OPX6SwGT5ABUCn4xog, Donl9OS8THSJhlnk2brrhg], have discovered [{vm04}{-aZpSwG2TEimF8KT6m8xJQ}{...}, 
{vm03}{C9H9OPX6SwGT5ABUCn4xog}{...}] which is not a quorum; discovery will continue using 
[172.16.0.4:9300, 172.16.0.5:9300, 172.16.1.4:9300] from hosts providers and [{vm03}{...}, {vm02}{...}, 
{vm01}{...}, {vm04}{...}] from last-known cluster state; node term 232, last-accepted version 805 
in term 232
```

長いログメッセージですが、

```
an election requires at least 2 nodes with ids from [xPWHs5B6SgKZYiesfBUQ3A, C9H9OPX6SwGT5ABUCn4xog, Donl9OS8THSJhlnk2brrhg]
```
とあるように、voting configの3ノードのうち2ノードのvoteが必要だがこれが充足されてないといった旨のメッセージが出力されています。このメッセージは10秒毎に繰り返し出力されています。

確認が終わったら、カスタムルートをサブネットから割り当て解除して、元の4ノードクラスタに戻しておきます。


## 4ノードクラスタの耐ノード障害性についてまとめてみる
ここまでの説明と実験では、Elasticsearch7.xからは偶数のMaster-eligibleノードからなるクラスタでは自動調整機能により、投票権(vote)を意味するvoting configのリストは（ノード数-1）となる奇数に設定されることがわかりました。

このとき、クラスタとしては何台までのノード障害を許容するのか、以下で整理します。

まず4ノードクラスタの場合をまとめます。voting configが3ノードになるため、このときのquorumに必要なvote数は「2」です。つまり、voteが2以上を維持できればクラスタとしては継続動作するということを意味します。

|ケース#|障害ノードのタイプ・台数|失うVote|残るVote|クラスタ状態|
|---|---|---|---|---|
|case1|voting configに含まれないノード1台|0|3|継続動作|
|case2|voting configに含まれるノード1台|1|2|継続動作|
|case3|voting configに含まれないノード1台 + <br>voting configに含まれるノード1台|1|2|継続動作|
|case4|voting configに含まれるノード2台|2|1|停止|

このように、従来Elasticsearch6.xまででは2台障害でクラスタ停止となっていたケースでも、voting configに含まれないノードが絡む障害についてはElasticsearch7.xでは許容される点が大きな違いと言えるでしょう。
なお、前述したスプリットブレインの例は上記のcase3に該当するため、クラスタは継続動作したということになります。

同様に6ノード、8ノードとノード数が増えた場合でも、残るVote数が過半数かどうかによりクラスタ動作・停止が決まることになり、いずれも半数で分断するようなスプリットブレインではクラスタ停止は起こりません。

## さらに詳細を知りたい方に
バージョン7のクラスタコーディネーションレイヤの設計の経緯をGitHub issueで確認することができます。

**A new cluster coordination layer #32006**
https://github.com/elastic/elasticsearch/issues/32006

このような分散合意システムを設計することは技術的難易度が高いのですが、ElasticsearchではTLA+という形式手法言語・ツールを活用して、発見困難なバグや最適化を行っています。TLA+は他にもAWS S3やDynamoDBの設計でも使われています。

**Elastic blog: Reliable by Design: Applying Formal Methods to Distributed Systems**
https://www.elastic.co/jp/elasticon/conf/2018/sf/reliable-by-design-applying-formal-methods-to-distributed-systems

クラスタコーディネーションでの実際のモデルに興味があれば、以下の実装も参照してみてください。

**Formal models of core Elasticsearch algorithms**
https://github.com/elastic/elasticsearch-formal-models



## まとめ
Elasticsearchがバージョン7から新しいクラスタコーディネーションが導入されましたが、Quorum獲得のアーキテクチャであるVoting Configurationの仕組みを知っておくことで、耐障害性設計などに役立つと思い、内容を簡単にご紹介しました。

バージョン6まではMaster-elibigleノードは偶数台にしないでください、とアドバイスしてきたのですが、今後は偶数台構成であってもElasticsearchが裏で自動調整してくれるおかげで安心して使うことができそうです。