Writing in progress: Quorum(定足数)による決定

Quorum-based decision making

Quorum-based decision making | Elasticsearch Guide [8.1] | Elastic

  • master-eligible nodeの基本的なタスクは次の二つ。
    • master nodeの選出
    • cluster状態の変更
  • この二つのタスクは例えいくつかのノードが使用不能になったとしても正しく動作することが求められる。
    • なぜならこれらのタスクが正常に完了しないと、cluster全体が機能しなくなるからである。
    • このような外的要因による不測の事態においても、システムが正常に稼働する性質のことをロバスト性というらしい。
  • Elasticsearchはmaster eligible nodeの定足数以上から成功を示す応答を受信し上記のタスクを進めることで、ロバスト性を担保する仕組みになっている。
    • ここでmaster eligible nodeが応答することをElasticsearchでは 「投票」(Voting) と言う。

  • master-eligible nodeは動的に追加、削除が可能。
  • master-eligible nodeの数の変化に伴い、Elasticsearchは最適な投票構成の設定を行い、堅牢性を維持しようとする。
    • 投票構成の設定についてはこちら
    • それぞれのタスクにおける決定は、投票構成の半数以上のノードが応答することにより行われる。
    • 通常、投票構成はmaster-eligible nodeと同じ数になるが異なる場合もある。

  • clusterを確実に利用し続けたいのであれば、半数以上の投票構成に含まれるnodeを一度に停止してはいけない。
    • 例えば3つ、または4つのmaster-eligible node構成でclusterを運用しているとき2つ以上が同時に停止するとclusterは正しく動作しなくなるかもしれない。
  • 通常、master-eligible nodeの追加・削除時にmaster nodeは直ぐclusterのステータスを更新し投票構成を適切な値に設定する。

Voting configurations

Voting configurations | Elasticsearch Guide [8.1] | Elastic

  • voting configurationはcluster stateとして管理、保存されている。
    • 次のAPIにより、現在の設定値を確認できる。
GET  /_cluster/state?filter_path=metadata.cluster_coordination.last_committed_config
  • 実際に試してみる。
    • 上記のリクエストのレスポンスは下記の通り。これらの値はnodeIdを表しているらしい。
{
  "metadata": {
    "cluster_coordination": {
      "last_committed_config": [
        "jRRXtWgsQ7atny8aKekv8A",
        "S7MNYHX8TJytYeTBxQGmOw",
        "TlLSYvQASLaxTeO6FCRd0Q"
      ]
    }
  }
}
  • 実際にcat apiで実際のnodeIdを取得してみる。
    • この時、全nodeは計4つでroleにmasterを付与しているノードは計3つのため、それらのnodeがvoting configurationに含まれているということになる。
[
  { "nodeId": "VgZvdqvXSKWeKxneZutYMQ" },
  { "nodeId": "TlLSYvQASLaxTeO6FCRd0Q" },
  { "nodeId": "jRRXtWgsQ7atny8aKekv8A" },
  { "nodeId": "S7MNYHX8TJytYeTBxQGmOw" }
]

Elasticsearchでcat shards APIを使いshardがどこのnodeに割り当てられているかを確認する

Elasticsearchでshardがcluster内のどこのnodeに属しているか確認したいと思うことがあったので、表示方法を調べてみました。

Elasticsearchに対するREST APIは以下のドキュメントから調べることができます。

REST APIs | Elasticsearch Guide [8.1] | Elastic

REST APIs にはElasticsearchに対するREST API操作の列挙とその操作内容について記載されています。他のドキュメントもそうですが、elastic-stackのドキュメントはよく整理されていて、例えばREST APIは操作のカテゴリごとにまとまっています。探すのが楽だし、理解しやすいので助かりますね。

cat shards API

cat shards APIを叩くことでshardごとの情報を確認できます。例えば、shardがどのindex、nodeに属しているか。primary shardか、replica shardか、等を確認できます。

cat shards API | Elasticsearch Guide [8.1] | Elastic

以下、実行例です。

GET _cat/shards?format=json

