Edited at

初めてのElasticsearch with Docker


はじめに

Elasticsearchの公式チュートリアルやってみました。

公式ドキュメント以外にも色々調べながら進めたのですが、「7.0系(type新規作成廃止後)」×「Docker」の記事が少なかったので、備忘も兼ねたまとめです。


Elasticsearchとは


Elasticsearchは、オープソースの高スケーラブルな全文検索および分析エンジンです。大容量のデータをすばやく、ほぼリアルタイムで保存、検索、分析できます。通常、検索の機能と要件が複雑なアプリケーションを強化する基礎となるエンジン/技術として使用されます。


Elasticsearchリファレンスより)

つまり、めっちゃ検索ができるすごいミドルウェアです。


座学

実際に触る前にお勉強です。


用語とイメージ


論理構成

点線で囲った部分がElasticsearchの外側から見た構成(論理構成)です。

cluster > index > document の順に粒度が細かくなっていきます。


  • cluster

    Elasticsearch全体を指します。1つのclusterの中に複数のindexを含めることができます。


  • index

    同じ種類のdocumentを集約格納するための箱です。RDBにおける「テーブル」が概念としては一番近いですが、index別にスケーリングの設定を組むことができたり、複数のindexを横断して検索することができたり、カラム定義が不要だったり等、「テーブル」よりも柔軟なことができるイメージです。


  • document

    indexに格納するデータです。json型で格納します。RDBにおけるレコードと同様の概念です。キー情報のことを特に「fields」と呼んだりします。イメージ図では全てのdocumentで同一のfieldsを使用していますが、統一する必要はありません(が、全く意味の異なるデータを同じindexに格納しても検索性能が落ちるだけなので、同じ性質のdocumentを同一indexに格納するようにしましょう)。


実は6.0系まではこのindexとdocumentの間に「type」という粒度の箱を作ることができたのですが、7.0系からtypeの新規作成ができなくなりました(8.0系でtypeに関するAPIが全廃されるそうです)。古い記事だとtypeについて記載されているものが多いですが、基本typeでできることはindexでもできるので、type=indexとして読み進めれば問題ないと思います。

※参考 タイプレスAPIに移行する:Elasticsearch 7.0の変更点 | Elastic Blog

※参考 Removal of mapping types


物理構成

実線で囲った部分がelasticsearchの内部的な構成(物理構成)です。

documentは表向きはindexに格納されていますが、実体としてはnode内のshardに格納されます。


  • node

    サーバです。Elasticsearchは複数node構成を取っており、このnodeを増減させることで容易にスケールさせることができます。1つのnodeに複数のshardを格納することができます。



  • shard

    node内でdocumentを格納するための箱です。indexごとにprimary shardをいくつ作成するか、1つのprimary shardに対し、いくつのreplica shardを用意するか、を設定することができます。負荷分散のため、documentは複数ある中のどれか1組のprimary/replica shardに格納されます。


    • primary shard : 書き込み可能なshardです。ここで更新されたデータは対応するreplica shardにコピーされます。primary shardがダウンしているとステータスがredになります(正しい検索結果が返されません)。

    • replica shard : 読み取りのみ可能なshardです。primary shardおよび他のreplica shardとは別のnodeに配置される必要があります。node数が足りなくて指定した数のreplica shardを作れないとステータスがyellowになります(primary shardは生きているので、性能は落ちますが機能としては問題なく動きます)。




elasticsearchクラスタの起動

今回はDockerを使ってelasticsearchクラスタを起動したいと思います。

Dockerおよびdocker-composeを使ったことのない方は事前にインストールを済ませておきましょう。

※参考 DockerをMacにインストールする(更新: 2019/7/13)

※参考 Docker Compose のインストール


Dockerfileの作成

任意のディレクトリにDockerfileを作成し、以下のように記載します。

FROM docker.elastic.co/elasticsearch/elasticsearch:7.3.0

RUN elasticsearch-plugin install analysis-kuromoji

FROMでelasticsearchのベースイメージを取得し、RUNで日本語入力の形態素解析を行うためのプラグイン「kuromoji」のインストールを行うよう、コマンド記載しています。

