はじめに
こんにちは。クックパッド SRE の @mozamimyです。先日この開発者ブログで One Experience プロジェクトについての紹介がありました。
このプロジェクトにおいて、わたしは日本版からグローバル版への移行の際の全般的なパフォーマンス周りについて取り組んでいました。
パフォーマンスと一言でいっても、その中にはネットワークやアプリケーションレイヤでのレイテンシ、MySQL などのミドルウェアでのレイテンシなど、様々な要因が関わってきます。それらの改善において、何よりも重要なのはまず観測することです。移行において取り組んだ様々な作業のうち、ここでは CloudWatch RUM や Calibre といったツールを用いた Web ブラウザからのアクセスのパフォーマンス観測に焦点を当てて紹介します。
プラットフォーム移行によるパフォーマンスの劣化をできる限り避けたい
先述の One Experience の紹介記事でも書かれている通り、日本版とグローバル版は完全に独立したプラットフォーム上で動作しています。すなわち日本版とグローバル版ではパフォーマンス特性もまったく異なるということです。
まず、データの統合による MySQL 関連のデータ増加によるパフォーマンス特性の変化が懸念事項の一つでした。グローバル版と日本版のデータ量を比較すると日本版の方が大きく、統合後にはデータ量が大幅に増加するからです。データ移行の作業の一部である継続的なデータ移行については、先日鈴木による記事が公開されています。
その他にもユーザにとって確実に悪影響があると見込まれていたのが、システムが動作しているリージョンの違いによるレイテンシの増加です。両方とも AWS を利用していますが、日本版は ap-northeast-1 (東京) に、グローバル版は us-east-1 (バージニア北部) にデプロイされています1。
One Experience ではグローバル版に日本版を統合するという方針になった以上、そのままのアーキテクチャだと日本からのトラフィックは太平洋と北米を横断することになるため、ネットワーク起因のレイテンシの増加は避けられません。この差を埋めるために、
- グローバル版の Rails アプリケーションの実装を改善してパフォーマンスを上げる
- MySQL クエリの改善や適切なキャッシュの利用などでパフォーマンスを上げる
- フロントエンドの実装を改善して見かけのパフォーマンスを上げる
- マルチリージョンデプロイ: 日本ユーザから近い場所にサーバを置く
などなど、移行について動き始めた段階でいろいろと改善する余地・手段があることは漠然と分かっていました。ただし取り組みによっては実装・運用コストが大きいため、まずは現状をしっかりと把握して何から取り組んでいくとよいかを考える必要がありました。また、パフォーマンス差を埋めるための判断材料としての計測ももちろんですが、それはそうとして実際に移行を進めていくにあたって日本版とグローバル版のパフォーマンス差の日々の変化をトラックする必要もありました。
ユーザから見た総合的なパフォーマンスを測定するための手法として、RUM (Real User Monitoring) および synthetic monitoring があります。One Experience では RUM として Amazon CloudWatch RUM (以下 CloudWatch RUM と表記) を採用し、synthetic monitoring として Calibreを採用しました。CloudWatch RUM の利用にあたっていろいろ工夫した点があるので、本稿では特に CloudWatch RUM について深掘りしていきます。
CloudWatch RUM の導入と活用するための工夫
RUM について
RUM (Real User Monitoring) はユーザから見た総合的なパフォーマンスを計測する上でメインとなるもので、クライアントサイドで計測したパフォーマンス指標となる数値やエラーなどを収集し、分析・可視化するためのツールです。
One Experience においては RUM で収集した Core Web Vitalsのうち、LCP (Largest Contentful Paint) を KPI として利用することにしました。詳細な説明はリンク先に譲りますが、LCP とはウェブページ上のもっとも大きな面積を占める画像または HTML エレメントが描画されるまでの時間を指します。
RUM を実現するためのサービスは Datadog などをはじめいろいろと選択肢はありますが、以下の理由から CloudWatch RUM を選択しました。
- 競合のソリューションと比べて比較的安価
- 既に AWS を利用しており、社内での手続きのもろもろを省けるため導入のハードルが低い
- 生のログを CloudWatch Logs に出せるので高度な分析がしやすく、AWS の別サービスとの連携が可能
CloudWatch RUM の導入
CloudWatch RUM の Rails アプリケーションへの組み込みや AWS 側でのリソースの準備は特に難しいこともなく、ドキュメントにしたがって以下のような手順を踏めば実際にイベントデータが送信される状態になりました。
- Cognito identity pool を用意
- RUM の app monitor を作成
- 2 で作成した app monitor にログを送信できる権限を持つ IAM role を作成
- identity pool に IAM role を紐付け
- Rails アプリケーションのフロントエンドに RUM 用のコードスニペットを追加
対象となる Rails アプリケーションは日本版とグローバル版の両方になりますが、app monitor はあえて一つにしました。日本版・グローバル版ともに同一の web origin である https://cookpad.comを利用していたことと、のちほど紹介する分析においてイベントデータが一つの CloudWatch Logs グループにまとまっているほうが都合がよいからです。
導入自体は簡単ですが、詳細な分析を行うためにいくつかの設定を行う必要があったので、以下のセクションでそれらについて説明します。
CloudWatch Logs へのエクスポート
イベントデータの詳細な分析をしたい場合、app monitor の作成時に CloudWatch Logs に出力する設定を入れると便利です。またこの際、ロググループに expire を設定しておくとよいでしょう。CloudWatch Logs のコストの多くをログの取り込みが占めるとはいえ、ストレージにかかるコストも無視できません。
要件に応じた attribute の追加
Rails アプリケーションの JavaScript コードで RUM クライアントを初期化する際、任意の attribute を追加することができます。今回は、グローバル版か日本版を区別するための railsApp、Rails のコントローラを区別するための railsController、Rails のアクションを区別するための railsAction をそれぞれ設定することで分析しやすくしました。これらを設定しておくことで CloudWatch RUM のコンソールでイベントを絞り込んで分析できます。たとえば、デフォルトで付与される countryCode などの attribute を組み合わせて以下のようにフィルタすると、「グローバル版のレシピ詳細ページに日本からスマートフォンでアクセスしたイベント」に絞り込むことができます。
サンプリングレートの設定
RUM クライアントの設定の際には適切なサンプリングレートを定める必要があります。100% に近付ければ近付けるほどよりよい精度でデータが得られますが当然コストは増加します。CloudWatch RUM はイベント数による課金なのでトラフィック量からおおよその料金を予測しやすいです。うっかりクラウド破産しないよう、ある程度見積もった上で少ない割合から始めて、AWS Cost Explorer を眺めながら要件や制約に応じて適切に調整するとよいでしょう。
収集したデータを AWS コンソールから分析・トラックする
イベントデータの収集を開始すれば、以下のスクリーンショットのように CloudWatch RUM のコンソールからフィルタや期間で掘り下げていく形でパフォーマンスについて分析することができます。エラーや JavaScript による HTTP(S) 通信の実行、セッションごとにイベントを確認するなど、インクリメンタルに分析したり、パフォーマンスについてざっと眺めたりできます。
75 パーセンタイルで LCP を確認したい
いっぽうで少し融通の効かないところもあり、特に LCP の 75 パーセンタイルの値がコンソール上で確認できないことは問題でした。
Core Web Vitalsから引用した以下の図のように、LCP の良し悪しを判断するしきい値として一般的に 75 パーセンタイルを用いるとよいとされています。平均値ではデータに偏りがある場合に指標として適切でなくなってしまう場合があるからです。もちろん要件によってこの条件をカスタマイズできますが、One Experience においては基本の 75 パーセンタイルで 2.5 秒以内を基準とすることに決めていたので、平均値しか見られないことは問題でした。
この点については AWS に既に要望をあげていますが、これを自力で解決できないか考えてみます。たくさんあるビルディングブロックの組み合わせでユーザごとの要求に柔軟に対応できるのが AWS の強みです。
CloudWatch RUM で収集したデータを利用しやすいように集計する
さて、ここまでの流れでコンソールに頼らずに RUM のデータを独自に集計して分析したいというモチベーションについて説明しました。このセクションでは、それを実際にどのように実現するかを考えてみます。
CloudWatch Logs Insights を利用する
はじめに思いつくのが CloudWatch Logs Insights です。RUM のデータは JSON 文字列として CloudWatch Logs にエクスポートされているので自然に Insights が利用できます。Grafana もデータソースとして CloudWatch Logs Insights をサポートしているので、これを利用すれば Grafana でダッシュボードが作れそうです。
SQL に親しんでいる身として構文にちょっとクセを感じますが、たとえば以下のようなクエリで、日本からモバイルデバイスでレシピページにアクセスしたときの LCP を p75 で集計して求めることができます。漉し器となる filter をパイプでつないで上からレコードを流していき、最後に stats で集計するというイメージですね。
filter event_type = "com.amazon.rum.largest_contentful_paint_event" | filter metadata.railsApp = "Global" | filter metadata.railsController = "recipes" | filter metadata.railsAction = "show" | filter metadata.countryCode = "JP" | filter (metadata.deviceType = "mobile" or metadata.deviceType = "tablet") | stats pct(event_details.value, 75)
これでめでたしめでたし... とはいきません。スクリーンショットの集計結果の 7.8 GB (!)という値に注目してください。これは見たままスキャン量で、これに比例して金銭的コストとクエリ実行時間がかかります。上述の例では期間を1 日に絞って集計してこのスキャン量となっており、アドホックな分析なら大きな問題になりませんが、期間を伸ばした上で Grafana 上にたくさんペインを作って表示させると、それだけたくさんのクエリが発行されることになってしまうので実用的ではありませんでした。
Timestream を利用したサマリーテーブルの作成
このようなシチュエーションは CloudWatch Logs Insights に限らず一般的なデータ分析あるあるです。このような場合、集計を定期実行して専用のテーブルに保存しておくのが常套手段です。
ではどこに集計結果を保存するのかということが問題となりますが、ここでは Amazon Timestream for LiveAnalytics (以下 Timestream と表記します) を採用しました。Timestream は AWS のマネージドな時系列データベースです。ヘビーユースに耐えることを特長としていますが、ライトな使い方でもコストが非常に少なく済み、雑にデータを入れてクエリできる便利ストレージであることが個人的には魅力だと感じています。DynamoDB も似たような用途で使えますが、シンプルな KVS では微妙にかゆいところに手が届かないユースケースをカバーしているところが好きです。
さて、以下に Timestream および Lambda を用いた集計システムの概要を示します。
矢印はイベントデータの流れを示しており、CloudWatch RUM からエクスポートされた生のイベントデータが CloudWatch Logs に送られ、CloudWatch Logs Insights API を叩く Lambda function が Timestream table に結果を保存し、開発者がその Timestream table に Grafana を通してクエリするという形になっています。この Lambda function は EventBridge によって日次で実行されるように設定されています。
この Timestream テーブルに対して、たとえば以下のようなクエリを実行すると以下のような結果が返ってきます。SQL 風にクエリできるので脳にやさしいです。
select * from cookpad_rum.global_web_lcp_jp where time between ago(7d) and now() orderby time desc ;
d_ プレフィックスをもつカラムを dimension (キーのようなもの) として設定しています。ここではデバイス・Rails アクション・Rails コントローラを dimension にし、それに対して LCP を保存するという形になっています。
実際に Grafana ダッシュボードに設定されているクエリは以下のような感じになっています。パーセンタイルやデバイスをダッシュボードのプルダウンから変更できるように Grafana の変数を利用していることや、そのままだとギザギザして分かりにくい傾向をなめらかに見やすくするために移動平均をとっているところがミソです。
select time , avg(lcp_p${statistics}_ms) over (orderby time rowsbetween5 preceding andcurrentrow) as JP from cookpad_rum.global_web_lcp_jp where d_controller = 'recipe'and d_action = 'show'and d_device = '$device' ;
ここまでの作業によって、以下のような感じで Grafana で LCP を可視化することができました2。水色の線は One Experience の web 版の移行が完全に完了した日です。
ちなみに日本版のレシピページの LCP はおよそ 0.9s で、日本からアクセスしたグローバル版の LCP はおよそ 1.3s です。元より悪化していることは否定できませんが、太平洋をまたぐことによる 150ms ほどのペナルティがある状態で、Core Web Vitals で基準とされている 2.5s よりは速いですし、まあまあ悪くないと言って差し支えないのではないでしょうか。
このようにして、Timestream を利用して RUM で収集した生のデータを Grafana から利用しやすい形に変えたことで、CloudWatch Metrics や Prometheus を通してクエリできる他のシステムメトリクスとあわせて安価かつ高速に表示できるようになりました。また、Grafana の他にも QuickSight などの BI ツールと連携することもでき、Redshift や Athena を通してクエリできる多様なデータソースと組み合わせるなど応用の幅が広がります。
実際に移行する前に日本からアクセスした場合のパフォーマンスについて知りたい
さて、ここまでの話で、CloudWatch で収集した RUM データの分析ができるようになりました。しかし RUM においては、実際にユーザに RUM クライアントを通じてデータを送ってもらう必要があります。すなわち、実際にリリースするまでデータがとれない、ということです。
このような問題があるため、一般的に RUM と synthetic monitoring の両方を使い分けるとよいとされています。synthetic monitoring はサーバ上のヘッドレスブラウザから人工的なリクエストを発生させ、RUM のようにメトリクスを収集してパフォーマンス改善に役立てるツールです。ツールにより機能の差異がありますが、パフォーマンスの数値や描画時の動画、回線状況のシミュレーション (光ケーブルやモバイル回線など)、パフォーマンス改善のためのヒントなどが提供されるのが一般的です。
One Experience では、RUM と synthetic monitoring を組み合わせて、以下のようにしてリリース前から観測を行っていました。
- グローバル版に台湾からアクセスしたときの RUM データを活用した
- synthetic monitoring として Calibreを採用してテストを設定した
グローバル版は One Experience 以前から多くの国と地域に向けてサービスを展開しており、台湾もその中の一つです。台湾には日本と似た比較的高速なネットワーク環境があり、地理的にも近い場所にあります。そのため、台湾から送られてくる RUM のデータはベンチマークとして利用しやすいと考えました。
台湾の RUM データだけでは不十分なところを補うため、先述の Calibreを導入してグローバル版の日本向けページ (https://cookpad.com/jp) に対して、アクセス元のロケーションを日本に設定してテストを設定しました。本稿の趣旨から外れるため詳細な説明は割愛しますが、比較的安価で既に利用実績があったことと、我々にとって必要十分な機能を揃えていたのが Calibre を採用した理由です。
Synthetic monitoring による決まったページへの人工的なリクエストでは、どうしても回線状況を含めた実際のユーザの状況と乖離してしまう部分はありますが、おおまかな傾向は掴めますしどこに改善する余地があるのかを調査する助けになりました。Calibre をはじめとした synthetic monitoring ツールに実装されている、改善点をアドバイスしてくれる機能も活用しました。
定期テスト完了時に webhook を介して外部に通知する機能が Calibre にはあるのですが、この通知を受けて Timestream にデータを格納し、Grafana で可視化する仕組みも作りました。これはこれで Lambda の Function URL を活用していたり、Function URL の弱点を補うちょっとした認証の仕組みを入れてみたり、Timestream を活用したりなど個人的に面白いと思っている要素が詰まった仕組みなのですが、記事がどんどん大きくなるのでここでは割愛します。ともかく、以下のスクリーンショットのような感じで synthetic monitoring のメトリクスも Grafana に出せるようになりました。
まとめ
本稿では、One Experience におけるパフォーマンス関連の取り組みのうち、ユーザから一番近い場所でのパフォーマンス情報を収集して可視化することにフォーカスして説明しました。
これらの仕組みを整えた上で、他のシステムメトリクスや手作業で行うテストによる定性的な問題の調査および、パフォーマンスの観測結果を組み合わせてパフォーマンスを改善してきました。一通り One Experience がリリースされた現在も、クックパッドを快適に使ってもらうためのパフォーマンス改善の取り組みは続いています。