ドキュメントを書くときの「メンタルモデルの原則」

こんにちは。クリエイション開発部の丸山@h13i32maruです。

みなさんドキュメント書いてますか?私はドキュメントを書くのは結構好きです。最近もプライベートで開発しているJasperというGitHub用Issueリーダーのユーザ向けドキュメント(マニュアル)を書きました。でも良いドキュメントを書くのって難しいですよね。

そこで、本記事では「ツールやライブラリなどを対象にしたユーザ向けドキュメント」を書くときに私が考える原則を紹介します。ちなみに私はテクニカルライティングの専門家ではなく、普通のソフトウェアエンジニアです。そのあたりはいい感じに汲み取っていただけると🙏

🕵️メンタルモデルの原則

良いドキュメントとはどのようなものなのでしょうか?私は「そのツールやライブラリに対して読者がメンタルモデルを構築できる」のが良いドキュメントだと考えています。これを「メンタルモデルの原則」と呼びます(私が勝手に呼んでいるだけなので、ググっても出てこないかも)。

💡メンタルモデルを構築すると推測可能になる

メンタルモデルとは「これをするにはこうする。こうしたらこうなる。」というように動作や結果をイメージできるような心のありようです。あるツールについてメンタルモデルを構築できると、ドキュメントを読むこと自体が楽になります。また、いずれはドキュメントをほとんど読まなくてもツールを使いこなせるようになります。

例えば使い慣れたプログラミング言語であればドキュメントを読まなくても「こういう場合はこう書けそう」とか「多分このへんのドキュメントみたら使い方書いてありそう」となると思います。これはそのプログラミング言語に対してメンタルモデルを構築できているからです。

ようするにメンタルモデルを構築するとはその対象について色々なことを推測できるようになるということです。なのでメンタルモデルを構築できるドキュメントは有益なドキュメントだと考えています。

ではどのようにすれば「メンタルモデルを構築できるドキュメント」を書けるのでしょうか?それには以下のことを意識してドキュメントを書くことが重要だと考えています。

  1. 読者の現在のメンタルモデルや目的
  2. 演繹的・帰納的な説明

📝メンタルモデルの有無や目的に応じてドキュメントをかき分ける

ドキュメントを読む人は様々です。そのツールを使い始めようとしている人、特定の使い方を探している人、100%使いこなしたいと思っている人、などなど。そういった読者に合わせたドキュメントを書くことが重要です。このときに読者はどれくらいメンタルモデルを構築済みかも重要になってきます。

例として「初めて使う人向け」「特定の使い方を探している人向け」「もっと使いこなしたい人向け」のドキュメントについて説明していきます。

① 初めて使う人向け
初めてツールを使う人の場合メンタルモデルは全く構築されておらず、目的はとりあえずさくっと使ってみるというのがほとんどだと思います。そういう読者のためにセットアップ方法とすごく基本的な使い方のドキュメントとして「クイックスタート」や「チュートリアル」を用意するのがよいでしょう。

このとき注意すべきことはメンタルモデルが全く構築されていないので、多少冗長でも丁寧な説明をすることです。そうしないと思わぬところでハマってしまうことがあるでしょう。ただし、ツールの外側のことについてはメンタルモデルが構築されていると思うのである程度省略してもよいと思います。例えばJasperであればGitHubやMacアプリのインストール方法などはサラッと書くにとどめました。

② 特定の使い方を探している人向け
特定の使い方を探している人はある程度メンタルモデルが構築されています。なので「こういうことをするには、多分こういう機能がありそう」と思いながらドキュメントを読み始めます。よって「こういうことをするには」に相当するような「よくあるユースケース」や「サンプル集」を用意するのが良いでしょう。

③ もっと使いこなしたい人向け
こういう人はメンタルモデルがバッチリ構築済みなので、網羅的なドキュメントを用意するのが良いでしょう。いわゆる「リファレンス」や「辞書」みたいなものです。一つ気をつけるべきアンチパターンは「APIリファレンスしか用意されていないライブラリのドキュメント」みたいなものです。これは「もっと使いこなしたい人向け」以外の読者のメンタルモデルや目的を完全に無視していると思います。(それで問題ない場合ももちろんありますが)

ちなみにJasperでは以下のようにドキュメントの入り口を分けることによって、異なる読者を適切なドキュメントに誘導しています。

