Caching & CDNs with micro-frontends

Alex O'Callaghan

マイクロフロントエンドにおけるキャッシュとCDN

マイクロフロントエンドアーキテクチャにおけるキャッシュは、モノリシックなフロントエンドよりも繊細な調整が求められます。シェル、複数のリモートのマニフェスト、そしてそれらが参照するチャンクが存在し、それぞれデプロイの頻度も、古いコンテンツが許容される度合いも異なります。この記事では、Mintelで私たちがどのようにこの問題に向き合ってきたのか、過去に何がうまくいかなかったのか、そしてまだ完全には解決できていない課題について紹介します。

追記(2026年6月):その後、マイクロフロントエンドのキャッシュ戦略の最適化についての続編を公開しました。パフォーマンス向上と帯域コスト削減のために行った変更をまとめています。

私たちのスタック

私たちはWebpack Module Federationを使って約30のマイクロフロントエンドを運用しています。シェルは完全に静的なJamstackアプリで、S3にデプロイしCloudFront経由で配信しています。さらに最外層にはAkamaiが全体の前面に立っています。リモートは固定された周知のURLでホストしています。

UserAkamaiCloudFrontPython servicesDjango MPAS3 bucketMFE routesAPI routesMPA routes

デプロイは、ビルドされたdistディレクトリをMFEごとにS3上の特定のサブディレクトリへコピーする、たった1つのrcloneコマンドで完結します。シェルと各リモートはそれぞれ独立したデプロイであり、別々のチームが保守しています。

アセットタイプごとのキャッシュ設定

アセットの種類によってキャッシュの要件は異なります。ここでは、現在どのような設定をしているのか、そしてその理由を説明します。

index.html - no-cache

シェルのindex.htmlはすべての起点となります。これが古いままになっていると、その先のすべてが誤った内容になる可能性があります。私たちはデプロイ時に自動化された処理として、S3のオブジェクトメタデータでCache-Control: no-cacheヘッダーを付与しています。

no-cacheは、ファイルがキャッシュされないという意味ではありません。CDNやブラウザが配信前に必ずオリジンへ再検証しなければならない、という意味です。オリジンが304を返せばキャッシュされたコピーが配信され、内容が変わっていれば新しいファイルが返されます。

remoteEntry.js - キャッシュしない

remoteEntry.jsはModule Federationのマニフェストです。シェルに対して、リモートのチャンクがどこにあるかを伝えます。リモートをデプロイすると、このファイルの中身は変わりますが、ファイル名は変わりません。

古いremoteEntry.jsが残っていると、2つの障害パターンが生じます。1つ目は分かりやすいエラーです。すでに置き換えられた過去のデプロイのチャンクを指し示している場合、ランタイムエラーが発生します。もう1つはより気づきにくい問題で、新しいマニフェストを取得するまでユーザーが新しいバージョンを見ることができません。その結果、リモートチームが修正や新機能をリリースしても、マニフェストが古いためにユーザーは古いコードを実行し続けてしまいます。

Akamaiのレイヤーでは、remoteEntry.jsに対してCache-Control: no-store, max-age=0を設定し、ブラウザにキャッシュされないようにしています。また、動的なリモート読み込みmodule-federation-import-remoteパッケージ経由で利用しており、デフォルトでremoteEntry.jsのURLにキャッシュバスター用のクエリパラメータが付与されます。私たちのCloudFrontディストリビューションはクエリ文字列をキャッシュキーに含めているため、たとえCloudFrontにファイルがキャッシュされていたとしても、リクエストごとに一意のURLとなりキャッシュをバイパスしてS3から最新版を取得できます。

チャンク - 明示的なヘッダーなし

remoteEntry.jsが参照するJSチャンクは、Webpackのcontenthash置換によってコンテンツアドレス化されています。ファイルの中身が変わればハッシュも変わり、ファイル名も変わります。そのためチャンクは積極的にキャッシュできます。新しいデプロイでは新しいファイル名が生成されるので、CDNは自動的に別アセットとして扱います。

output.filenameでの設定はシンプルです。

output: {
  filename: '[name].[contenthash].js',
}

過去には、チームがリモートのアセットファイル名にcontenthashを設定し忘れたことがありました。予測可能な名前でデプロイされたチャンクがキャッシュされ、TTLが自然に切れるまで次のデプロイがユーザーに反映されませんでした。

