🌊

検索エンジンをElasticsearchに移行するまでに起きたこと、やったこと

2022/10/11に公開約9,200字

ROXXのエンジニアの夕暮おこはです。
先日Agent bankの求人検索エンジンをMySQLの全文検索からElasticsearchに移行しました。
それ自体特に技術的に新しいことはありませんが、移行開始からβリリースまでの期間が一週間とそれなりに短く、チームとしてどう動いたのか興味があるとのご意見をいただくことがありましたので、記憶が新しいうちにどういった動きがあったか備忘録的にまとめてみました。

移行を決めるまで

移行を決めてからはスムーズに行けましたが、移行を決めるまでは正直お世辞にもうまくいったとは言えない状況でした。
反省の意味を込めてこの経緯もまとめてみます。

APMによる可視化と全文検索のbigram化

去年ごろから社内でdatadogによるモニタリングをしっかりやっていこうという機運が高まり、APMをいろんなところに仕込んだことで、本番環境のクエリの実行時間などが詳細に把握できるようになりました。
これによりあからさまなn+1が発生しているようなクエリについては特定ができたため解消できたのですが、求人検索に関してはn+1などのシンプルな問題は特に見いだされず、特に全文検索が行われると非常に処理が遅くなり、RDSに最も負荷を与えているクエリになっているという問題が認識されるようになりました。
この時点で一回NoSQLへの移行も議題に上がったのですが、bigramでの分割検索でそれなりのパフォーマンス改善が見られたため、一旦NoSQLへの移行は見送りとなりました。

負荷テストと"死の宣告"

bigram分割で一時的にパフォーマンスは改善したものの、4月から始まったパーソルキャリア社との求人連携により求人数は劇的に増え続けており、システムがこのまま求人数が増加しても耐えられるかテストを行うことにしました。
求人数と選考データを倍に増やすバッチを流し、Apache Benchでリクエスト数をあげながらどれくらいまで耐えられるか試そうとしたのですが、なんとこの状態では一回全文検索しただけでRDSが即ダウンするレベルの負荷が発生してしまいました。
このままいくと死んでしまうことが確定したため、抜本的対策を行うためのタスクを起票してバックログに積みました。

リードレプリカへの分散化、失敗

その翌週、負荷テストの結果と、既にかなり高くなってしまっているRDSのスペックダウンを行うために、RDSのリードレプリカを用意して負荷分散を行うことになりました。
もともとAuroraのリードレプリカはmetabase用に用意していたのですが、metabaseは時々非常に重いクエリが飛ばされることもあり、サービス側と同居させることに懸念する声が上がりました。
これについてはAuroraにはカスタムエンドポイントが設定できるため、これを利用してリーダーインスタンスを二つにわけ、それぞれカスタムエンドポイントで向き先を固定することでmetabaseの重さにサービスが引きずられないように対策しました。
Laravel側は単純にconfigでライター/リーダーの切り分けができるということだったので、アプリケーション側はこれに任せて分散を行ったのですが、Queの処理がリードレプリカの同期より早く行われてしまい、何かしらwrite後にQueで行った通知などの内容が書き換え前の内容を元にしてしまっているという問題が発生してしまい、これらの問題を完全に解決するのは時間がかかることが予想されたため、一旦リードレプリカへの分散は断念することになりました。

ゾンビ化プロセス大量発生

リードレプリカへの分散を断念し元の状態に戻して週があけた8/29、システム全体が非常に重くなりました。
RDSの状態を見てみるとCPUが100%に張り付いた状態がずっと続いており、プロセスを見るとゾンビ化して死ねなくなった大量のプロセスが残留している状態でした。
どうやらクエリが一分を超えてタイムアウトしてしまうとプロセスが正常に終了できなくなってしまい、ゾンビ状態になってしまったようです。
これらのゾンビ化したプロセスを手動でキルし、RDSの設定でタイムアウト後にプロセスを殺すようにしたことでCPU負荷の増加は少し緩やかになったものの、クエリが一分を超えるほど長くなってしまっている状態は改善しませんでした。
クエリが遅くなっている原因は結局この日特定までは至れず、後日別の方法で負荷テストを行った際にやっと原因を特定することができました。

暫定対応