f:id:h13i32maru:20201126174616p:plain

🙆演繹的・帰納的な説明により推測を可能にする

人間がものを推測するときは大きく分けて演繹的な方法と帰納的な方法があります。

演繹的というのは「プログラミング言語には四則演算がサポートされている。TypeScriptはプログラミング言語である。よって、TypeScriptでは四則演算が使える」のような論理的な因果関係で推測することです。一方で帰納的というのは「TypeScriptは加算・減算・乗算が使える。なので除算も可能なはずであり、四則演算を使えるだろう」のようにいくつかの具体例をもとに推測することです。

よって演繹的・帰納的な方法で説明されたドキュメントを読むことで、そのツールの使い方や目の前のドキュメントには書かれていないことなどを推測できるようになっていきます(=メンタルモデルを構築する)。

例えばJasperでは「JasperはGitHubの検索クエリと完全互換があります」という説明をいれています。これによって読者は「ということはGitHub検索で使っていたあれもつかえるのでは?」という推測を可能にします。ほかにも「リポジトリ指定するにはこう、ユーザ指定するにはこう、ラベル指定するにはこう」という説明をしています。これによって「チーム指定する方法もあるのでは?」という推測を可能にします。

演繹的・帰納的という言葉を使いましたが、ようは「前提としていることを説明」「繰り返しやパターンで説明」を意識してドキュメントを書くとよいという話でした。


というわけでドキュメントを書く上での「メンタルモデルの原則」について紹介でした。良いドキュメントは著者も読者もwin-winなので、いいもの書いていきましょう。

検索インフラを安全に切り替えた話

こんにちはこんにちは。技術部クックパッドサービス基盤グループの id:riseshia です。

本記事では直前の記事で提案された新しい検索システム(以下、 solr-hako と呼びます)を利用し、レシピサービスの検索インフラの切り替えた話をします。 solr-hako の設計を直接参照する内容はありませんが、それを前提においた移行作業ですのでそちらの記事を先に読むことをおすすめします。

インフラ構成の変化

まずインフラ構成の変化ををみておきましょう。

f:id:riseshia:20201124172550p:plain
検索インフラ(変更前)

今まではこのようなインフラ構成でした。特徴としては、 search-cache というキャッシュサーバ(Varnish)が手前にあることくらいでしょうか。今回、 solr-hako を利用することで以下のような感じになりました。

f:id:riseshia:20201124172618p:plain
検索インフラ(変更後)

しれっとキャッシュレイヤーである Varnish がなくなったことがわかります。これに関しては後ほど述べます。 では、このインフラの切り替えのためにどういう作業をしてきたのかを話していきたいと思います。

取り組んだ施策

新しい検索システムにマイグレーションするために次のような施策を行いました。

  • solr-hako という新しい仕組みに沿った実装
  • Solr バージョンアップによる影響調査 & 対策
  • 負荷実験
  • 試験運転
  • 切り替え

solr-hako の設計に関しては同僚の id:koba789 が書いた記事で紹介しているのでそちらを読んでください。実装に関してはひたすら必要な設定を書いたり、権限を付与したり、 kuroko2 にジョブ定義を作成したりのようなものがいっぱいなので割愛し、本記事では残りの施策に関して説明します。

Solr バージョンアップによる影響調査 & 対策

あらゆるフレームワークに対して、バージョンアップの正攻法はまずリリースノートおよびチェンジログを調べるところから始まるのかなと思います。

ですが今回の場合だと、 Solr 4.9 から Solr 8.6 までの変更履歴を全て調べることになるので流石に無理です。 ではどうやって変更を調べるのか。今回は実クエリを利用することにしました。 実際のクエリを数日分抽出し、 Solr 4.9 と Solr 8.6 に投げてそのレスポンスの差分を取りました。そこで見つかった差分から関連の変更を逆引きし、必要な対策をするという流れです。

影響が大きかったのは以下の2つです。

前者は一応クエリの書き方を変えるだけでよかったのですが、後者は検索順番への影響がありました。 流れが大きく変わってしまうので詳細は省きますが、検索サービスの責任を持つサービスチームにお願いし、いくつかの指標を選定した上で検索の品質評価を行いました。結論としては品質に悪影響してない、むしろ少し改善した可能性がありそう?ということになったのでこのまま切り替え作業をやっていくことになりました。 これに関しては機会があれば、別の記事を通して紹介できるかなと思います。

