初めてのElasticsearch with Docker

# はじめに
[Elasticsearchの公式チュートリアル](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/getting-started.html)やってみました。
公式ドキュメント以外にも色々調べながら進めたのですが、「7.0系（type新規作成廃止後）」×「Docker」の記事が少なかったので、備忘も兼ねたまとめです。

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

（[Elasticsearchリファレンス](https://www.elastic.co/guide/jp/elasticsearch/reference/current/getting-started.html)より）

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

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

## 用語とイメージ
<img width="901" alt="スクリーンショット 2019-08-13 8.05.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214279/436dcd6c-a92f-0edd-49b7-4cdfde564896.png">

### 論理構成
点線で囲った部分が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](https://www.elastic.co/jp/blog/moving-from-types-to-typeless-apis-in-elasticsearch-7-0)
※参考 [Removal of mapping types](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/removal-of-types.html)

### 物理構成
実線で囲った部分が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）](https://qiita.com/kurkuru/items/127fa99ef5b2f0288b81)
※参考 [Docker Compose のインストール](http://docs.docker.jp/compose/install.html)

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

```Dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:7.3.0
RUN elasticsearch-plugin install analysis-kuromoji
```
FROMでelasticsearchのベースイメージを取得し、RUNで日本語入力の形態素解析を行うためのプラグイン「kuromoji」のインストールを行うよう、コマンド記載しています。
※詳細を知りたい方はこちら[Dockerfile リファレンス](http://docs.docker.jp/engine/reference/builder.html)を参照ください。

## 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:
```

ざっくり説明します。
`services`で`es01`と`es02`の二つのコンテナ（≒ノード）を起動することを指定しています。
その中の`networks: - esnet`で、esnetを利用して両ノードを接続することを指定しています。（esnetの宣言は一番下の`networks`文で行われています。）
また、es01の`ports: - 9200:9200`で、ローカルホストの9200ポートとes01コンテナの9200ポートを接続しています。これにより、`localhost:9200`へアクセスするとes01コンテナに接続でき、前述のesnetを介してes02にも接続することができる、という仕組みです。
※詳細を知りたい方はこちら[Compose ファイル・リファレンス](http://docs.docker.jp/compose/compose-file.html)を参照ください。

## コンテナの起動
まずDockerイメージのビルドを行います。

```console
$ docker-compose build
```
次にDockerコンテナの起動を行います。

```console
$ docker-compose up -d
```
以下のメッセージが出ればelasticsearchコンテナの起動完了です。

```
Creating es01 ... done
Creating es02 ... done
```

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

```console
$ 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%
```

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

```console
$ 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の作成

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

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

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

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

```console
$ 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
```
先ほど作成した`customer`indexが追加されていることがわかると思います。
ステータスをかいつまんで説明すると、

- health：indexの状態。
    - green：異常なし。
    - yellow：レプリカシャード作成失敗。
    - red：プライマリシャード作成失敗。
- pri：プライマリシャードの数。今回は1つ。
- rep：レプリカシャードの数。今回は1つ。
- docs.count：保存しているドキュメント数。現段階ではドキュメントを登録していないので0。

という感じです。

## documentの作成

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

`customer`indexにname属性のみのシンプルなdocumentを投入したいと思います。
コマンドラインから以下のcurlリクエストを送ります。

```console
$ curl -X PUT "localhost:9200/customer/_doc/1?pretty&pretty" -H 'Content-Type: application/json' -d'
{
  "name": "John Doe"
}
'
```
以下のように返ってきたら成功です。

```json
{
  "_index" : "customer",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 2,
    "successful" : 2,
    "failed" : 0
  },
  "_seq_no" : 0,
  "_primary_term" : 1
}
```
レスポンスから、`customer`indexの2つのシャードに正しくdocumentが投入されたことがわかります。

ちなみに存在しないindexを指定した場合、自動でindexを作成してくれるそうです。チュートリアルとして先に`customer`indexを作成してからdocumentを投入しましたが、いきなり今のコマンドを叩いても問題ないそうです。
また、[document id]を指定しないでリクエストを送るとelasticsearch側でidを自動採番してくれます（「wWhNGawBR0ib7km4-Dke」というようなランダム文字列が割り当てられます）。

## documentの取得

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

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

```console
$ curl -X GET "localhost:9200/customer/_doc/1?pretty&pretty"
```
以下のように返ってきたら成功です。

```json
{
  "_index" : "customer",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 1,
  "_seq_no" : 1,
  "_primary_term" : 1,
  "found" : true,
  "_source" : {
    "name" : "John Doe"
  }
}
```
`found`に取得結果が、`_source`に投入したjsonデータが表示されています。

## documentの更新

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

documentを更新する場合は`doc`で指定します。また、documentの更新時にフィールドを追加することもできます。
例えば`name`を`Jane Doe`に変更し、`age`フィールドの追加を同時に行う場合は以下のリクエストを送ります。

```console
$ curl -X POST "localhost:9200/customer/_update/1?pretty&pretty" -H 'Content-Type: application/json' -d'
{
  "doc": { "name": "Jane Doe", "age": 20 }
}
'
```

## documentの削除

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

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

```console
$ curl -X DELETE "localhost:9200/customer/_doc/1?pretty&pretty"
```
documentの取得を行なっても`"found" : false`が返ってくるはずです。

## batch実行

```console
$ 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になります。）

```console
$ 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の削除
```console
$ curl -X DELETE "localhost:9200/[index name]?pretty&pretty"
```
今回サンプルで作成した`customer`indexを削除する場合には以下をコマンドラインに入力しましょう。

```console
$ curl -X DELETE "localhost:9200/customer?pretty&pretty"
```
indexの状態を取得してみると、一覧から`customer`indexが削除されているはずです。


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

## サンプルindexの作成
「bank」indexを以下の条件で作成します。

- プライマリシャード数：5
- 1つのプライマリシャードに対するレプリカシャード数：1

```console
$ curl -X PUT 'localhost:9200/bank' -H 'Content-Type: application/json' -d'
{
    "settings" : {
        "index" : {
            "number_of_shards" : 5,
            "number_of_replicas" : 1
        }
    }
}
'
```

## サンプルデータの取得
[こちら](https://raw.githubusercontent.com/elastic/elasticsearch/master/docs/src/test/resources/accounts.json)からjsonデータを取得し、任意のディレクトリに`accounts.json`として保存します。
※サンプルデータは公式チュートリアルのものです↓
https://www.elastic.co/guide/en/elasticsearch/reference/7.3/getting-started-explore-data.html

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

```console
$ 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が含まれています。

```console
$ 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が返されます。

```json
"match": 
  { "address": "mill" }
```
また、複数のワードを指定することができます。デフォルトではスペース区切りのor検索になります。
以下の例であれば、`address`フィールドに「mill」もしくは「lane」という単語が含まれているdocumentが返されます。

```json
"match": 
  { "address": "mill lane" }
```

#### match_phrase
指定したフレーズが含まれているdocumentを返します。
matchと違い、複数の単語をひとまとまりとして認識します。
例えば以下の例であれば、`address`フィールドに「mill lane」という文字列が含まれているdocumentが返されます。

```json
"match_phrase": 
  { "address": "mill lane" }
```

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

- gte＝以上（greater than or equal）
- lte＝以下（less than or equal）

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

```json
"range" : 
  { "age" : { "gte" : 20, "lte" : 29 }}
```

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

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

```json
"bool" : 
  { "must" : 
    { "match" : { "address" : "mill" } }
  }
```
複数条件書きたい場合は配列表記にします。

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

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

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

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

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

#### filter
mustと同様、filterに書いた条件も必ず満たされるようになります。
ただしmustと違い、条件とdocumentの適合度算出には使われません。
単純なフィルタリングを行いたい場合はmustよりもfilterの方が良いでしょう。

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

### 実際に検索してみる
以下のリクエストを投げると、

- ageが20以上、30以下

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

- addressにroadが含まれている
- addressにmillが含まれていない
- balanceが30000以上だと望ましい
- genderがFだと望ましい

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

```console
$ 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件のみ表示しています）。
上位の検索結果は指定した条件を全て満たしていることがわかると思います。

```json
{
  "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クラスタ（コンテナ）の停止は以下のコマンドで実行できます。

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

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

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

# おわりに
主にelasticsearchクラスタの起動、CRUD処理、および基本的な検索機能について学習することができました。
今後は以下のトピックについて挑戦したいと思います。

- kuromojiを利用した日本語の全文検索
- 自前のブログアプリに検索機能を実装
- k8sによるnodeの死活監視