こんにちは、会員事業部の新井です。余暇を全て Auto Chess に喰われています。
過去このブログにはサービス開発に関する記事*1を投稿させていただいているのですが、今回はシステム改修についての記事になります。 クックパッドには検索バッチと呼ばれる大規模なバッチが存在するのですが、今回それを刷新することに成功しました。 そこでこの記事では旧システムに存在していた問題点、新システムの特徴や実際の開発について述べたいと思います。
背景
クックパッドのレシピ検索では Apache Solr を検索サーバーとした全文検索を利用しています。古くは Tritonn を利用して MySQL に作られた専用 table を対象に全文検索を実行していたようですが、その頃から「検索バッチ」と呼ばれるバッチが存在していました。 このバッチは、簡単に言うと「検索インデックス」と呼ばれる検索用メタデータを生成するものです。関連各所からデータを収集し、分かち書きやスコアの計算といった処理を実行して検索インデックスを生成し、現在はそれを Solr にアップロードするところまでを実行するバッチ群となっています。
この検索バッチは 10 年以上利用されており、年々検索のメタデータとして使用したいデータ(field)が増加してきたこともあって、種々の問題を抱えたレガシーシステムとなっていました。サービスにとって非常に重要なシステムであるがゆえに思い切った改修に踏み切れなかったのですが、今回のプロジェクトはその一新を目的としたものでした*2。
旧検索バッチの問題点
複数の DB やサービスに依存している
検索バッチはレシピ情報にとどまらず、レシピに紐づく様々なメタデータや、別バッチによって集約された情報などを収集する必要があるため、依存先のサービスや DB が多岐にわたっていました。
DB でいえばレシピサービスが利用している main
, 集約されたデータが格納されている cookpad_summary
, 検索や研究開発関連のデータが格納されている search_summary
などなど……。サービスへの依存についても、料理動画サービスの API を叩いてそのレシピに料理動画が紐付けられているかを取得してくるなどの処理がおこなわれており、新規事業が次々に増えている現在の状況を考えると、この手の依存はこれからも増大することが予想されていました。
cookpad_all に存在している
旧検索バッチは cookpad_all と呼ばれる、レシピ Web サービスとその管理画面や関連するバッチ群、mobile アプリ用 API などがすべて盛り込まれたレポジトリ上に存在しており、各サービス間でモデルのロジック等が共有されていました。このこと自体はそれほど大きくない規模のサービスであれば問題になることはありません。しかし、クックパッドについて言えば、ロジック共有を通したサービス間の依存を把握するのが困難な規模になっており、「レシピサービスに変更を加えたらバッチの挙動が意図せず変わった」というようなことが起こる可能性がありました。このような状況であったため、特に新しいメンバーがコードに変更を加える際に考えるべき要素が多すぎて生産性が著しく低下し、バグを埋め込んでしまう可能性も高くなってしまっていました。
不必要に Rails である
cookpad_all に存在するバッチ群は kuroko と呼ばれていますが、それらが Rails で実装されていたことから、旧検索バッチも Rails で実装されていました。 しかし、このバッチの実態は「大量のデータを収集して処理」することであり「user facing な Web アプリケーションをすばやく開発することができる」という Rails の強みが活かされるようなものではありませんでした。 実際の実装としても、その大部分が「データを取得するためだけに ActiveRecord のインスタンスを大量に生成する」といったロジックで構成されており、オーバーヘッドの大きさが目立つものになっていました。
責務が大きすぎる
旧検索バッチでは、検索インデックスにおける全ての field が一つのメソッド内で生成されていました。そのため、新たな field の追加や既存の field の編集において必ずそのメソッドに手を入れる必要があり、メンテナンス性に問題を抱えていました。
たとえば、新たな field を追加する際に該当メソッド内に存在する既存のロジックを踏襲したとします。しかし、クックパッドには「レシピ」を表現するモデルが複数存在するため、既存ロジックで利用されていた「レシピ」を表現するモデルと、新たな field のロジックが参照するべきだった「レシピ」のモデルが食い違っており、意図した挙動になっていなかったといったような問題が起こることがありました。
また、研究開発部の施策で検索インデックスに field を追加したいケースなど、レシピサービスにおける検索機能の開発以外を主業務としているメンバーも検索バッチに手を入れることがありました。このように、ステークホルダーの観点から見ても複数の理由からこのバッチが編集されており、「単一責任の原則」が満たされていないシステムになってしまっていました。
実行時間が長すぎる
旧検索バッチではすべての field を生成する処理が直列に実行されているため、Rails での実装であるということも相まって実行時間が非常に長くなってしまっていました。 この時間はバッチの構成がそのままであるうちは今後も field の増加に伴って増大していくことが予想されていましたし、実行時間短縮のために自前で並列実行の実装をおこなっていたのも可読性やメンテナンス性に影響を与えていました。
新検索バッチ概要
上に挙げた旧検索バッチの問題点を解消するため、新検索バッチ(以下 fushigibana*3)は以下の要素を実現するように実装されました。
データフローの一本化
先ほど「検索バッチはその性質上多くの箇所からデータを収集し加工する必要がある」と述べましたが、現在クックパッドには組織内のあらゆるデータが集約されている DWHが存在します。各種データソースから DWH へのインポートという作業が存在するためデータの更新頻度に関する問題はありますが、旧検索バッチの時点で検索結果の更新は日次処理であったことも鑑み、fushigibana が利用するデータソースは DWH に限定しました。こうすることで各種 DB やサービスへの依存が解消され、データフローを一本化することが可能となりました。
Rails ならびに cookpad_all からの脱却
fushigibana は redshift-connectorを用いて DWH から取得したデータに、Ruby で分かち書きなどの処理を施して検索インデックスを生成し、それを S3 にアップロードするというつくりになっており、plain Ruby で実装されています(Ruby を選択したのは社内に存在する分かち書き用の gem などを利用するため)。その過程で cookpad_all からもコードベースを分離し、完全に独立したバッチ群として存在することになりました。
クラスの分割と並列実行
fushigibana では検索インデックスの生成処理をサービスにおける意味やアクセスする table などの観点から分割し、「単一責任の原則」を満たすよう実装しています。分割されたクラスはそれぞれいくつかの field を持つ検索インデックスを生成します。最後にそれらのインデックスを join することですべての field を持った検索インデックスを生成しています。こうすることで、それぞれのクラスを並列実行することが可能になり、バッチの実行時間が短縮されました。
また、検索インデックスに新しく field を追加する際にも、既存のロジックに手を加えることなく新しいクラスを実装することで対応が可能となり、システム全体で見ても「オープン・クローズドの原則」を満たしたバッチとなりました。
fushigibana の開発と移行作業
ここからは、実際にどのようにして fushigibana を開発し、それをどのようにして本番環境に適用したかについて述べていきます。
開発の流れ
fushigibana の開発は、大まかに次のような流れでおこなわれました。
- 現状の調査
- ロジックの SQL 化
- 新ロジックの検証
- staging 環境における検索レスポンスの検証
- kuroko 上での本番運用
- コードベースの分離
現状の調査
旧検索バッチの改修に入る前にまずは現在利用されていない field を洗い出し、少しでも移行時の負担を軽減することを目指しました。 コードを grep して一見使われていなさそうな field について識者に聞いて回ります。 この辺りは完全に考古学の域に入っており、作業の中で過去のサービスについていろいろなエピソードを知ることができておもしろかったです。 この作業の後、最終的には 111 の field についてロジックの移行をおこなうことになりました。
ロジックの SQL 化
既存の Rails ロジックを凝視しながらひたすら SQL に書き換えていきます。中には既存のロジックの挙動が明確でないもの、単純にバグっているものなども存在しており、適宜直しながらひたすら SQL に書き換えます。最終的には 32 のクラスで 111 の field を生成することになりました。
新ロジックの検証
新旧ロジックで生成した検索インデックス同士を比較することで、新ロジックの妥当性を検証します。 「データソースが変わるためそもそもデータの更新タイミングが違う」「ロジック改修の際にバグ改修もおこなった」などの理由から厳密な比較は不可能でしたが、できる範囲で新ロジックの不具合を潰していきました。
開発環境における検索レスポンスの検証
それぞれの検索インデックスをアップロードした Solr に対して検索リクエストを投げ、そのレスポンスを比較します。 実際に開発環境のレシピサービスを利用して手動で挙動を確認することはもちろん、検索回数上位 1000 キーワードほどについてスクリプトを回し、「人気順」「新着順」「調理時間絞り込み」など、利用ユーザー数や重要度の観点から選択した、いくつかの機能で発行される検索クエリのレスポンス件数や順序を比較しました。 ここでも厳密な比較はできないものの、ユーザー視点で重要な体験に絞った上で「ある程度の誤差を許容する」「誤差の原因を特定することを目的とする」ことで費用対効果を意識して検証作業を進めました。
旧実行環境上での本番運用
本番運用に入るにあたっていきなりコードベースを分離するのではなく、まずは既存のバッチが動いているシステムの上で新ロジックを走らせる方針を取りました。これは、なにか問題があった際に、それが「コードベースの分離ではなく新しいロジックそのものに問題がある」ことを保証するためのステップです。
コードベースの分離
上記のステップで新ロジックに問題がないことを確認した上でコードベースを分離していきます。実際には cookpad_all 内に存在したロジックをいくつか社内 gem に移行するなどの作業が発生したため、新ロジックの妥当性が完全に保証された状態でコードが分離できたわけではありませんでしたが、それでも一度既存のシステム上で問題なく実行できていたため比較的不安なく分離を進めることができました。
移行作業における安全性の保証
検索バッチが影響を与えるレシピサービスは非常に多くのユーザーが利用しているサービスであり、移行作業に際して不具合が発生する可能性は可能な限り抑える必要がありました。 今回の開発ではシステムの安全性を以下の 4 地点で検証してから反映しています。
- 新ロジックの生成する検索インデックスと、旧ロジックの生成する検索インデックスを比較
- 新検索インデックスをアップロードした Solr が返すレスポンスと、現在の Solr が返すレスポンスを比較
- 新検索インデックスを production の Solr にアップロードした後、現在の検索結果と前日の検索結果を比較
- ユーザーからの問い合わせを監視
このうち、1 と 2 については上述した「開発の流れ」における「新ロジックの検証」と「開発環境におけるレスポンスの検証」そのものであるため、ここでは 3 と 4 について述べます。
前日との検索結果の比較
クックパッドの Solr は master-slave 構成で運用されており、検索インデックスが master Solr にアップロードされた後、ユーザーからのリクエストを受ける slave Solr がそれをレプリケーションしてくる形になっています(厳密にはこれに加えてキャッシュ機構があったりします)。逆にいうと検索インデックスをアップロードしても、slave のレプリケーション処理をおこなわなければユーザーへの影響は出ないということになります。
この仕組みを利用して、検索インデックスをアップロードした後検索回数上位の各キーワードについて前日の検索結果と新しい検索結果を件数ベースで比較し、大きな差があった場合レプリケーションを実行しないというテスト機構が存在していました。 この機構は検索インデックスの生成ロジックを変更しても問題なく利用できるものであったため、そのまま活用することになりました。
ユーザーからの問い合わせ監視
いくら開発段階での検証を繰り返しても、実際に不具合の出る可能性を 0 にすることはできません。当然のことではありますが、本番適用日はインフラやサポートチームに共有し、万が一のときにすばやくロールバックできるよう検索インデックスをユーザーからアクセスのこない slave Solr にバックアップした上で反映作業を実施しました。 その後もユーザーから届くお問い合わせには定期的に目を通し、fushigibana 導入による不具合らしきものが報告されていないかどうかを確認していました。
プロジェクトの振り返り
成果
以上に述べたように検索バッチの改修をおこなった結果、どのような成果を得ることができたのかをまとめます。 冒頭に上げた「旧検索バッチの問題点」についてはそれぞれ
- 複数の DB やサービスに依存している
- DWH をデータソースとすることで解消した
- cookpad_all に存在している
- 別レポジトリに切り出して実装することで解消した
- 結果 cookpad_all から 1,357 行のコードを削除することに成功した
- 不必要に Rails である
- plain Ruby として実装することで解消した
- 責務が大きすぎる
- index-generator を複数のクラスに分割して実装することで解消した
- 「小さな処理を並列で実行する」形に改修したことでリトライ処理も入れやすくなり、バッチ全体の安定性も向上した
- 同様の理由でバッチ実行基盤の spot instance 化も達成され、将来的にはコスト削減にも繋がりそう
- 実行時間が長すぎる
- 分割実装した index-generator を並列実行することで解消した
- 具体的には全体で 7.5h かかっていたものが 4.5h となり、約 3 時間の短縮化に成功した
という形で解決することができました。 丁寧に検証フェーズを重ねたこともあり、今のところ不具合やユーザーからのお問い合わせもなく安定して稼働しています。 また、上記に加えて「コードの見通しが改善したことによる開発の容易化」や「ドキュメンテーションによるシステム全体像の共有」といった成果もあり、検索バッチ周りの状況は今回のプロジェクトによって大きく改善されました。
反省
今回の一番大きな反省点はプロジェクトの期間が間延びしてしまったことです。 着手してみた結果見積もりが変わった・プロジェクトのスコープが広くなっていったという事実もあるため仕方のないところもありましたが、特に検証フェーズにおいてはより費用対効果の高い方法を模索することができたのではないかと思います。
たとえば検索インデックスの比較と Solr レスポンスの比較はかなり近いレイヤーに属するものであり、どちらか一方を省略しても検証の精度に大きな差は存在しなかった可能性があります。 結果として「不具合が出ていない」という事実は喜ばしいことですが、組織にとってはエンジニアリソースも重要な資源ですし、今後は「かかるコスト」についてもしっかりと意識をしてプロセスやアーキテクチャの選定をしていきたいと思います。
今後の課題
今回のバッチ改修はあくまで「レシピ検索」についてのものであり「つくれぽ検索」「補完キーワード検索」などについては(また別の)古いシステムで動いています。 今後はそれらの検索インデックスを生成するシステムについても改修をおこなう必要があると思いますし、その際に fushigibana に乗せるのか、あるいはどう関係させるのかというのはしっかりと考慮する必要があると思います。
fushigibana そのものについての課題としては、現在 AWS Glue へのスキーマ登録を AWS console から手動でおこなう必要があることがあげられます。 ドキュメントは残しているものの、この作業だけ fushigibana のリポジトリ上で完結しないのは開発者に優しくないと感じていますし「スキーマ定義ファイルの内容に従って AWS Glue の API を叩くスクリプトを実装する」といったような解決策を取るべきであると思っています。
まとめ
今回の記事ではクックパッドにおける検索バッチシステムの改修について解説しました。 「現状のシステムを調査することで洗い出した問題点を解決する構成を考え、技術を用いて可能な限りシンプルに実現する」という当然かつ難しいことを、規模の大きなシステムに対して実践するのは非常にやりがいがあり、エンジニア冥利に尽きる仕事でした。 システムの構成も現時点における「普通」にかなり近いものになっており、今後の開発にもいい影響があると期待されます。
クックパッドには検索バッチ規模のシステムが多数存在し、その多くはよりよい実装に改修されることが期待されているものです。もちろんそのためには多くのリソースが必要であり、弊社は年がら年中エンジニアを募集しています。 大規模なシステムの開発に挑戦したいエンジニア、多くのユーザーを支えるサービスに関わりたいエンジニア、技術の力でサービスをよくしたいエンジニアなど、少しでも興味を持たれた方は是非ともご応募ください。
*1:https://techlife.cookpad.com/entry/2018/02/10/150709と https://techlife.cookpad.com/entry/2018/12/07/121515
*2:クックパッドでは 2017 年よりレシピサービスのアーキテクチャ改善を目的とするお台場プロジェクトが進んでおり、それに貢献する意味もありました
*3:Solr にデータを撃ち込む → ソーラービーム → フシギバナ