Amazon Web Services ブログ

CloudFront と S3 の階層化 TTL でシングルページアプリケーション (SPA) をホストする

数多くのお客様がシングルページアプリケーション(SPA) のデプロイのために Amazon CloudFrontAmazon Simple Storage Service (Amazon S3) を利用しています。ウェブアプリケーションは React、 Angular、 Vue 等のフレームワークで実装されています。これら SPA を開発しているチームは、一見相反するようにも思える以下のような要件を持っていることが多いです。

  • ウェブアプリケーションをダウンロードする時のユーザーが体感する遅延をできるだけ小さくしたい。そのために、ユーザーの近くにある CloudFront のエッジロケーションにウェブアプリケーションをキャッシュさせます。さらに、ウェブアプリケーションはブラウザにもキャッシュされるため、再訪時の遅延はさらに短くなります。
  • 即時のデプロイ。つまり、更新されたコードをデプロイし、ウェブアプリケーションの新しいバージョンをユーザーができるだけ早く「見られる」ようにしたい、ということです。
  • CDN(今回は CloudFront )のキャッシュの無効化の代わりにCache-Control の HTTP ヘッダーを活用したい。キャッシュの無効化はユーザーのブラウザキャッシュに影響を与えませんが、追加のコストが発生する場合があります( CloudFront の無料枠を超える場合)。また、キャッシュ無効化はコントロールプレーンの操作であり、適切な Cache-Control の HTTPヘッダーでコンテンツを提供する場合(データプレーンの操作)よりも可用性保証が低くなっています。

これらの相反するように見える要件すべてを同時に満たすことはできるのでしょうか?はい、HTTP ヘッダーを用いた適切なキャッシュ戦略とバージョニングを組み合わせることで実現できます。この記事では「階層化 TTL ( TTL 、time-to-live: オブジェクトがキャッシュされうる最大合計時間)」を使用してウェブアプリケーションでそれを実現する方法について説明します。

アーキテクチャの概要

以下の図は、ハイレベルのアーキテクチャを示しています。ウェブアプリケーションのファイルは S3 バケットに格納されており、CloudFront を介して提供されます。CloudFront のエッジロケーションとユーザーのブラウザの両方が、これらファイルのキャッシュされたバージョンを保持している可能性があります。

ユーザー、ブラウザ、CloudFront と S3 のシチュエーション概要図

図1: シチュエーションの概要

上図の概要説明は以下となります(詳細は後ほど説明します)。

  1. ユーザーは、ウェブブラウザで URL を開くことによって SPA をリクエストします。このリクエストはユーザーに最も近い CloudFront のエッジロケーションから提供されます。
  2. CloudFront のエッジロケーションは、 S3 へ SPA のファイルをリクエストします。
  3. CloudFront のエッジロケーションは、S3 から受け取った SPA のファイルをキャッシュし、それらのファイルをユーザーのブラウザに返します。
  4. ブラウザは、 CloudFront から受け取った SPA のファイルをキャッシュし、ウェブアプリケーションをレンダリングし、表示します。

まず、急いでいる読者のために階層化 TTL の実装方法を説明します。その後、この記事の残りの部分で、その背後にある「なぜ」を詳しく説明していきます。

階層化 TTL を SPA に実装

行うべきことは2つあります。

  1. Amazon S3 にあるウェブアプリケーションのファイルにオブジェクトメタデータとして、適切な cache-control 属性を指定します。例えば、index.html には短い TTL を、JavaScript や CSS のような不変なアセットには長い TTL を指定します。このオブジェクトメタデータのフィールドは、 CloudFront によって使用され、HTTP レスポンスヘッダーの cache-control としてユーザーのブラウザに返されます。以下がキャッシュの設定となりますが、この記事の後半で説明します。
    • JavaScript や CSS ファイルのような不変のアセット: public,max-age=31536000,immutable(1年間キャッシュし、その後再検証する)
    • index.html: public,max-age=60(60秒間キャッシュし、その後再検証する),stale-while-revalidate=2592000(バックグラウンドでコンテンツを再検証する間、最大30日間、有効期限切れのキャッシュされたバージョンを提供する)
  2. CloudFront のキャッシュポリシーが、小さい TTL(例えば1秒)と大きい TTL(例えば31536000秒=1年)の両方の許可を確認します。これによって、S3 オブジェクトの max-age cache-control ディレクティブで設定した TTL が CloudFront によって制限されないようにします。例えば、 Managed-CachingOptimized キャッシュポリシーを選択します。

