美容メディア ARINE の記事を Amazon CloudFront を通して配信するようにした話

アリネチームの平田です。毎日に憧れを、な美容メディアARINEでサーバーサイドの開発をしたりインフラ回りを見ています。

最近ARINE(https://arine.jp)はAmazon CloudFrontを通してページを配信するようにしました。主な目的はほぼ静的で、かつARINEへのアクセスの中心である記事ページをキャッシュすることでレスポンスを速くし、かつアクセスが増加した際のオリジンの負担を軽くすることです。

今回はCloudFrontを入れるまでにやったこと、入れてみての効果、そしてやってしまった失敗について書きます。

想定している読者について書いておきますと、この記事は初めてCDNの設定をした自分が振り返って書いてみている内容ですので

  • 普段は主にサーバーサイドのアプリを書いているけど、たまにインフラも見ていて、
  • CDNの設定はやったことがないけどやろうとしている
  • もしくはやったばかり

という方にはお役に立てる内容があるかもしれません。

やったこと

今回の主な目的は前述の通りレスポンスの改善とオリジンの負荷対策です。そのため、キャッシュ可能なページはキャッシュします。

そのためにまずCloudFrontの設定以前にやったこととしては、

  • キャッシュキーの洗い出し
  • テンプレートのレンダリング時点での条件分岐をロード後のJavaScriptでの処理に変更
  • 無駄なSet-Cookieの削除
  • コントローラーでアクセスごとに実行している、レスポンスを返すこととは関係のない副作用的な処理のAPI化

などがあります。

キャッシュのキーを考える

CDNでページをキャッシュするうえで重要なことについて、弊社インフラ所属のいわなちゃんさんの記事、 CDNとの付き合い方を何度も読み参考にしました。

こちらの記事から重要なことを引用させていただくと、

一番重要なことは、一つのキャッシュするオブジェクトに対して複数の意味を持たせないということです。

です。

あるキーに対応付けてキャッシュするオブジェクトが必ず1つの意味を持つように、ページの生成に関係するパラメータを注意深く洗い出してそれらが全てキャッシュのキーに含まれるようにキャッシュキーを設定する必要があります。

ARINEの記事ページの場合、以下のような要素があり、それぞれ対処しました。

  • UserAgent
    • PC向けかスマートフォン向けページのどちらを返すかの判別
    • iOSかAndroidかに応じて、アプリのダウンロードを促すバナーの出し分け
  • ログイン状態
    • ユーザーのアイコンや記事のいいねの状態を出したり、通知を出したり

UserAgentによる出し分けは、ARINEではwoothee (とrack-user_agent)を使ってUserAgentからデバイスタイプ(variant)を判定し、variantがpcかsmartphoneかによってテンプレートを使い分けていました。

CloudFrontでは、リクエストヘッダーをオリジンに転送する場合はそれがキャッシュのキーに含まれます。つまりUserAgentを転送するとUserAgentごとにキャッシュすることになり効率が非常に悪くなります。

そのため、CloudFrontではざっくりとUserAgentの種類を判定するためのヘッダーが用意されています。(参考:デバイスタイプに基づいてオブジェクトをキャッシュするように CloudFront を設定する)wootheeで判定していたところを、CloudFrontからのアクセスの場合はそれらのヘッダーを見て判断するように変更しました。

アプリダウンロードのバナーについては、サーバーサイドで判定して出しわけるのをやめ、JavaScriptでUserAgentを取得して画像などを差し替えるように修正しました。

 

先程紹介した記事にも書いてある通り、クッキーの取扱にも注意が必要です。

今回キャッシュするにあたっては、Set-Cookieを発行するページは原則キャッシュしていませんし、ユーザーからarine.jpドメイン向けのクッキーが送られてきている場合全てキャッシュのキーに含んでいます。(CloudFrontの設定でオリジンに転送するように設定している) Set-Cookie を誤ってキャッシュに含んでしまいクッキーが共有されてしまったり、特定のユーザー向けのページをキャッシュし、他のユーザーに配信してしまうことを防ぐためです。(この点については先程の記事に、著者のいわなちゃんさんのポリシーが書いてありますのでそちらが参考になります。)

つまり今回はユーザーがログインしている場合のキャッシュは実質ほぼ諦めています。

ARINEの場合は99%以上のアクセスはログインしていない状態でのアクセスです。ログインしていないケースだけでもキャッシュできれば十分な効果が得られるため、今回は見送りました。しかし、ログインしてARINEを見にきてくださっている貴重なユーザーがより良い体験を得られないことは良くないので、今後は取り組んでいこうと考えています。

無駄なSet-Cookieの削除

元々のARINEのRailsアプリケーションはそれを使わないページでも、必ずcsrf_meta_tagsを呼び出し、CSRF対策のauthenticity tokenを発行していました。csrf_meta_tagsはセッションを使ってリクエストを検証するので、全てのページでSet-Cookieが返されていました。

CDNで余計なSet-Cookieを削除するという方法もありますが、この場合はそもそもアプリ側で不必要にセッションを発行しているので、必要な場合のみcsrf_meta_tagsを呼び出すようにして余計なSet-Cookieを返さないようにアプリを改修しました。

コントローラーの処理の分離

ページが表示されていてもコントローラーにはアクセスが来なくなることにも備えなければなりません。

ARINEでは、コントローラーで詳細なアクセスログを残したり、記事のPV数を計測したりしていました。(※Railsアプリケーション内で使う用のPVカウンターで、分析や実績値の集計ではGoogle Analyticsなどで計測しているPVを見ています。)

CloudFrontでキャッシュしてしまうと、アクセスがありページが表示されていてもコントローラーは動かなくなります。そうするとログや計測上困るので、ログやPVの計測を行うためのAPIエンドポイントを用意して、ページロード後に叩くようにしました。

これでキャッシュされたページでも以前と同様にログが取れるようになりました。

 

これでアプリ側(というかCloudFrontの設定以外)で対応すべきことは全部やった、と思っていましたが、実際には重要な設定が漏れていました。何かは失敗の箇所で後述します…。

 

そしてCloudFrontを実際に設定します。ここではパスごとのキャッシュの設定(Behavior)の話しかしません。

ここでのポイントは

  • 絶対にキャッシュをしない設定を間違いなく確認する
  • キャッシュのキーを適切に設定する
  • エラーの場合のキャッシュも適切に

といったところでした。

絶対にキャッシュしない

キャッシュが目的と言っていた割には、今回は安全を第一に考え、なるべくキャッシュをしない方針です。デフォルトの設定は一切キャッシュをしないようにし、極めて限定されたページでのみ、保守的にキャッシュを設定しました。

一切キャッシュをしないようにするためには、CloudFrontでは 「すべてのリクエストヘッダーをオリジンに転送する」設定にします。当初自分は全てのTTLの設定を0秒にすることで対応しようかと考えていましたが、キャッシュを完全に無効にするためにはこの設定にします。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/Expiration.html

キャッシュ動作のためにすべてのヘッダーをオリジンに転送するように CloudFront を設定した場合、CloudFront は関連付けられたオブジェクトをキャッシュしません。その代わり、CloudFront はそのオブジェクトに関するすべてのリクエストをオリジンに転送します。

ここについては、TTLを0にしておけばキャッシュされないだろうとか、オリジンからno-cacheやno-store, privateなどを返しておけば大丈夫だろうなどと考えてしまいがちかなと思います。

これはCDNによってどういった扱いをするかは異なると思いますので、注意深く確認して設定する必要があります。特にTTLが0秒の場合、ほぼ同時刻にリクエストがあった場合にはキャッシュが使われる可能性があります。

CloudFrontのドキュメントの同じオブジェクト (トラフィックスパイク) の同時リクエストにも、ざっくりというと

  • キャッシュが存在しないかキャッシュの 有効期限が切れていれば すぐにオリジンにリクエストを送信するが
  • 「同じオブジェクトへの追加のリクエストが、オリジンが最初のリクエストに応答する前にエッジロケーションに届く」場合、なるべくキャッシュを使えるように一瞬待ってオリジンにリクエストするかキャッシュを使うか決める

とあります。後者の場合でTTLが0秒の場合、全く同じ時刻であればキャッシュは有効期限内とみなされ、キャッシュが使われる可能性はあるように読めます。(検証はしていません)

こちらのページの「ウェブディストリビューションで CloudFront がキャッシュにオブジェクトを保持する期間の指定」の表の一番下の段に記載されている通り、「 オリジンが、Cache-Control: no-cache、no-store、および private ディレクティブ、またはこのいずれかをオブジェクトに追加する 」場合でも、「最小 TTL > 0秒」であれば「 CloudFront は、CloudFront 最小 TTL の値に対応する期間、オブジェクトをキャッシュに保持します。」!

これらの事例や説明を踏まえ、キャッシュをさせないページではTTLを0秒に設定することや、no-storeを送っているから大丈夫だと安心するのではなく、各CDNで一切キャッシュをさせないためにどう設定すればよいのかを確認する必要があるかなと思います。

キャッシュのキーを適切に

defaultの設定としてキャッシュを一切しない設定をした後は、キャッシュしたいページの設定をします。 この時、さきほど洗い出した変数を漏れなく盛り込みます。必要なヘッダーをオリジンに転送し、クッキーもarine.jpでセッションキーとして使っているものは転送します。間違ってもログインしているユーザのページが他のユーザに見えないようにするためです。

もしクエリストリングによってページの表示が変わる場合は、クエリストリングも転送します。 ARINE の記事ページは以前は page によって何ページを出すかを分けていましたが、現在はページングがなくなったためクエリストリングによる変更はありませんでした。

結局 ARINE の記事ページの場合は、パスの他に

  • Originヘッダー
  • Hostヘッダー
  • CloudFront-Is-* ヘッダー
  • Acceptヘッダー
  • Accept-Encodingヘッダー
  • arine.jp向けのクッキー

をオリジンに転送しています。

パスパターンを過不足なく

CloudFrontではパスパターンにワイルドカードは使えますが正規表現ではないので、IDなどを含みワイルドカードを含むパターンの場合、意図しないページが前方一致してしまっていないかに注意する必要があります。

今回ARINEではキャッシュしたかったのは/articles/ですが、正規表現で書くならば /\articles/\d+(?.)?$/ のようになります。実際には/articles/\d+/editというページなども存在するため、そちらにもマッチしてしまいます。

そういった場合には該当するパスパターンより上(高い優先順位)に、より正確に一致するパターンでキャッシュしないように設定しておく必要があります。

エラーの際のキャッシュ

ステータスコードが200でなかった場合のキャッシュがデフォルトでは5分と(他のCDNと比較して)長いので、必要に応じて設定を変更します。

効果

導入してみた結果、どの程度効果があったのかが気になるところです。

ARINEではtiming APIを使い、ユーザーが実際にページを見た時にロードにどのくらい時間がかかっているのかを計測しています。実際には、広告が入っているためロード時間は参考にならないのですが…、少なくともTTFBが速くなっていればキャッシュによる効果があるといえそうです。

結果としては、確実にTTFBが短くなっており、効果があったといえます。ヒット率もかなり良いのでサーバー側の負担も減りました。

下記の表は導入後1週間~2週間の間のアクセス数が上位10件記事のヒット率を順に並べたものです。高いものは80%ほどキャッシュできていることがわかります。

/articles/9399 79.73%
/articles/9892 79.52%
/articles/9072 79.28%
/articles/9488 78.71%
/articles/8782 77.49%
/articles/9210 76.90%
/articles/24210 76.45%
/articles/5555 73.23%
/articles/27377 45.36%
/articles/23383 39.79%
/articles/25640 37.73%

平均的にも記事ページは50%程度はキャッシュできています。

失敗

さて、先ほど重要な設定を忘れてしまっていたと書きましたが、なんだったかというと nginx でいう realip の設定です。 もともと ALB → nginx → Railsという構成ですので、設定をしていなかったわけではありませんが、再帰的にクライアントのアドレスを解決するreal_ip_recursiveを有効にすることと、CloudFrontのIPレンジをset_real_ip_fromに追加することを失念していました。

このためクライアントのIPアドレスを正しく取得することがしばらくできていませんでした。最悪でもリリース後のアクセスログをちゃんと見ておけばリリース後すぐに気付くことができたはずですので、反省です。また、本来はCDNやインフラ周りの設定はインフラ担当の部署に依頼すれば正しく設定してくれるのですが、今回は自分の希望で自分で設定してミスが発生しました。インフラの担当チームに依頼していれば起きなかった問題です。CDNの設定だけではなく構成全体とその設定含めレビューしてもらうべきでした。

これはリリース翌日、見えるはずのページが見えないというチーム内からの問い合わせで発覚しました。キャッシュに複数の意味を持たせないことが重要で、ページに影響する変数を洗い出すことが重要だといい、慎重に調査していたにも関わらず漏れてしまっていたものがありました。

実はアクセス元のIPアドレスによって見えるか見えないかを判断している箇所があり、アクセス元のアドレスが正しく取得できなくなったことによって判定に失敗し、意図せず見えなくなってしまっていました。見えるべきなのに見えない、という方向の失敗であったことは不幸中の幸いでした。

今は正しくクライアントのIPアドレスを取得するよう設定し、CloudFrontのIPアドレス帯をデイリーでチェックして、変更があったら検知して反映できるようにしています。

今後

まだまだCDNを導入できただけの段階なので、課題として残っていることも書いておきます。

キャッシュできる範囲を増やす

キャッシュについては現状の設定では、なんらかのページでクッキーが一度発行されてしまうと、ログインしているかどうかとは関係なく記事ページがキャッシュされなくなってしまいます。これをログインしていなければキャッシュできるようにページを作りかえて、よりヒット率を高められるようにしていきたいと思っています。

また、現在はウェブのページしかCDNを通していませんが、アプリ向けのAPIもCDNを通すようにしようと計画しています。現状のAPIはほとんど全てのエンドポイントでユーザーに固有の情報を返すようになっているため、CDNを通してもほぼキャッシュはできず意味がないので、そこを作り変えるところからでかなり長い道のりにはなりそうです。

レンダリングを最適化する

Google の PageSpeed Insights や Lighthouse で ARINE のページをチェックすると、レンダリングをブロックしているJavaScriptやCSSがあると注意されます。今までフロントエンド側の速度の改善には着手できていませんでしたが、やれることがたくさんあるので今後改善していきます。

gzipを有効にする

ブログを書いていたら早速いわなちゃんさんからアドバイスを頂いたので、gzip を有効にしようと思います。

Set-Cookieは消す

Set-Cookieを返したままのページもあるので、特にキャッシュをするページではユーザーごとのキャッシュになっていても念の為 Lamdba@edge で削除しようと思っています。

最後に

今回は美容メディアARINEをCloudFrontを通して配信するようにしたのでその振り返りを一通り書いてみました。動的なページをCDNでキャッシュすると、不備があった際に情報の流出などのリスクもありますので慎重な対応が必要ですが、導入できると特にアクセスが多くなったときなどに大きな効果が期待できます。今回は高度なことは全然やっておらず、またキャッシュ周りの設定を中心に書いたので既にわかりきったことが多くなってしまったかもしれませんが、CDNを導入するにあたり参考になることが少しでもあれば幸いです。