白ヤギの開発者の森本です。
白ヤギではニュース記事のキュレーションをする カメリオ API というサービスを開発していますが、検索バックエンドとして Elasticsearch を使っています。
カメリオ API は約1年3ヶ月前に開発を始めたのですが、当時は 1.7 系を使っていました。昨年の夏から秋ごろにかけて 2.0.0 のベータ版がリリースされ、2015-10-28 に GA がリリースされました。現時点の最新バージョン (安定版) は 2.3.5 になります。
Elasticsearch 1.x と 2.x は非互換となるため、検索インデックスやアプリケーションの変更なしに移行することはできません。2.0.0 リリース後の翌日に早速 ITS にチケットを作りました。その後どうなったかというと、いまこの記事を書いていることから察して頂けると思いますが、そのときに作ったチケットの変遷を一部抜粋しながら眺めてみましょう。
長かった。。。
このチケットは実に約10ヶ月間にわたり生存していました。「elasticsearch 2.0 対応」から「elasticsearch 2.x 対応」にチケットタイトルを変更してからも3ヶ月を要しました。作業を始めようとして割り込みが入ることも何度もありました。結果的にはいろいろあって8月中旬ぐらいから移行作業に取り掛かっていました。実際に移行に要した開発/作業期間は2週間ほどでした。終わってみてから振り返ると、想定していたよりはトラブルなく簡単に移行できました。
何かの役に立つかもしれないので Elasticsearch 1.7.5 から 2.3.5 へ移行したときの変更/作業内容についてまとめておきます。あくまで当社での利用における差異なので全ての変更内容を網羅しているわけではありません。互換性のない変更については以下のドキュメントを参照してください。
アプリケーションの変更
白ヤギでは Go 言語で API サーバーを開発しています。Go 言語向けの Elasticsearch クライアントとして elastic というライブラリを使っています。以前にも Elasticsearch Advent Calendar 2015 向けに記事を書きました。
Elastic ライブラリは、Elasticsearch 1.x と 2.x 向けでそれぞれライブラリのバージョンが違います。そのため、まずやることはこのライブラリのバージョンを上げます。ちょっと紛らわしいですが、1.x が elastic.v2、2.x が elastic.v3 となっています。
- "gopkg.in/olivere/elastic.v2" + "gopkg.in/olivere/elastic.v3"
ちなみに静的型付き言語の利点として、こういった非互換のバージョンアップに対して型エラーを直していくことで影響範囲を把握するのが容易だという点があげられます。
Elastic ライブラリのソースコードには Elasticsearch のドキュメントへのリンクも記載されています。例えば elastic/search_queries_bool.go をみると、以下のように Bool Query ドキュメントへのリンクがあります。
// Copyright 2012-2015 Oliver Eilhard. All rights reserved. // Use of this source code is governed by a MIT-license. // See http://olivere.mit-license.org/license.txt for details. package elastic import "fmt" // A bool query matches documents matching boolean // combinations of other queries. // For more details, see: // http://www.elasticsearch.org/guide/reference/query-dsl/bool-query.html type BoolQuery struct { Query ...
実際の変更作業の過程としては、型エラーが発生したクエリのソースコードからドキュメントを辿り、ドキュメントの内容を確認しながら修正していきました。
Filter から Query に統一された
おそらくこの変更に影響を受けるアプリケーションが多いのではないかと推測します。クエリ DSL で Filter していた処理を全て Query に書き換えないといけません。この変更に関するドキュメントは以下になります。
使っている Query の種類によって4つの移行方法が書かれています。白ヤギでは基本的に Constant Score Query で Filter を移行しました。Constant Score Query については Combining Filters のドキュメントの例が分かりやすいと思います。ドキュメントとしてはこちらの方がお勧めです。
当初、Bool Query に追加された filter 句を使って移行していました。しかし、Not Filter が Not Query に置き換わったものの、それも 2.1.0 で非推奨扱いとなっています。移行方法としては、Bool Query の must_not 句を使うように記載されていますが、ここで1つ落とし穴があります。
Bool Query の filter 句はスコアに影響を与えないのでそのまま移行できますが、must_not はスコアに影響が出てしまいます。そのため、Not Filter の代替として Bool Query の must_not 句を使うと、フィルターとしての機能は実現できますが、従来の Not Filter の検索結果と比較するとスコアに差異が生じます。Bool Query の filter 句にネストした Bool Query の must_not 句を定義してみたり、他にもいくつかの方法を試してみたりしたのですが、検索結果かスコアのどちらが意図したものにはなりませんでした。
この課題を解決する方法が Constant Score Query です。例えば、以下のようなクエリを生成することで従来と同様の機能とスコア算出ができました。
"filter": { "constant_score": { "filter": { "bool": { "must_not": { ... }, "filter": { ... } } } } }
あと1点 Filter から Query に変更された副作用として filter 句のみにマッチするとき、スコアの値がゼロで検索結果に現れるようになってしまいました。検索結果が少ないときにノイズとなってしまうため、min_score を 0.001 などと指定してスコアの値がゼロのものを取り除くようにしました。
Constant Score Query のもう一つの用途として名前の通りスコアを一意にすることもできます。例えば、以下のように Bool Query の must 句で ID にマッチするものを取得したい場合、そのときの取得されるスコアは指定した ID の数によってばらばらになります。
"bool": { "must": { "terms": { "id": [ ... ] } } }
このとき min_score で基準値のスコアを指定していたりすると、それより小さいスコアの検索結果を取得できなくなってしまいます。そういったときに Constant Score Query でラップすることでマッチした ID の数に依らず一定値のスコアで取得できるようになります。デフォルトではスコアの値が1になるようです。
"bool": { "must": { "constant_score": { "filter": { "terms": { "id": [ ... ] } } } } }
このような過程で従来の 1.7.5 のときの機能/スコアを 2.3.5 の環境でも実現できました。実際は生成するクエリがもっと複雑で変更箇所も多岐に渡りましたが、原理的にはこれで大丈夫なはずです。
extended-analyze プラグインが標準機能になった
カメリオ API では自然言語処理技術を使った機能をいくつか実装していますが、その前処理として文章を形態素解析して単語やその品詞を判別する必要があります。
これまでは @johtani さんの elasticsearch-extended-analyze プラグインを使っていました。この機能が 2.2.0 から Explain Analyze として標準機能として取り込まれています。これは素晴らしいですね。
例えば「この先生き残れる」という文章に tokenizer と explain フラグを指定して解析すると以下のレスポンスが返されます。
$ curl -s -X POST localhost:9200/_analyze -d '{ "text": "この先生き残れる", "tokenizer": "kuromoji_tokenizer", "explain": true }'
{ "detail": { "tokenfilters": [], "tokenizer": { "tokens": [ { "reading (en)": "kono", "reading": "コノ", "inflectionForm": null, "bytes": "[e3 81 93 e3 81 ae]", "baseForm": null, "position": 0, "type": "word", "end_offset": 2, "start_offset": 0, "token": "この", "inflectionForm (en)": null, "inflectionType": null, "inflectionType (en)": null, "partOfSpeech": "連体詞", "partOfSpeech (en)": "adnominal", "positionLength": 1, "pronunciation": "コノ", "pronunciation (en)": "kono" }, { "reading (en)": "saki", "reading": "サキ", "inflectionForm": null, "bytes": "[e5 85 88]", "baseForm": null, "position": 1, "type": "word", "end_offset": 3, "start_offset": 2, "token": "先", "inflectionForm (en)": null, "inflectionType": null, "inflectionType (en)": null, "partOfSpeech": "名詞-一般", "partOfSpeech (en)": "noun-common", "positionLength": 1, "pronunciation": "サキ", "pronunciation (en)": "saki" }, { "reading (en)": "ikinokoreru", "reading": "イキノコレル", "inflectionForm": "基本形", "bytes": "[e7 94 9f e3 81 8d e6 ae 8b e3 82 8c e3 82 8b]", "baseForm": null, "position": 2, "type": "word", "end_offset": 8, "start_offset": 3, "token": "生き残れる", "inflectionForm (en)": "base", "inflectionType": "一段", "inflectionType (en)": "1-row", "partOfSpeech": "動詞-自立", "partOfSpeech (en)": "verb-main", "positionLength": 1, "pronunciation": "イキノコレル", "pronunciation (en)": "ikinokoreru" } ], "name": "kuromoji_tokenizer" }, "charfilters": [], "custom_analyzer": true } }
attributes を指定することで任意の属性のみを返すように制御できます。
$ curl -s -X POST localhost:9200/_analyze -d '{ "text": "この先生き残れる", "tokenizer": "kuromoji_tokenizer", "attributes": ["partOfSpeech", "pronunciation"], "explain": true }'
{ "detail": { "tokenfilters": [], "tokenizer": { "tokens": [ { "pronunciation": "コノ", "partOfSpeech": "連体詞", "position": 0, "type": "word", "end_offset": 2, "start_offset": 0, "token": "この" }, { "pronunciation": "サキ", "partOfSpeech": "名詞-一般", "position": 1, "type": "word", "end_offset": 3, "start_offset": 2, "token": "先" }, { "pronunciation": "イキノコレル", "partOfSpeech": "動詞-自立", "position": 2, "type": "word", "end_offset": 8, "start_offset": 3, "token": "生き残れる" } ], "name": "kuromoji_tokenizer" }, "charfilters": [], "custom_analyzer": true } }
extended-analyze プラグインとは多少の API パラメーターやレスポンスのデータ構造に変更はありますが、その処理は単体テストがあったのでそう苦もなくテストが通るように修正して対応できました。
Scripting のパラメーターの指定方法が変わった
これも小さな変更ですが、Scripting のパラメーターの指定方法が少し変わっていました。Groovy スクリプトを使っているのですが、そのスクリプト自体は変更することなくそのまま動作しました。
その他の作業内容
Elasticsearch 1.x と 2.x では検索インデックスの互換性がありません。そのため、検索インデックスを移行するか再作成しなくてはいけません。
をざっと読む限りは、クラスター全体を再起動することで (2.x で廃止された仕様を使っていない場合は) 検索インデックスの移行も行われるように読めます。白ヤギでは既存環境と新環境との比較検証もしたかったのでクラスターそのものを新規に再作成する方針で移行しました。そのため、このドキュメントにあるアップグレードの方法は試していません。
各種バッチ処理ツールの修正
日々の記事を取り込むバッチ処理や検索インデックステンプレートの変更に伴うメンテナンスなど、運用向けには elasticsearch-py を使って CLI の社内ツールで運用しています。以下はそのツールのヘルプですが、サブコマンドをみれば何となく雰囲気は分かるかと思います。
$ esc conf_dev.ini -h usage: esc [-h] [-v] conf_path {delete,update_user_attributes,termvector,list, alias,bulk_sourcegroups,bulk_sources,template,bulk,bulk_topics} ... positional arguments: conf_path set config.ini {delete,update_user_attributes,termvector,list, alias,bulk_sourcegroups,bulk_sources,template,bulk,bulk_topics} commands delete delete index update_user_attributes update user attributes termvector handle termvector action list list index and alias alias handle alias action bulk_sourcegroups bulk sourcegroups indexing bulk_sources bulk sources indexing template handle template action bulk bulk indexing bulk_topics bulk sources indexing optional arguments: -h, --help show this help message and exit -v, --verbose set verbose mode
簡単に言うと、検索インデックステンプレートの操作や検索インデックスそのもののメンテナンスをするものです。特別なことはしていないせいか、基本的には elasticsearch-py のバージョンをアップデートするのみでそのまま動きました。
たまたま検証環境でいろいろ作業していて以下のエラーが発生したのでその仕様変更に気付きました。
Failed to parse mapping [index-name]: Mapper for [_all] conflicts with existing mapping in other types:
[mapper [_all] has different [analyzer], mapper [_all] is used by multiple types. Set update_all_types to true to update [search_analyzer] across all types.,
mapper [_all] is used by multiple types. Set update_all_types to true to update [search_quote_analyzer] across all types.]
このエラーメッセージでググってみると、以下の issue とドキュメントが回答になります。
- same field names may raise confliction with different type and analyzer #14575
- Conflicts between fields in different types
Elasticsearch 2.x から同一インデックスでは異なる type であっても同名フィールドは共有されるようになっているようです。そのため、異なる type で同名フィールドのパラメーターを更新しようとするとエラーが発生します。ここでは _all をインデクシングしようとした際にエラーが発生しました。特に複数の type を使い分ける必要がないのであれば気にしなくても良いです。もし複数の type をまとめて更新したいときは update_all_types を指定して更新すれば良いそうです。
Elasticsearch プラグインの構成変更
社内で作っていたプラグインをインストールしようとして以下のエラーが出ました。
ERROR: Could not find plugin descriptor ‘plugin-descriptor.properties’ in plugin zip
プラグインの作り方が変わったようで plugin-descriptor.properties というメタデータのファイルを作成しないといけないようです。より厳密にプラグインの種別や対応する Elasticsearch のバージョンを定義してインストール時にチェックできるようになったようです。詳しくは以下のドキュメントを参照してください。
デプロイツールの修正
ansible でサーバーのデプロイを行っています。
これまでは RPM ファイルをダウンロードしてインストールしていましたが、Elastic 社が Repositories を公開しているのを知って yum リポジトリ経由でインストールするように変更しました。
- name: import gpg key for elasticsearch become: yes rpm_key: state=present key={{ es_gpg_key_url }} - name: copy yum repository file for elasticsearch become: yes copy: src=elasticsearch.repo dest=/etc/yum.repos.d - name: install the latest elasticsearch rpm become: yes yum: name={{ item }} enablerepo=elasticsearch state=latest with_items: - elasticsearch
また ansible 2.0 から elasticsearch_plugin – Manage Elasticsearch plugins モジュールが提供されているのでそれを使ってプラグインのインストールも行うように変更しました。
- name: install {{ es_plugin_kuromoji }} plugins become: yes elasticsearch_plugin: state=present name={{ es_plugin_kuromoji }} - name: install {{ es_plugin_kuromoji_neologd }}/{{ es_plugin_kuromoji_neologd_version }} plugins become: yes elasticsearch_plugin: state=present name={{ es_plugin_kuromoji_neologd }} version={{ es_plugin_kuromoji_neologd_version }}
機能/動作の検証
あとは既存環境と新環境を用意して様々なテストをしたり、動作を比較したりして検証するだけです。本当は結合テストが充実していてその結果を比較できると格好が良いのですが、まだまだそこまでには至っていないため、人間が管理画面で実行しながら結果を検証しました。
とはいえ API サーバーは Elasticsearch クラスターを任意に切り替えられる仕組みになっているので既存環境と新環境を用意して比較検証すること自体は容易でした。
まとめ
白ヤギで Elasticsearch を 1.7.5 から 2.3.5 へ移行するにあたり、行った変更内容や作業について紹介しました。
OSS を本番環境で使うにあたり、その OSS のバージョンアップに追随できる開発体制を維持することはとても重要です。私の経験だと、バージョンアップに伴う開発や作業そのものよりも、安定した環境を変更することや互換性を崩してしまうリスクがあるといった心理的な抵抗の方が大きいように思います。本稿の Elasticsearch のアップグレードも正に後者でした。
良いプロダクト/アプリケーションを作る秘訣の1つは、常に開発を継続してメンテナンスし続けることだと私は考えています。そのためには重要なアプリケーション/ミドルウェアのメンテナンスをしやすい開発体制を築く必要があります。具体的にはアプリケーションの個々の機能のモジュール性を高める、テストを整備する、リファクタリングして良い設計を保つといったことなどです。冒頭で紹介したように10ヶ月前から検討していながらも対応できなかったという事実からそういった開発体制の未熟さにも気付くことができました。
最後に白ヤギコーポレーションでは一緒に API サービスを開発/運用してくれるエンジニアを募集しています。Elasticsearch を使ったプロダクト開発をしてみたい方、ぜひご応募ください。