適切な cache-control オブジェクトメタデータと共にファイルを Amazon S3 にアップロード

AWS コンソールでファイルを手動でアップロードし、 cache-control オブジェクトメタデータを設定することもできますが、ここでは AWS CLI を使用した設定方法を紹介します。これは、継続的インテグレーション/継続的デプロイ(CI/CD)パイプラインの一部として実行することもできます。

# Use a short TTL for index.html:
aws s3 cp <sourcedir>/index.html s3://<bucketname> --cache-control 'public,max-age=60,stale-while-revalidate=2592000'

# Use a long TTL for immutable assets, e.g. JavaScript and CSS: 
aws s3 cp <sourcedir>/bundle.hash778.js s3://<bucketname> --cache-control 'public,max-age=31536000,immutable' 
aws s3 cp <sourcedir>/styles.hash631.css s3://<bucketname> --cache-control 'public,max-age=31536000,immutable'

aws s3 sync <sourcedir> <s3bucketname> を使用する代わりに、各ファイルを上記のようにアップロードします。この方法を用いることで、ファイルの種類ごとにキャッシュ設定を分けることができます。同じ理由から、CDK の aws_s3_deployment モジュールは使用しません。(内部で aws s3 sync を使用しています)

特にアプリケーションのファイルが多い場合には、この AWS CLI のコマンドを手動で入力するのは退屈でしょう。そのため、例えば以下のような(サードパーティの)オープンソースの Node.js スクリプト: s3-spa-upload を使用することもできます。このスクリプトは、指定したディレクトリを Amazon S3 にアップロードし、前述のような各ファイルの cache-control ヘッダーの設定をします。(もう一つの利点として Content-Type も設定します)

npx s3-spa-upload <sourcedir> <s3bucketname>

例えば、 React と Vite を使ったプロジェクトでは、まず npm run build を実行します。すると、アプリケーションの全てのファイルを含んだ dist ディレクトリが作成されます。ここには、 index.html 、バンドルされた JavaScript ファイル、CSS ファイルなどが含まれています。その後、次のコマンドを実行します。

npx s3-spa-upload dist my-bucket-name

オプションとして、--delete を指定することで、新しいファイルのアップロードが終了した後に Amazon S3 から古いファイルの削除も行います。どちらにせよ、このクリーンアップ作業を行わなければなりません。そうしないと、古いバージョンが無制限に積み重なってしまいます。

npx s3-spa-upload dist my-bucket-name --delete

このコマンドは、現在のアップロード分を除いた、 S3 バケットに存在する全てのファイルを削除することに注意してください。きめ細やかなソリューションを使いたい場合もあるかもしれないので、注意して使用してください。

これで完了です!たったこれだけです!ファイルは適切な cache-control ヘッダーにて Amazon S3 に保存されました。CloudFront とブラウザはこの設定を尊重します。これでもうキャッシュの無効化は必要ありません。なぜこれが実際に動作するのかを理解するために、読み続けてください。

なぜ 階層化 TTL が SPA のキャッシュ最適化に役立つのか

階層化 TTL の使用方法を理解するためには、まず CloudFront のオブジェクトのバージョニングの動作を理解しなければなりません。

SPA の一般的なバージョニング

