GKEで検索サーバ(Solr)を運用する話

GCP上で検索エンジンを動かしたくなることは割とある思いますが、小規模な検索サーバを極力メンテフリーでGCP上で動すための方法を模索・稼働することになったのでメモ代わりに。

# はじめに

検索が必要とされるプロジェクトであっても、検索対象となるコンテンツは割と小規模(10万程度)で、検索インデックスの更新頻度も日次で十分という、ライトな条件であることも案外多いのではないかと思います。そこで以下条件を踏まえてGCP上で検索サーバを動かす方法をいろいろ考えた結果、GKEで検索サーバを立てて動かすことにしました。

* 検索条件
  * 検索対象のコンテンツはDatastore(やBigQuery)に入っている
  * いろいろなフィールドに対して込み入った論理式で条件絞り込みを行いたい(位置条件含む)
  * 表記揺れ・記入ミスが多少あってもうまく文書を引っ張りたい
  * 用途に応じてランキングをカスタマイズしたい
* 検索インデックス条件
  * 検索インデックスの容量が少ない(数百MB程度)
  * 検索インデックスの更新頻度がそれほど多く無い(1日程度)

ElasticsearchやSolrなどの検索エンジンを用いれば複雑な論理式での絞り込みもお手のものですしbigramなどでインデックスを張れば多少クエリが間違っても文書を引っ張ってこれます。今回はSolrを検索サーバとして利用しましたが同様のことはElasticsearchでも実現できると思います。

具体的な上記条件のユースケースとしては、ちょっとした社内情報の全文検索システムや、チャットボットの質問問い合わせシステム、レコメンドシステムとして利用したりしました。
レコメンドエンジンと検索エンジンはぱっとは結びつきにくいかもしれないですが、推薦候補が大量にある場合に第1ステップで対象を絞り込むときに重宝します。
スマホで近くのレストランを探すときを例にとると、ユーザが指定したキーワードに店舗説明やユーザコメントが合致するお店で、現在位置から一定範囲以内で、かつ今の曜日の現在時刻から一定時間の間で営業しているお店を、ここからの近さとレビューポイント平均の指定した重み付けした値の和で候補を絞り込むなどの使い方をしたりします。(絞り込まれた結果に対してそのあとにより高度なランキングアルゴリズムを使ってスコアをつけたりもします)

# 構成

検索サーバのインデックス更新も含めた運用を極力自動化することを念頭にContainer Builder(以降GCB)を中心とした以下のような構成を作りました。
検索サーバに検索インデックスごとイメージとして固めてGKEに乗せてしまえば実行時のスケールや運用は楽になるという算段です。その分面倒になるDatastoreにあるデータのイメージへの同期はGCBでカバー・自動化することができました。

<img width="639" alt="スクリーンショット 2017-12-16 8.33.13.png" src="https://qiita-image-store.s3.amazonaws.com/0/71651/bfe98940-f57e-2df7-ec43-34b80053bdab.png">

全文検索やレコメンド機能はGKEでAPIとして公開し、アプリサーバのAppEngineからAPIを通じて利用しています。
レコメンド用途の場合はGKEの中で検索サーバと検索結果からレコメンド結果としてアイテムを絞り込むランキングサーバのサービスをそれぞれ立てて、ランキングサービスから検索サービスを呼び出しています。レコメンド方式が複数ある場合はそれに応じて複数サービスを立てて検索サービスを共通に利用しています。
複数のレコメンド機能追加もGKEでマイクロサービスとして利用することにより柔軟に取り込むことができます。

<img width="478" alt="スクリーンショット 2017-12-17 23.55.04.png" src="https://qiita-image-store.s3.amazonaws.com/0/71651/0ac2bd8a-c679-c59a-4960-8c7dde07c457.png">


# Cloud Container Builder (GCB)とは