ゾンビ化が止まったことで負荷が青天井に増え続ける状況は回避できるようになったため、RDSのスケールアップで最低限サービスが使える状態に持っていくことができました。
それでもまだ軽くなったとは言えない状態だったため、APMでクエリの処理にかかっている時間を各エンドポイントごとに集計した結果を解析した結果、全マシンタイムの7割程度は求人検索の件数取得エンドポイントに食われていることが確認できました。
クエリ自体は件数取得も検索結果取得もあまり大きくは変わらないはずですが、件数取得は絞り込み条件を変えるたびに(間引きしてるものの)毎回フロントからリクエストが飛んでいるため回数が多いのが負担になっている原因でした。
お客さんに全文検索した状態で検索条件変えたりしないでくれというわけにもいかず、事業部と相談して一旦件数のリアルタイム表示機能を封鎖することにしました。
ここまでしてようやくタイムアウトなどせずクエリが捌ききれる状態に持っていくことができました。

β版までのタイムライン

8/30 (火)

スプリントゴールの変更

これまでの対応はコスト的にもユーザーの利便性的にも強い負担をかける暫定対応でしかないため、開発チームとして根本的対策を行うことを最優先とすることで認識をそろえ、すでにスプリントが始まった直後ではあったもののスプリントゴールを急遽「検索エンジンの移行を実現可能な状態にする」に変更しました。

移行先の選定


当日のMiro
改めて求人検索の負荷対策を行う方針についてチームで話し合いました。
いくつか候補が上がりましたが、今回は「ユーザーへ最速で価値を届けられるのはどれか」という観点が非常に重要であると判断し、技術的に無理がなさそうなものをある程度決め打ちで、比較にあまり時間を掛けすぎず、というスタンスでElasticsearchへ移行することを決めました。

一次設計

この段階ではESの詳細な仕様がわかっていなかったので、リリースまでに必要であろうタスクをだけ整理しました。
必要な機能を列挙し、ふんわりと優先度付けを行いました。

当日のMiro
これ以降はこの付箋を見ながらチーム内で分業しながら開発を進めていきました。

docker・Laravel環境用意

とりあえずdockerさえ動けば検証は何でもできるようになるので、真っ先にdocker環境を用意しました。
Laravel側の設定は業後にniisanが個人的に試してみて、翌日リポジトリに入れてくれました。

8/31(水)

ES側のデータ構造設計

ESのドキュメントを読み、とりあえず配列でデータを持たせること自体は特に問題なさそうなこと、nestedはパフォーマンスを悪化させる場合があること、配列でなくnestedを使わなければならないのはどういうケース化、などを把握し、求人検索の現要件でnestedを使わないとならない部分がないか検討しました。

当日のMiro
データ的に1:Nになってるところは結構な数ありましたが、

  • データの類型が決まっている
    • 類型の名前をキーにしてしまえばただのkey-value構造にできる
  • 単純に子データを持っているだけ
    • IDの配列を持たせてIDで検索すれば事足りる

といった形でnestedを回避できそうなことが分かったので、ざっとデータ構造の方針だけ決めて、あとはデータ作成と検索を実際に行いながらカラムを追加していくことにしました。

ES側へのデータ格納

データの格納についても「ユーザーへ最速で価値を届けられる」ことが最優先だったので、Laravelで全件流し込むバッチを作ってしまい、出来上がったバッチの実行時間を見てから定期実行のタイミングを調整することにしました。
ESへデータを格納する際はEloquentのモデルをそのまま使わず、前述の設計に従いkeyを一つ一つ明示的に指定して格納しました。
結果的に余計なデータが入っていないため検索時のパフォーマンスが良好にできました。
データの流し込みはBulk APIを使用しました。
この時点では1000件ずつ流し込んでみましたが、後にリクエストが早すぎるとindex再構築に手間取ってES側のメモリが溢れることがあったため、最終的には500件ずつ時間を空けて流し込む形に調整しました。

ESの絡んだPHPユニットテストの調整

大筋とはあまり関係ないですがこの日はPHPでのユニットテストではまりました。
ESへデータを格納したあと検索を行って、格納した通りのものが格納されてるか確認するテストを作ったのですが、デフォルトではindexの追加から検索に現れるまでにタイムラグがあるため、検索結果に出てこない、という問題に遭遇しました。
こちらは結論、リクエストを送る際にrefreshというパラメーターwait_forを指定することで検索結果に出るようになるまでレスポンスが返ってこなくなるため、回避できました。