バージョニングは単純に言ってしまうと、ファイルに変更を加える度に、異なるファイル名をそれに対して使用することを意味します。例えば、

  • CloudFront で JavaScript ファイル(mycode-v1.js)をホストするとします。
  • ファイルを変更したい場合、実際にはそのファイル自体を変更するのではなく、新しいファイル名(mycode-v2.js)でコピーを作成し、その新しいファイルに対して変更を加えます。
  • このとき CloudFront は両方のファイルを提供しますが、必要に応じて古いファイルを削除することもできます。

この方法はキャッシュを簡単に実現できます。異なるファイル(バージョン)ごとに、永久にキャッシュさせることができます。言い換えると、各バージョンは不変になる、ということです。ただし、この方法には欠点があります。ユーザーは新しいファイル名にいつ「切り替わる」かを知る必要があるのです。

一般的な解決方法を議論していきましょう。多くの SPA のビルドツールは、先述したようなバージョニングを実装しています。ソースコードに変更を加える度に、新しい SPA の配布物をビルドし、新たな JavaScript ファイルや CSS ファイルが、新しく、そして、一意となるファイル名で生成されます(コンテンツのハッシュ値を用いる方法です。例えば index-ae387ba8.js となったりします )。ここで重要なのは、ユーザーが実際にアクセスする「ルート」ファイルは index.html であり、そのファイル名は変更されないということです。例えば、ユーザーが https://mysite.cloudfront.net に遷移すると、実際には index.html をダウンロードします(これは CloudFront ディストリビューションのデフォルトのルートオブジェクトとしてよく使用されます)。index.html には、必要なすべての JavaScript ファイルや CSS ファイルへのリンクが含まれています。SPA のビルドツールは、参照している全てのファイル名を最新のバージョンに更新しています。ユーザーはこれについて気にする必要はありません。 index.html をダウンロードし、ウェブブラウザがコンテンツをパースし、適切な JavaScript ファイルと CSS ファイルをダウンロードします。

SPA の階層化 TTL

オブジェクトバージョニングの概要を読んだ後であれば「階層化 TTL」を使用したキャッシュ戦略が非常に合理的であると理解できるでしょう。

  • JavaScript と CSSファイル: public,max-age=31536000,immutable(1年間キャッシュし、その後再検証する)
  • index.html: public,max-age=60(60秒間キャッシュし、その後再検証する)

備考

  • 「再検証」とは、キャッシュされたバージョンが、未だに最新のバージョンかどうかをオリジンに確認することを意味します。さらに新しいバージョンがある場合は、それをダウンロードし、そうでなければキャッシュされたバージョンを使用します。技術的には、これは HTTP 条件付きリクエストによって動作します(次のセクションで説明します)。
  • 厳密に言えば、これは「即時の」デプロイではありません。(最大でも)60秒後に展開されます。ただし、これはほとんどのユースケースにおいて十分に即時と言えますし、それと引き換えに CloudFront エッジキャッシュの利用率を向上させ、 Amazon S3 オリジンへのリクエスト数も減らすことができます。もちろん、60秒よりも短い時間に設定することもできます。しかし、 max-age0 に設定してしまうとリクエストの折りたたみが無効になります(同一オブジェクトへの同時リクエストを参照してください)。

このシンプルなキャッシュ戦略は、多くのユースケースで効果的です。利点は、キャッシュの無効化を必要としないことです。ウェブアプリケーションの新しいバージョンをデプロイするたびに、ユーザーがウェブアプリケーションの URL にアクセスする際、60秒以内にユーザーはその新しいバージョンを「見る」ことができます。なぜなら、CloudFront とユーザーのブラウザの両方がキャッシュ命令を尊重するため、index.html を再検証するからです。

HTTP 条件付きリクエスト