※詳細を知りたい方はこちらDockerfile リファレンスを参照ください。


Composeファイルの作成

Dockerfileと同じディレクトリにdocker-compose.ymlを作成し、以下のように記載します。


docker-compose.yml

version: '3'

services:
es01:
build: .
container_name: es01
environment:
- node.name=es01
- discovery.seed_hosts=es02
- cluster.initial_master_nodes=es01,es02
- cluster.name=docker-cluster
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- esdata01:/usr/share/elasticsearch/data
ports:
- 9200:9200
networks:
- esnet
es02:
build: .
container_name: es02
environment:
- node.name=es02
- discovery.seed_hosts=es01
- cluster.initial_master_nodes=es01,es02
- cluster.name=docker-cluster
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- esdata02:/usr/share/elasticsearch/data
networks:
- esnet

volumes:
esdata01:
driver: local
esdata02:
driver: local

networks:
esnet:


ざっくり説明します。

serviceses01es02の二つのコンテナ(≒ノード)を起動することを指定しています。

その中のnetworks: - esnetで、esnetを利用して両ノードを接続することを指定しています。(esnetの宣言は一番下のnetworks文で行われています。)

また、es01のports: - 9200:9200で、ローカルホストの9200ポートとes01コンテナの9200ポートを接続しています。これにより、localhost:9200へアクセスするとes01コンテナに接続でき、前述のesnetを介してes02にも接続することができる、という仕組みです。

※詳細を知りたい方はこちらCompose ファイル・リファレンスを参照ください。


コンテナの起動

まずDockerイメージのビルドを行います。

$ docker-compose build

次にDockerコンテナの起動を行います。

$ docker-compose up -d

以下のメッセージが出ればelasticsearchコンテナの起動完了です。

Creating es01 ... done

Creating es02 ... done


クラスタのヘルスチェック

以下のリクエストを投げることでクラスタのステータスを取得することができます。

$ curl -X GET "localhost:9200/_cat/health?v&pretty"

以下のように返ってきたら成功です。

epoch      timestamp cluster        status node.total node.data shards pri relo init unassign pending_tasks max_task_wait_time active_shards_percent

1565413858 05:10:58 docker-cluster green 2 2 0 0 0 0 0 0 - 100.0%

また、以下のリクエストを投げると各ノードのステータスを取得することができます。

$ curl -X GET "localhost:9200/_cat/nodes?v&pretty"

以下のように返ってきたら成功です。

ip         heap.percent ram.percent cpu load_1m load_5m load_15m node.role master name

172.19.0.2 41 96 4 0.05 0.14 0.15 dim * es02
172.19.0.3 39 96 4 0.05 0.14 0.15 dim - es01


CRUD処理


indexの作成

$ curl -X PUT "localhost:9200/[index name]?pretty&pretty"

例えば顧客情報を管理するcustomerindexを作成する場合は以下のようになります。

$ curl -X PUT "localhost:9200/customer?pretty&pretty"

indexのステータスは以下のコマンドで確認することができます。

$ curl -X GET "localhost:9200/_cat/indices?v&pretty"

レスポンスがこちらです。

health status index    uuid                   pri rep docs.count docs.deleted store.size pri.store.size

green open customer nCGMonD_QKaMYhP7jUQd1g 1 1 0 0 460b 230b

先ほど作成したcustomerindexが追加されていることがわかると思います。

ステータスをかいつまんで説明すると、


  • health:indexの状態。


    • green:異常なし。

    • yellow:レプリカシャード作成失敗。

    • red:プライマリシャード作成失敗。



  • pri:プライマリシャードの数。今回は1つ。

  • rep:レプリカシャードの数。今回は1つ。

  • docs.count:保存しているドキュメント数。現段階ではドキュメントを登録していないので0。

という感じです。


documentの作成

$ curl -X PUT "localhost:9200/[index name]/_doc/[document id]?pretty&pretty" -H 'Content-Type: application/json' -d [json data]

customerindexにname属性のみのシンプルなdocumentを投入したいと思います。

コマンドラインから以下のcurlリクエストを送ります。