現在、チャンクには明示的なCache-Controlヘッダーを設定していません。そのためCloudFrontは、S3から返されるLast-ModifiedETagヘッダーから導かれるヒューリスティックなTTLに基づいてこれらのファイルをキャッシュします。Akamaiレイヤーではオリジンレスポンスに対してno-storeの挙動を設定しているため、キャッシュはブラウザ内かCloudFrontのどちらかで行われます。

404のハンドリング

私たちのシェルはシングルページアプリケーションで、ルーティングはクライアントサイドで行われます。ユーザーが直接特定のルートにアクセスしたりページを更新したりすると、CDNはS3上のそのパスにファイルを探しに行きますが、何も見つからず、デフォルトでは404を返します。

対処方法は、オリジンからの4xxレスポンスに対してindex.htmlを返すようにCloudFrontを設定することです。AWSではこれをCloudFrontディストリビューション設定のカスタムエラーレスポンスとしてドキュメント化しています。

Akamaiは、既知のAPIやMPAのルートに一致しないリクエストをすべてCloudFrontへプロキシするため、このフォールバックの挙動はCloudFrontレイヤーで処理され、すべてのMFEルートに適用されます。

ユーザーリクエストの流れ

リモートは遅延読み込みされ、React.lazyとdynamic importでラップされています。シェルは、ユーザーがそのリモートを必要とする場所へ遷移したときにのみ、リモートのremoteEntry.jsを取得します。

UserBrowser cacheAkamaiCloudFrontS3GET /shell/index.htmlindex.htmlGET /remote-a/remoteEntry.js?t=1234remoteEntry.jsGET /remote-a/chunk.abc123.jschunk.abc123.jsStore chunk.abc123.jsGET /remote-a/chunk.xyz789.jsServed from browser cacheNew chunks are fetched; previously loaded chunks can be served from the browser cache.

CloudFront / S3の障害

上記の構成は、時間をかけて徐々に進化してきたもので、一部は障害への対応から生まれました。2025年1月、AWS側の障害により、CloudFrontのオリジンがS3からのコンテンツ取得に失敗し始め、404 NoSuchBucketエラーが発生しました。CloudFrontはS3からの4xxレスポンスに対してindex.htmlを返すように設定しており、これはSPAで一般的なキャッチオール設定ですが、このためそれらのエラーはAkamaiに届く前に200レスポンスへ変換されてしまいました。Akamai側では異常を検知する手段がなく、そのまま通常どおりキャッシュしてしまいました。AWSが復旧した後も、ユーザーにはAkamaiのエッジとブラウザのキャッシュの両方から、不正なキャッシュレスポンスが配信され続けました。

Akamaiでのパージは遅く、手間がかかります。パスをワイルドカードで指定してパターンに一致するものを一括削除することはできず、具体的なURLを1つずつ指定する必要があります。数十のMFEと数百のJSチャンクファイルを抱える状況では、緊急時に現実的な手段とは言えません。私たちは対策室に集まり、読み込み中のまま進まないパージリクエストに追われながら、TTLが自然に切れることで数時間かけて徐々にキャッシュが無効化されていくのを見守るしかありませんでした。しかも、この方法ではブラウザに不正なレスポンスをキャッシュしてしまったユーザーには効果がありません。

最終的にたどり着いた緊急避難策は、主要なMFEのWebpack設定でcontenthashの長さを変更し、再デプロイすることでした。contenthashの長さを変えると生成されるファイル名がすべて変わるため、CDNはキャッシュされた不正なレスポンスを返すのではなく、新しいアセットとして扱わざるを得なくなります。この方法は効果がありましたが、切迫した状況の中で思いついたものであり、正式な手順書に記載された対応ではありませんでした。

それ以来、MFEアセットについてはAkamaiでのキャッシュを無効化し、同じ事態に陥るリスクを下げるためにS3バケットのマルチリージョンフェイルオーバーを追加しました。また、変更を素早く反映させ、誤ったフォールバックレスポンスが長期間キャッシュされないように、index.htmlには明示的にno-cacheを設定するようにしました。

「不正なレスポンスがキャッシュされたらどうするのか」という問いに対する正直な答えは、今でもすっきりした解決策はない、というものです。急いで全てを無効化する必要があるときに、全体でファイル名を強制的に変えるための核兵器的な手段として、contenthashの長さを変えるトリックが今も残っています。

キャッシュ戦略の改善

この記事を書くことは、現在のキャッシュ戦略がどのように機能しているかを振り返る良い機会になりました。システムが正常に動いているとき、キャッシュの設定を改めて見直すことはあまりありませんが、現状の構成は実運用でも十分に持ちこたえています。

