技術部クックパッドサービス基盤グループの id:koba789 です。
昨年まではデータ基盤グループというところで 最新のログもすぐクエリできる速くて容量無限の最強ログ基盤 を作ったりしていました。
今年はちょっとチームを移動しまして、検索システムをいじっていました。今回はそのお話です。
なお、クックパッドには様々な検索システムがありますが、この記事では説明を簡単にするためにレシピの検索のみに焦点をあてています。
クックパッドの検索システムにあった課題
クックパッドにはレシピを検索できる機能があります。
プレミアム会員限定の人気順検索もこの機能の一部です。
しかし、この重要な機能を支える検索システムにはいくつもの課題がありました。
Solr が古すぎる
クックパッドでは、レシピ検索を含む多くの検索機能にSolrを用いています。
今年の始めに私がこの課題に取り組み始めた時点では、その Solr のバージョンは4.9でした。これは5年以上前にリリースされたバージョンです。
古いミドルウェアをあまりに長い間維持し続けてしまうと、OS や運用のためのツールといった周辺のソフトウェアのアップデートも困難になります。
OS だけ一気に最新のバージョンにアップデートしようとしても、互換性の問題で古いミドルウェアは動かなかったりするのです。
こうなると、もはやインクリメンタルなアップデートは非現実的で、古すぎること自体がアップデートをより難しくしているためデッドロックのような状況です。
現代的なプラクティスが実践されていない
上記のような状況から想像に難くないですが、インフラのアーキテクチャも大変古めかしいものでした。
クックパッドのインフラは AWS 上に構築されており、ほとんどのワークロードは ECS と Hako を用いてコンテナ化されています。
またその多くは運用にかかる金銭的コストを最適化するため、負荷に応じてオートスケールするようになっています。
しかしこの検索システムはそうではありませんでした。
Solr は専用の EC2 インスタンスにインストールされており、そのインスタンスはピークでもオフピークでも同じ数だけ動き続けていました。
また、この EC2 インスタンスをインスタンスの退役と入れ替えなどの理由で新規にプロビジョニングする場合は人による作業が必要でした。
その作業はスクリプトでほとんど自動化されてこそいるものの、このスクリプトも例に漏れず非常に古くなっており、保守性を悪化させていました。
これらの課題がありながらも、5年という長い期間ずっと変わらずにいました。
運用上は意外にも安定してしまっていたことと、サービスのコア機能に関わるため迂闊には手を出せなくなっていたことが理由です。
まさに触らぬ神に祟りなしといったところです。
ほぼすべてのワークロードがコンテナ化されたクックパッドにおいて、最後まで残り続け強烈な "レガシー" となっていたのがこの検索システムでした。
検索システムを見つめ直す
前述のような理由から、インクリメンタルなアップデートは諦め、ゼロからアーキテクチャを考え直すことにしました。
システムの作り直しは失敗しやすく困難なので、しばしば悪い方針だと言われます。
もし作り直しを成功させたいならば、新しいシステムで得られるものばかりに目を向けて既存のシステムの観察を怠るようなことがあってはいけません。
必要な要素を見落とせば不十分なシステムができあがって失敗するでしょう。
不要な要素を見極められなければ余計な工数がかかったり実現不可能になったりして失敗するでしょう。
私は既存のシステムを観察し、要件や事実を整理するところから始めました。
まず説明のために、既存のシステムの概略を紹介しましょう。 既存のシステムでは Solr の検索性能をスケールアウトさせるために、1台のプライマリからたくさんの(固定台数の)レプリカに向かってレプリケーションをしていました。 (実際の構成はもう少し複雑ですがここでは本質的ではないため単純化しています)
そしてこのレプリケーションは自動ではなく、日に1度のバッチジョブで明示的にトリガーして実行していました。
そのジョブの内容はおよそ以下のとおりです。
- たくさんあるレプリカの中から1台選ぶ
- そのレプリカをロードバランサから外す(リクエストが来ないようにする)
- レプリケーションを実行する
- キャッシュを暖めるため "暖機運転" をする
- ロードバランサに戻す
- 以下、すべてのレプリカに対して繰り返し
わざわざレプリケーションを1台ずつ実行したり、レプリケーション中はロードバランサから外しておいたり、ロードバランサに戻す前に暖機運転をしたりするのは検索クエリへのレスポンスタイムを遅くしないためです。
さて、観察中に上記以外にも様々なことを発見しましたが、最終的に重要だった点は以下のとおりです。
- インデックスは日に1度しか更新されない
- レプリケーション後にキャッシュを暖めるため "暖気運転" をしている
- Solr がインストールされている EC2 インスタンスのタイプは c4.2xlarge である
- セグメントファイル(インデックスデータの実体。Solr が内部で使っている全文検索ライブラリである Lucene 用語)のサイズは 6GB 程度である
新しい検索システム
大前提として、新しい検索システムでは最新版の Solr を使うことにしました。
バージョンは4.9→8.6という大ジャンプでしたが、ほぼ schema.xml の書き換えのみで動かすことができました。
丁寧な移植作業の結果、バージョンアップ前後での検索結果の差は非常に少なくできましたが、それでも完全に一致させることはできなかったため、検索結果の品質について責任を持っているチームにお願いしてバージョンアップ後の検索結果の検証をしてもらいました。
また、新しい検索システムではすべてのワークロードをなんとかしてコンテナ化できないかと考えました。
もしコンテナ化できれば、社内に蓄積されたコンテナ運用の豊富なツールやノウハウの恩恵を受けることができ、圧倒的に運用が楽になるからです。
一般に、コンテナの利点を最大限に活かすためにはコンテナはステートレスであるべきとされます。
しかしながら、データベースや検索エンジンのようなミドルウェアは原理的にファイルシステムの永続化が必要でありステートフルです。
いきなり要件が矛盾しているようにも見えますが、一般論ではなく実際の要件に着目するのが大切です。
ここで最も重要なのはクックパッドのレシピ検索のインデックスは日に1度しか更新されないという点です。
ステートの更新頻度が十分に低いのであれば、ステートの変化の度にコンテナを使い捨てることでステートレスとすることができます。
以上のようなアイデアにより、新しい検索システムではセグメントファイルをファイルシステムではなく S3 に永続化することにしました。
また、セグメントファイルを S3 に置いたことでプライマリとレプリカ間のレプリケーションが不要になり、更新系と参照系を完全に分離することができました。
それでは、更新系と参照系について、動作を追って解説します。
まずは更新系の動作を見てみましょう。
なお、図中の s3ar は独自開発した S3 アップローダー・ダウンローダーです。詳細については後述します。
更新系のコンテナでは、まずインデックスの元になるデータを S3 から Solr に流し込み、すべてのデータを流し込み終えたら Solr を停止します。
この時点でローカルのファイルシステムにはセグメントファイルができあがっていますが、コンテナのファイルシステムは永続化されないため、このままコンテナを停止するとせっかくのセグメントファイルは失われてしまいます。
そのため、コンテナを停止する前にできあがったセグメントファイルを S3 にアップロードします。
アップロードが完了したらコンテナは停止し、破棄します。
この更新系のコンテナは日次のインデックス更新のバッチジョブの一部として起動されます。処理が完了するとコンテナは破棄されるため、処理中以外は計算リソースを消費しません。
続いて参照系の動作を見てみましょう。
参照系のコンテナは起動するたびに最新のセグメントファイルを S3 からダウンロードします。
セグメントファイルのダウンロードが完了したら単純に Solr を起動するだけです。
参照系のコンテナはインデックスの更新の度に、つまり日に1度すべて作り直され、置き換えられます。
以上の説明から明らかなように、起動プロセスが完全に単純化されたため、スケールアウトのための手作業はもはや不要です。
これは参照系のオートスケールが可能になったことを意味します。
セグメントファイルのダウンロード高速化
ところでこの設計にはひとつ懸念点があります。
それはセグメントファイルのダウンロードに時間がかかると、当然ながら参照系コンテナの起動にも時間がかかるということです。
オートスケールを実践するにはスケールアウトに対する即応性が大切です。
さもなくば増加する負荷に処理能力が足らなくなり、サービスの提供は停止するでしょう。
新しい検索システムではこの問題を解決するためにいくつかの工夫をしています。
セグメントファイルを tmpfs に書く
ダウンロードしたセグメントファイルは tmpfs に書いています。
これはブロックストレージへの書き込みがダウンロードのボトルネックになるためです。
tmpfs を利用するとなると気がかりなのがメモリ使用量です。
ここで既存の検索システムの観察で得た情報の一部を思い出しましょう。
- レプリケーション後にキャッシュを暖めるため "暖気運転" をしている
- Solr がインストールされている EC2 インスタンスのタイプは c4.2xlarge である
- セグメントファイルのサイズは 6GB 程度である
c4.2xlarge のメモリは 15GiB ですから、6GB のセグメントファイルのサイズや暖機運転をあわせて考えると、既存の検索システムはセグメントファイルのデータがすべてメモリ上のページキャッシュに乗り切っていることを期待していたことがわかります。 新しい検索システムでも同等以上の性能を達成したいので、セグメントファイルはすべてメモリに乗るようにすべきでしょう。
するとブロックストレージは完全に無駄であることに気が付きます。 結局ページキャッシュに乗せなければならないならはじめから tmpfs に書くべきです。
専用の高速な S3 アップローダー・ダウンローダー使う
S3 からのダウンロードにおいて、1接続あたりの速度はそこまで速くありません。 そのため、いかにしてダウンロードを並列にするかが高速化の鍵になります。
セグメントファイルは実際には複数のファイルで構成されているため、それぞれのファイルを S3 の1つのオブジェクトとして保存することでダウンロードの並列度を高められそうに思えます。
しかし、これはうまくいきません。なぜなら、それぞれのファイルサイズが大きく偏っており、並列化の効果を十分に発揮できないためです。
そこで、S3 の Multipart upload を利用し、1つのオブジェクトを複数のパートに分割してアップロードし、その分割されたパートごとにダウンロードします。 これは Performance Guidelines for Amazon S3 でも紹介されており、AWS SDK for Java の TransferManager や AWS CLI の s3 cp コマンドでも利用されているテクニックです。 これにより、ファイルサイズの偏りに関わらず、効果的にダウンロードを並列化できます。
また、高速なファイル書き込みではメモリコピーのコストが無視できなくなります。
通常のファイル書き込みで用いる write(2)
システムコールはユーザーランドのバッファからカーネルランドのバッファへのコピーを伴います。
ブロックデバイスへの書き込みであればブロックデバイスは十分に遅いため無視できるコストですが、tmpfs のような高速なファイルシステムを使っている場合はそうではありません。
そもそも、せっかく tmpfs を使っていてファイルの実体がメモリ上にあるのですから、そのページに直接書き込みたいと思うのが自然でしょう。
実は、その願いは tmpfs 上のファイルを mmap(2)
すると叶えることができます。
ファイルに対する mmap は page cache をユーザーランドに見せる操作であり、tmpfs はファイルの実体が page cache に存在するファイルシステムであるので、mmap すると tmpfs のファイルの実体がそのままユーザーランドから見えるようになるという理屈です。
(スワップを考慮していない雑な説明です)
これらのテクニックをすべて実装したのが独自開発した s3ar
という S3 アップローダー・ダウンローダーです。
これは Rust で書かれており、マルチコアを完全に使い切って超高速なダウンロードができます。
この s3ar を利用すると約 6GB のセグメントファイルのダウンロードは10秒程度で完了します。
これはスケールアウトの即応性として十分な速度です。
追記: s3ar のコードを公開しました。公開するつもりはなかったのでそれほど読みやすいコードではないのですが、ご笑覧ください。 github.com
まとめ
5年間も変化を寄せ付けず、強烈なレガシーとなっていたクックパッドの検索システムは、丁寧な観察に基づく大胆な設計とそれを実現する確かな実装によって近代化されました。
この記事では新しい検索システムの開発について紹介しましたが、既存の検索システムから新しいシステムへの切り替えについては触れていません。 次の記事では、この変化の大きな切り替えをいかにして安全に成し遂げたかについて、同僚の id:riseshia が紹介します。