GCBはDockerイメージをサーバレスに構築することができるサービスで、以下のようにビルド対象のソースファイルの場所(source)と、ビルド手順(steps)と、正常にビルドが完了したかの確認のためのイメージ登録先(images)を定義してAPIに指示を送ればクラウド上でビルドを実行することができます。[公式ドキュメント](https://cloud.google.com/container-builder/docs/how-to/writing-build-requests?hl=ja)の他、[yananaさんの記事](https://qiita.com/yanana/items/5492d880827aa880064c)を参考にさせてもらいました。
コマンドラインで起動する場合はyaml形式で記載しますが今回はREST APIでの入力形式のjsonで記載しています。

```json:cloudbuild.json
{
  "source": {
    "storageSource": {
      "bucket": "mybucket",
      "object": "source.tar.gz"
    }
  },
  "steps": [
    {
      "name": "gcr.io/cloud-builders/xxx",
      "args": ["args"],
      "id": "name of step"
    }, 
    ....
  ],
  "images": [
    "gcr.io/$PROJECT_ID/target"
  ]
}
```


GCBを外部から呼ぶためのREST APIでは以下のようにビルドをキックすることができます。
以下のような処理をAppEngineのcronで日次で呼び出すことになります。

```python:build.py
import json
from oauth2client.client import GoogleCredentials
from apiclient.discovery import build

credentials = GoogleCredentials.get_application_default()
service = build("cloudbuild", "v1", credentials=credentials)

with open('cloudbuild.json') as f: #先のjsonファイル
    config = json.load(f)
    builds = service.projects().builds()
    request = builds.create(projectId="PROJECT_ID", body=config)
    response = request.execute()
    print(response)
```

単純にイメージをビルドするだけでなく、mvnコマンドやgcloud,gsutilコマンドなどを使うこともでき、かなりいろいろなことができます。
今回の用途ではDatastoreからのインデックス生成のためのDataflowバッチジョブや、イメージ作成した後のGKEのサービスの更新までもGCBから実行しています。

# GCB処理ステップ

以下、GCSにDataflow,Dockerfile含むKubernetesのソースファイル一式をzipで固めて置いてsourceに指定したものとした、日次で実行される検索インデックス更新のGCBのビルドステップになります。
下記ビルドにより、Datastoreの検索対象Kindの全データを読込・インデックスとして加工し、SolrのDockerイメージにインデックス登録し、GKEへのデプロイまでを実行することができます。
`$PROJECT_ID` と `$BUILD_ID` はビルドインの環境変数で実行時のプロジェクトIDとGCBで払いだされるビルドIDを参照できます。
(実際にはバックアップ処理なども書きますが以下説明のため最小の記載となっています)

```json:cloudbuild.json
  "steps": [
        {
      "name": "gcr.io/cloud-builders/mvn",
      "args": ["compile", "-f", "dataflow/pom.xml", "exec:java",
        "-Dexec.mainClass=jp.co.topgate.pipeline.IndexerPipeline",
        "-Dexec.args='--project=$PROJECT_ID' '--tempLocation=gs://$PROJECT_ID/dataflow/tmp' '--runner=DataflowRunner'"],
      "id": "buildIndexJson"
    }, {
      "name": "gcr.io/cloud-builders/gsutil",
      "args": ["cp", "gs://$PROJECT_ID/solr/index/index.json", "kubernetes/solr/docker/solr/index/index.json"],
      "id": "donwloadIndex"
    }, {
      "name": "gcr.io/cloud-builders/docker",
      "args": ["build", "--tag=gcr.io/$PROJECT_ID/solr:$BUILD_ID", "--build-arg", "PROJECT_ID=$PROJECT_ID", "kubernetes/solr/docker/solr"],
      "id": "buildSolrImage"
    }, {
      "name": "gcr.io/cloud-builders/gcloud",
      "args": ["container", "clusters", "get-credentials", "recommend-cluster", "--zone", "us-central1-c", "--project", "$PROJECT_ID"],
      "id": "setKubernetesCluster"
    }, {
      "name": "gcr.io/cloud-builders/kubectl",
      "args": ["replace", "-f", "kubernetes/solr/deployment.yaml"],
      "id": "deploySolrImage"
    }
  ],
```

以下、上記ビルドステップの処理内容です。

1. mvnコマンドでDataflowジョブを起動する
  * Dataflowの処理内容は割愛しますが、Datastoreに入っているデータを検索インデックスファイルに変換して`gs://$PROJECT_ID/solr/index/index.json`に置くDataflowバッチジョブを起動します。
  * インデックスファイルができるまで処理をブロックするためいDataflowのパイプライン実行では `pipeline.run().waitUntilFinish();`としておきます
2. 作成したインデックスファイルをビルド実行環境にダウンロードする
3. 手元のGKEソースとインデックスファイルをdockerビルドする
4. デプロイ先のGKEクラスタ認証を取得する
5. 作成したイメージをGKEにデプロイする

# Apache Solrの設定

ここまではGCP側の処理の流れになりますが、以下検索サーバとしてのSolrの具体的な設定内容の説明になります。

## Dockerfile

インデックス更新処理の最小化とミドルウェアのバージョン固定のため、Solrサーバをインストールしたベースイメージを最初に作っておきます。

```Dockerfile:solr-base
FROM ubuntu:xenial
RUN apt-get update -y; \
    apt-get install -y wget; \
    apt-get install -y curl; \
    apt-get install -y openjdk-8-jdk; \
    wget http://ftp.jaist.ac.jp/pub/apache/lucene/solr/7.0.1/solr-7.0.1.tgz; \
    tar zxvf solr-7.0.1.tgz; \
    mv solr-7.0.1 /usr/local/
ENV SOLR /usr/local/solr-7.0.1
ENV JAVA_HOME /usr/lib/jvm/java-8-openjdk-amd64
ENV PATH $PATH:$JAVA_HOME/bin;$SOLR/bin;
```

以下はインデックス更新用に毎日実行されるビルドのためのDockerfileです。
先に落としてきたインデックスファイルを取り込んでSolrサーバを一時的に立ち上げてインデックス登録しています。(スキーマやインデックスファイルの内容は後述)
最初の行の`ARG PROJECT_ID`はstep側から引数`--build-arg`でプロジェクトIDを渡してDockerfile内で環境変数として埋め込むための宣言になります。

```Dockerfile:solr
ARG PROJECT_ID
FROM gcr.io/$PROJECT_ID/solr-base
ENV CORENAME mycore
ADD index/schema.json .
ADD index/index.json .
RUN $SOLR/bin/solr start -force; \
    $SOLR/bin/solr create_core -c $CORENAME -force; \
    curl -X POST -F "file=@schema.json" http://localhost:8983/solr/mycore/schema; \
    curl -X POST -H "Content-type:text/json" --data-binary @index.json http://localhost:8983/solr/mycore/update; \
    $SOLR/bin/solr stop -force; \
    rm -f schema.json, index.json
EXPOSE 8983
ENTRYPOINT ${SOLR}/bin/solr start -force -f
```

## インデックス スキーマ/ファイル

Solrで検索インデックスを作るには以下のような検索インデックスのスキーマとインデックスファイルを作ります。
スキーマでは検索フィールドの型定義と具体的なフィールドを設定します。
以下スキーマ例ではbigramのフィールド(content)と位置検索のフィールド(location)を定義しています。

```json:schema.json
{
  "add-field-type": {
    "name": "bigramtext",
    "class": "solr.TextField",
    "analyzer": {
      "tokenizer": {
        "class": "solr.NGramTokenizerFactory",
        "minGramSize": "2",
        "maxGramSize": "2"
      },
      "filters": [{
        "class": "solr.CJKWidthFilterFactory"
      },
      {
        "class": "solr.LowerCaseFilterFactory"
      }]
    }
  },
  "add-field-type": {
    "name": "latlng",
    "class": "solr.LatLonType",
    "subFieldSuffix": "_coodinate"
  },
  "add-field": {
    "name": "content",
    "type": "bigramtext",
    "indexed": "true",
    "stored": "true",
    "required": "false",
    "multiValued": "false"
  },
  "add-field": {
    "name": "location",
    "type": "latlng",
    "indexed": "true",
    "stored": "true",
    "required": "true",
    "multiValued": "false"
  },
  "add-dynamic-field": {
    "name": "*_coodinate",
    "type": "pdouble",
    "indexed": "true",
    "stored": "false"
  }
}
```

以下、上記スキーマに基づいたインデックスに登録したいコンテンツの中身を定義したファイルになります。Dataflowではこうした内容のファイルを出力する処理を記載することになります。

```json:index.json
[
  {
    "id":"8514d3b7-5239-41f9-8b1d-f00fd608c5b0",
    "location":"35.658581,139.74543",
    "content":"東京タワーだよ！"
  },
  ...
]
```

## 検索

GKEでsolr-serviceという名前のServiceで登録している前提とするとGKE内部の別サービスからは以下のように検索実行することができます。以下例は位置絞り込みのクエリですがqの部分でフィールドとクエリを指定すればさらにいろいろ絞り込むことができます。

```python:検索
import requests

params = {
        "q": "*:*",
        "fl": "score,*",
        "fq": "{!geofilt}",
        "sfield": "location",
        "d": 5,
        "pt": "{0},{1}".format(35.658581, 139.74543),
        "sort": "geodist() asc",
        "wt": "json"}

url = "http://solr-service.default.svc.cluster.local/solr/mycore/select"
res = requests.get(url, params=params)
```

# その他

* GCBサービスアカウントに必要な権限
  * 今回紹介した処理を行うために`xxx@cloudbuild.gserviceaccount.com`に以下ロールを追加する必要があります
      * Dataflow Developer
      * Dataflow Service Agent
      * Kubernetes Engine Developer
      * Kubernetes Engine Service Agent
* GAE/FE(custom runtime)で動かす場合
  * 今回は作成した検索サーバをいろいろな用途で使うためマイクロサービス的な利用を想定してGKEを利用しましたが、GAE/FE custom runtimeでも同じようなことはできるため単一の検索サーバとして利用したい場合はこちらを利用するのも手かと思います。
* インデックスが単一イメージに乗らない場合
  * 大規模なデータを扱う場合でもインデックスファイルを排他的に作って別イメージ、別ServiceとしてGKEに登録すればSolrの分散検索機能を使い複数サービス並列問い合わせすることで対応することは可能と思います。ただ検索用にたくさんのServiceができることになりコスト的な検証は必要かと思われます。

# おわりに

今回の記事はGKEで検索サーバを動かすのが主要な内容となりましたが、個人的に応用の幅に可能性を感じたのはGCBの方でした。Dataflow起動やGKE更新の他にも、BigQueryのジョブ制御もシーケンシャルの簡単な制御ならGCBで実行できそうに思います。GKEやGAE/FE(custom runtime)の運用においてGCBはまだまだいろいろな組み合わせで役に立つ用途があるのではと思っています。
自分はまだまだGKEは初心者ですがこのあたりの運用パターンを模索しながら精進していきたいと思います。