性能実験によるパラメータ・チューニング

影響範囲が分かったし、検索順の変更に対する影響調査を依頼したところで、次は性能実験です。 Solr のバージョンが大幅に変わるので、 Solr の設定及びリソース要求を見直す必要があります。ぱっと思い浮かぶだけでも各種 Solr のキャッシュ設定、スキーマの設定、JVM の設定、 solr-hako で動かすわけですから、ECS のタスクのリソース割り当てなどもあります。 それにオートスケーリングも設定していくので負荷状況による影響なども把握したい。

これらの設定値をいい感じの組み合わせにして一つ一つ試しながら良さそうな組み合わせを探す必要があります。とはいえ、丁寧に新しい組み合わせを試すたび設定を更新して Docker image を作って〜というのは疲れるし、やってられません。 そこで Solr の Config API に目をつけました。 Config API を利用すると Solr の設定を REST-like API で取得したり、更新したりすることができます。これを利用して実験をあるほど自動化できるツールを作ることにしました。

solr-hako-load-tester

設計目標としては次のようなことを上げました。

  • 多様な Solr のパラメータを試せること
  • 手軽に設定を変更できること
  • 試したい設定をキューに詰め込んで順番に実行できるようにすること

一方でこのツールは今後 solr のバージョンアップの時くらいにしか使われないと予想されるので、実装コストに見合わない機能は目標外にしました。

  • Solr の外のパラメーター(e.g. ECS タスクごとの CPU や JVM の設定)は考慮しない
    • これらは比較的変更が少ない値であり、かつこれらの設定を動的に変更可能な設計にするのは利点に比べて実装コストが高い
  • 積極的なメトリクスの収集
    • 負荷をかけるツール(k6)、 Prometheus、 CloudWatch があれば詳細なデータが取れるのでツールではデータ収集を頑張らなくても問題ない
  • 負荷テストの対象の状態管理
    • 費用はやや高くなるが実験の間は負荷テスト対象(ECS Service)を常駐にすることでエンドポイントを毎回用意する手間を減らせるし、メトリックス収集も楽になる

結果、どうなったかというと、S3 に設定セットをキューイングし、 kuroko2 の定期実行でそれを消費しながら実験を行う仕組みが誕生しました。

f:id:riseshia:20201124172648p:plain
load-test の動作の流れ

この図からありそうな質問点をあげていくとこんな感じかなと思います。

  • 負荷をかけるときに使ったクエリはピークタイムのログをサンプリングしたものです
  • 実験ツールとして k6 を選択したのはクエリログをリプレイするという観点で非常にシンプルで扱いやすかったからです
  • 結果はどうみるかというと kuroko2 のジョブ実行ログ、 Prometheus、 CloudWatch の監視結果から確認できるのでそちらを参考にしました。
  • 直前の実験の影響を受けないように実験の直前には ECS タスクの入れ替えをしています

実験結果

適切そうな設定を決めることができました。 そしてどういう設定にしても Solr 4.9 より速い、現状より少ないリソースでも十分なスループットを提供できることが判明したので、急遽このタイミングで Varnish のキャッシュレイヤーを捨てるという意思決定が行われました。

試験運転

使えそうな設定が出来上がったところで実ワークロードでも問題なく動作するかを確認するために試験運転をすることにしました。 試験運転をする方法はいくつかあるかなと思うのですが、今回は Traffic shadowing という手法を選びました。これはリクエストをコピーし、テスト目的でサービスインしてない別の upstream に送る手法です。 もう一つの候補として、一部のユーザに対してロールアウトしてみるという選択肢もありましたが、検索サービスである voyager は検索をリクエストしているユーザが誰なのかわからないので、その情報を何らかの方式で渡すなり、新しい API をはやすなり、新しい Solr 用の voyager を用意するかなりいくつのサービスを跨る対応が必要でコストが増えるし、予想される設計もあまりうれしくないものでした。

一方 Traffic shadowing の場合、2つの利点があります。

  • ユーザに影響を出さない
    • 検索品質の検証は別途やっている、今回の実験の目的は実ワークロードでも安定して動くのかを確認するためで、必ずしもユーザに出す必要はないし、むしろ出さなくていいならそっちがいい
  • すべてのリクエストを流せるので実ワークロードを完全再現できる

