🦔

回避せよCWVスコア悪化、CDNとABテスト〜CWVスコアを維持する挑戦者たち〜

2023/05/09に公開

こうなんか仰々しいタイトルをつけてみたものの、そんなに壮大な話でもありません。
プロジェクトXみたいなタイトルを付けたかっただけです。

こんにちは、スペースマーケットでエンジニアリングマネージャーをしています。

記事を書くたびに肩書きが変わるね、次はどんな風になっているんだろうね、ハム太郎♪

タイトルの話をする前に前提の話をしていきます。

CDNとは

そもそもみなさんCDNをご存知でしょうか?
なんかキャッシュとかそういうのだよね?みたいな認識の方はいませんか?
いますよね、私もそうでした(?)

CDNとはContents Delivery Networkの略です。
決してCache Delivery Networkではありません、CDNはキャッシュすることが目的ではないわけです。
CDNはコンテンツをより速くより効率的に配信するために構築されたネットワークのことです。
つまりその機能の一部にキャッシュがあるわけですね。

では他にはどんな機能があるかというと

  • 分散配置されたエッジサーバー
  • ルーティング
  • 画像最適化

などといった機能もあります。
キャッシュだけではないということを覚えておくと良いかと思います。

CDNを使ったABテスト

さて、CDNを使っている環境でABテストをする場合どのような方法が使えるでしょうか?
ABテストは結局A/Bの切り分けが必要になります。
CDNを使わない場合、基本的にはオリジンサーバーへリクエストが飛ぶためユーザーの振り分けさえできればすぐにでもABテストを行えるわけです。

しかしCDNを使いキャッシュを利用している場合はどうでしょうか?
CDNキャッシュを利用したレンダリングのイメージ
1度目のリクエストではオリジンサーバーへリクエストが飛びます。
しかし2度目以降のリクエストではCDNからストアされたデータでレスポンスが返却されます。
そのためキャッシュの期限が切れるまではオリジンサーバーにリクエストされず、ABテストの振り分けはストア前の振り分けに依存されてしまい想定の通りにならないわけです。

「さて、それじゃあどうやってABテストをしよう・・・」
「オリジンサーバーにリクエストせずにABテストをすればいいんや!CSRや!!!工藤!!!」
ということでCSRでのABテストが候補として上がりました。

CSRでのABテスト

CSRでのABテストはレスポンスのHTMLを切り替える方法ではなく、JavaScriptによってクライアントサイドでUIを切り替える方法です。
例えばですがsampleというidを持ったボタンがあったとします。

<button id="sample" type="button">ボタン</button>

デフォルトのカラーが青だったとした場合、これをHTMLをレンダリングした後に赤などに切り替えると言うことです。
これをJavaScriptで制御します。

document.getElementById('sample')

で要素を取得しbackground-colorなどを上書きするイメージですね。
代表的なサービスで行くと、Googleオプティマイズを使ったABテストはこちらに該当します。

じゃあCSRでABテストすればいいじゃんという話なのですがそんなに良いことばかりでもありません。

例に出したものがボタンの色を変える内容ですが、これはあくまでJavaScriptが読み込まれてしか実行されません。
そのため、読み込まれる前までは元の色で表示されるわけです。
また、色を変える程度であれば良いですがもっとレイアウト自体を変えたい、要素を消したいという話になってくるとレイアウトシフトが発生しかねません。
Core Web VitalsでいうところのCLSの悪化も気になります。
そのためCSRでABテストするにはかなり小さい規模でのみのテストに限定されてしまいます。

そのためSSRをしつつABテストを実施する方法を探る必要があります。

SSRとCDNとABテスト

さあSSRでABテストを実施したいわけですが、先ほど説明した通りCDNでキャッシュされてしまうため簡単にはできません。

CDNキャッシュを利用したレンダリングのイメージ

そのため何かしらの方法を使う必要があるわけです。
キャッシュは主にリクエストされたコンテンツのURLを基にキャッシュを管理しています。
そのため、このままだと1つのURLのリクエストに対して1つのコンテンツしか返却できません。
ABテストのため複数のコンテンツ表示が存在するのでABテストができなくなっています。
URLの変更によってコンテンツを変えることができるのであれば、ABテストのたびにURLを切り替えるという方式もあります。
しかし、ABテストのたびにURLが増えてしまうと大変です。
URLをブックマークなどされてしまうとABテストを撤退するたびにリダイレクト対応を入れたりと撤退するたびにコストばかりかかってしまいます。

そこで必要になってくるのがVaryヘッダーです。
Varyヘッダーを制するものはABテストを制することができます(?)

Varyヘッダーとは?

MDNにVaryの説明があります。

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Vary