$ curl -X PUT "localhost:9200/customer/_doc/1?pretty&pretty" -H 'Content-Type: application/json' -d'

{
"name": "John Doe"
}
'

以下のように返ってきたら成功です。

{

"_index" : "customer",
"_type" : "_doc",
"_id" : "1",
"_version" : 1,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 2,
"failed" : 0
},
"_seq_no" : 0,
"_primary_term" : 1
}

レスポンスから、customerindexの2つのシャードに正しくdocumentが投入されたことがわかります。

ちなみに存在しないindexを指定した場合、自動でindexを作成してくれるそうです。チュートリアルとして先にcustomerindexを作成してからdocumentを投入しましたが、いきなり今のコマンドを叩いても問題ないそうです。

また、[document id]を指定しないでリクエストを送るとelasticsearch側でidを自動採番してくれます(「wWhNGawBR0ib7km4-Dke」というようなランダム文字列が割り当てられます)。


documentの取得

$ curl -X GET "localhost:9200/[index name]/_doc/[document id]?pretty&pretty"

先ほど投入したdocumentを取得するには以下のリクエストを送ります。

$ curl -X GET "localhost:9200/customer/_doc/1?pretty&pretty"

以下のように返ってきたら成功です。

{

"_index" : "customer",
"_type" : "_doc",
"_id" : "1",
"_version" : 1,
"_seq_no" : 1,
"_primary_term" : 1,
"found" : true,
"_source" : {
"name" : "John Doe"
}
}

foundに取得結果が、_sourceに投入したjsonデータが表示されています。


documentの更新

$ curl -X POST "localhost:9200/[index name]/_update/[document id]?pretty&pretty" -H 'Content-Type: application/json' -d [json data]

documentを更新する場合はdocで指定します。また、documentの更新時にフィールドを追加することもできます。

例えばnameJane Doeに変更し、ageフィールドの追加を同時に行う場合は以下のリクエストを送ります。

$ curl -X POST "localhost:9200/customer/_update/1?pretty&pretty" -H 'Content-Type: application/json' -d'

{
"doc": { "name": "Jane Doe", "age": 20 }
}
'


documentの削除

$ curl -X DELETE "localhost:9200/[index name]/_doc/[document id]?pretty&pretty"

先ほど投入したdocumentを削除する場合には以下のリクエストを送ります。

$ curl -X DELETE "localhost:9200/customer/_doc/1?pretty&pretty"

documentの取得を行なっても"found" : falseが返ってくるはずです。


batch実行

$ curl -X POST "localhost:9200/[index name]/_bulk?pretty&pretty" -H 'Content-Type: application/json' -d [commands]

_bulkで複数の処理を同期実行(batch実行)することができます。

以下のリクエストでは、

1. id:1を指定

2. {"name": "John Doe" }というデータのdocumentを投入

3. id:2を指定

4. {"name": "Jane Doe" }というデータのdocumentを投入

という処理を順に行なっています。

(指定したidが存在しない場合はcreate、存在する場合にはupdateになります。)

$ curl -X POST "localhost:9200/customer/_bulk?pretty&pretty" -H 'Content-Type: application/json' -d'

{"index":{"_id":"1"}}
{"name": "John Doe" }
{"index":{"_id":"1"}}
{"name": "Jane Doe" }
'


indexの削除

$ curl -X DELETE "localhost:9200/[index name]?pretty&pretty"

今回サンプルで作成したcustomerindexを削除する場合には以下をコマンドラインに入力しましょう。

$ curl -X DELETE "localhost:9200/customer?pretty&pretty"

indexの状態を取得してみると、一覧からcustomerindexが削除されているはずです。


検索機能を使う

公式チュートリアルのサンプルデータを利用して、検索機能を使ってみたいと思います。


サンプルindexの作成

「bank」indexを以下の条件で作成します。


  • プライマリシャード数:5

  • 1つのプライマリシャードに対するレプリカシャード数:1

$ curl -X PUT 'localhost:9200/bank' -H 'Content-Type: application/json' -d'

{
"settings" : {
"index" : {
"number_of_shards" : 5,
"number_of_replicas" : 1
}
}
}
'


