CLOVER🍀

That was when it all began.

Elasticsearchクラスタで必要なシャード数、ノード数を計算する

これは、なにをしたくて書いたもの?

Elasticsearchクラスタのシャード数やノード数を算出する時の考え方についていろいろ調べたので、メモをしておこうかなと。

Elasticsearchクラスタでのシャード数とノード数を計算する

基本となるのは、以下のブログエントリでしょう。

How many shards should I have in my Elasticsearch cluster? | Elastic Blog

こちらに、シャードひとつあたりのサイズについての考え方が書かれています。

ヒント: 小さなシャードは小さなセグメントとなり、結果としてオーバーヘッドが増えます。そのため、平均シャードサイズは最小で数GB、最大で数十GBに保つようにしましょう。時間ベースのデータを使用するケースでは、シャードサイズを20GBから40GBにするのが一般的です。

20〜40GBにするのが、一般的だと書かれていますね。AWSのブログエントリを見ると、30GBが目安とも書かれています。

Amazon Elasticsearch Service をはじめよう: シャード数の算出方法 | Amazon Web Services ブログ

ここでは、30GBを目安に考えましょう。

また、ひとつのElasticsearchノードが、どのくらいシャードを持てるかについては、以下のようにヒープサイズで算出するようですね。

ヒント: ノードに保持できるシャード数は、利用できるヒープ量に比例しますが、Elasticsearchによって強制される固定の上限はありません。経験則では、ノードごとのシャード数は構成したヒープのGBあたり20未満に維持することが良いと言えます。したがって30GBのヒープでは最大600シャードとなりますが、この上限よりも大幅に下回る数にするほうがより適切です。

ヒープ1Gあたり20シャード、ということになります。

とすると、インデックスのサイズとレプリカ数、Elasticsearchのヒープサイズが決まれば、シャード数とElasticsearchのノード数が算出できることに
なります。

たとえば、以下の条件で考えます。

  • Elasticsearchに保持するインデックスは1種類
  • ひとつのインデックスのサイズが150GB
  • レプリカ数が1
  • Elasticsearchのヒープサイズが15GB
  • 1日単位に同じ種類のインデックスを作成して、最大3ヶ月(90日)分保持する
    • LogstashやBeatsで日単位のインデックスを作成するイメージ
    • 1日あたり、150GBのインデックスができるものとする

計算すると

  • インデックスあたりのシャード数: 150GB / 30GB = 5シャード(余りが出た場合は、1シャード分切り上げ)
  • クラスタ内のシャード数: 5シャード × 2(プライマリー+レプリカ) × 90(保持日数がインデックス数になる) = 900シャード
  • 必要なElasticsearchノード数: 900シャード / 15 × 20(ヒープサイズ × 1Gヒープあたり20シャード) = 3ノード(余りが出た場合は、1ノード分切り上げ)

という感じでしょうか。さらに、複数の種類のインデックスを持つなら、シャード数の部分に追加計算していく感じですね。

レプリカが計算から落ちやすい気がしないでもないですが、レプリカシャードも含めます。レプリカシャードは、検索にも使われるようですし、
プライマリーシャードが更新された後に合わせてレプリカシャードも更新されますからね。ふつうに使われます、と。

Elasticsearchでは、各クエリはシャードごとに単一のスレッドで実行されます。ただし、同一のシャードに複数のクエリおよび集約が実行できるのと同様に、複数のシャードを並行して処理することが可能です。

インデックスの実データ量を見る

それはそうと、インデックスのサイズはどうやって測る?ということになると思いますが、データを入れてcat APIを使うことになるのでは
ないでしょうか。

cat indices API | Elasticsearch Reference [7.5] | Elastic

cat shards API | Elasticsearch Reference [7.5] | Elastic

実例がないとなんともなので、適当なサイズ(簡単に終わらせたいのでGBはいかず、でもMBは欲しい?くらいの…)のネタがないかどうか
考えた結果、このブログのデータを使うことにしました。

※ここでは、ここまでに書いてきた1シャードあたり30GBなどの目安は無視して、シャードを分けたことによる変化やレプリカ数の影響を見ます

このブログのデータをエクスポートして取得。

f:id:Kazuhira:20200102173728p:plain

f:id:Kazuhira:20200102173735p:plain