Amazon S3 にアップロードするオブジェクトは、Amazon S3 から自動的に ETag を取得します。 ETag は、そのオブジェクトのバージョンに対する一意の識別子です。例えば、 9ee3d9fdce32d3387f822383bf960027 のような値です。 ETagは、ブラウザが以前ダウンロード済みで、キャッシュしていたものの、有効期限切れになったファイルを CloudFront からリクエストするときに使用されます。次に、ブラウザは、 if-none-match HTTP ヘッダー に ETag をヘッダーの値として渡し、新たな HTTP GET リクエストを CloudFront に対して行います。これは再検証、または、条件付きリクエストと呼ばれています。

ファイルの ETag が変更されていないと CloudFront が判断した場合、実はファイルを送信しません。代わりに、 CloudFront はステータスコード 304 Not Modified の空のレスポンスを返します。まるでブラウザが CloudFront に対して「このファイルのコンテンツをください。ただし、ファイルの現在の ETag が私が送信した ETag と同じである場合は気にしないでください。なぜなら、私が送信した ETag は私のキャッシュに既に存在しているバージョンのものだからです」と伝えているかのようです。これと同じ仕組みは、 CloudFront と Amazon S3 間でも使用されます。

そのため、ブラウザが CloudFront からのファイルを再検証する場合でも、 ETag が一致している場合はファイルを再度インターネット上で転送する必要がないため、非常に高速になることがあります。ただし、ブラウザによる再検証を防ぐ目的で、適切な cache-control HTTPヘッダーを設定することは依然として意味があります。結局のところ、これは依然として HTTP フェッチです。最も速い HTTP フェッチは、ブラウザが実行する必要のない HTTP フェッチですから。

s-maxage

網羅性を期すためにも、オリジンが max-age 以外にも s-maxage を指定できる点について、この記事では言及する必要があるでしょう。s-maxage ディレクティブはブラウザには無視されますが、 CloudFront のようなすべての共有キャッシュでは使用され、共有キャッシュの max-age を上書きします。つまり、ブラウザと共有キャッシュの間で異なるキャッシュ設定を使用することができます。たいていは、共有キャッシュ( CloudFront など)が自分の管理下にあり、それらに対してキャッシュの無効化を実行できることから、s-maxage の方が高く設定されるでしょう。しかしながら、この記事ではキャッシュの無効化が不要となる戦略について述べていることから、 s-maxage は使用しません。

シーケンス図

具体的なシナリオに対するシーケンス図を見て、これまでの説明を再度確認していきましょう。

シナリオ: 初回のキャッシュ生成

ファーストユーザーがウェブアプリケーションにアクセスする場合の様子は以下の通りです。話を単純にするために、 index.html が1つの外部ファイル( JavaScript バンドル)にのみ紐づいているとします。

初回のキャッシュ生成のシーケンス図

図2: 初回のキャッシュ生成

シナリオ: 再訪問者

同じユーザーが再度ウェブサイトを訪れた場合の様子は以下の通りです。 index.html ファイルは再検証されます(TTLが max-age: 60 と短いため)。しかし、Amazon S3 上のファイルは変更されていないため、実際にはダウンロードする必要がありません。ブラウザのキャッシュ内にある JavaScript バンドルはまだ有効で(TTLが max-age: 31536000 と長いため)、再検証される必要はありません。

再訪問者のシーケンス図

図3: 再訪問者

シナリオ: 新規訪問者

新規ユーザーがウェブサイトを訪れた場合は、すべてのファイルをダウンロードする必要があります。CloudFront は以前にキャッシュした index.html を再検証するかもしれませんが(60 秒以内に別のユーザーによって再検証されていない限りは)、 JavaScript バンドルは直接キャッシュから提供されるでしょう。

ウェブアプリケーションへの新規訪問者のアクセスのシーケンス図

図4: 新規訪問者

シナリオ: ウェブアプリケーション の v2 へのアップデート

ウェブアプリケーションの新しいバージョンをアップロードした場合の動作は次のようになります。60秒後に index.html は再検証され、ETag が変更されていることから、 index.html はダウンロードされます。この index.html は同じくダウンロードされる別の JavaScript バンドルを指しています。したがって、これは「即時のデプロイ」の例となります。

