マイクロフロントエンドの高速化:パフォーマンスのためのCDN挙動の最適化
原文は Alex O'Callaghan により に公開されました。 このブログを購読する
CDNレイヤーをAkamaiからCloudFrontへ移行し、マイクロフロントエンドアーキテクチャのキャッシュ戦略の改善に取り組みました。パフォーマンス指標は大幅に改善し、特にTime To First Byte(TTFB)はp90で54%削減され、Largest Contentful Paint(LCP)もp90で約36%短縮しています。
数か月前、マイクロフロントエンドアーキテクチャにおけるキャッシュ戦略について書きました。当時は障害をきっかけにAkamaiのキャッシュを無効化しており、Akamai -> CloudFront -> S3という3ホップ構成で、キャッシュ制御はCDNレイヤーの明示的なルールに頼っていました。ほとんどのアセットには明示的なキャッシュ制御が設定されておらず、キャッシュの挙動はS3のメタデータ、Akamaiのルール、CDNやブラウザのヒューリスティックに分散していました。
Akamaiの廃止:3ホップから2ホップへ
Akamaiは長らくアーキテクチャにおける余計な複雑さとなっており、削除を検討してきました。インフラの大部分はすでにAWS上にあり、CloudFront経由のマイクロフロントエンドアーキテクチャを導入してからは、Akamaiはレイテンシーと複雑さを増やす余分なホップになっていました。
インフラチームは、長年にわたって蓄積されたさまざまなAkamaiルールをCloudFrontへ移植する作業に取り組み、レガシーなTLSバージョンのサポートからデフォルトのセキュリティ挙動の変更まで、幅広い課題に対処しました。トラフィックは3週間かけて段階的にAkamaiからCloudFrontへ移行しました。
本記事では、この移行がマイクロフロントエンドアーキテクチャに与えた影響と、同時に実施したキャッシュ挙動の改善に焦点を当てます。
移行前
移行後
オリジンルーティング:404フォールバックから明示的なロジックへ
以前は、シングルページアプリケーションのルートでindex.htmlにフォールバックするために、CloudFrontのカスタムエラーレスポンスに依存していました。そのため、静的アセットに一致しないルートがリクエストされるたびに、リクエストはオリジンへ送られ、オブジェクトが見つからないことを確認したうえで、ステータスコード200でindex.htmlにフォールバックしていました。
既存のAkamaiエッジルールの多くを処理するCloudFront Functionをちょうど構築していたため、SPAのフォールバックロジックも同じFunction内に実装することにしました。これにより、リクエストがオリジンに到達する前にエッジでルーティングを処理できるようになりました。
function handler(event) {
var request = event.request;
var uri = request.uri || '/';
var qs = request.querystring || {};
// ... other routing logic, omitted for simplicity
// SPA fallback: if the path is extensionless, rewrite to index.html
var lastSegment = uri.split('/').pop();
if (lastSegment === '' || lastSegment.indexOf('.') === -1) {
request.uri = '/path/to/index.html';
}
return request;
}これにより挙動がより明示的かつ予測可能になり、静的アセットのリクエスト時にオリジン側の本当のエラーを隠してしまうリスクも軽減されました。この問題は以前CloudFront/S3の障害時に実際に影響を受けました。
また、SPAルートのリクエストごとにオリジンへの2回のリクエストを回避できたため、TTFBのパフォーマンス向上にもつながりました。
アセットタイプごとの明示的なキャッシュヘッダー
マイクロフロントエンドのデプロイでは、S3から配信されるアセットは次の3種類に分かれます。
index.html- ホストシェルのエントリーポイントで、可変でありシェルのデプロイごとに変更されます。remoteEntry.js- 各マイクロフロントエンドのエントリーポイントで、こちらも可変でありマイクロフロントエンドの更新時に変わります。assets- 各マイクロフロントエンドのJSチャンクやアセットで、不変(immutable)でありコンテンツのハッシュを含むファイル名で管理されます。
CDNルール、S3メタデータ、ブラウザのヒューリスティックが混在する状態から、S3へのアップロード時にアセットタイプごとにキャッシュヘッダーを明示的に設定する方式へ移行しました。
デプロイパイプラインでは、各オブジェクトにCache-Controlメタデータを次のように設定するようにしました。
# Upload assets with immutable caching
rclone copy -vv \
--exclude 'index.html' \
--exclude 'remoteEntry.js' \
-M --metadata-set 'Cache-Control=max-age=31536000, immutable' \
./dist $DEST
# Upload remoteEntry.js with short caching
rclone copy -vv \
-M --metadata-set 'Cache-Control=max-age=30, s-maxage=86400' \
./dist/remoteEntry.js $DEST
# Upload index.html with no caching
rclone copy -vv \
-M --metadata-set 'Cache-Control=no-cache' \
./dist/index.html $DESTアセット
マイクロフロントエンドのビルドで生成されるすべてのアセットは、ファイル名にWebpackの[contenthash]を使用しています。そのため、チャンクの内容が少しでも変われば新しいファイル名が生成されます。これにより、古いコンテンツを配信する心配なく、これらのアセットに長いキャッシュ期間を設定できます。
remoteEntry.js
remoteEntry.jsファイルは各マイクロフロントエンドのエントリーポイントで、読み込むべきさまざまなJSチャンクを指し示します。各マイクロフロントエンドは独立してデプロイされるため、ファイル名は周知の場所に固定する必要があります。ブラウザが古いリリースを長くキャッシュし続けるのを防ぐため、エンドユーザー向けには短いキャッシュ期間を設定しています。s-maxageディレクティブにより、CloudFrontのような共有キャッシュではより長くキャッシュでき、オリジンへの負荷を軽減しています。
デプロイパイプラインにCloudFrontのinvalidationステップを追加し、remoteEntry.jsが更新された際にCloudFront上のキャッシュを無効化して、ユーザーが次回リクエスト時に新しいバージョンを取得できるようにしました。
aws cloudfront create-invalidation \
--distribution-id $DISTRIBUTION_ID \
--paths '/path/to/remoteEntry.js'なお、一部の顧客は独自のプロキシ経由でアプリケーションにアクセスしており、s-maxageディレクティブに基づいてキャッシュされる可能性があります。そのため、CloudFrontのレスポンス時にCache-Controlヘッダーからs-maxageを取り除くようにしています。これにより、長期間キャッシュするのは自社のCDNだけとなり、下流の利用者には常に短いキャッシュ期間だけが適用されます。
index.html
index.htmlについては、ブラウザが常にサーバーに最新バージョンを確認するようにCache-Control: no-cacheを設定しています。現在はCloudFront Functionのロジックで無効化すべきindex.htmlのキャッシュキーが1つだけになったため、remoteEntry.jsと同様のアプローチでさらに最適化できる可能性はありますが、シェルの更新を迅速に反映させるため、当面は既存のno-cacheの挙動を維持しています。
効果測定
本番環境のRUMデータ収集にはGrafana Faroを使用しており、このデータで変更がパフォーマンス指標に与えた影響をモニタリングしました。トラフィックをAkamaiから新しいCloudFrontディストリビューションへ移行するにつれて、ページ読み込み関連の指標が全体的に改善し始めました。
| 指標 | p50 | p75 | p90 |
|---|---|---|---|
| TTFB (ms) | 1493 -> 507 (-66%) | 2484 -> 1074 (-57%) | 4483 -> 2041 (-54%) |
| FCP (ms) | 2284 -> 1140 (-50%) | 3524 -> 1988 (-44%) | 5308 -> 3440 (-35%) |
| LCP (ms) | 4312 -> 2688 (-38%) | 6040 -> 3788 (-37%) | 8608 -> 5516 (-36%) |
特にTTFBの削減がFCPやLCPの改善へと波及しています。また、TTFBとLCPの差も縮まっており、キャッシュ改善によって他のリソースもブラウザやCDNのキャッシュからより速く読み込まれるようになったことがうかがえます。
| 指標 | 移行前(Akamai) | 移行後(CloudFront) |
|---|---|---|
| TTFB | 2484ms | 1074ms |
| FCP | 3524ms | 1988ms |
| LCP | 6040ms | 3788ms |
FaroのデータからTTFBリクエストの内訳を分析すると、request_durationが大幅に削減されていることがわかります。残念ながらFaroはデフォルトでは<script>タグのリソースについてリクエスト時間を計測しないため、アセットのキャッシュがリクエストの読み込み時間をどれだけ改善したかを正確に把握することはできません。
| 指標 | 移行前(Akamai) | 移行後(CloudFront) |
|---|---|---|
| DNS | 56ms | 58ms |
| Connection | 47ms | 33ms |
| Cache | 4ms | 4ms |
| Request | 1056ms | 184ms |
| Waiting | 11ms | 22ms |
Faroではアセットのリクエスト時間は確認できませんが、CloudFrontのオブジェクトごとのキャッシュ統計からキャッシュの効果は見て取れます。マイクロフロントエンドのアセットに絞ると、エッジキャッシュのヒット率は52%から95%へ上昇しました。キャッシュ可能な2種類のアセットはいずれも約100%に達しており、remoteEntry.jsは63%から、不変のcontenthash付きチャンクは48%から上昇しています。
| 指標 | 移行前(Akamai) | 移行後(CloudFront) |
|---|---|---|
| remoteEntry.js | 63% | 100% |
| 不変のチャンク | 48% | 100% |
| すべてのMFEリクエスト | 52% | 95% |
改善の大部分は再検証(revalidation)の排除によるものです。以前は明示的なキャッシュヘッダーがなかったため、アセットリクエストの約40%が再検証でした。CloudFrontはファイルを保持していながらも、ほぼすべてのリクエストでオリジンに確認(304 Not Modified)を取っていました。contenthash付きチャンクにCache-Control: immutableを、remoteEntry.jsにs-maxageを設定したことで、これらの往復が真のキャッシュヒットに変わりました。これによりオリジンへの帯域幅も削減され、パフォーマンス向上だけでなくコスト削減にもつながりました。
Faroは各セッションにユーザーの前回セッションへの参照をタグ付けしており、これにより初回(コールドキャッシュ)の読み込みと再訪(ウォームキャッシュ)の読み込みを区別できます。新しいキャッシュ戦略では、再訪ユーザーが最も恩恵を受けることが期待されます。First Contentful Paintを見ると、移行前は再訪ユーザーが新規ユーザーより約17%速く、移行後はその差が約29%に広がっています。
| 指標 | 移行前(Akamai) | 移行後(CloudFront) |
|---|---|---|
| 新規ユーザー | 2296ms | 1156ms |
| 再訪ユーザー | 1912ms | 816ms |
一方LCPについては同等の変化は見られませんでした。LCPはJSの読み込み速度よりも、アプリケーションのレンダリングロジックやデータ取得のAPI呼び出しに大きく左右されるためです。LCPでは新規ユーザーも再訪ユーザーも同様に約38%改善しています。
今後の展望
フロントエンドアプリケーション全体のパフォーマンスについては、まだまだ改善の余地があります。Web Vitalsではp75で2.5秒未満のLCPを「良好」としていますが、現状は約3.8秒であり、さらなる改善が必要です。
私たちのアーキテクチャは、分散したチームでの機能開発のスピードを重視して設計されており、SPAやマイクロフロントエンドはそれを実現する一方で、パフォーマンスというトレードオフを伴います。プラットフォームレベルでは、index.htmlのキャッシュや、FCP向上のためのスケルトンローディング画面のHTMLへのインライン化など、まだ改善できる点があります。最終的にLCPをさらに削減するには、プロダクトチームがフロントエンドとバックエンドの両方にわたる初期レンダリングまでのクリティカルパスを最適化することにも取り組む必要があります。
また、キャッシュのinvalidationステップを導入したことで、デプロイパイプラインに多少の複雑さが加わりました。CloudFrontのキャッシュタグは、キャッシュ無効化のロジックを構成するための柔軟な手段を提供してくれるため、これを活用してロジックを簡素化し、必要に応じてキャッシュされたアセットを無効化する能力を高めていくことを検討しています。
記事をランダムに読む
コメント
ログインしてコメントする