マイクロフロントエンドにおけるキャッシュとCDN
原文は Alex O'Callaghan により に公開されました。 このブログを購読する
マイクロフロントエンドアーキテクチャにおけるキャッシュは、モノリシックなフロントエンドよりも複雑です。シェル、複数のリモートマニフェスト、そしてそれらが参照するチャンクがあり、それぞれデプロイの頻度も、古いデータに対する許容度も異なります。本記事では、Mintelでどのように取り組んできたか、過去に何がうまくいかなかったのか、そしてまだ完全には解決できていない課題について紹介します。
2026年6月追記:その後、マイクロフロントエンド向けキャッシュ戦略の最適化についてのフォローアップ記事を書きました。パフォーマンス向上と帯域幅コスト削減のために行った変更について解説しています。
私たちのスタック
約30のマイクロフロントエンドをWebpack Module Federationで運用しています。シェルはS3にデプロイされた完全に静的なJamstackアプリで、CloudFront経由で配信され、最も外側のレイヤーではすべてがAkamaiの背後にあります。リモートは固定された周知のURLに配置されています。
デプロイは、ビルドされたdistディレクトリをMFEごとにS3内の特定のサブディレクトリへコピーする、単一のrcloneコマンドです。シェルと各リモートは独立したデプロイであり、それぞれ異なるチームが管理しています。
アセットタイプごとの設定
アセットごとにキャッシュ要件は異なります。現在どのように設定しているか、その理由とともに紹介します。
index.html - no-cache
シェルのindex.htmlはすべての起点となります。これが古いと、下流のすべてが不正確になる可能性があります。デプロイの一環として自動化された処理で、S3のオブジェクトメタデータを介してCache-Control: no-cacheヘッダーを付与しています。
no-cacheは、ファイルがキャッシュされないという意味ではありません。CDNやブラウザが配信前に必ずオリジンに再検証しなければならない、という意味です。オリジンが304を返せばキャッシュされたコピーが配信されますが、内容が変更されていれば新しいコピーが返されます。
remoteEntry.js - never cache
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-ModifiedやETagヘッダーから導き出されるヒューリスティックなTTLに基づいてこれらのファイルがキャッシュされます。Akamaiレイヤーではオリジンレスポンスに対してno-storeの挙動を設定しているため、キャッシュはブラウザ内かCloudFrontのいずれかで行われます。
404の処理
シェルはシングルページアプリです。ルーティングはクライアントサイドで行われます。ユーザーが直接ルートにアクセスしたりページを更新したりすると、CDNはS3内でそのパスにあるファイルを探しますが見つからず、デフォルトでは404を返します。
対策は、S3からの4xxレスポンスに対してindex.htmlを返すようにCloudFrontを設定することです。AWSではこれをCloudFrontディストリビューション設定のカスタムエラーレスポンスとしてドキュメント化しています。
Akamaiは既知のAPIやMPAのルートに一致しないリクエストをすべてCloudFrontにプロキシするため、このフォールバックの挙動はCloudFrontレイヤーで処理され、すべてのMFEルートに適用されます。
ユーザーリクエストの流れ
リモートはReact.lazyや動的importでラップされ、遅延読み込みされます。シェルは、ユーザーがそのリモートを必要とする場所に遷移したときのみ、リモートのremoteEntry.jsを取得します。
CloudFront/S3障害
上記の構成は、時間をかけて進化してきたものであり、一部は障害への対応から生まれたものです。2025年1月、AWS側の障害によりCloudFrontのオリジンがS3からのコンテンツ取得に失敗し、404 NoSuchBucketエラーが発生しました。CloudFrontはS3からの4xxレスポンスに対してindex.htmlを返すように設定されていたため、SPAで一般的なキャッチオール設定により、これらのエラーはAkamaiに届く前に200レスポンスへと変換されてしまいました。Akamai側では異常を検知する手段がなく、通常通りそれらをキャッシュしました。AWSが復旧した後も、ユーザーはAkamaiのエッジと自身のブラウザキャッシュの両方から、キャッシュされた不正なレスポンスを配信され続けることになりました。
Akamaiでのパージは遅く、扱いづらいものです。パスをglobで指定して一致するものを一括削除することはできず、特定の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"
}
しかし私たちはこれを採用していません。1年以上前にこのパターンを特定し、将来的な方向性の1つとしてアーキテクチャのブループリントに追加したにもかかわらずです。これによりcontenthashの設定漏れを防ぐことはできますが、それは規律の問題であり、30ものMFEを移行する理由にはなりません。各CDNレイヤーでより積極的にキャッシュすればパフォーマンス向上や帯域幅コスト削減にもつながりますが、キャッシュヒット率や帯域幅コストの詳細な分析なしにその効果を定量化するのは難しく、現在のやり方でチャンクにimmutableヘッダーを設定することでも同様の効果は得られます。
ディスカバリーサービスがあれば、カナリアリリースやデプロイレベルでのフィーチャーフラグといったより複雑なデプロイパターンも可能になりますが、その分複雑さと運用のオーバーヘッドが増します。ほとんどのケースではアプリケーションロジック自体の中でフィーチャーフラグを扱えているため、ユーザーが実行しているコードについて推論しやすくなっています。カナリアリリースを本当に有用なものにするには、自動化された監視やアラート戦略への時間投資も必要です。
ディスカバリーサービスは将来的な選択肢として残ってはいますが、より差し迫った取り組みとしては、シンプルなデプロイアプローチの中で現在のキャッシュ戦略を改善することに注力しています。
記事をランダムに読む
コメント
ログインしてコメントする