このブログを対象にしているので、「kazuhira-r.hatenablog.com.export.txt」というファイルが取得できます。サイズは32MBほどですが、まあいいでしょう。

$ ll -h kazuhira-r.hatenablog.com.export.txt 
-rw-rw-r-- 1 xxxxx xxxxx 32M  1月  2 15:48 kazuhira-r.hatenablog.com.export.txt

中には、ドラフト状態のものも入っていますが…。

$ head -n 20 kazuhira-r.hatenablog.com.export.txt 
AUTHOR: Kazuhira
TITLE: Elasticsearchクラスタのシャード数を計算する
BASENAME: 2020/01/02/011901
STATUS: Draft
ALLOW COMMENTS: 1
CONVERT BREAKS: 0
DATE: 01/02/2020 00:58:10
CATEGORY: Elasticsearch
-----
BODY:
<p>Elasticsearchのシャード数を算出する時の考え方を、メモをしておこうかなと。</p>

<p>基本となるのは、以下のブログエントリでしょう。</p>

<p><a href="https://www.elastic.co/jp/blog/how-many-shards-should-i-have-in-my-elasticsearch-cluster">How many shards should I have in my Elasticsearch cluster? | Elastic Blog</a></p>

<p>こちらに、シャードひとつあたりのサイズについての考え方が書かれています。</p>

<blockquote><p>ヒント: 小さなシャードは小さなセグメントとなり、結果としてオーバーヘッドが増えます。そのため、平均シャードサイズは最小で数GB、最大で数十GBに保つようにしましょう。時間ベースのデータを使用するケースでは、シャードサイズを20GBから40GBにするのが一般的です。</p></blockquote>

形式は、Movable Type。

ここから、インデックスに

  • タイトル(TITLE)
  • カテゴリー(CATEGORY)
  • 投稿日時(DATE)
  • コンテンツ(BODYからHTMLのテキストだけを抜き出したもの)
  • ステータス(STATUS)

を登録し、idとしてはBASENAMEを「/」抜きで使うものとします。あと、コメントは全部読み飛ばします。

そういうちょっとしたプログラムをPythonで書いてみましょう。HTMLからテキストを取得するのにBeautifulSoup4、Elasticsearchへの
データ登録に、PythonのElasticsearchクライアントを使用します。

$ pip3 install beautifulsoup4 elasticsearch

バージョン。

$ pip3 freeze
beautifulsoup4==4.8.2
elasticsearch==7.1.0
pkg-resources==0.0.0
soupsieve==1.9.5
urllib3==1.25.7


$ python3 -V
Python 3.6.9

で、適当にスクリプトを作ります。「mv_export_to_es.py」というファイル名で、引数にエクスポートしたMovable Typeのファイルパスを渡すように
します。作成したスクリプトはこのエントリの本筋ではないので、最後に載せますね。

このスクリプトでは、データを登録するインデックス名を「blog」として、バルク処理で100件ずつ登録します。

接続先のElasticsearchは、192.168.33.11〜13でクラスタ構成されているものとします。

情報はこちら。

$ curl localhost:9200/_cat/nodes?v
ip            heap.percent ram.percent cpu load_1m load_5m load_15m node.role master name
192.168.33.12            9          96   0    0.00    0.01     0.00 dilm      -      node-2
192.168.33.13            7          96   0    0.02    0.02     0.02 dilm      -      node-3
192.168.33.11            8          95   0    0.00    0.00     0.02 dilm      *      node-1


$ curl localhost:9200
{
  "name" : "node-1",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "0PtgLGF_Q2-IWYMVUtcV4Q",
  "version" : {
    "number" : "7.5.1",
    "build_flavor" : "default",
    "build_type" : "deb",
    "build_hash" : "3ae9ac9a93c95bd0cdc054951cf95d88e1e18d96",
    "build_date" : "2019-12-16T22:57:37.835892Z",
    "build_snapshot" : false,
    "lucene_version" : "8.3.0",
    "minimum_wire_compatibility_version" : "6.8.0",
    "minimum_index_compatibility_version" : "6.0.0-beta1"
  },
  "tagline" : "You Know, for Search"
}