9/1(木)

ESによるカテゴリごとの件数集計の検証

件数表示用エンドポイントは、既存実装では業界ごと職種ごとの細分化された件数を出す必要があり、検索結果をPHP側で全件取得したあとforeachで集計していました。
そのためSQL自体も遅いだけでなくPHP側も遅く、メモリも食うという三重苦状態であり、この状況は何とか改善したいところでした。
上記のタスクでES内にデータが格納できたので、早速kibanaのdev toolを使ってワンクエリで集計が可能か検証を行いました。
結論、Terms aggregationを使うことでSQLとPHPを組み合わせるより圧倒的に簡潔な表現で高速に集計できることが分かりました。
この集計関数の多様さ、簡潔さ、高速さだけもってしてもESへ移行してよかったと思えたポイントでした。

クエリビルダーの作成

ESへの移行の際、フロントの既存機能は変更したくなかったため、検索のクエリパラメーター自体は既存のものをそのまま踏襲することにしました。
求人検索機能はもともとRepositoryパターンを採用していたので、Repositoryにはクエリパラメーターを既存実装のまま渡してしまい、Repositoryの中でESのクエリに変換するビルダーを通すようにすることでElasticとの結合部分を抽象化しました。
実装に際してはまず私がべた書きで変換する関数を書き、並行してniisanがクラス化したビルダーに移植してくれたりテストパターンを追加してくれたりしました。
このあたりの詳細はniisanが記事にしてくれているのでそちらをご覧ください。

9/2(金)

βリリースの方針決定

9/1までの作業兼検証の結果から、技術的な面で求人検索のリプレイスが可能である見通しが立ったため、リリースの方針を決めました。
ユーザーへ早く提供することが重要であるという判断から、デバッグを長い時間かけて行ってからのリリースは避けたく、かといってユーザーが操作不能になったりする事態も避けたかったため、元の検索エンジンを残したままESの方をβ版として並行リリースする方針を決定しました。
また、βリリースするにしても最低限のデバッグは必要であり、それを週明けに行うとリリースが翌週水曜くらいまで伸びてしまう可能性もあったため、私とniisanだけ土日出勤し、月曜にβリリースを見届けてから代休を取ることにしました。

フロント側にβページを追加

既存画面をそのまま複製してβ版のページとして、フラグを見てたたくエンドポイントの先だけ変えるようにしました。
また、βページと通常ページの区別がつかなくなるとトラブルのもとになりそうだったので、そうとわかるようなデザインを作ってもらうようデザイナーさんに依頼しました。

インフラ環境の用意

実際に本番で使用するインフラ環境を用意しました。
例によってスピード重視でしたのでフルマネージドサービスを使うことはすぐ決めましたが、Elasticsearch公式のElastic cloudと、AWSがやってるAWS OpenSearchのどちらを使うか検討しました。
ぶっちゃけ開発メンバーほとんどがどっちでもよくね?という感じでしたが、公式の方が新機能の適用が速そう、そもそもAWSちゃんとElasticsearchに金払えよといった声が上がり、Elastic cloudを採用することにしました。

9/3(土)

動作確認をしながらクリティカルなバグを取る作業をしました。

aggregationの際に自己絞り込みをしないようにする

求人のカテゴリごとの件数を表示する機能を動作確認してみたところ、カテゴリの絞り込みを行った状態だと選んでないカテゴリがすべて0件になってしまって件数表示が役に立たなくなってしまうという不具合が発生しました。
これを直すには、例えばカテゴリの集計を行う際にカテゴリの絞り込みは反映しないけど、職種の絞り込みを行う際はカテゴリの絞り込みは反映する、といった処理が必要になりました。
Elasticsearchではfilterを使ってこの要件を満たすことができました。
ES用のクエリを作るためのビルダーはすでに作ってあったので、このうちfilterとして機能させたいものを分離し、それぞれQueryBuilderとFilterBuilderにわけ、普通の検索のときは両方結合してクエリとして使用し、集計の際は各々filterを当てたいところだけに当てることができるようにリファクタを行いました。

9/4(日)

引き続きクリティカルなバグを取る作業を行いました