サンプルデータの取得

こちらからjsonデータを取得し、任意のディレクトリにaccounts.jsonとして保存します。

※サンプルデータは公式チュートリアルのものです↓

https://www.elastic.co/guide/en/elasticsearch/reference/7.3/getting-started-explore-data.html


サンプルデータの投入

accounts.jsonを保存したディレクトリで以下のコマンドを実行し、データを「bank」indexに投入します。

$ curl -H "Content-Type: application/json" -X POST "localhost:9200/bank/_bulk?pretty&refresh" --data-binary "@accounts.json"


検索

検索の基本構文は以下の通りです。


  • query:検索の条件(≒where句)

  • _source:取得するフィールド名(≒select句)

  • from:取得開始位置(≒offset句) ※0-indexedです

  • size:取得件数(≒limit句)

  • sort:ソート条件(≒order by句)

例えば以下のリクエストを投げると、balanceで降順ソートされたドキュメントの11〜20件目が返されます。レスポンスの内容にはaccount_numberとbalanceが含まれています。

$ curl -X GET "localhost:9200/bank/_search?pretty" -H 'Content-Type: application/json' -d'

{
"query": { "match_all": {} },
"_source": ["account_number", "balance"],
"from": 10,
"size": 10,
"sort": { "balance": { "order": "desc" } }
}
'


詳細な条件指定

queryの中身で詳細な検索条件を指定することができます。


検索クエリ


match

指定したワードが含まれているdocumentを返します。

例えば以下の例であれば、addressフィールドに「mill」という単語が含まれているdocumentが返されます。

"match": 

{ "address": "mill" }

また、複数のワードを指定することができます。デフォルトではスペース区切りのor検索になります。

以下の例であれば、addressフィールドに「mill」もしくは「lane」という単語が含まれているdocumentが返されます。

"match": 

{ "address": "mill lane" }


match_phrase

指定したフレーズが含まれているdocumentを返します。

matchと違い、複数の単語をひとまとまりとして認識します。

例えば以下の例であれば、addressフィールドに「mill lane」という文字列が含まれているdocumentが返されます。

"match_phrase": 

{ "address": "mill lane" }


range

範囲指定をすることができます。


  • gte=以上(greater than or equal)

  • lte=以下(less than or equal)

を表しています。

以下の例であればageフィールドが20以上、29以下のものを返します。

"range" : 

{ "age" : { "gte" : 20, "lte" : 29 }}


複数条件の組み合わせ(bool句)

複数の条件を組み合わせて条件に適合するdocumentを検索する場合にはbool句を使用します。なお、説明に出てくる「条件とdocumentの適合度」は検索結果の_scoreで確認できる数値です。(※恐らく条件に一致する単語数をカウントして重み付け加算しているんだと思いますが、厳密な算出ロジックは分からなかったので、詳しい方どなたか教えてください。)


must

mustの中に書いた条件は必ず満たされるようになります。また、条件とdocumentの適合度が高ければ高いほど検索結果の上位に表示されます。

"bool" : 

{ "must" :
{ "match" : { "address" : "mill" } }
}

複数条件書きたい場合は配列表記にします。

"bool" : 

{ "must" : [
{ "match" : { "address" : "mill" } },
{ "range" : { "age" : { "gte" : 21, "lte" : 30 }}}
]}


should

shouldの中に書いた条件はmustと違いプラスアルファ要素として扱われます。条件とdocumentの適合度が高ければ高いほど検索結果の上位に表示されます。

"bool" : 

{ "should" : [
{ "match" : { "address" : "mill" } },
{ "range" : { "age" : { "gte" : 21, "lte" : 30 }}}
]}


must_not

must_notの中に書いた条件を満たすdocumentは必ず出力されなくなります。

"bool" : 

{ "must_not" : [
{ "match" : { "address" : "mill" } },
{ "range" : { "age" : { "gte" : 21, "lte" : 30 }}}
]}


filter

mustと同様、filterに書いた条件も必ず満たされるようになります。

ただしmustと違い、条件とdocumentの適合度算出には使われません。

単純なフィルタリングを行いたい場合はmustよりもfilterの方が良いでしょう。