Vary HTTPレスポンスヘッダーは、オリジンのサーバーから新しく要求するのではなく、キャッシュされたレスポンスを使用できるかどうかを決定するために将来のリクエストヘッダーをのどうように一致させるかを決定します。

はい、皆さんはどんなものか分かったと思うのですが残念ながら私はさっぱりわかりませんでした。
そのため私向けに解説を入れていきます。

まずVaryがどんなものかというと、URLが主キーだとするとVaryはセカンダリーキーに当たります。
そのためVaryを指定することで、1つのURLに対して複数のキャッシュを持つことができるようになるわけです。

そもそもVaryとは、変化という意味を持つ動詞です。
Variation(バリエーション)が名詞でVaryが動詞と考えるとよりわかりやすいのではないでしょうか。
ちなみになんとなくVary(ヴァリー)と読みそうですが発音としてはVary(ヴェリー)が正しいらしいです。
よく分からないので伝わればいいと思います。
※ 著者は英語貧弱です

さて、Varyがセカンダリーキーだと分かりましたが何を指定するといいのでしょうか?

将来のリクエストヘッダーをのどうように一致させるかを決定します

はい、そこで出てくるのがリクエストヘッダーです。
クライアントが送ってくるリクエストヘッダーの値ごとにCDNではキャッシュを作成し返却します。

例えばAccept-Encodingを指定した場合は下記のようになります。

vary: Accept-Encoding

さらにVaryはカンマ区切りで複数指定することも可能です。

vary: Accept-Encoding, User-Agent

このようにVaryを使うことでABテストができるようになります。

Varyを使う際の注意点

さて、早速ABテストについて書きたいのですがその前に少し注意点があります。
それはキャッシュヒット率です。

Varyは複数指定できると書きましたがこの数が増えれば増えるほど組み合わせが増えます。
そうなるとキャッシュヒット率が極端に低くなってしまいます。
キャッシュを利用し表示速度を上げるという意思入れをしていてもこれで無駄なキャッシュが生成されたりと効率が悪くなります。

ちなみにキャッシュヒット率は次の計算式で算出可能です
キャッシュヒット率 = キャッシュヒット ÷ (キャッシュヒット + キャッシュミス)

また、先ほど複数指定で記載した User-Agent をVaryに指定する場合にも気をつけましょう。
User-AgentにはバージョンやOSなどさまざまな情報が含まれています。
次に記載するUser-AgentはChromeのDevTool上で端末を変えた時に取得したUser-Agentです。

iPhone    : 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
iPad      : 'Mozilla/5.0 (iPad; CPU OS 13_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/87.0.4280.77 Mobile/15E148 Safari/604.1'
NestHub   : 'Mozilla/5.0 (Linux; Android) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.109 Safari/537.36 CrKey/1.54.248666'
Pixel5    : 'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36'
Galaxy S8+: 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36'

ここにあげただけでも5通りあります。
User-Agentを使いたくても複数指定と同様にキャッシュヒット率が低くなるので気をつけましょう。

Varyを使ったABテスト

これで、Varyについて「完全に理解した」の状態になったと思います。
それではここからはABテストについてより詳しく勉強していきましょう。

まずは、リクエストヘッダーにABテストのグループを判定できる情報を付与してあげる必要があります。
そこでリクエストヘッダーにカスタムヘッダーを付与してあげます。
名称はなんでも良いですが一旦ここでは X-ABTest-Pattern とでもし、テストパターンをAとBの2つとしましょう。
そうするとキャッシュのイメージは次のように少し変わります。

カスタムヘッダーを使ったキャッシュのイメージ

さてなんとなくキャッシュされるイメージが理解できてきたでしょうか。

それではユーザーごとにカスタムヘッダーを付与していきます。
これはアプリケーション側ではなくCDN側で実装します。
処理フローとしては次のようになります。

処理フロー

まずはユーザーはURLに対してリクエストを行います。

CDNでリクエストを受け取ったら、リクエストに対してユーザーIDを振り分けます。
振り分けするIDは何でもいいのですが、乱数などで振り分けすると良いでしょう。
そして振り分けしたユーザーIDを元にテストの振り分けを行います。
これもどのような振り分け方でもいいのですが乱数などを使うと良いと思います。

振り分けが完了したら次にCDN上でリクエストに手を加えます。
カスタムヘッダーを付与するので X-ABTest-Pattern をヘッダーに付与し先ほど振り分けたグループを設定します。

その後にCookieにユーザーIDまたは振り分けした結果を保持します。
なぜ保持する必要があるかというと、保持した情報がなければ毎回ユーザーIDを生成しなければならずそうすると表示するたびにABテストの振り分けも変わってしまうためです。
そうなると「A / Bどちらによって良くなったのか」が判別できなくなってしまいます。
そのため、ABテスト実施中は可能な限り振り分けが切り替わらない方が良いため、情報を保持できるようにCookieに入れてあげるのが良いです。