ただ、キャッシュ設定はindex.htmlについてはS3のオブジェクトメタデータ、remoteEntry.jsについてはAkamaiのルールと分散しており、それ以外のアセットには明示的なキャッシュヘッダーがまったく設定されていません。全体のキャッシュポリシーを一元的に把握できる場所がないのです。また、前回の障害の痛手から強い反動として、外側のAkamaiレイヤーでは一切キャッシュしていませんが、これがパフォーマンスを損ない、帯域コストを押し上げています。

よりすっきりしたアプローチは、すべてのアセットタイプについてS3のオブジェクトメタデータを通じてオリジン側で明示的なCache-Controlヘッダーを設定し、CDNレイヤーはキャッシュポリシーを定義する場所ではなくオリジンのヘッダーを尊重するキャッシュとして扱うことです。そうすれば、仮にAkamaiレイヤーを入れ替えたり再設定したりしても、キャッシュの挙動はオリジンに紐づいて維持され、気づかないうちに失われることもありません。

現在チャンクにはキャッシュヘッダーを一切設定していないため、どれくらいの期間キャッシュするかはCDNやブラウザのヒューリスティックに依存しています。コンテンツハッシュ化されたチャンクに明示的にCache-Control: max-age=31536000, immutableヘッダーを設定し、Akamaiでオリジンのキャッシュ指示を尊重する挙動を再び有効化すれば、不変のアセットとして積極的かつ正しくキャッシュされるようになり、良い改善となります。ただ、現在の構成ではすべてのチームがビルド出力のファイル名にcontenthashを正しく設定している保証はありません。とはいえ、両方の問題を一度に解決できる別のアプローチもありますが、それにはより大きなアーキテクチャ変更が必要です。

代替案:バージョン付きURLとディスカバリーサービス

ここまで説明してきた内容は、すべてリモートが固定された周知のURLに存在することを前提としています。これは最もシンプルなデプロイモデルですが、remoteEntry.jsのキャッシュが難しい根本原因でもあります。ファイルをその場で上書きしていく限り、長期間安全にキャッシュすることはできないからです。

より堅牢なアプローチは、URL自体にバージョンを含めることです。

https://cdn.example.com/remote-a/v1.4.2/remoteEntry.js

バージョン付きのパスにすれば、remoteEntry.jsも他のチャンクと同様にコンテンツアドレス化されたファイルになります。max-age=31536000, immutableでキャッシュできます。古いバージョンはS3に無期限に残るため、セッション途中のユーザーがデプロイによって壊れることもありません。ロールバックも再デプロイではなく、マニフェストが指すバージョンを戻すだけで済みます。

これを実現するには、シェル側でリモートのURLをハードコードできなくなります。ディスカバリーサービスが必要です。シェルが起動時に呼び出し、各リモートの現在のURLを取得する仕組みです。

{
  "remote-a": "https://cdn.example.com/remote-a/v1.4.2/remoteEntry.js",
  "remote-b": "https://cdn.example.com/remote-b/v2.1.0/remoteEntry.js"
}

MFEディスカバリーサービス

ただ、私たちはこの方法を採用していません。1年以上前にこのパターンを特定し、将来的な方向性の1つとしてアーキテクチャのブループリントに追加してはいたのですが。contenthashの設定漏れを防げるという利点はありますが、それは規律の問題であり、30ものMFEを移行する理由にはなりません。各CDNレイヤーでより積極的にキャッシュすればパフォーマンス向上や帯域コスト削減にもつながりますが、キャッシュヒット率や帯域コストを詳細に分析しなければ効果を定量化するのは難しく、現在のやり方でチャンクにimmutableヘッダーを付与することでも同様の効果は得られます。

ディスカバリーサービスがあれば、カナリアリリースやデプロイレベルでのフィーチャーフラグといった、より複雑なデプロイパターンも可能になります。しかし、その分複雑さと運用負荷が増します。多くのケースでは、アプリケーションロジックの中でフィーチャーフラグを扱うことができ、その方がユーザーが実行しているコードを理解しやすくなります。カナリアリリースも、本当に役立てるには自動モニタリングやアラート戦略への時間投資が不可欠です。

ディスカバリーサービスは将来的な選択肢として残しつつ、当面はよりシンプルなデプロイ方式の中で、現在のキャッシュ戦略を改善することに注力していきます。

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

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