"bool" : 

{ "filter" : [
{ "match" : { "address" : "mill" } },
{ "range" : { "age" : { "gte" : 21, "lte" : 30 }}}
]}


実際に検索してみる

以下のリクエストを投げると、


  • ageが20以上、30以下

のフィルターを掛けた後、


  • addressにroadが含まれている

  • addressにmillが含まれていない

  • balanceが30000以上だと望ましい

  • genderがFだと望ましい

という検索条件に適合するdocumentを検索しています。

$ curl -X GET "localhost:9200/bank/_search?pretty" -H 'Content-Type: application/json' -d'

{
"query": {
"bool" : {
"must" : {
"match" : { "address" : "road" }
},
"filter": {
"range" : { "age" : { "gte" : 20, "lte" : 30 } }
},
"must_not" : {
"match" : { "address" : "mill" }
},
"should" : [
{ "range" : { "balance" : { "gte" : 30000 } } },
{ "match" : { "gender" : "F" } }
]
}
}
}
'

検索結果がこちらです(スペースの都合上4件のみ表示しています)。

上位の検索結果は指定した条件を全て満たしていることがわかると思います。

{

"took" : 74,
"timed_out" : false,
"_shards" : {
"total" : 5,
"successful" : 5,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 17,
"relation" : "eq"
},
"max_score" : 5.5885725,
"hits" : [
{
"_index" : "bank",
"_type" : "_doc",
"_id" : "951",
"_score" : 5.5885725,
"_source" : {
"account_number" : 951,
"balance" : 36337,
"firstname" : "Tran",
"lastname" : "Burris",
"age" : 25,
"gender" : "F",
"address" : "561 Rutland Road",
"employer" : "Geoform",
"email" : "tranburris@geoform.com",
"city" : "Longbranch",
"state" : "IL"
}
},
{
"_index" : "bank",
"_type" : "_doc",
"_id" : "376",
"_score" : 5.2215624,
"_source" : {
"account_number" : 376,
"balance" : 44407,
"firstname" : "Mcmillan",
"lastname" : "Dunn",
"age" : 21,
"gender" : "F",
"address" : "771 Dorchester Road",
"employer" : "Eargo",
"email" : "mcmillandunn@eargo.com",
"city" : "Yogaville",
"state" : "RI"
}
},
{
"_index" : "bank",
"_type" : "_doc",
"_id" : "869",
"_score" : 5.0654063,
"_source" : {
"account_number" : 869,
"balance" : 43544,
"firstname" : "Corinne",
"lastname" : "Robbins",
"age" : 25,
"gender" : "F",
"address" : "732 Quentin Road",
"employer" : "Orbaxter",
"email" : "corinnerobbins@orbaxter.com",
"city" : "Roy",
"state" : "TN"
}
},
{
"_index" : "bank",
"_type" : "_doc",
"_id" : "510",
"_score" : 4.9729834,
"_source" : {
"account_number" : 510,
"balance" : 48504,
"firstname" : "Petty",
"lastname" : "Sykes",
"age" : 28,
"gender" : "M",
"address" : "566 Village Road",
"employer" : "Nebulean",
"email" : "pettysykes@nebulean.com",
"city" : "Wedgewood",
"state" : "MO"
}
},

他にも様々な条件で試してみてください。


elasticsearchクラスタの停止

elasticsearchクラスタ(コンテナ)の停止は以下のコマンドで実行できます。

$ docker-compose down

再度クラスタを起動させる場合にはdocker-compose up -dを入力しましょう。

また、Dockerイメージの削除まで同時に行いたい場合は以下のコマンドで実行できます。

$ docker-compose down --rmi all

この場合、再度クラスタを起動させる場合にはdocker-compose buildを行なってからdocker-compose up -dを入力しましょう。


おわりに

主にelasticsearchクラスタの起動、CRUD処理、および基本的な検索機能について学習することができました。

今後は以下のトピックについて挑戦したいと思います。


  • kuromojiを利用した日本語の全文検索

  • 自前のブログアプリに検索機能を実装

  • k8sによるnodeの死活監視