ウェブアプリケーションの新バージョンの即時のデプロイのシーケンス図

図5: 即時のデプロイ

stale-while-revalidate の追加

ここで言及されているキャッシュ戦略を改善する可能性のある方法として、index.html に対する stale-while-revalidate ディレクティブの指定があります。これは、手元にあるキャッシュ( CloudFront またはユーザーのブラウザ)に対して、max-age で指定された時間を超えて、 stale-while-revalidate ウィンドウの間、ファイルが使用される可能性があることを意味しています。これは、キャッシュがバックグラウンドでオリジンに対してファイルを再検証する(つまり、新しいバージョンがある場合、それをダウンロードする)ことを前提としています。したがって、 stale-while-revalidate という名前がつけられています。(バックグラウンドで再検証を行う間は古いバージョンのファイルを使用します)その後、ユーザーが後で同じファイルをリクエストした場合には、キャッシュはバックグラウンドでダウンロードされて更新済みのバージョンをすぐに提供することができます。

変更後のキャッシュ設定(変更箇所は太字):

  • JavaScript と CSS ファイル: public,max-age=31536000,immutable(1年間キャッシュし、その後再検証する)
  • index.html: public,max-age=60 (60秒間キャッシュし、その後再検証する) , stale-while-revalidate=2592000 (バックグラウンドでコンテンツを再検証する間、最大30日間、有効期限切れのキャッシュされたバージョンを提供する)

具体的には、新しいバージョンのファイルをダウンロードするときの遅延はユーザーからは隠されていて、ユーザーはキャッシュから古いバージョンのファイルを取得することになります。これは、低遅延即時デプロイの間での良好なトレードオフ関係と言えるかもしれません。それは、遅延がクライアントからは認識できないからです(ローカルのブラウザキャッシュのキャッシュされたバージョンを使用するため)。

さらに、次回そのファイルをリクエストするとき(たとえば、ページをリフレッシュする場合)、ブラウザはバックグラウンドで予めダウンロードしていた新しいバージョンを即座に表示します。基本的に、ユーザーはページのダウンロードを待つ必要がありません。もちろん、ブラウザの「更新」ボタンを繰り返し押したり、ローカルキャッシュを無効にしている場合は別です。しかし、そのようなことをするのはユーザーではなく、開発者です。おそらくあなた自身のことでしょう。(なお、そのような並列リクエストは CloudFront によってまとめられます。 CloudFront はオリジンに対して一度だけアクセスします)

stale-while-revalidate は、 CloudFront とオリジン間の再検証の遅延を「隠す」のにも役立ちます。たとえば、オーストラリアのシドニー近くのユーザーを考えてみましょう。 S3 バケットがアイルランドのダブリンにある場合、 CloudFront のシドニーのエッジキャッシュは、ユーザーのファイルの再検証のために、地球を約半周するほどの長いラウンドトリップを必要とします。ネットワークトラヒックが AWS の高速なネットワークを通過するとはいえ、光の速度にも限界があり、遅延は避けられません。 stale-while-revalidate により、この遅延をユーザーから効果的に隠蔽できるのです。なぜなら、ダウンロードはバックグラウンドで行われるからです。

結論として、 stale-while-revalidate は、更新が必要なコンテンツに対してはうまく機能しますが、最新バージョンを即座に取得することに対しては必須ではありません。これは多くのシングルページアプリケーションに当てはまるケースであり、この記事でも同様でしょう。コンテンツの性質やリクエストパターンを慎重に考慮した上で、 stale-while-revalidate を実装することをおすすめします。即座に更新を反映させる必要があるコンテンツには、 stale-while-revalidate を使用しないでください。例えば、最新の利用可能な在庫が必要な時のリアルタイムの在庫データを返す API や、正確なアカウント残高のようにユーザー固有のコンテンツを表示する必要がある場合などです。変更されない、もしくはめったに変更のないコンテンツの場合は、 stale-while-revalidate が引き起こす不必要なバックグラウンドネットワークリクエストを防ぐためにも、大きな maxage 指定を選択するのが良いでしょう。