$ java --version
openjdk 11.0.5 2019-10-15
OpenJDK Runtime Environment (build 11.0.5+10-post-Ubuntu-0ubuntu1.118.04)
OpenJDK 64-Bit Server VM (build 11.0.5+10-post-Ubuntu-0ubuntu1.118.04, mixed mode, sharing)

とりあえず、なにも考えずにデータを登録します。

$ python3 mv_export_to_es.py kazuhira-r.hatenablog.com.export.txt

1243件のデータが入りました。

$ curl localhost:9200/blog/_count?pretty
{
  "count" : 1243,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  }
}

インデックスの情報を見てみます。

$ curl localhost:9200/_cat/indices?v
health status index uuid                   pri rep docs.count docs.deleted store.size pri.store.size
green  open   blog  jTUukj5PQ-eZoMo3GSVO8w   1   1       1243            0     23.6mb         11.8mb

データサイズは23.6MBです。でも、よく見ると「pri.store.size」は11.8MBと表示されていますね。

一方でシャードのサイズを見てみます。

$ curl localhost:9200/_cat/shards?v
index shard prirep state   docs  store ip            node
blog  0     p      STARTED 1243 11.8mb 192.168.33.13 node-3
blog  0     r      STARTED 1243 11.7mb 192.168.33.11 node-1

2つシャードがあり、11.7〜8MBですね。

インデックスはデフォルトの設定で作成したので、シャード数1、レプリカ数1になっています。

$ curl localhost:9200/blog/_settings?pretty
{
  "blog" : {
    "settings" : {
      "index" : {
        "creation_date" : "1577954953969",
        "number_of_shards" : "1",
        "number_of_replicas" : "1",
        "uuid" : "jTUukj5PQ-eZoMo3GSVO8w",
        "version" : {
          "created" : "7050199"
        },
        "provided_name" : "blog"
      }
    }
  }
}

というわけで、インデックスの方に出ているデータサイズ(store.size)は、プライマリーシャードとレプリカシャードの値を
足したものですね。

計算のベースとしては、プライマリーシャードの合計値を見て必要となるデータサイズを見てみればよいでしょう。

別パターンとして、シャード数とレプリカ数を変更したものも試してみましょう。

今のインデックスを1度削除。

$ curl -XDELETE localhost:9200/blog
{"acknowledged":true}

シャード数9、レプリカ数2でインデックスを作成。

$ curl -XPUT "localhost:9200/blog" -H 'Content-Type: application/json' -d '{ "settings" : { "index" : { "number_of_shards" : 9, "number_of_replicas" : 2 } } }'
{"acknowledged":true,"shards_acknowledged":true,"index":"blog"}

再度データ登録。

$ python3 mv_export_to_es.py kazuhira-r.hatenablog.com.export.txt

確認。

$ curl localhost:9200/_cat/indices?v
health status index uuid                   pri rep docs.count docs.deleted store.size pri.store.size
green  open   blog  4iAlQWUURxypKkNqmilbsg   9   2       1243            0     41.1mb           14mb

プライマリーシャードの容量が、1シャードの時からちょっと増えていますね。トータルのサイズとしても、レプリカ数が2つになったので、
プライマリーシャードの約3倍になっています。

シャードの状態は、こんな感じです。