github actions上でelasticsearchを動かすようにする

github actions上でCIを回してる都合上、elasticsearchを動かせるようにする必要がありました。
普通にdocker-composeでvolumeをマウントしようとすると立ち上がらなくなってしまったので、github actions上ではvolumeをマウントしないようdocker-compose.yamlを調整しました。
localではvolumeをマウントしたかったので、local用のyamlを用意して上書き[1]するようにしました。

9/5(月)

ステージングテスト

お休み組と休日出勤組がそろったのでみんなで動作チェックを行いました。
特に大きな不具合はありませんでした。

β版のフロント調整

β版と通常版を行き来できるようにし、β版の方にgoogle formで作ったアンケートへの導線を付けました。
これにより何かβ版でトラブルがあった場合に開発が直接情報を取れるようにしました。

広報準備

ユーザー向けの広報の準備を行いました。
今までプロダクト上でβ版リリースしたことはほとんどなかったようで、期待値調整になかなか難航しました。
ここはもっと早くやっておくべきだったと反省したポイントです。

β版リリース

広報文面のにぎりも取れたので、β版のリリースを行いました。
以降ぱらぱらと来た不具合報告の対応を行いました。

βリリース後

β版のリリースでユーザーさんに与えていたペインは概ね取り除けたので、ここからはペースを落として他のタスクをこなす傍ら不具合対応や負荷テストを行いました。

負荷テスト

ESがどれくらいの負荷に耐えれるか検証がしたかったのですが、ESは全文検索のキャッシュが強く、同じ文言で何回もリクエストしてもキャッシュが使われてしまう可能性が高かったため、今回の負荷テストにはApache benchではなくjMeterを利用しました。
APMから負荷の高い検索語句上位を抽出し、検索語句を毎回変えることでESのキャッシュを回避して連続アクセスによる負荷を検証しました。
アクセス頻度もAPMからリクエスト頻度を算出し、それを再現する形でテストしました。
結果、ESの方は今後かなりの長期にわたって十分なパフォーマンスが出せることが確認できました。

ES移行後のjMeterの結果。処理時間は一定の値に収束している

なお、比較のためにSQLの方の検索エンジンにも同じ負荷テストを行ってみたのですが、最初のうちは遅いなりに動いていたものの検索時間がじわじわと伸びていって、あるところから急激に検索時間が伸びてタイムアウトが発生する、という現象が確認できました。
どうやらリクエスト頻度が処理できる数を下回っている場合はただ遅いだけで済むけど、クエリが長引いて処理できる数が減ってくるとリクエスト頻度が処理数を超えてしまい、前の処理が終わってないのに次の処理が~~ということが繰り返されて負荷が急激に上昇していたようです。

ES移行前のjMeterの結果

ESへデータを流し込むタイミングの最適化

先述の通り全件流し込みはメモリ溢れが発生することがあったので、バルクリクエストの量と頻度を減らすことで調整しました。
また、ユーザーによるデータ編集のエンドポイントについても、なるべく検索に早く反映されてほしいであろう公開非公開の変更の際だけ即時ESに反映するように調整しました。

正式リリース

負荷テストも問題なく、不具合報告も来なくなったため、改めてESの検索エンジンを正式版としてリリースしました。
ESへ移行したことでRDSのスケールを二段階下げることができたため、トータルでインフラコストを軽減しつつ、ユーザーの検索時間は目下平均200ms以内を推移する程度のパフォーマンスが維持できています。

まとめ

良かったと思う点

  • β版と現行版を併用したのでスピード感と品質を両立できた
  • ユーザーへの価値提供を最速で行うという明確な共通認識のもと、その時その時でやることやらないことの認識をそろえながら進められた
  • ES自体もクエリの書き味がよくメンテしやすく、パフォーマンスもあがってコストも下がっていいことづくめだった

反省点

  • 一回目の負荷テストで落ちることまでは分かったのに、時期的にいつ頃落ちそうかまでは見積もりが立てれていなかったので、優先度が適切に決められなかった
脚注
  1. 一度volumeを設定した後上書きでnullを渡しても無視されてしまうようであとから削除することができず、ない方をベースにしてマウントする方を上書きする形になってしまいました ↩︎

GitHubで編集を提案

Discussion

ログインするとコメントできます