ということで Traffic shadowing を試してみることにしました。

Envoy と request_mirror_policy

クックパッドは Service mesh を導入しており、その Data plane として Envoy を採用しています。そして Envoy は Traffic shadowing に利用可能な request_mirror_policy という設定を提供しています。 これはあるクラスタへのリクエストをコピーして別のクラスタに送る機能で、コピーして送ったリクエストのレスポンスはそのまま捨てられます(メトリクスは残ります)。設定を少しいじるだけなので 実際使ってみた感じだと以下のような感じでした。

  • envoy による CPU 負荷はやや増えるが、そこまで目立つ変化ではない
  • Host ヘッダーに -shadow という suffix をくっつけてくるので、 Host ヘッダーを利用する処理が必要な場合、注意が必要

それ以外の気になる点はなく、快適に利用できました。

実験結果 & 対策

予想外のスパイクによるキャパシティー不足

実環境だと利用サービス側のキャッシュパージやバッチ実行などによりリクエストのスパイクが発生するわけですが、これが予想以上に影響が大きく、観測した範囲だと直前の rps より最大2倍くらいに跳ねたり、レイテンシーが不安定になる現象が観測されました。対策としてはオートスケーリングの閾値をやや下げ、もう少し余裕をもたせることでレイテンシーを安定させることができました。

Cold start 問題

Solr は起動してからキャッシュが温まるまではリクエスト処理速度が遅く、いわゆる暖気が必要ということが知られているのですが、性能実験の時はあまり意味のある遅延がみられませんでした。 solr-hako は tmpfs を利用し、メモリーにインデックスを乗せることを推奨しており、実設計でもそれに従ってインデックスストレージとして tmpfs を採用していました。ですのでこれが tmpfs の力なのか?!と思っていたのですが、そんなことはなく実ワークロードだと普通に Cold start 問題が目立つようになってました。後になって思うと、性能実験にはピークタイムのクエリをサンプリングしていたのでクエリのパターンが偏っていたのかもしれません。 対策として2つ考えられて投入前にクエリをいくつか投げるとこで暖気を行う方法と ELB の Slow start mode を利用する方法がありました。 前者だと送られてくるクエリの傾向に合わせて暖気用クエリをメンテするコストがあるので、徐々にリクエストを流すことでレイテンシーの劣化を抑えることができる ELB の Slow start mode を有効にすることにしました。

切り替え

Traffic shadowing を一定期間運用したことにより、これは大丈夫だなという確信を得られたので実際に切り替えることにしました。ロールアウト作業は検索結果の順序の変化と、利用サービス側のキャッシュ事情が混ざるとややこしいことになりかねないので徐々に切り替えるのではなく一気に展開することにしました。試験運用で時間帯による要求キャパシティーも完全に把握できていたので特に懸念点もなく無事切り替えることができました。

と思っていたら、その数日後に移行漏れのバッチが発覚したのはまた別の話です 😇 移行後もしばらくの間、運用上の理由でロールバックする可能性を考慮し古い Solr サーバを残しておきインデックスも更新していたのがここで役に立ちました。備えあれば憂いなし。

結果

コスト節約

f:id:riseshia:20201124172749p:plain
コスト変化

実運用が始まったのが 9月中旬、古いインフラのリソースを片付けたのが10月初です。こちらは Cost Explorer からみた検索インフラのコストの推移ですが、新しいインフラコストが 1/3 くらいになったことがわかります。これは2つの理由があります。 今までの仕組みではそもそも運用しやすい形に実現するのが難しいのでオートスケーリングが有効ではなかったのが一つで、もう一つは Solr のバージョンアップによりそもそも Solr の性能が向上したことがあります。ピークタイムでも以前に比べて半分以下のリソースで運用できているんですね、これが。すごい。

レイテンシーの改善

f:id:riseshia:20201124172807p:plain
レイテンシーの変化

レイテンシーも相当改善されました。これも2つの理由があります。 わかりやすい理由は Solr の性能向上ですね。図の2つ目の崖がそれです。途中でも触れていましたが、 Varnish を介さなくなったのにも関わらず早くなったのが印象的です。 左側の崖は差分検証中に見つけた激重クエリが無駄な subquery を発行していることに気づいてそれを解消した結果発生したものです。

その他