[
  {
    "index" : ".kibana-event-log-8.0.0-000001",
    "shard" : "0",
    "prirep" : "p",
    "state" : "STARTED",
    "docs" : null,
    "store" : null,
    "ip" : "172.18.0.6",
    "node" : "esmaster"
  },
  {
    "index" : ".apm-agent-configuration",
    "shard" : "0",
    "prirep" : "p",
    "state" : "STARTED",
    "docs" : null,
    "store" : null,
    "ip" : "172.18.0.6",
    "node" : "esmaster"
  },
  // 以下略

パスにindex名を指定したり、ワイルドカードを用いて検索対象のshardを絞り込むことも可能です。

GET _cat/shards/index-*?format=json

これで、レスポンスの中身を見ればどのnodeにshardが属しているかがわかりますね。

ちなみに、 prirep フィールドで示されている値は、 p の場合primary shard、 r の場合replica shardとなります。

Written with StackEdit.

Logstashを使用してApacheのログをElasticsearchに取り込んでみた

elasticの公式リポジトリにサンプルデータがいくつも存在しており、この中にApacheアクセスログがあるので、これをLogstashを使ってElasticsearchに取り込んでみました。

サンプルデータのリポジトリ

GitHub - elastic/examples: Home for Elasticsearch examples available to everyone. It's a great way to get started.

Apacheアクセスログ

examples/Common Data Formats/apache_logs at master · elastic/examples · GitHub

環境

Elasticsearch、LogstashはDockerにて動かします。取り込むログファイルやLogstashの設定ファイルはDockerVolumeとして読み込ませました。Kibanaもいますけれど、今回は関係ないです。

version: '3.9'
services:
  es01:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.0.0
    ports:
      - 9200:9200
      - 9300:9300
    environment:
      - discovery.type=single-node
      - action.destructive_requires_name=true
      - xpack.security.enabled=false
      - path.repo=/usr/share/elasticsearch/backup
    networks:
      - elastic
    volumes:
      - backup:/usr/share/elasticsearch/backup
    deploy:
      resources:
        limits:
          memory: 2G
  ki01:
    image: docker.elastic.co/kibana/kibana:8.0.0
    environment:
      ELASTICSEARCH_HOSTS: "http://es01:9200"
    ports:
      - 5601:5601
    networks:
      - elastic
    deploy:
      resources:
        limits:
          memory: 2G
  lo01:
    image: docker.elastic.co/logstash/logstash-oss:8.0.0
    environment:
      - MONITORING_ENABLED=false
    volumes:
      - ./logstash/example-logstash.conf:/usr/share/logstash/pipeline/logstash.conf
      - ./logstash/apache_log:/usr/share/logstash/example-data/apache_log
      - ./logstash/completed_log_file:/usr/share/logstash/example-data/completed_log_file
    networks:
      - elastic
    deploy:
      resources:
        limits:
          memory: 1G
  fi01:
    image: docker.elastic.co/beats/filebeat:8.0.0
    volumes:
      - ./filebeat/filebeat.yml:/usr/share/filebeat/filebeat.yml
      - ./filebeat/apache_log:/var/apache_log
    networks:
      - elastic
    deploy:
      resources:
        limits:
          memory: 1G
volumes:
  backup:
    driver: local
networks:
  elastic:
    driver: bridge

DockerでLogstashを起動する場合は /usr/share/logstash/pipeline/ ディレクトリに設定ファイルを配置します。すると、コンテナを起動すれば勝手にファイルを読み込んで実行してくれます。補足ですが、複数の設定ファイルがある場合それぞれパイプラインが作成されることになります(パイプライン数の数は別の設定ファイルで設定できたはず)。elasticが提供しているコンテナはデフォルトの設定ファイルが logstash.conf として既に存在するので、docker-compose.ymlにてボリュームをマウントする時に上書きするようにしています。

LogstashをDockerで使用するやり方について詳しくは以下のページに記載されています。

Configuring Logstash for Docker | Logstash Reference [8.1] | Elastic

今回使っているDockerイメージですが、ポストフィックスに -oss とあります。これがついていないもので起動してみたのですが、ライセンスの関係でうまくいきませんでした。-oss は無償で使って良いライセンスになっているようです。elasticが公式に出しているDockerイメージはこちらから確認できます。

Docker @ Elastic | Elastic

Logstashの設定

Logstashの使い方についてざっくりと読んで、ざっくりと設定しました。

Logstashは大きく分けて次のような処理をするみたいです。

How Logstash Works | Logstash Reference [8.1] | Elastic

  • 入力 ... データを読み込む。
  • フィルタ ... データを分析、加工する。
  • 出力 ... データを吐き出す。

上記の3つの処理をまとめて、パイプラインと言うらしいです。パイプラインは設定ファイルで定義します。

Structure of a config file | Logstash Reference [8.1] | Elastic

今回作成した設定ファイルは次の通りです。推測ですが、Rubyシンタックスで記載するみたいですね。input filter output が上記の処理ステップにそれぞれ対応しています。個々のセクションの中で、それぞれパラメータを設定するような作りになっています。

input {
  file {
    path => "/usr/share/logstash/example-data/apache_log"
    mode => "read"
    file_completed_action => "log"
    file_completed_log_path => "/usr/share/logstash/example-data/completed_log_file"
  }
}
filter {
  grok {
    match => { "message" => '%{IPORHOST:clientip} %{USER:ident} %{USER:auth} \[%{HTTPDATE:timestamp}\] "%{WORD:verb} %{DATA:request} HTTP/%{NUMBER:httpversion}" %{NUMBER:response:int} (?:-|%{NUMBER:bytes:int}) %{QS:referrer} %{QS:agent}' }
  }
  date {
    match => [ "timestamp", "dd/MMM/yyyy:HH:mm:ss Z" ]
    locale => "en"
  }
}
output {
  elasticsearch {
    hosts => [ "es01:9200" ]
    index => "logtest-%{{yyyy.MM}}"
  }
}

input では指定したファイルを入力データとして扱うようにしました。moderead に設定してファイルを先頭から読み込ませるようにしています。デフォルトの動作だとtailするため新たに追加された行しか読み込んでくれません。なので今回のように事前にログが書き込まれている場合はデフォルトの動作だとうまくいきません。また、 file_completed_actionlog にしていますが、デフォルトの動作だと読み込んだ後にファイルが削除されてしまうようなのでこのようにしています。

filter ではgrokプラグインを使ってログを構造化しています。grokの使い方はネットにたくさん転がっているのでそれらを参考にすれば一般的なログフォーマットであれば事足りそうです。ただ、特殊な形式か、独自の形式をサポートしたい場合などはしっかり調べないとダメそうです。grokに関しては少し奥が深そうですね。

また、 filter にてdateプラグインを使ってログの時刻データを処理しています。

Date filter plugin | Logstash Reference [8.1] | Elastic

dateプラグインを使うことで時刻を表す文字列をパースすることができます。さらに、ロケール情報も合わせて追加できます。パースしたデータは時刻情報としてそれ以降の処理で利用することができます。今回は時刻情報を使いログを異なるインデックスに振り分けています。

dateプラグインはパースした文字列をデフォルトで @timestamp フィールドに保存します。このフィールドはLogstashが初めから予約しているフィールド名になっています。別のフィールドに格納することもできますが、基本的に変える必要はないでしょう。

ここでフィールドという言葉が出てきましたが、フィールドが何であるのか、どういった使い方ができるのかについてはこの辺りに書いてありました。

Accessing event data and fields in the configuration | Logstash Reference [8.1] | Elastic

output ではelasticsearchに解析した結果を出力しています。 index に年月を指定することで、月ごとに別のインデックスにしています。ログデータの場合、大体は月または日ごとにインデックスを分けるんじゃないでしょうか。このように分ければ、ログの参照頻度に応じてデータの圧縮や削除を行うことが容易になります。便利ですね。

時刻データ(@timestamp)のフィールドへの参照方法は他と違っていて、 {{<フォーマット形式>}} とできるみたいです。おそらくよく使われる参照データなので簡単に書けるようにしているんでしょうね。フォーマット形式の指定は、JavaのDateTimeFormatterに従うようです。

DateTimeFormatter | Java 11 API

実行する

次のコマンドにより実行します。事前にelasticsearchは立ち上げておく必要があります。

$ docker compose up lo01

起動後のログはこんな感じ。一部を抜粋しています。

elastic-stack-lo01-1  | [2022-03-20T03:22:23,517][INFO ][logstash.javapipeline    ][main] Starting pipeline {:pipeline_id=>"main", "pipeline.workers"=>2, "pipeline.batch.size"=>125, "pipeline.batch.delay"=>50, "pipeline.max_inflight"=>250, "pipeline.sources"=>["/usr/share/logstash/pipeline/logstash.conf"], :thread=>"#<Thread:0x523b2745 run>"}
elastic-stack-lo01-1  | [2022-03-20T03:22:25,144][INFO ][logstash.javapipeline    ][main] Pipeline started {"pipeline.id"=>"main"}
elastic-stack-lo01-1  | [2022-03-20T03:22:25,217][INFO ][logstash.agent           ] Pipelines running {:count=>1, :running_pipelines=>[:main], :non_running_pipelines=>[]}
elastic-stack-lo01-1  | [2022-03-20T03:22:25,244][INFO ][filewatch.observingread  ][main][3541ad80183caa8e3cba3fce295c047e2e81e3e5484c373c62a635dc40efbf18] START, creating Discoverer, Watch with file and sincedb collections

pipeline.sourcesの箇所で読み込んだパイプラインの設定ファイルが書かれていますね。正しい設定ファイルが読み込まれているのかどうかはここを見ればわかりますね。

Kibanaを立ち上げてログがどのように取り込まれたのかを見てみます。

GET _cat/indices

yellow open logtest-2022.03 ITAM0Vp-QWK6MNHlqctaQw 1 1    1 0 9.9kb 9.9kb
yellow open logtest-2015.05 ldm4q7jIQU2n5IrhqEEToQ 1 1 9999 0 6.1mb 6.1mb

2つのインデックスが作成されているのがわかりました。調べてみたところ読み込みに使用したログファイルは全て2015年の時刻なのですが一部のログが想定していない形式になっているため、誤った時刻データとして処理されているようです。事前に想定していないような形式のログがある場合、どうすればいいんでしょうかね?今回はスルーします。

検索してドキュメントを確認します。

GET logtest-*/_search
{
  "query": {
    "match_all": {}
  }
}

検索結果の一部抜粋です。

"hits" : [
      {
        "_index" : "logtest-2015.05",
        "_id" : "g9NZpX8BMC2yzG7fBnHy",
        "_score" : 1.0,
        "_source" : {
          "timestamp" : "18/May/2015:13:05:37 +0000",
          "message" : "208.115.113.88 - - [18/May/2015:13:05:37 +0000] \"GET /articles/ppp-over-ssh HTTP/1.1\" 301 336 \"-\" \"Mozilla/5.0 (compatible; Ezooms/1.0; help@moz.com)\"",
          "agent" : "\"Mozilla/5.0 (compatible; Ezooms/1.0; help@moz.com)\"",
          "referrer" : "\"-\"",
          "ident" : "-",
          "httpversion" : "1.1",
          "verb" : "GET",
          "auth" : "-",
          "@timestamp" : "2015-05-18T13:05:37Z",
          "clientip" : "208.115.113.88",
          "log" : {
            "file" : {
              "path" : "/usr/share/logstash/example-data/apache_log"
            }
          },
          "host" : {
            "name" : "14e739f913d0"
          },
          "response" : 301,
          "@version" : "1",
          "bytes" : 336,
          "event" : {
            "original" : "208.115.113.88 - - [18/May/2015:13:05:37 +0000] \"GET /articles/ppp-over-ssh HTTP/1.1\" 301 336 \"-\" \"Mozilla/5.0 (compatible; Ezooms/1.0; help@moz.com)\""
          },
          "request" : "/articles/ppp-over-ssh"
        }
      },

正しくドキュメントが作成されているのがわかりました。grokプラグインを用いてログを構造化しましたがそちらも正しく動作しているようです。@timestampのフィールドも正しく時刻データを示していますね。

スナップショットとリストアをやってみる

RDBと同じようにデータのバックアップとリストアをやってみました。

次のdocker-compose.ymlを使って環境構築しています。

version: '3.9'
services:
  es01:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.0.0
    ports:
      - 9200:9200
      - 9300:9300
    environment:
      - discovery.type=single-node
      - action.destructive_requires_name=true
      - xpack.security.enabled=false
      - path.repo=/usr/share/elasticsearch/backup
    networks:
      - elastic
    volumes:
      - backup:/usr/share/elasticsearch/backup
    deploy:
      resources:
        limits:
          memory: 2G
  ki01:
    image: docker.elastic.co/kibana/kibana:8.0.0
    environment:
      ELASTICSEARCH_HOSTS: "http://es01:9200"
    ports:
      - 5601:5601
    networks:
      - elastic
    deploy:
      resources:
        limits:
          memory: 2G
volumes:
  backup:
    driver: local
networks:
  elastic:
    driver: bridge

elasticsearchが8.0.0になりましたが、デフォルトでセキュリティの機能が有効化されているので、OFFにしています。ONにしてあると色々と設定してあげないとkibanaが接続できませんでした。ローカルの開発以外では必ずONにするべきだとは思います。

バックアップ

バックアップ先に指定できるものはいくつか種類があるようです。ここでは共有ファイルシステムでやってみます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/snapshots-filesystem-repository.html

dockerで動かしているので、volumeを作成/アタッチしバックアップ先のディレクトリとして利用します。

elasticsearchのコンテナの環境変数 path.repo に、バックアップ先のディレクトリとしてこのvolumeを指定します。この設定がないと、バックアップ先として設定できないので注意が必要です。

次のリクエストでリポジトリを作り、バックアップ先として登録します。

PUT _snapshot/repository_1 <-- ①
{
  "type": "fs",
  "settings": {
    "location": "/usr/share/elasticsearch/backup", <-- ②
    "compress": true
  }
}

① repository_1 はリポジトリの名前なので、自由に決められます。

② バックアップ先に利用するパスを指定します。path.repo に指定がないディレクトリは使えません。

バックアップする対象を作成したいので、一件ドキュメントを保存します。

PUT demo-index

POST demo-index/_doc
{
  "name": "bookstore"
}

次のリクエストでスナップショットを作成できます。

PUT _snapshot/repository_1/snapshot_1?wait_for_completion=true
{
  "indices": "demo-index" <-- ①
}

① スナップショットに含めるインデックスを指定します。 * にすると、全インデックスのスナップショットを作成できます。

レスポンスは次の通りです。

{
  "snapshot" : {
    "snapshot" : "snapshot_1",
    "uuid" : "R-S8paawT0mUdt2f3UjvPg",
    "repository" : "repository_1",
    "version_id" : 8000099,
    "version" : "8.0.0",
    "indices" : [ <-- ①
      ".kibana_8.0.0_001",
      "demo-index",
      ".apm-custom-link",
      ".fleet-policies-7",
      ".kibana_task_manager_8.0.0_001",
      ".apm-agent-configuration",
      ".geoip_databases"
    ],
    "data_streams" : [ ],
    "include_global_state" : true,
    "state" : "SUCCESS",
    "start_time" : "2022-02-20T11:33:20.796Z",
    "start_time_in_millis" : 1645356800796,
    "end_time" : "2022-02-20T11:33:21.997Z",
    "end_time_in_millis" : 1645356801997,
    "duration_in_millis" : 1201,
    "failures" : [ ],
    "shards" : {
      "total" : 7,
      "failed" : 0,
      "successful" : 7
    },
    "feature_states" : [
      {
        "feature_name" : "geoip",
        "indices" : [
          ".geoip_databases"
        ]
      },
      {
        "feature_name" : "fleet",
        "indices" : [
          ".fleet-policies-7"
        ]
      },
      {
        "feature_name" : "kibana",
        "indices" : [
          ".kibana_8.0.0_001",
          ".apm-custom-link",
          ".apm-agent-configuration",
          ".kibana_task_manager_8.0.0_001"
        ]
      }
    ]
  }
}

① スナップショットに含まれるインデックス。指定したもの以外も入ってますが、この辺りはまだよくわかってません。

リストア

バックアップができたところで、リストアを試してみたいと思います。

https://www.elastic.co/guide/en/elasticsearch/reference/current/snapshots-restore-snapshot.html

リストアは、作成したスナップショットを元にインデックスを作成する操作になります。

なので、バックアップ時にやっていたように、リポジトリやスナップショットがelasticsearchに設定されていなければなりません。

今回のケースではそのまま同じDockerコンテナでリストアを試そうと思うので、リポジトリとスナップショットはバックアップ時に作成したものをそのまま使用できます。もし、別のコンテナやインスタンスにリストアをする場合には、バックアップデータを元にリポジトリを設定します。

リポジトリやスナップショットの確認は次のように行います。

// リポジトリの確認
GET _snapshot/repository_1

// スナップショットの確認
GET _snapshot/repository_1/snapshot_1

ワイルドカードを使って検索を行うこともできます。

GET _snapshot/*/*

リストアは、次のように行います。

POST _snapshot/repository_1/snapshot_1/_restore
{
  "indices": "demo-index"
}

URLは _snapshot/<リポジトリ名>/<スナップショット名>/_restore になります。

ボディではリストアするインデックスを指定します。

ただ、この場合だと同じ名前でインデックスをリストアしようとするために、同名のインデックスが既に存在するとエラーとなります。

{
  "error" : {
    "root_cause" : [
      {
        "type" : "snapshot_restore_exception",
        "reason" : "[repository_1:snapshot_1/TUBO3UFDTwWbi199smmt6A] cannot restore index [demo-index] because an open index with same name already exists in the cluster. Either close or delete the existing index or restore the index under a different name by providing a rename pattern and replacement name"
      }
    ],
    "type" : "snapshot_restore_exception",
    "reason" : "[repository_1:snapshot_1/TUBO3UFDTwWbi199smmt6A] cannot restore index [demo-index] because an open index with same name already exists in the cluster. Either close or delete the existing index or restore the index under a different name by providing a rename pattern and replacement name"
  },
  "status" : 500
}

リストアする時にインデックス名を変更するには、次のように、元のインデックス名から文字を一部分切り抜き、新しいインデックス名と連結させます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/snapshots-restore-snapshot.html#rename-on-restore

POST _snapshot/repository_1/snapshot_1/_restore
{
  "indices": "demo-index",
  "rename_pattern": "(.+)",
  "rename_replacement": "restored_index_$1"
}

この場合、次のように、リストアが行われました。

GET restored_index_demo-index/_search
{
  "query": {
    "match_all": {}
  }
}

// レスポンス
{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "restored_index_demo-index",
        "_id" : "ZdevNH8BGshAomOEMFkj",
        "_score" : 1.0,
        "_source" : {
          "name" : "bookstore"
        }
      }
    ]
  }
}

BucketとMetricsを使ってみる

Bucket と Metrics を使って、検索ではなくドキュメントの情報を分析できるようです。

Bucket と Metrics はどちらも、Aggregation と言われるものの一部みたいですね。

https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html

それぞれ単体でも使用できますが、一般的には合わせて使うケースのほうが多そうです。

ドキュメントを Bucket でグループ化し、Metrics で分析を行う、というのが一般的な使い方ではないでしょうか。

ドキュメントの作成

item インデックスに、いくつかドキュメントを保存しました。

PUT item
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text"
      },
      "category": {
        "type": "keyword"
      },
      "price": {
        "type": "long"
      }
    }
  }
}

POST item/_doc
{
  "name": "soba",
  "category": "noodle",
  "price": 1000
}

POST item/_doc
{
  "name": "ramen",
  "category": "noodle",
  "price": 1500
}

POST item/_doc
{
  "name": "lettuce",
  "category": "vegetable",
  "price": 2000
}

POST item/_doc
{
  "name": "bacon",
  "category": "meat",
  "price": 2500
}

Buket と Metrics を使って分析してみる

まずは Bucket を使って、ドキュメントをグループ化してみます。 _search エンドポイントで Aggregation を使えます。

Bucket ごとにまとめただけでは、デフォルトでドキュメント数のみが返されます。

GET item/_search
{
  "size": 0,
  "aggs": { <-- Aggregation を使う
    "category_bucket": {
      "terms": { <-- Keyword 型のフィールドに対し、指定する値に一致するグループにドキュメントを分類する
        "field": "category" <-- category でドキュメントをグループ化する
      }
    }
  }
}

// レスポンス
{
  "took": 14,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 4,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "category_bucket": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [ <-- category ごとのドキュメント数が返ってくる
        {
          "key": "noodle",
          "doc_count": 2
        },
        {
          "key": "meat",
          "doc_count": 1
        },
        {
          "key": "vegetable",
          "doc_count": 1
        }
      ]
    }
  }
}

値の幅でBucketを使うこともできます。

GET item/_search
{
  "size": 0,
  "aggs": {
    "price_bucket": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 1000 },
          { "from": 1000, "to": 2000 },
          { "from": 2000, "to": 3000 }
        ]
      }
    }
  }
}

// レスポンス
{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 4,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "price_bucket": {
      "buckets": [
        {
          "key": "*-1000.0", <-- グループ化された値の幅を示す
          "to": 1000,
          "doc_count": 0
        },
        {
          "key": "1000.0-2000.0",
          "from": 1000,
          "to": 2000,
          "doc_count": 2
        },
        {
          "key": "2000.0-3000.0",
          "from": 2000,
          "to": 3000,
          "doc_count": 2
        }
      ]
    }
  }
}

最後にMetricsを合わせて使ってみます。

Aggregationは入れ子に指定できるようで、以下のようにaggsの中にaggsを使います。

つぎのAggregationはcategoryごとのpriceの平均値を取得します。

GET item/_search
{
  "size": 0,
  "aggs": {
    "category_bucket": {
      "terms": {
        "field": "category"
      },
      "aggs": {
        "price_avg": {
          "avg": {
            "field": "price"
          }
        }
      }
    }
  }
}

// レスポンス
{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 4,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "category_bucket": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "noodle",
          "doc_count": 2,
          "price_avg": {
            "value": 1250 <-- categoryごとのpriceの平均値
          }
        },
        {
          "key": "meat",
          "doc_count": 1,
          "price_avg": {
            "value": 2500
          }
        },
        {
          "key": "vegetable",
          "doc_count": 1,
          "price_avg": {
            "value": 2000
          }
        }
      ]
    }
  }
}

全インデックスをリストで取得する方法

こちらにやり方が書いてありました。

cat indices API | Elasticsearch Guide [8.0] | Elastic

次のHTTPリクエストで、取得できます。

GET /_cat/indices

// レスポンス
[
  {
    "health": "green",
    "status": "open",
    "index": ".geoip_databases",
    "uuid": "eHBNIY2oQJee3w52alIHNA",
    "pri": "1",
    "rep": "0",
    "docs.count": "39",
    "docs.deleted": "39",
    "store.size": "37.5mb",
    "pri.store.size": "37.5mb"
  },
  {
    "health": "yellow",
    "status": "open",
    "index": "demo-index",
    "uuid": "G8rwDsaRSpCMYayBfbfPdw",
    "pri": "1",
    "rep": "1",
    "docs.count": "2",
    "docs.deleted": "0",
    "store.size": "4.9kb",
    "pri.store.size": "4.9kb"
  },
  {
    "health": "green",
    "status": "open",
    "index": ".apm-custom-link",
// 以下略

ただこれだと、JSON形式で見づらいし、情報が多いです。

次のようにすると見やすくなります。インデックス名と、ヘルスの状態に絞り込み、YAML形式で取得できます。

GET /_cat/indices?h=index,health&format=yaml

// レスポンス
---
- index: ".geoip_databases"
  health: "green"
- index: "demo-index"
  health: "yellow"
- index: ".apm-custom-link"
  health: "green"
- index: ".kibana_task_manager_7.16.2_001"
  health: "green"
- index: ".kibana_7.16.2_001"
  health: "green"
- index: ".apm-agent-configuration"
  health: "green"
- index: "accesslog-2022"
  health: "yellow"
- index: ".tasks"
  health: "green"

dynamic templateを使うと動的に追加されるフィールドのmappingを指定できる

サマリ

dynamic templateを使えば、mappingを動的に決められます。

決め方は次の3つがあります。

  • match_mapping_type:デフォルトのmappingをもとに、mappingを割り当てます。
  • match , unmatch:フィールド名から、mappingを割り当てます。
  • path_match , path_unmatch:フィールドのパスから、mappingを割り当てます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/dynamic-templates.html

型からmappingを決める

elasticsearchはJSONの型から、mappingをどう設定するかデフォルトで決まっています。

https://www.elastic.co/guide/en/elasticsearch/reference/current/dynamic-templates.html#match-mapping-type

この設定を上書きするのが、 match_mapping_type です。

次の例では、 string , long とmappingされるフィールドの場合、 text , integer とmappingされるようにしたものです。text はさらに、マルチフィールドになるようにしています。

PUT demo-index
{
    "mappings": {
        "dynamic_templates": [
            {
                "integers": {
                    "match_mapping_type": "long",
                    "mapping": {
                        "type": "integer"
                    }
                }
            },
            {
                "strings": {
                    "match_mapping_type": "string",
                    "mapping": {
                        "type": "text",
                        "fields": {
                            "raw": {
                                "type": "keyword",
                                "ignore_above": 256
                            }
                        }
                    }
                }
            }
        ]
    }
}

実際にdocumentを保存して、mappingがどうなるか確認してみました。

PUT demo-index/_doc/1
{
    "demo-integer": 1,
    "demo-string": "Hello, Elasticsearch"
}

GET demo-index/_mapping

// レスポンス
{
    "demo-index": {
        "mappings": {
            "dynamic_templates": [
                {
                    "integers": {
                        "match_mapping_type": "long",
                        "mapping": {
                            "type": "integer"
                        }
                    }
                },
                {
                    "strings": {
                        "match_mapping_type": "string",
                        "mapping": {
                            "fields": {
                                "raw": {
                                    "ignore_above": 256,
                                    "type": "keyword"
                                }
                            },
                            "type": "text"
                        }
                    }
                }
            ],
            "properties": {
                "demo-integer": {
                    "type": "integer"
                },
                "demo-string": {
                    "type": "text",
                    "fields": {
                        "raw": {
                            "type": "keyword",
                            "ignore_above": 256
                        }
                    }
                }
            }
        }
    }
}

demo-integer はintegerに、 demo-string はtextに、それぞれmappingされていることがわかります。これらのフィールドに対するmappingは事前に無かったのでdynamic templateによりmappingが動的に決定されたことになります。

フィールド名からmappingを決める

match , unmatch を使うと、フィールド名から型を決定できます。

match を使って、 long_ から始まるフィールドをlong型にmappingするよう設定してみました。

PUT demo-index
{
    "mappings": {
        "dynamic_templates": [
            {
                "longs_as_string": {
                    "match": "long_*",
                    "mapping": {
                        "type": "long"
                    }
                }
            }
        ]
    }
}

この場合、文字列でドキュメントを作成したとしても、フィールド名が一致する場合はlong型にmappingされます。

PUT demo-index/_doc/1
{
    "long_number": "1" <-①
}

GET demo-index/_mapping

// レスポンス
{
    "demo-index": {
        "mappings": {
            "dynamic_templates": [
                {
                    "longs_as_string": {
                        "match": "long_*",
                        "mapping": {
                            "type": "long"
                        }
                    }
                }
            ],
            "properties": {
                "long_number": {
                    "type": "long" <-②
                }
            }
        }
    }
}

long_ から始まるフィールド名 long_number で、文字列の値を保存します。

long_number が、long型にmappingされています。

ドキュメントを取得する場合には、文字列として返されます。あくまでもmappingをlong型にしているのであり、値を変換しているのではない、ということですかね。

GET demo-index/_doc/1/_source

// レスポンス
{
    "long_number": "1"
}

mappingでlong型になっているので、rangeで検索できます。

GET demo-index/_search
{
    "query": {
        "range": {
            "long_number": {
                "gte": 0,
                "lte": 2
            }
        }
    }
}

// レスポンス
{
    "took": 2,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 1,
            "relation": "eq"
        },
        "max_score": 1,
        "hits": [
            {
                "_index": "demo-index",
                "_type": "_doc",
                "_id": "1",
                "_score": 1,
                "_source": {
                    "long_number": "1"
                }
            }
        ]
    }
}

では、数値ではない値を指定するとどうなるのでしょうか?

PUT demo-index/_doc/2
{
    "long_number": "abc"
}

// レスポンス
{
    "error": {
        "root_cause": [
            {
                "type": "mapper_parsing_exception",
                "reason": "failed to parse field [long_number] of type [long] in document with id '2'. Preview of field's value: 'abc'"
            }
        ],
        "type": "mapper_parsing_exception",
        "reason": "failed to parse field [long_number] of type [long] in document with id '2'. Preview of field's value: 'abc'",
        "caused_by": {
            "type": "illegal_argument_exception",
            "reason": "For input string: \"abc\""
        }
    },
    "status": 400
}

ステータス400で、エラーが返ってきました。 error.reason に値をパースできないと書いてあります。long値としてパースできる文字列でなければドキュメントを保存することができなくなるようですね。

パスからmappingを決める

ドキュメントのJSONパスからmappingを決めることもできます。

以下は、 *.middle を除く name.* のパスに一致するフィールドをtext型にmappingする例です。 path_matchpath_unmatch を使ってそれらの条件を指定しています。

PUT demo-index
{
    "mappings": {
        "dynamic_templates": [
            {
                "full_name": {
                    "path_match": "name.*", <--①
                    "path_unmatch": "*.middle", <--②
                    "mapping": {
                        "type": "keyword"
                    }
                }
            }
        ]
    }
}

① 一致する条件をパスで指定します。

② 一致しない条件をパスで指定します。

ドキュメントを保存してmappingの状態を確認してみました。

PUT demo-index/_doc/1
{
    "name": {
        "first": "John",
        "middle": "Winston",
        "last": "Lennon"
    }
}

GET demo-index/_mapping

// レスポンス
{
    "demo-index": {
        "mappings": {
            "dynamic_templates": [
                {
                    "full_name": {
                        "path_match": "name.*",
                        "path_unmatch": "*.middle",
                        "mapping": {
                            "type": "keyword"
                        }
                    }
                }
            ],
            "properties": {
                "name": {
                    "properties": {
                        "first": {
                            "type": "keyword" <--①
                        },
                        "last": {
                            "type": "keyword" <--①
                        },
                        "middle": {
                            "type": "text", <--②
                            "fields": {
                                "keyword": {
                                    "type": "keyword",
                                    "ignore_above": 256
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

① dynamic_templateで指定した、パスに一致しているフィールドのmappingが、keywordになっています。

② 一方、path_unmatch で明示的に除いているパスに一致しているフィールドのmappingは、Elasticsearchのデフォルトのままになっています。

ここで、 path_match に一致するがkeywordとして解釈できない値(例えばJSONオブジェクト)を与えるとどうなるのでしょうか。

PUT demo-index/_doc/2
{
    "name": {
        "first": "John",
        "middle": "Winston",
        "last": "Lennon",
        "other": {
            "nickname": "JWL"
        }
    }
}

// レスポンス
{
    "error": {
        "root_cause": [
            {
                "type": "mapper_parsing_exception",
                "reason": "failed to parse field [name.other] of type [keyword] in document with id '2'. Preview of field's value: '{nickname=JWL}'"
            }
        ],
        "type": "mapper_parsing_exception",
        "reason": "failed to parse field [name.other] of type [keyword] in document with id '2'. Preview of field's value: '{nickname=JWL}'",
        "caused_by": {
            "type": "illegal_state_exception",
            "reason": "Can't get text on a START_OBJECT at 6:18"
        }
    },
    "status": 400
}

エラーが返され保存できませんでした。 error.reason に、 name.other の値をkeyword型にパースできないと書かれています。これはフィールド名でmappingを決めたときにも同じようなエラーがありましたが、パスでmappingを決める時も同様にパースできない値が含まれているとエラーになるようですね。今回のようにワイルドカードを使って一定の範囲のパスに対してmappingを決める場合には注意が必要です。