NEWSニュース

2020.04.06

TECH BLOG

Fensiの静的ファイルのキャッシュが大変だった話

はじめに

はじめまして。のりたまです。

普段はFensiのバックエンドエンジニアとしてプログラムを書いてます。

(※弊社サービスのFensiについてはこちら

今回はFensiのキャッシュ機構を改善した話を書きます。

 

キャッシュが効いていないことが上司にバレました

とある日のSlack。

システム概要は後述しますが、簡潔に書くと

静的ファイルを管理するS3バケットの前段に置いているCloudFront(以下CF)のキャッシュTTLを5秒に設定していたため、

高負荷でもない通常時のアクセスではほぼキャッシュヒットしない状態でした。

 

ゴール

キャッシュTTLを無期限にし、オリジンが更新されたらInvalidation(無効化)を行う。

これだけだとすごく簡単に聞こえるかもしれませんが、Fensiのシステムの性質上それほど簡単にはいかず。。。

今回はこの対応の中で発生した問題と対応内容を書いていきます。

 

システム概要

今回の内容のメインとなる、静的ファイルを持つバケット周辺の機構だけの図。

特徴は、複数のサイトで静的ファイルを同一バケットで管理している点と、

オーナーの作業によって更新が発生するためいつ更新されるかわからないところです。

 

1. 複数ドメインへのリクエストを1つのCFで受け付けるため、サイト単位のInvalidationができない


CFのCNAMEにワイルドカードを使うことで複数ドメインを1つのCFに向けています。

 

例えば、サイトaaaaのオーナーによってオリジンが更新されたとします。

このときに `https://aaaa.fensi.plus/*` のキャッシュだけInvalidationできれば良いのですが、

CFはドメインを含むフルパスではなく `/` 以下のパスをキーに、キャッシュを保持します。

そのためInvalidationのパラメータは `/*` を指定する必要があるのですが、

そうすると `https://aaaa.fensi.plus/*` だけでなく 

`https://bbbb.fensi.plus/*` も、他のすべてのサイトもInvalidationの対象になってしまいます。

 

[ 対応方法 ]

lambda@EdgeのViewerRequestでパスを変換し、CFが別のパスとしてキャッシュできるようにしました。

———-

https://aaaa.fensi.plus/ => https://aaaa.fensi.plus/aaaa.fensi.plus/

https://bbbb.fensi.plus/ => https://bbbb.fensi.plus/bbbb.fensi.plus/

———-

これで、Invalidationのパスに `/aaaa.fensi.plus/*` と指定することで

サイトaaaaのキャッシュだけをInvalidationすることができます。

 

lambda@Edgeを利用してURIを変更した場合のInvalidationパスについて、公式ドキュメント(https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html#invalidation-lambda-at-edge)

ViewerRequestのURIと関数による変更後のURIの両方を削除する必要がある」という記載がありますが、実際は変更後のパスのみで問題ありません。(AWSサポート問い合わせ済み。)

ViewerRequestのパスを指定すると全サイトのキャッシュが消えてしまいます。

 

2. ボットからのリクエストはキャッシュを効かせず、SSRして返したい


 

1.の対応により個別にInvalidation可能になりましたが、今のままでは全リクエストに対してキャッシュが有効になっています。

FensiはCSR(client-side-rendering)を採用しているため、HTMLにコンテンツが含まれていません。HTMLを読み込んだブラウザがJSを実行しAPI経由でコンテンツを取得しレンダリングします。

世の中のボットの多くはHTMLを取得してもレンダリングをしてくれません。

そのためボットからのアクセスにはSSRした結果を返す必要があります。

Fensiではuser-agentヘッダでボット判定を行いSSRしています。

 

2-1. オリジンにHTTPヘッダーを転送したいが、ヘッダーに基づいてキャッシュされるとヒット率が下がる

proxyでuser-agentを判定するため、user-agentをオリジンに転送する必要があります。

しかし、user-agentを転送するように設定すると、CFはuser-agentに基づいたキャッシュを行います。

proxyではなくViewerRequestでボット判定を行うことも可能ですが、lambdaで生成したレスポンスサイズの制限にかかってしまうことが判明したため上記の構成になっています。Lambda@Edgeの制限についてはこちらのドキュメントを参考にしました。(https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-requirements-limits.html#lambda-requirements-see-limits)

 

HTTPヘッダーに基づくキャッシュについてはオリジンがS3かカスタムオリジンかによって大きく異なっています。

詳しくはこちらのドキュメントを参照してください。(https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/header-caching.html#header-caching-web-selecting)

 

つまり、user-agentをオリジンに転送したいが、転送するとキャッシュに使われヒット率が低下する、という状態です。

 

[ 対応方法 ]

lambda@EdgeのViewerRequestでHTTPヘッダーを変換し、値は転送するがキャッシュには使わせないようにしました。

———-

user-agent => x-user-agent

———-

このようにキャッシュに使用されないヘッダーに変換することで、

異なるUAからのリクエストでもキャッシュにヒットさせることが可能です。

 

2-2. そもそもアクセスURLが同じだとキャッシュが効いてしまう

ヘッダーに基づくキャッシュを使用しないので、CFはパス(とクエリパラメータ)でキャッシュします。

そのため、ボットのみキャッシュを効かせないということができません。

 

[ 対応方法 ]

lambda@EdgeのViewerRequestでボット判定を行い、クエリパラメータに毎回異なる値(現在日時など)をつけてCFのキャッシュを無効にする。

なお、SSRサーバ側で別途キャッシュを保持しているので、そのキャッシュが効くようにproxyでクエリを外しています。

 

これで、ボット時のみCFのキャッシュを効かせずにSSRできるようになりました。

 

3. サイト更新直後にInvalidationしても再度古いファイルがキャッシュされる可能性がある


サイトaaaaに更新が発生すると、S3の `/a/aaaa/current/` 以下に対してファイルがアップロードされます。

この直後に `/aaaa.fensi.plus/*` のキャッシュをInvalidationしても、再度古いファイルがキャッシュされることが稀に発生してしまいます。

これはS3の結果整合性によるもので、更新/削除を行った際に反映までに時間がかかるというものです。

 

S3のデータ整合性についての詳細はこちらのドキュメントを参照してください。

(https://docs.aws.amazon.com/ja_jp/redshift/latest/dg/managing-data-consistency.html)

 

[ 対応方法 ]

更新時にバージョンを払い出し、アップロード先に付加することで毎回すべて新しいオブジェクトキーにしました。

———-

/a/aaaa/current/ => /a/aaaa/current-${version}/

———-

新規ファイル追加時はリードアフターライト整合性が保証されるため、上書きよりも推奨されています。

バージョンをサイトごとにDBに保持したり、proxyからバージョンを取得するためのAPIが必要になったりと手間は増えてしまいましたが。

 

4. 共通コンポーネントの更新を検知できない


Fensiにはサイト毎に保持する静的ファイルとは別に、プラットフォーム全体が共通で利用するコンポーネント群が存在し、別のバケットにデプロイされています。

これまでは、サイトアクセス時にproxyが共通コンポーネントのバージョンを取得し、

その値を”set-cookie”に入れて返すようにしていました。

ブラウザはCookieに保持されたコンポーネントバージョンをクエリパラメータにつけることで、

CFのキャッシュを適切に回避できるという仕組みです。

しかしここまでの対応で、サイトが更新されない限りはCFが常にキャッシュを返すようになっていて、proxyにアクセスが届きません。

 

対応として

 1.コンポーネント更新時に全サイトのキャッシュをInvalidationする

 2.CFよりも前段でコンポーネントのバージョンを取得する

の2つを検討しましたが、コンポーネントのデプロイが頻繁に行われている現状では、1のコストが2を上回ると判断し、2で対応することとしました。

 

[ 対応方法 ]

lambda@EdgeのViewerResponseでコンポーネントのバージョンを取得するようにしました。

これで今まで通りコンポーネントの更新を即時検知し、ブラウザに伝えることができるようになりました。

 

ただ、キャッシュが効かないためアクセスのたびにバージョン取得のためのリクエストが発生しています。

HTML以外のアクセスではリクエストさせないなどの工夫はしていますが、HTMLアクセス時には数十msのオーバーヘッドが発生しています。

 

まとめ

どれくらいパフォーマンスが改善したかと言うと、約10倍ですね。

HTMLだけ100msを超えていますが、最後に行った共通コンポーネントのバージョン取得の影響と思われます。

 

今回行った対応をまとめると

 1.サイト毎にInvalidationできるようにCFへのパスを変換

 2.ボットリクエストはSSRできるようにキャッシュを無効化

 3.S3の結果整合性を回避するためオブジェクトキーにバージョンを付加 

 4.コンポーネントバージョンの取得をキャッシュに影響されないように変更

となります。

 

 ●複数ドメインを1つのCFで受け付ける

 ●静的ファイルを配信するS3が複数あり、異なるトリガーで更新されるが、常に最新のバージョンを利用したい(がキャッシュも効かせたい)

 

といった少し複雑な要件の中、なんとかキャッシュを最大限効かせられる構成を実現できました。