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を決める場合には注意が必要です。

Elasticsearchのcomponent templateは左から順に適用される

サマリ

component templateは、index templateに配列で指定されますが、左から順に適用されます。

適用の動作は各フィールドを合成するイメージです。重複するフィールドの場合は値が上書きされ、重複するフィールドが無ければ追加されます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-put-template.html#multiple-component-templates

環境

前回の記事と同様の環境です。

https://baubaubau.hatenablog.com/entry/2022/01/21/230451

また、RESTクライアントとして、vscodeの以下のプラグインを使いました。

https://github.com/hsen-dev/vscode-elastic

二つのcomponent templateを作成する

次の通り、二つのcomponent templateを作成します。

それぞれ、フィールドには重複するものと、しないものをあえて記述します。hostは両方に記述されますが、methodとurlは片方にしか記述がありません。

PUT _component_template/my_component_template_1
{
    "template": {
        "mappings": {
            "properties": {
                "host": {
                    "type": "keyword"
                },
                "method": {
                    "type": "keyword"
                }
            }
        }
    }
}

PUT _component_template/my_component_template_2
{
    "template": {
        "mappings": {
            "properties": {
                "host": {
                    "type": "text"
                },
                "url": {
                    "type": "keyword"
                }
            }
        }
    }
}

これらのcomponent templateを使って、index templateを作成します。

PUT _index_template/my_index_template
{
    "index_patterns": ["access*"],
    "composed_of": ["my_component_template_1", "my_component_template_2"]
}

indexを作成しmappingを確認すると、component templateが指定された順番に適用されていることがわかります。

PUT accesslog-20220101/

GET accesslog-20220101/_mapping

{
    "accesslog-20220101": {
        "mappings": {
            "properties": {
                "host": {
                    "type": "text"
                },
                "method": {
                    "type": "keyword"
                },
                "url": {
                    "type": "keyword"
                }
            }
        }
    }
}

重複して指定されていたhostフィールドは左から順に合成されたため、typeがtextになっています。

それ以外のフィールドは重複していないため、指定したtype通りに設定されています。

Elasticsearchのインデックステンプレートのpriorityで優先度を変えてみる

重複した内容のインデックステンプレートを作成する場合について、少しだけ調べてみました。

サマリ

priorityで設定された値が大きいものが、適用されます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-put-template.html

環境

次のDockerComposeファイルを使って、elasticsearchを起動しました。

version: '2.2'
services:
  es01:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.16.2
    ports:
      - 9200:9200
      - 9300:9300
    environment:
      - discovery.type=single-node
      - action.destructive_requires_name=true
    networks:
      - elastic
  ki01:
    image: docker.elastic.co/kibana/kibana:7.16.2
    environment:
      ELASTICSEARCH_HOSTS: "http://es01:9200"
    ports:
      - 5601:5601
    networks:
      - elastic
networks:
  elastic:
    driver: bridge

インデックステンプレートを2つ作り、priorityの動作を確かめる

1つ目のインデックステンプレートを作ります。priorityの指定がない場合、priotiyは最低値の0と解釈されます。

priorityの値がelasticsearchで自動的に生成されることはありません。

こちらのインデックステンプレートには、hostをtextとしてマッピングします。

PUT /_index_template/my_template_1
{
    "index_patterns": "accesslog-*",
    "template": {
        "mappings": {
            "properties": {
                "host": {
                    "type": "text"
                }
            }
        }
    }
}

2つ目のインデックステンプレートを作ってみます。あえて、priorityを指定せず、同じインデックスパターンで作成をしてみます。

PUT /_index_template/my_template_2
{
    "index_patterns": "accesslog-*",
    "template": {
        "mappings": {
            "properties": {
                "host": {
                    "type": "keyword"
                }
            }
        }
    }
}

すると、同じ条件のインデックスパターンで、同じpriorityのインデックステンプレートは作成できないと言われます。

{
    "error": {
        "root_cause": [
            {
                "type": "illegal_argument_exception",
                "reason": "index template [my_template_2] has index patterns [accesslog-*] matching patterns from existing templates [my_template_1] with patterns (my_template_1 => [accesslog-*]) that have the same priority [0], multiple index templates may not match during index creation, please use a different priority"
            }
        ],
        "type": "illegal_argument_exception",
        "reason": "index template [my_template_2] has index patterns [accesslog-*] matching patterns from existing templates [my_template_1] with patterns (my_template_1 => [accesslog-*]) that have the same priority [0], multiple index templates may not match during index creation, please use a different priority"
    },
    "status": 400
}

と、言うわけで、priorityを高くしてもう一度リクエストを飛ばし、作成します。

こちらのインデックステンプレートには、hostをkeywordとしてマッピングします。

PUT /_index_template/my_template_2
{
    "index_patterns": "accesslog-*",
    "template": {
        "mappings": {
            "properties": {
                "host": {
                    "type": "keyword"
                }
            }
        }
    },
    "priority": 1
}

priorityが大きいインデックステンプレートが使われるか確かめる

インデックスを作成し、どちらのインデックステンプレートが作成されるか確かめます。

PUT /accesslog-20220117

GET /accesslog-20220117/_mapping
{
    "accesslog-20220117": {
        "mappings": {
            "properties": {
                "host": {
                    "type": "keyword"
                }
            }
        }
    }
}

hostがkeywordとなっているので、priorityが大きいmy_template_2が適用されたことがわかりました。