その後CDNはリクエストを行うのですが、この時点でストアされたキャッシュがある場合はそのままキャッシュを返却します。
なければオリジンサーバーにリクエストが飛びます。

オリジンサーバーでは当然ながらABごとにUIを切り替えた結果をレンダリングします。
レンダリング後にレスポンスヘッダーにVaryを設定しレスポンスを返します。
ここで設定するのは vary: X-ABTest-Pattern になるでしょう。
こうすることで次回 X-ABTest-Pattern: A または X-ABTest-Pattern: B で再度リクエストが来ればキャッシュがストアされているのでキャッシュから返却できます。

CDNではレスポンスヘッダーにVaryがついているのを判断しセカンダリキーにしてキャッシュしレスポンスを返します。
こうすることでVaryを使ってABテストができるようになります。

オリジンサーバー側でABテストのグループに合わせた結果がレンダリングされた状態になるのでCWVへの影響もかなり少なくなります。
またキャッシュがあることでテストを中の表示速度も一定の速度を保てるわけです。

なお、ABテストのグループ分けの部分の実装についてはVCLという言語ですが、Fastlyにてチュートリアルコードがあるので参考にすると良いです。

ABテストをするにあたってのTips

さて、Varyを使ったABテストのやり方がわかったのですが気をつけるべきこともあります。

まずはVaryで説明した通り複数のテストが走るとキャッシュヒット率が落ちます。
文中では X-ABTest-Pattern という例を挙げましたが、 X-ABTest-Pattern1 X-ABTest-Pattern2 など複数のテストグループを作ることも可能です。
並行で走るテストが増えれば増えるほどキャッシュヒット率は下がりますので気をつける必要があります。
そのため、共通処理としてレスポンスヘッダーを設定しないようにすることが必要です。
アプリケーション、サービスで単一のURLであることは珍しいでしょう。
例えばEC系のサイトでしたら、一覧ページ、商品ページ、購入ページなど複数のページが存在すると思います。
ABテストをやる際に全ページにテストを適用するということはかなり少ないと思います。
そのためVaryヘッダを付与するのは テスト対象のページ だけに限定することをお勧めします。
一覧ページではテストをやっていない、商品ページではテストをやっているという状態の時に、共通処理としてVaryヘッダーを毎回付与してしまうとそれぞれ2つずつキャッシュが生成されてしまいます。
一覧ページではテストを実施していないわけなのでVaryヘッダーは不要です。
そのためテスト対象のページでのみ付与するようにするとキャッシュヒット率が下がるという自体も避けられます。
また一覧ページでは X-ABTest-Pattern1 商品ページでは X-ABTest-Pattern2 を使っているということであればそれぞれでVaryヘッダーを付与しましょう。
どちらも A or B だった場合、共通処理にしてしまうと1ページに対して4パターンのキャッシュができてしまいますが、一覧、商品それぞれのページで個別にVaryヘッダーを付与すれば1ページに対して2パターンずつのキャッシュで済みます。

次にキャッシュヒット率を保つ観点では並行でABテストを走らせる上限値を決めておくのも良いでしょう。
組織内で「キャッシュヒット率はN%保持したい」という目標数値を決めておき、それ以下になるような組み合わせではABテストを実施しないような運用にするのも良いです。
実装だけ先にしておき何かのテストと入れ替わりでテストを実施するなどでリリース速度に大きく影響が出ることは少ないと思います。
また、SEOに関する工夫としてはGoogleのクローラーだけはテスト対象から外すというのもいいでしょう。
UserAgentからGoogleのクローラーかどうかというのは判定可能です。
こことテストのグループ分けの部分をうまく使ってクローラーにはキャッシュヒット率を上げるというやり方も一つ有効だと思います。

また、ABテストはUIの切り替えだけではなくAPIのアルゴリズムの変更で使うこともあると思います。
先ほどのECサイトの場合、一覧の表示アルゴリズムもABテストをしたくなります。
CSRだとリクエスト方式を変えたりなども検討しなければいけませんが、この仕組みを使えばAPIのヘッダーに情報を載せればそのまま今回の仕組みを使えます。
API側でテストする場合もVaryヘッダーを付与する必要がありますが、その辺りも含めてオリジンサーバー側の実装を検討されると良いと思います。

さいごに

ABテストしたいけどキャッシュしているしなかなか運用できないとお困りの方の参考になればと幸いです。
今回実装については触れなかったのですが、「よく分からなかったキャッシュとちょっと仲良くなれた」という気持ちになれたら嬉しいです。

GitHubで編集を提案
スペースマーケット Engineer Blog

Discussion