Elasticsearch + Cloud Run で検索 API を作る

# はじめに

![elastic-search-logo-color-horizontal.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265024/c0683074-fe20-4a0d-ce57-109c6f8618ed.png)


この記事は ZOZO Advent Calendar 2021 24日目の記事です。

この記事では全文検索エンジンの一つである [Elasticsearch](https://www.elastic.co/jp/elasticsearch/) を [Cloud Run](https://cloud.google.com/run) にデプロイして検索APIを作ります。
メルカリさんのテックブログを見たことがある方にはわかるかと思いますが、以下の記事に大きく影響を受けています。

https://engineering.mercari.com/blog/entry/20210906-46976c788c/


上記記事では [Apache Solr](https://solr.apache.org) を使っています。Solr も Elasticsearch も広く利用されている検索エンジンであり、どちらが優れているということもありませんが、両者の違いが気になる方は以下の記事などが参考になるかと思います。

https://logz.io/blog/solr-vs-elasticsearch/

https://sematext.com/blog/solr-vs-elasticsearch-differences/

以降、Elasticsearch を "ES" と省略して表記しています。


# システム構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265024/c6260bd5-5a9d-f080-8b66-c17599434479.png)



 - インデックスを作成する `search-indexer` は Cloud Run で稼働
 - 生成されたインデックスファイルは GCS に保管
 - ユーザーからのリクエストを受ける `search-api` も Cloud Run で稼働
 - 実際に検索を行う `es-instance` も Cloud Run で稼働

完全に Cloud Run 頼みですね。
ポイントはユーザーのリクエストを受ける `search-api` は最小インスタンス数（`min_instances`）を0にして、検索を行う `es-instance` の最小インスタンス数は1としている部分です。

Cloud Run ではインスタンスに対するトラフィックがない場合に起動させておくインスタンス数を決めておくことができ、それを `min_instances` というパラメータで制御します。

https://cloud.google.com/run/docs/configuring/min-instances

料金を極力安く抑えるのであれば全て `min_instances=0` と設定すれば良いのですが、 ES には登録されたインデックスを保持しておく必要があり、インスタンスが停止すると登録されたインデックスも初期化されてしまうため、 ES のインスタンスは常時1つのインスタンスが起動し続けるような設定としています。

この記事では上記システム構成のうち、 `es-instance` を Cloud Run にデプロイするところまでを取り上げます。


# Elasticsearch をデプロイする
Cloud Run にデプロイする前にまずはローカル環境で動くものを作ります。
手っ取り早くローカルで動かすなら以下から ES のバイナリをダウンロードして実行する方法が良いと思います。

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

```bash:ESを起動
$ ./bin/elasticsearch
```


が、今回は最終的に Cloud Run にデプロイするため Docker で動かします。

## ローカルでの起動
### イメージのビルド
Dockerfile を作成します。日本語を使うためにプラグインを追加しています。

```Dockerfile:Dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:7.15.1
RUN elasticsearch-plugin install analysis-kuromoji
RUN elasticsearch-plugin install analysis-icu
```

このイメージをビルドします。

```bash
$ docker build -t es-run .
```

### 動作確認
Elasticsearch を立ち上げ、ヘルスチェックをしてみます。

```bash
$ docker run -it --rm -p 9200:9200 es-run
```

```bash
# ヘルスチェック
$ curl 'http://localhost:9200/'
{
  "name" : "es01",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "wGcmJOWPQlGjYr-7xpK2lw",
  "version" : {
    "number" : "7.15.1",
    "build_flavor" : "default",
    "build_type" : "docker",
    "build_hash" : "83c34f456ae29d60e94d886e455e6a3409bba9ed",
    "build_date" : "2021-10-07T21:56:19.031608185Z",
    "build_snapshot" : false,
    "lucene_version" : "8.9.0",
    "minimum_wire_compatibility_version" : "6.8.0",
    "minimum_index_compatibility_version" : "6.0.0-beta1"
  },
  "tagline" : "You Know, for Search"
}
```

## Cloud Run へのデプロイ
### 注意事項
あとは Cloud Run へデプロイするだけですが、デプロイするにあたっていくつか注意点があります。
まずポイントとなるのは、**クラスタをシングルノードで立ち上げること**です。
docker-compose を使うと kibana や他のノードへの接続が容易にできますが、Cloud Run で起動できるのは一つのコンテナなのでシングルノードで立ち上げることになります。

シングルノードで立ち上げた場合、ノードが落ちると即サービス停止となるため可用性は低く、本番運用に向いていません。
今回紹介する Elasticsearch + Cloud Run の構成はそうした制約があることを念頭におく必要があります。

https://www.elastic.co/guide/en/elasticsearch/reference/7.16/bootstrap-checks.html


### 各種 ES の設定
シングルノードとなるように ES の設定を行います。自分はここの設定で一番詰まりましたが、以下を見ると結局シンプルな設定で十分ということがわかるかと思います。

#### ディレクトリ 構成
```
tree .
.
├── Dockerfile
├── cloudbuild.yml
├── config
│   ├── elasticsearch.yml
│   └── jvm.options
└── security
    └── limits.conf
```

```config/elasticsearch.yaml
node.name: es01
bootstrap.memory_lock: true
network.host: 0.0.0.0
discovery.type: single-node
```

```config/jvm.options
-Xms512m
-Xmx512m
```

```security/limits.conf
elasticsearch soft nofile 65536
elasticsearch hard nofile 65536
elasticsearch memlock unlimited
```

### イメージのビルド・デプロイ
先ほどローカルでビルドしたイメージをそのままデプロイしても良いですが、ここではビルドからデプロイまで全てを Cloud Build で行います。

ポイントは

- **ポート番号を 9200 に指定すること**
- **`min-instances`を1にすること**
- **memory に余裕を持たせておくこと**

といったところでしょうか。ちなみに `_GITHUB_SHA` という環境変数がいるのはこの cloudbuild.yaml を CI/CD で利用できるようにしたことの名残です。

```cloudbuild.yaml
steps:
- name: 'gcr.io/cloud-builders/docker'
  id: Build:Image
  args: ['build', '-t', '$_DOCKER_URI:$_GITHUB_SHA', '.']
- name: 'gcr.io/cloud-builders/docker'
  id: Ship:Push
  args: ['push', '$_DOCKER_URI:$_GITHUB_SHA']
- name: 'gcr.io/cloud-builders/gcloud'
  id: Ship:Deploy
  args: ['run', 'deploy', 'es-run',
         '--image', '$_DOCKER_URI:$_GITHUB_SHA',
         '--region', 'asia-northeast1',
         '--platform', 'managed',
         '--min-instances', '1',
         '--cpu', '4',
         '--memory', '4Gi',
         '--port', '9200',
         '--no-allow-unauthenticated'
  ]
timeout: 3600s
images:
- $_DOCKER_URI:$_GITHUB_SHA
options:
 machineType: 'E2_HIGHCPU_8'
```

Cloud Build で Cloud Run にデプロイするために以下の権限をサービスアカウントまたは自分自身のユーザーアカウントに付与します。

 - `roles/run.developer`
 - `roles/cloudbuild.builds.editor`

一番手っ取り早いのは `roles/editor` を付与してしまうことですね。

続いて Cloud Build を実行します。

```bash
$ export PROJECT_ID=<your-project>
$ gcloud builds submit --substitutions=_DOCKER_URI=gcr.io/${PROJECT_ID}/es-run,_GITHUB_SHA=latest
```

うまくデプロイされると以下のようなログが出力されます。

```bash
ID: 5f04ad8a-7724-4a38-a579-78d2ec43a48f
CREATE_TIME: 2021-12-22T03:19:28+00:00
DURATION: 2M26S
SOURCE: gs://<PROJECT_ID>_cloudbuild/source/1640315967.398871-209c689985db46cc93b2c6c8c030cffa.tgz
IMAGES: gcr.io/<PROJECT_ID>/es-run (+1 more)
STATUS: SUCCESS

```

### 動作確認
Cloud Run のエンドポイントを叩くため、今回はヘッダーに Bearer トークンを付与します。
この Bearer トークンはユーザー自身の権限に紐づくトークンなので、予め `roles/run.invoker` の権限を付与しておきましょう。

```bash
$ export RUN_URL=<Cloud Run のエンドポイント>
$ curl -s \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $(gcloud auth print-identity-token)" \
${RUN_URL}
{
  "name" : "es01",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "I1xcMEiRQECoQ9iMeqEfYQ",
  "version" : {
    "number" : "7.15.1",
    "build_flavor" : "default",
    "build_type" : "docker",
    "build_hash" : "83c34f456ae29d60e94d886e455e6a3409bba9ed",
    "build_date" : "2021-10-07T21:56:19.031608185Z",
    "build_snapshot" : false,
    "lucene_version" : "8.9.0",
    "minimum_wire_compatibility_version" : "6.8.0",
    "minimum_index_compatibility_version" : "6.0.0-beta1"
  },
  "tagline" : "You Know, for Search"
}
```

ここまで来れば、あとはインデックス用のファイルを作って登録して検索APIとして遊ぶことができます。

注意事項として、上記の手順で作成された Cloud Run のサービスは `min_instances=1` で作成しているのでインスタンスの消し忘れに注意が必要です。
デフォルトでは`min_instances=0`となっているためトラフィックが来ないと自動的にコンテナが停止しますが、上記の設定ではコンテナは停止せず課金され続けます。
GCP に限らず、クラウドサービスで遊んだ後のお片付けは忘れないようにしましょう。

```bash
# min_instances を更新する場合
$ gcloud run services update es-run --min-instances=0

# Cloud Run のサービスを削除する場合
$ gcloud run services delete es-run
```


# まとめ
本記事では Elasticsearch と Cloud Run を組み合わせて簡易検索 API を作成しました。
ただAPIとは名ばかりで、 ES を Cloud Run にデプロイしただけなのでインターフェースとなる API も必要となります。
そのインターフェースにあたるものが記事冒頭のシステム構成に記載した `search-api` ですが、時間の都合上省略してしまったのでまた別な記事でご紹介できればと思います。

明日の記事はいよいよアドベントカレンダー最終日、担当は ZOZO のメシア、我らがそのっつさんです。