stale-while-revalidate を追加するとシーケンス図がどのように変化するかを見てみましょう。

シナリオ: stale-while-revalidate を用いた ウェブアプリケーション の v2 への更新

再訪問のユーザーがウェブアプリケーションにアクセスすると、ブラウザキャッシュからすぐに古いバージョンを取得します。これは、ユーザーのブラウザ、それ以降の CloudFront の再検証をトリガーします。これは以下のようになります( index.html の60秒の TTL が経過し、他のユーザーからの類似のリクエストにより、すでに CloudFront キャッシュで V2 が作成されていると仮定します)。

stale-while-revalidate を追加した初回リクエストのシーケンス図

図6: stale-while-revalidate 初回リクエスト

ユーザーのブラウザキャッシュはバックグラウンドで v2 に更新されましたが、ユーザーはまだ v1 を表示しています。次のリクエストをして初めてユーザーは v2 を見ることができるのです(60秒後に再検証をトリガーして再度バックグラウンドで更新されます)。

stale-while-revalidate を追加した2回目のリクエストのシーケンス図

図7: stale-while-revalidate 2回目のリクエスト

結論として、stale-while-revalidate によって、ユーザーは V2 を表示するために追加でリクエストを1回行う必要が出てきます。利点は、ユーザーが index.html のダウンロード(または再検証)を待つ必要がないことでしょう。

ユーザーが最新バージョンのウェブアプリケーションを表示するために追加でリクエストを要求することは、望ましくない影響を与える可能性があります。ただし、これについては既に考慮している必要がありました。ユーザーが決してブラウザのタブを閉じない状態でウェブアプリケーションをいつまでも実行している場合、新しいバージョンのウェブアプリケーションをどのようにデプロイしていましたか?ユーザーに対して最新バージョンを取得させるために、ユーザーが(最終的には)明示的にページをリフレッシュすることを期待していましたか?もし重要だと判断するのであれば、それを自動的に解決することも可能ですが、それはこの記事の範囲を超えてしまいます。

まとめ

この記事では、ウェブアプリケーションのためのキャッシュ戦略を探求しました。この戦略では、階層化 TTL を使用してキャッシュが行われます。これにより、開発者はキャッシュの無効化を行わずにコードの新しいバージョンをデプロイすることができます。要点は、オブジェクトのバージョニングと HTTP ヘッダーを使用した適切なキャッシュ戦略を組み合わせることです。 SPA の新しいバージョンの低遅延なダウンロードを実現するために、 stale-while-revalidate キャッシュディレクティブを追加しました。

Call to action

  • SPA に対して階層化 TTL を実装してください。index.html には短い TTL (例:max-age: 60)を設定し、 JavaScript や CSS ファイルなどの不変なアセットにはより長い TTL を設定します。多くの SPA のビルドツールは、ソースコードの変更に応じて、 JavaScript や CSS ファイルの新しいファイル名を生成するため、これをサポートしています。もし SPA をビルドするための優れたツールをお探しであれば、 Vite を使用してください。
  • 長い TTL を許可する CloudFront のキャッシュポリシーを使用してください。(例: Managed-CachingOptimized
  • 低遅延でファイルをダウンロードするために、 stale-while-revalidate キャッシュディレクティブを使用してください。これは、ユーザーがファイルの最新バージョンを即座にダウンロードすることが重要ではない場合に使用します。多くの SPA で当てはまるでしょう。
  • この記事で言及されている NodeJS スクリプト を使用して、適切なキャッシュヘッダーを持つ SPA を S3 にアップロードするか、このパターンを理解した今、同様のものを自分で構築してください。

本記事は「Host Single Page Applications (SPA) with Tiered TTLs on CloudFront and S3」の翻訳となります。

このブログの翻訳はプロフェッショナルサービスの 鈴木(隆) が担当しました。