Faster micro-frontends: optimising CDN behaviour for performance

Alex O'Callaghan

より高速なマイクロフロントエンド:パフォーマンスのためのCDN挙動最適化

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へ移行しました。

本記事では、この移行がマイクロフロントエンドアーキテクチャにどのような影響を与えたのか、そして同時に実施したキャッシュ挙動の改善について焦点を当てて説明します。

移行前

UserAkamaiCloudFrontPython servicesDjango MPAS3 bucket

移行後

UserCloudFrontS3 bucketPython servicesDjango MPA

オリジンルーティング:404フォールバックから明示的なロジックへ

以前は、シングルページアプリケーションのルートでindex.htmlにフォールバックするために、CloudFrontのカスタムエラーレスポンスに依存していました。そのため、静的アセットに一致しないルートがリクエストされるたびに、リクエストはオリジンまで到達し、オブジェクトが見つからないことを確認してからindex.htmlを200レスポンスとして返すという動きになっていました。

Akamaiの既存のエッジルールを処理するためにCloudFront Functionを構築していたため、同じFunction内でSPAのフォールバックロジックも実装することにしました。これにより、リクエストがオリジンに到達する前のエッジでルーティングを処理できるようになりました。

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チャンクやその他のアセットで、不変であり、内容のハッシュを含むファイル名で管理されます。

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のキャッシュキーは単一になったため、remoteEntry.jsと同様の手法でさらに最適化できる可能性はありますが、シェルの更新を迅速に反映させるため、当面は従来どおりno-cacheの挙動を維持しています。

効果の計測

本番環境のRUMデータの収集にはGrafana Faroを利用しており、今回の変更がパフォーマンス指標に与えた影響のモニタリングにも活用しました。トラフィックをAkamaiから新しいCloudFrontディストリビューションへ移行するにつれて、ページ読み込み関連の指標が全体的に改善し始めました。

指標p50p75p90
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のキャッシュからより速く読み込まれていることがうかがえます。

移行前と移行後のCore Web Vitals(75パーセンタイル、ms)の比較
指標移行前(Akamai)移行後(CloudFront)
TTFB2484ms1074ms
FCP3524ms1988ms
LCP6040ms3788ms
移行前と移行後のCore Web Vitals(75パーセンタイル、ms)の比較

FaroのデータからTTFBリクエストの内訳を分解すると、request_durationが大幅に削減されたことがわかります。残念ながらFaroはデフォルトでは<script>タグのリソースについてリクエスト時間を計測しないため、アセットのキャッシュがリクエストの読み込み時間をどれだけ改善したかを正確に把握することはできません。

TTFBの内訳(p50、ms):サーバーの応答時間であるrequest_durationは約1056msから約184msへ低下し、改善のほぼすべてを牽引しました。DNS、接続、キャッシュはほとんど変化していません。
指標移行前(Akamai)移行後(CloudFront)
DNS56ms58ms
接続47ms33ms
キャッシュ4ms4ms
リクエスト1056ms184ms
待機11ms22ms
TTFBの内訳(p50、ms):サーバーの応答時間であるrequest_durationは約1056msから約184msへ低下し、改善のほぼすべてを牽引しました。DNS、接続、キャッシュはほとんど変化していません。

Faroではアセットのリクエスト時間は確認できませんが、CloudFrontのオブジェクトごとのキャッシュ統計からキャッシュの効果を確認できます。マイクロフロントエンドのアセットに絞って見ると、エッジキャッシュのヒット率は52%から95%へ上昇しました。キャッシュ可能な2種類のアセットはいずれも約100%に達しており、remoteEntry.jsは63%から、immutableなcontenthash付きチャンクは48%から上昇しました。

マイクロフロントエンドアセットのCloudFrontエッジキャッシュヒット率(ヒット数 / リクエスト数)の移行前と移行後の比較
指標移行前(Akamai)移行後(CloudFront)
remoteEntry.js63%100%
immutableチャンク48%100%
すべてのMFEリクエスト52%95%
マイクロフロントエンドアセットのCloudFrontエッジキャッシュヒット率(ヒット数 / リクエスト数)の移行前と移行後の比較

この改善の大部分は、再検証の排除によるものです。以前は明示的なキャッシュヘッダーがなかったため、アセットリクエストの約40%が再検証でした。CloudFrontはファイルを保持していながらも、ほぼすべてのリクエストでオリジンに確認(304 Not Modified)をしていました。contenthash付きチャンクにCache-Control: immutableを、remoteEntry.jss-maxageを設定したことで、これらの往復が真のキャッシュヒットへと変わりました。これによりオリジンの帯域幅が削減され、パフォーマンス向上だけでなくコスト削減にもつながりました。

Faroは各セッションにユーザーの前回セッションへの参照を付与するため、初回(コールドキャッシュ)の読み込みと再訪(ウォームキャッシュ)の読み込みを分けて分析できます。新しいキャッシュ戦略の恩恵を最も受けるのは再訪ユーザーであると予想されます。First Contentful Paintで見ると、移行前は再訪ユーザーが新規ユーザーより約17%高速でしたが、移行後はその差が約29%へと広がりました。

FCP(p50、ms)の新規ユーザーと再訪ユーザーの比較
指標移行前(Akamai)移行後(CloudFront)
新規ユーザー2296ms1156ms
再訪ユーザー1912ms816ms
FCP(p50、ms)の新規ユーザーと再訪ユーザーの比較

一方、LCPについては同様の変化は見られませんでした。LCPはJSの読み込み速度よりも、アプリケーションのレンダリングロジックやデータ取得のAPI呼び出しに大きく左右されるためです。そこでは新規ユーザーも再訪ユーザーも同じく約38%改善しました。

今後の展望

フロントエンドアプリケーション全体のパフォーマンスについては、まだ改善の余地が多く残されています。Web Vitalsでは、良好なLCPの目安としてp75で2.5秒未満を推奨しています。現在は約3.8秒であり、さらなる改善が必要です。

私たちのアーキテクチャは、分散したチームでの機能開発のスピードを重視して設計されており、SPAやマイクロフロントエンドはそれを実現する一方で、パフォーマンスという面ではトレードオフを伴います。プラットフォームレベルでは、index.htmlのキャッシュや、FCP向上のためのスケルトンローディング画面のHTMLへのインライン化など、まだ改善できる点があります。最終的にLCPをさらに削減するには、プロダクトチームがフロントエンドとバックエンドの両面から初期レンダリングまでのクリティカルパスを最適化することにも注力する必要があります。

また、キャッシュのinvalidationステップを導入したことで、デプロイパイプラインに多少の複雑さが加わりました。CloudFrontのキャッシュタグは、キャッシュ無効化のロジックを柔軟に構成する方法を提供してくれます。今後はこれを活用してロジックを簡素化し、必要に応じてキャッシュされたアセットを無効化できる能力を高めていきたいと考えています。

原文は Alex O'Callaghan により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。