その他の細かい改善点というと以下のようなものがありました。

  • Solr 起因の staging 環境でのエラーがほぼなくなった
    • staging 環境で利用してた Solr は本番用に比べると大変小さく、リクエスト数が急増すると悲鳴を上げがちだったのですが、今回の Solr の性能向上により安定するようになりました
  • 今まではインデックスの更新直後の負荷を恐れてピークタイムでのインデックス更新を避けていたが、そうする必要がなくなりました
  • 普通の ECS サービスになったことによりスキーマの更新と運用がしやすくなりました

まとめ

今回の検索インフラ改善作業の流れ、その時利用した技術などに関する説明は以上になります。このような面白いお仕事がいっぱいあるので興味があるエンジニアの方はぜひご連絡ください! https://info.cookpad.com/careers/jobs/

人気順検索のSolrはスケールのためにディスクを捨てた

技術部クックパッドサービス基盤グループの id:koba789 です。
昨年まではデータ基盤グループというところで 最新のログもすぐクエリできる速くて容量無限の最強ログ基盤 を作ったりしていました。
今年はちょっとチームを移動しまして、検索システムをいじっていました。今回はそのお話です。
なお、クックパッドには様々な検索システムがありますが、この記事では説明を簡単にするためにレシピの検索のみに焦点をあてています。

クックパッドの検索システムにあった課題

クックパッドにはレシピを検索できる機能があります。
プレミアム会員限定の人気順検索もこの機能の一部です。
しかし、この重要な機能を支える検索システムにはいくつもの課題がありました。

Solr が古すぎる

クックパッドでは、レシピ検索を含む多くの検索機能にSolrを用いています。
今年の始めに私がこの課題に取り組み始めた時点では、その Solr のバージョンは4.9でした。これは5年以上前にリリースされたバージョンです。
古いミドルウェアをあまりに長い間維持し続けてしまうと、OS や運用のためのツールといった周辺のソフトウェアのアップデートも困難になります。
OS だけ一気に最新のバージョンにアップデートしようとしても、互換性の問題で古いミドルウェアは動かなかったりするのです。
こうなると、もはやインクリメンタルなアップデートは非現実的で、古すぎること自体がアップデートをより難しくしているためデッドロックのような状況です。

現代的なプラクティスが実践されていない

上記のような状況から想像に難くないですが、インフラのアーキテクチャも大変古めかしいものでした。
クックパッドのインフラは AWS 上に構築されており、ほとんどのワークロードは ECS と Hako を用いてコンテナ化されています。
またその多くは運用にかかる金銭的コストを最適化するため、負荷に応じてオートスケールするようになっています。
しかしこの検索システムはそうではありませんでした。
Solr は専用の EC2 インスタンスにインストールされており、そのインスタンスはピークでもオフピークでも同じ数だけ動き続けていました。
また、この EC2 インスタンスをインスタンスの退役と入れ替えなどの理由で新規にプロビジョニングする場合は人による作業が必要でした。
その作業はスクリプトでほとんど自動化されてこそいるものの、このスクリプトも例に漏れず非常に古くなっており、保守性を悪化させていました。

これらの課題がありながらも、5年という長い期間ずっと変わらずにいました。
運用上は意外にも安定してしまっていたことと、サービスのコア機能に関わるため迂闊には手を出せなくなっていたことが理由です。 まさに触らぬ神に祟りなしといったところです。

ほぼすべてのワークロードがコンテナ化されたクックパッドにおいて、最後まで残り続け強烈な "レガシー" となっていたのがこの検索システムでした。

検索システムを見つめ直す

前述のような理由から、インクリメンタルなアップデートは諦め、ゼロからアーキテクチャを考え直すことにしました。

システムの作り直しは失敗しやすく困難なので、しばしば悪い方針だと言われます。
もし作り直しを成功させたいならば、新しいシステムで得られるものばかりに目を向けて既存のシステムの観察を怠るようなことがあってはいけません。
必要な要素を見落とせば不十分なシステムができあがって失敗するでしょう。 不要な要素を見極められなければ余計な工数がかかったり実現不可能になったりして失敗するでしょう。
私は既存のシステムを観察し、要件や事実を整理するところから始めました。