$ curl localhost:9200/_cat/shards?v
index shard prirep state   docs store ip            node
blog  3     p      STARTED  143 1.6mb 192.168.33.13 node-3
blog  3     r      STARTED  143 1.5mb 192.168.33.11 node-1
blog  3     r      STARTED  143 1.6mb 192.168.33.12 node-2
blog  4     r      STARTED  124 1.4mb 192.168.33.13 node-3
blog  4     p      STARTED  124 1.4mb 192.168.33.11 node-1
blog  4     r      STARTED  124 1.4mb 192.168.33.12 node-2
blog  6     p      STARTED  137 1.4mb 192.168.33.13 node-3
blog  6     r      STARTED  137 1.3mb 192.168.33.11 node-1
blog  6     r      STARTED  137 1.3mb 192.168.33.12 node-2
blog  7     r      STARTED  163 1.7mb 192.168.33.13 node-3
blog  7     p      STARTED  163 1.9mb 192.168.33.11 node-1
blog  7     r      STARTED  163 1.7mb 192.168.33.12 node-2
blog  8     r      STARTED  126 1.3mb 192.168.33.13 node-3
blog  8     r      STARTED  126 1.3mb 192.168.33.11 node-1
blog  8     p      STARTED  126 1.5mb 192.168.33.12 node-2
blog  2     r      STARTED  129 1.3mb 192.168.33.13 node-3
blog  2     r      STARTED  129 1.2mb 192.168.33.11 node-1
blog  2     p      STARTED  129 1.3mb 192.168.33.12 node-2
blog  1     r      STARTED  141 1.6mb 192.168.33.13 node-3
blog  1     p      STARTED  141 1.6mb 192.168.33.11 node-1
blog  1     r      STARTED  141 1.4mb 192.168.33.12 node-2
blog  5     r      STARTED  129 1.5mb 192.168.33.13 node-3
blog  5     r      STARTED  129 1.5mb 192.168.33.11 node-1
blog  5     p      STARTED  129 1.5mb 192.168.33.12 node-2
blog  0     p      STARTED  151 1.6mb 192.168.33.13 node-3
blog  0     r      STARTED  151 1.6mb 192.168.33.11 node-1
blog  0     r      STARTED  151 1.5mb 192.168.33.12 node-2

実際に計算する時は1シャードにデータを入れて基準となるデータサイズを見つつ、実際にシャード分割してどれくらい余剰に
増えるかを確認してみるといった感じになるんでしょうね。

なかなかcat APIに結果が反映されない時は、Refresh APIを使って、インデックスをリフレッシュするとよいでしょう。

$ curl -XPOST localhost:9200/[インデックス名]/_refresh
$ curl -XPOST localhost:9200/_refresh

オマケ

最後に、今回作成したスクリプトを載せて終わりにします。 mv_export_to_es.py

from bs4 import BeautifulSoup
from elasticsearch import Elasticsearch
from elasticsearch import helpers
import re
import sys

elasticsearch_hosts = ["192.168.33.11", "192.168.33.12", "192.168.33.13"]
elasticsearch_urls = [ f"http://{host}:9200"  for host in elasticsearch_hosts ]

es = Elasticsearch(elasticsearch_urls)
index_name = "blog"
bulk_size = 100

mv_export_file = sys.argv[1:][0]

body_separator = "-----"
entry_separator = "--------"

with open(mv_export_file, "rt", encoding = "utf-8") as file:
    docs = []
    
    while True:
        line = file.readline()  # AUTHOR

        if line == "":
            break

        title = re.match("TITLE:\s+(.+)", file.readline().strip()).group(1)  # TITLE

        basename = re.match("BASENAME:\s+(.+)", file.readline()).group(1).replace("/", "")  # BASENAME
        status = re.match("STATUS:\s(.+)", file.readline().strip()).group(1)  # STATUS
        file.readline()  # ALLOW COMMENT
        file.readline()  # CONVERT BREAKS
        m = re.match("DATE:\s+(\d\d)/(\d\d)/(\d\d\d\d) (\d\d:\d\d:\d\d)", file.readline())  # DATE
        datetime = f"{m.group(3)}-{m.group(1)}-{m.group(2)} {m.group(4)}"

        categories = []

        while True:
            line = file.readline()

            if line.startswith("CATEGORY:"):
                category = re.match("CATEGORY:\s+(.+)", line).group(1)
                categories.append(category)
            else:
                # BODY separator(------)
                break

        file.readline()  # BODY

        body = ""

        while True:
            line = file.readline()

            if line.strip() == body_separator:
                maybe_contents = line

                line = file.readline()

                if line.strip() == entry_separator:
                    break
                elif line.strip() == "COMMENT:":  # skip comment
                    while True:
                        line = file.readline()

                        if line.strip() == entry_separator:
                            break

                    break
                else:
                    body += maybe_contents
                    body += line
            else:
                body += line

        soup = BeautifulSoup(body, "html.parser")
        body_text = soup.get_text()

        doc = {
            "_index": index_name,
            "_id": basename,
            "_source": {
                "title": title,
                "datetime": datetime,
                "categories": categories,
                "body": body_text,
                "status": status
            }
        }

        docs.append(doc)

        if len(docs) >= bulk_size:
            helpers.bulk(es, docs)
            docs = []

    helpers.bulk(es, docs)  # last