こんにちは。NewsPicksでエンジニアやっております崔(チェ)です。現在は Data / Algorithm チームで検索エンジン開発を担当しております。弊社は、検索エンジンとして Elasticsearch を Amazon EC2 に乗せて構築しておりますが、メンテナンスに消極的だった部分があり、これからはマネージド化や検索精度向上など積極的に取り組んでいきたいと考えております(伸びしろしかない!)。今回は、その中でも色んなタスクのボトルネックだったアルゴリズムを変更した話をしたいと思います。ただ、アルゴリズムの詳細よりもそれの変更のために行ったインフラ的な内容にフォーカスしております。ご興味ある方は是非読んでいただけると嬉しいです。
はじめに
検索精度向上施策として、実は直近、いくつか細かな改善はしております。ただ、ほとんどはクエリを改善したのであって、そもそも形態素などトークンのレベルで分析精度が悪いケースが多々残っており、文書の解析方法自体を改善しないといけないと認識しておりました。
このように、今回のアルゴリズム変更は、検索精度を向上させたいという目的もあったのですが、実はマネージド化のためでもありました。Amazon OpenSearch Service への移行を検討中なのですが、今の仕様のままでは移行できないため template を修正し、reindex する必要がありました。
ちょっとまって、reindex とは?
Elasticsearchでは、一つの index が複数の document を持ちます。例えば RDB(Relational Database)のテーブルの中には複数のレコードが格納されています。「index = RDB のテーブル」「document = レコード」と思えばよいです。
一つの document に色んなデータを持たせるため、 field というものを用います。RDB のテーブルのカラムや JSON データのキーを想像すればわかりやすいと思います。
さらに、ある field にテキストデータを格納する場合は、形態素解析などの解析処理を行い、検索時にマッチしてほしい単語の列に変換します。この処理により index が作成されます。検索する際は、人が入れたテキスト全体ではなく、分析済みの単語だけを参照することになります。
どう分析するか(どの analyzer を使うか)は template というものに定義します。この template というのは、入れ直せば上書きされるのですが、これを用いて再分析するには再度 index を作成する(以下 reindex)必要があります。Elasticsearch の仕様上、既存 index に、document を再分析して上書きするのは不可能であるため、同じ document を持つもう一つの index を作成する必要があります。
本題に戻り
じゃあ今すぐにでも reindex すればいいよね?と思えるのですが、NewsPicksで検索を行うユーザに悪影響が出てはいけないため、懸念点を潰してからじゃないと難しい状況でした。なので、まず開発環境を本番環境に近いレベルに構築してから reindex の色んな実験を行う必要がありました。
これから、実験でわかったことを含め、どういった手順で実行できたかを述べていきます。
reindex の実験
今回知りたかったのは下記の3つです。
- reindex 実行中負荷はどれくらい高まるか → ユーザが検索するのに支障はないか
- reindex 完了までの所要時間
- 実際に本番で reindex する際に必要になる付随作業
実験環境づくり
開発環境で実験を行うには、1)本番環境の検索データのコピー、 2)本番で運用している cluster 環境と近い環境を構築 する必要がありました。
1)に関しては、Snapshot / Restore 機能を利用しコピーしました。詳しくは公式ドキュメントをご参考ください。
2)に関しては、やるべきことが2つあります。
- 最低でも本番で必要とする replica shard 数を合わせ node を構成します。
- replica shard を持たないか、本番より数が少ない場合 Node Failure(
no_valid_shard_copy
エラーなど) になる可能性があります。 - 「EC2インスタンス1台 = node 一個」にする方法については EC2 Discovery Plugin のドキュメント をご参考ください。
- reindex 対象の既存 index の
shard
/replica
の設定値がよくわからない場合は、_settings
API を使用し確認できます。
- replica shard を持たないか、本番より数が少ない場合 Node Failure(
- リストア時に、
"include_global_state": true
オプションを付けて実行することで、cluster / node / template などデータだけでなく設定もコピーします。
そもそも Elasticsearch のシステムってどう構成されるの?
Elasticsearch は、以下のように構成されます。簡単に言うと node は Elasticsearch が動作する各サーバ、cluster は複数の node をまとめたものです。
shard は Lucene Index としてディスクに保存されるのですが、Lucene Segment という小さなファイルを定期的にマージすることで作成されます。この処理を refresh するといい、同時に検索可能な状態になります。
実験スタート
本記事に添付したクエリは Kibana を介して実行することを前提に書きましたが、介さない場合は cURL コマンドにして投げてください。
さて、肝心の reindex は以下のクエリで行います。index 名に alias を付けていることを前提にしています。
source
が分析し直す対象の index で、dest
が再分析できたドキュメントを持つ index です。
POST _reindex { "source": { "index": "{alias}" }, "dest": { "index": "{new_index_name}" } } POST _aliases { "actions": [ { "add": { "index": "{new_index_name}", "alias": "{alias}" } }, { "remove": { "index": "{old_index_name}", "alias": "{alias}" } } ] }
すべての実験において以下の設定は変更せず行いました。node 数だけは本番より少ないのですが、今回の実験では一つの index に対してだけ reindex を行うため、全く同じにする必要はないと判断したためです。
項目 | 設定値 |
---|---|
同時に reindex する index 数 | 1 |
index が持つ document 数 | 約1,500万件 |
cluster 数 | 1 |
node 数 | 3 |
実験1)template を変更せず既存 index をそのまま reindex する
template を変更せずに reindex を行った場合、どれくらいの負荷と時間で終わるのか確認したく、まずは何も変えずそのまま reindex してみました。
実験に使ったデータや設定は下記のとおりです。
項目 | 設定値 |
---|---|
shard 数 | 5 |
replica shard 数 | 2 |
ノード全て reindex 開始から2分ほど経過した時点で CPU 使用率は92%に到達しました。
その後40分間様子を見たところ、更に高まることなく90%程度で維持されました。途中、複数回連続して検索(_search
)してみたのですが、特に CPU 使用率は上がることなく、既存 index に対する検索もスピーディー(0.5秒以下)にレスポンスを返しました。Elasticsearch の検索性能はファイルシステムキャッシュがどれほど確保できるかにより変わるので、各 node の持つ shard 数が少ないほど早くなると言われますが、node 数が本番より少ない状態でも実際に運用中の検索エンジンと等しいレベルでした。
既存 index が node 1台あたり15GB程度だったので、開発環境では node 1台あたり40GBにして行いました。完了までは約8時間かかりました。
より効率的に index を作成するため、document の id を自動で割り当てるようにするなど、Elasticsearch の公式ドキュメントで詳細が紹介されているので、ご参考ください。
実験2)template を修正し replica は持たさず reindex する
template を修正するには _template
API を実行します。
PUT _template/{alias} { ... } GET _template/{alias}
また、node 1台あたり shard 数が多く作成されている場合は、どの analyzer を使うかに関係なく遅くなるはずなので、実験1)以後は shard 数を少なくして reindex しました。
「実験環境づくり」に replica 数が0か本番より少ない場合は Node Failure が起こり得ると書きました。どういう風にエラーが起きるのかを確認すべく replica 0 にして reindex しました。
実験に使ったデータや設定は下記のとおりです。
項目 | 設定値 |
---|---|
shard 数 | 1 |
replica shard 数 | 0 |
replica 0 の場合は、primary shard だけが作成されます。なので cluster 全体で shard 数は (replica shard 数 + 1) * primary shard 数 = (0 + 1) * 1 = 1
になり、node が3台あっても shard は1つしか作成されません。つまり、既存 index の持つ約1,500万件の document を shard 一つに入れるということになり、40GBしか使えない状態になります。既存 index は node 一台あたり約15GBであるため、新規 index が node 1台のディスクを完全に使えたとしても、5GB足りないのです。
想定通り、shard 数が少ないと index の作成処理はより早くなり、実験1)と比較し3倍程度短縮できました。が、やはり正常終了はできませんでした。標準出力としてログファイルに以下のエラーが書き込まれており、ディスクの空き容量がなくなったことが分かりました。
[2022-03-31T03:52:53,741][WARN ][o.e.c.r.a.AllocationService] [ip-{node_ip_address}] failing shard [failed shard, shard [some_index][0], node[{node_id}], rel ocating [{id}], [P], recovery_source[peer recovery], s[INITIALIZING], a[id={id} rId={id}], expected_shard_size[18148 402752], message [shard failure, reason [index]], failure [IOException[No space left on device]], markAsStale [true]] java.io.IOException: No space left on device
データを書き込む空間が足りないと、index は読み込みのみが可能な状態になります。
{ "error": { "root_cause": [ { "type": "cluster_block_exception", "reason": "blocked by: [FORBIDDEN/12/index read-only / allow delete (api)];" } ], "type": "cluster_block_exception", "reason": "blocked by: [FORBIDDEN/12/index read-only / allow delete (api)];" }, "status": 403 }
どれかの index を削除するなどでディスク空間を確保したのにもかかわらず、cluster_block_exception
エラーが発生する場合、_cluster/settings
API を使用し制限を外すことで再度書き込みが可能な状態になります。
PUT _cluster/settings { "transient": { "cluster.blocks.read_only":null } }
reindex 後のディスクの容量を確認した結果、74%しか占めておりませんでした。100%になってから失敗したわけではないことについては、Lucene Segment ファイルの統合に瞬間的にディスク容量を多めに使うことが原因かもしれません。
実験3)template を変更し replica を持たせ reindex する
実験1)より高速化するため shard 数を少なくし、実験2)のように異常終了しないよう、cluster が shard を (replica shard 数 + 1) * primary shard 数 = (2 + 1) * 2 = 6
だけ持つようにしました。実験に使ったデータや設定は下記のとおりです。
項目 | 設定値 |
---|---|
shard 数 | 2 |
replica shard 数 | 2 |
書き換えると、1台の node は2つずつ shard を持つことになります。要するに、約45GBのサイズを持つ index を reindex して90GBのディスク容量が必要になったとしても、120GBが用意されているため正常終了するだろうと思ったわけです。
しかし、約90%進んだタイミングで実験2)と同様の理由で reindex が異常終了してしまいました。確か実験1)では node 1台あたり40GBあれば問題なかったのに、と不思議に思いました。推測するに、実験1)で使用した template より修正後の template の方がより多い品詞の単語を持つようになっていましたので、一つの document のサイズが大きくなったことが原因である気がします。
reindex 異常終了直後のディスク使用量を見ると、node1 100% / node2 100% / node3 79% を占めていました。
一方、CPU 使用率は実験1)と違い、60%程度で維持されました。異常終了まで3時間弱かかりました。
ディスク容量だけが問題でしたので、node 1台あたり約60GB用意したら、3時間で正常終了してくれました。各ノードの最終的なディスク使用量は54%でした。
実験でわかったこと
3つの実験からわかったこととして以下が述べられます。
- node 1台の shard 数が増えると reindex の速度は低下する
- reindex する際は最初 CPU 使用率がぐっと上がり、終わるまでその状態がつづく
- node 一台あたりの shard 数が多くない場合は、reindex 中の CPU 使用率が60%程度に留まる
- reindex 中に既存 index に対して検索を連続で行ってもレスポンスは問題なくスピーディーに返し、負荷もかからない
- template を変更しての reindex はディスクを多く占めるので、
既存 index サイズ * 3
のディスク容量を用意するのが安全である
reindex 時に発生する付随作業・気をつけること
上記の内容を踏まえ、実際に本番環境で reindex するにはどういった点に気をつければいいのかをまとめると以下になります。
- 1)reindex 中に既存 index に追加・更新・削除される document の追い更新処理の方法を考える
- 2)問題が生じ、巻き戻す場合を考える
- 3)ディスク容量的に問題ないか確認し、問題がある場合は解決策を考える
- 4)template を変える場合の諸事項を考慮する
- 5)更に高速化できる方法がないか考える
1)に関しては、最初、新規 index に対して document 追加・更新・削除する処理を行わせ、reindex が行われていた間のデータを下記のクエリで追い更新しようと思っていました。
POST _reindex { "source": { "index": "{alias}", "query": { "range": { "{timestamp 型の field 名}": { "gte": "now-4h" } } } }, "dest": { "index": "{new_index}" } }
この方法は、document の field に作成日時・更新日時が別途分けて入っているなら問題ないのですが、弊社のデータは作成日時のみだったのでこの方法だと「追加」には対応できるけど「更新」には対応できないことに気づきました。なので、reindex される間は、新しい document が追加・更新・削除されること自体を止めておく必要がありました。
3)に関しては、本番環境はそもそも node 1台あたり 100GB 程度用意されており、問題ありませんでした。
4)は、実験内容には書いていなかったのですが、実は綿密に確認する必要があったので少し述べます。「はじめに」の方で少し触れましたが、NewsPicks では現在 Elasticsearch を使用しており、近い将来 Amazon OpenSearch Service へ移行することを検討しております。今は Sudachi を使っていますが、Amazon OpenSearch Service だと日本語 analyzer として Kuromoji のみをサポートしているため、予め切り替えておく必要がありました。その上で、以下の点を考慮しました。
- template の書き方
- ユーザ辞書
- Sudachi
- 設定ファイルにユーザ辞書のパスを書き、template には設定ファイルのパスを書く
見出し,左連接ID,右連接ID,コスト,見出し (分析結果表示用),品詞1,品詞2,品詞3,品詞4,品詞 (活用型),品詞 (活用形),読み,正規化表記,辞書形ID,※未使用,※未使用,※未使用,※未使用
フォーマットで書く
- kuromoji
- ユーザ辞書のパスを template に直接書く
<text>,<token 1> ... <token n>,<reading 1> ... <reading n>,<part-of-speech tag>
フォーマットで書く
- Sudachi
- 品詞除外
- Sudachi
第1分類,第2分類,...
フォーマットで書く
- Kuromoji
第一分類-第2分類-...
フォーマットで書く
- Sudachi
- などなど
- ユーザ辞書
5)に関しては1)のときにも話しましたが、追加・更新・削除処理を一時期止める必要があり、これはユーザに影響する部分でもあったので、更に高速化したい状況でした。
reindex を実行し、_cat/indices
API で document 数が増えていくことが確認できるのですが、これは上で述べた refresh 処理が毎回行われるためと言えます。しかし、refresh処理はデフォルトでは1秒ごとに実行されるのですが、それなりの負荷と時間がかかるため、その頻度を下げるか、必要によってはしないことを試みる必要があります。
PUT {alias}/_settings { "index" : { "refresh_interval" : "30s" } }
それで、本番では30秒に一回行うように設定し reindex しました。node 数やディスク容量などファクターは異なりますが、実験に使用した index だと本番で1時間30分で正常終了し、実験1)より約5倍・実験3)より約2倍早い結果となりました。
戻す際は、上記クエリの "30s"
の部分をnull
に変え実行すれば毎秒 refresh するように戻すことができます。
実際の手順
さて、本番では以下の手順で reindex をしました。
- template 修正
- template 修正に伴う諸作業
- 修正後の template 適用
- 既存 index への document 追加・更新・削除処理一時停止
- reindex
- alias 変更
- 検索精度の確認(問題がある場合は、既存 index に alias を戻す)
- 貯まっていた追加・更新・削除処理を新規 index に対して行う
おわりに
本記事には最初から知っていたように書きましたが、実は当時知らないことだらけで、shard / primary shard / replica shard / node などへの理解も浅い状態でした。なのでエラーが起きてもその理由がわからない、理論上できないだろう実験をやってみるなど、沢山試行錯誤し、沢山調べ物しました。むしろこの記事を書いたことで更にわかるようになったものもあります。
検索エンジン開発を担当するようになってから、直したいものはとても多かったのですが、5割くらいは reindex しないとできない改善だったりしました。冒頭でも述べたのですが、現在は EC2 インスタンスに載せて構築しているため、ログ確認やエラー対応が難しい面があり、マネージド化してから reindex したほうがいいと優先度を下げておりました。結局マネージド化も reindex をしないといけないと知り一瞬唖然としていました。
順番はどうであれ、とにかく個人的には reindex は超えたいけど超えられない高い山みたいなものでした。やっとそれを超えたような気がします。山を下るのは登る時より気持ちが楽になるのと同じく、reindex もやってみればそこまで危険に思わなくても良かったかもとも思います。言い換えれば、これからは reindex したかったらできる、更に今そうなってない部分があれば改善を行うなど、Next Action に次々とつながっております。
今回のプロジェクト?も初めてのことばかりで、とても勉強になり、かなり成長できたのではと思います。 これからもチャレンジしていきますので、一緒に頑張りましょう!