まず説明のために、既存のシステムの概略を紹介しましょう。 既存のシステムでは Solr の検索性能をスケールアウトさせるために、1台のプライマリからたくさんの(固定台数の)レプリカに向かってレプリケーションをしていました。 (実際の構成はもう少し複雑ですがここでは本質的ではないため単純化しています)

1台のプライマリからたくさんのレプリカに向かってレプリケーションをしている
既存システムの構成

そしてこのレプリケーションは自動ではなく、日に1度のバッチジョブで明示的にトリガーして実行していました。
そのジョブの内容はおよそ以下のとおりです。

  1. たくさんあるレプリカの中から1台選ぶ
  2. そのレプリカをロードバランサから外す(リクエストが来ないようにする)
  3. レプリケーションを実行する
  4. キャッシュを暖めるため "暖機運転" をする
  5. ロードバランサに戻す
  6. 以下、すべてのレプリカに対して繰り返し

わざわざレプリケーションを1台ずつ実行したり、レプリケーション中はロードバランサから外しておいたり、ロードバランサに戻す前に暖機運転をしたりするのは検索クエリへのレスポンスタイムを遅くしないためです。

さて、観察中に上記以外にも様々なことを発見しましたが、最終的に重要だった点は以下のとおりです。

  • インデックスは日に1度しか更新されない
  • レプリケーション後にキャッシュを暖めるため "暖気運転" をしている
  • Solr がインストールされている EC2 インスタンスのタイプは c4.2xlarge である
  • セグメントファイル(インデックスデータの実体。Solr が内部で使っている全文検索ライブラリである Lucene 用語)のサイズは 6GB 程度である

新しい検索システム

大前提として、新しい検索システムでは最新版の Solr を使うことにしました。
バージョンは4.9→8.6という大ジャンプでしたが、ほぼ schema.xml の書き換えのみで動かすことができました。
丁寧な移植作業の結果、バージョンアップ前後での検索結果の差は非常に少なくできましたが、それでも完全に一致させることはできなかったため、検索結果の品質について責任を持っているチームにお願いしてバージョンアップ後の検索結果の検証をしてもらいました。

また、新しい検索システムではすべてのワークロードをなんとかしてコンテナ化できないかと考えました。
もしコンテナ化できれば、社内に蓄積されたコンテナ運用の豊富なツールやノウハウの恩恵を受けることができ、圧倒的に運用が楽になるからです。

一般に、コンテナの利点を最大限に活かすためにはコンテナはステートレスであるべきとされます。 しかしながら、データベースや検索エンジンのようなミドルウェアは原理的にファイルシステムの永続化が必要でありステートフルです。
いきなり要件が矛盾しているようにも見えますが、一般論ではなく実際の要件に着目するのが大切です。
ここで最も重要なのはクックパッドのレシピ検索のインデックスは日に1度しか更新されないという点です。
ステートの更新頻度が十分に低いのであれば、ステートの変化の度にコンテナを使い捨てることでステートレスとすることができます。
以上のようなアイデアにより、新しい検索システムではセグメントファイルをファイルシステムではなく S3 に永続化することにしました。
また、セグメントファイルを S3 に置いたことでプライマリとレプリカ間のレプリケーションが不要になり、更新系と参照系を完全に分離することができました。

それでは、更新系と参照系について、動作を追って解説します。
まずは更新系の動作を見てみましょう。 なお、図中の s3ar は独自開発した S3 アップローダー・ダウンローダーです。詳細については後述します。

f:id:koba789:20201124151408p:plain
新しい更新系の動作
更新系のコンテナでは、まずインデックスの元になるデータを S3 から Solr に流し込み、すべてのデータを流し込み終えたら Solr を停止します。
この時点でローカルのファイルシステムにはセグメントファイルができあがっていますが、コンテナのファイルシステムは永続化されないため、このままコンテナを停止するとせっかくのセグメントファイルは失われてしまいます。
そのため、コンテナを停止する前にできあがったセグメントファイルを S3 にアップロードします。
アップロードが完了したらコンテナは停止し、破棄します。
この更新系のコンテナは日次のインデックス更新のバッチジョブの一部として起動されます。処理が完了するとコンテナは破棄されるため、処理中以外は計算リソースを消費しません。

続いて参照系の動作を見てみましょう。

f:id:koba789:20201124151412p:plain
参照系の動作
参照系のコンテナは起動するたびに最新のセグメントファイルを 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 が紹介します。