微前端中的快取與 CDN
在 micro-frontend architecture(微前端架構)中的快取,比單體式前端更為細緻。你會面對外層框架(shell)、多個遠端資訊清單(remote manifests)以及它們所參照的程式碼片段(chunks),每一項都有不同的部署頻率與對內容過期的容忍度。本文將說明我們在 Mintel 是如何處理這個問題、過去曾發生過哪些故障,以及目前仍未完全解決的挑戰。
更新 — 2026 年 6 月:我後續撰寫了一篇關於為微前端最佳化我們的快取策略的追蹤文章,說明我們為了提升效能並降低頻寬成本所做的調整。
我們的技術堆疊
我們使用 Webpack Module Federation 運行約 30 個 micro-frontends。外層框架(shell)是一個純靜態的 Jamstack 應用程式,部署至 S3,透過 CloudFront 提供服務,最外層則由 Akamai 統一承接所有流量。遠端(remotes)皆位於固定且眾所皆知的 URL。
部署只需一條 rclone 指令,即可將建置好的 dist 目錄複製到 S3 上各個 MFE 專屬的子目錄。外層框架與每一個 remote 皆為獨立部署,由不同的團隊各自維護。
各類資源的快取設定方式
不同的資源有不同的快取需求。以下是我們目前的設定及其原因。
index.html - no-cache
外層框架的 index.html 負責啟動整個應用。若它過期,下游的所有內容都可能出錯。我們透過 S3 物件的中繼資料(object metadata)在其上設定 Cache-Control: no-cache 標頭,並在部署流程中自動完成。
no-cache 並不代表檔案不會被快取。它的意思是,CDN 或瀏覽器在提供檔案前,必須先向來源(origin)重新驗證。若來源回傳 304,則提供快取的副本;若內容已變更,則回傳最新的檔案。
remoteEntry.js - never cache
remoteEntry.js 是 Module Federation 的資訊清單(manifest)。它告訴外層框架去哪裡取得某個 remote 的 chunks。當你部署某個 remote 時,這個檔案的內容會變動,但檔名維持不變。
過期的 remoteEntry.js 會有兩種失效模式。較明顯的是錯誤:如果它指向已被覆蓋的舊版 chunks,就會導致執行階段失敗。較隱晦的是,使用者在取得最新的 manifest 之前,將無法看到新版應用程式。這會導致遠端團隊明明已發布修正或新功能,使用者卻因 manifest 過期而仍在執行舊版程式碼。
在 Akamai 這一層,我們對 remoteEntry.js 設定 Cache-Control: no-store, max-age=0,以防止瀏覽器快取它。我們也使用動態遠端載入,透過 module-federation-import-remote 套件,該套件預設會在 remoteEntry.js 的 URL 後附加用於破壞快取的查詢參數(cache-busting query param)。由於我們的 CloudFront 分發會將查詢字串納入快取鍵(cache key),即使檔案在 CloudFront 已有快取,每次請求也會因 URL 獨特而繞過快取,直接從 S3 取得最新版本。
Chunks - no explicit headers
remoteEntry.js 所參照的 JS chunks,是透過 Webpack 的 contenthash 替換機制來進行內容定址(content-addressed)。當檔案內容變更時,雜湊值(hash)也會變更,檔名跟著改變。這表示你可以積極地快取 chunks —— 新的部署會產生新的檔名,因此 CDN 會自動將其視為全新的資源。
在你的 output.filename 中進行此設定相當簡單:
output: {
filename: '[name].[contenthash].js',
}我們曾遇過團隊忘記為其 remote 的資源檔名設定 contenthash 的情況。以可預測檔名部署的 chunks 遭到快取後,後續的部署直到快取的 TTL 自然到期前,都不會反映給使用者。
我們目前並未對 chunks 設定明確的 Cache-Control 標頭。CloudFront 因而會依據來自 S3 的 Last-Modified 或 ETag 標頭所推導出的啟發式 TTL 來快取這些檔案。在 Akamai 這一層,我們對來源回應設定 no-store 行為,因此所有快取都只發生在瀏覽器或 CloudFront。
404 handling
我們的外層框架是一個單頁式應用程式(single-page app),路由在用戶端處理。若使用者直接導向某個路由或重新整理頁面,CDN 會在 S3 上尋找該路徑的檔案,找不到時預設會回傳 404。
解決方式是將 CloudFront 設定為在來源回傳 4xx 回應時提供 index.html。AWS 在 CloudFront 分發設定中將此文件化為自訂錯誤回應。
Akamai 會將任何不符合已知 API 或 MPA 路由的請求代理(proxy)至 CloudFront,因此此回退行為在 CloudFront 層處理,並套用至所有 MFE 路由。
使用者請求流程
Remotes 採延遲載入(lazy loading),以 React.lazy 與動態 imports 包裝。外層框架只有在使用者導向需要該 remote 的頁面時,才會去抓取該 remote 的 remoteEntry.js。
CloudFront/S3 服務中斷
上述架構是隨著時間演進而來,部分是為了回應實際事故。2025 年 1 月,一起 AWS 問題導致我們的 CloudFront 來源無法從 S3 取得內容,進而產生 404 NoSuchBucket 錯誤。由於 CloudFront 已設定為在 S3 回傳 4xx 回應時提供 index.html —— 這是標準的 SPA 萬用捕捉(catch-all)設定 —— 這些錯誤在抵達 Akamai 之前就被轉換為 200 回應。Akamai 無從得知發生異常,便照常將其快取。當 AWS 恢復後,使用者仍持續從 Akamai 的邊緣節點以及自身的瀏覽器快取中收到這些錯誤的快取回應。
在 Akamai 上清除快取(purging)既慢又痛苦。你無法用萬用字元(glob)批次清除符合模式的所有路徑,必須提供明確的 URL。在擁有數十個 MFE 與數百個 JS chunk 檔案的情況下,這在緊急時刻並非可行的選項。我們最後進入戰情室,手忙腳亂地送出卡在載入中的清除請求,只能在數小時內眼看快取隨著 TTL 自然到期才逐漸失效。況且,這也無法幫助那些已在瀏覽器中快取錯誤回應的使用者。
我們最終找到的逃生方案,是在關鍵的 MFE 上修改 Webpack 設定中的 contenthash 長度,然後重新部署。改變 contenthash 長度會改變所有產生的檔名,迫使 CDN 將其視為全新資源,而非提供已快取的錯誤回應。這個方法奏效了,但我們是在壓力下才想到它,它並非既有操作手冊中記載的步驟。
自此之後,我們停用了 Akamai 對 MFE 資源的快取,並為 S3 儲存貯體新增多區域容錯移轉(multi-region failover),以降低再次陷入相同困境的風險。我們也開始對 index.html 明確設定 no-cache,以確保變更能被快速取得,且任何錯誤的回退回應不會被長時間快取。
對於「如果又快取到錯誤回應該怎麼辦」這個問題,老實說答案仍是:我們沒有乾淨俐落的解決方案。在需要緊急讓所有內容失效時,改變 contenthash 長度的技巧仍是我們全面強制產生新檔名的最終手段。
改進我們的快取策略
撰寫這篇文章是回顧目前快取策略如何運作的一個有用練習。當系統運作正常時,快取設定並不是會經常重新檢視的項目,而目前的設定在實務上也還算堪用。
然而,我們的快取設定分散在針對 index.html 的 S3 物件中繼資料與針對 remoteEntry.js 的 Akamai 規則中,其他資源則完全沒有明確的快取標頭。沒有單一的地方可以完整理解整體快取策略。我們也因為前一次事故帶來的痛苦,而完全不在最外層的 Akamai 進行快取,但這正在損害效能並增加頻寬成本。
更乾淨的做法,是透過 S3 物件中繼資料在來源端為每種資源類型設定明確的 Cache-Control 標頭,並將 CDN 各層視為遵循來源標頭的快取,而非定義快取策略的地方。這也意味著,若未來更換或重新設定 Akamai 層,快取行為仍會依循來源,而不會被默默遺失。
我們目前完全未對 chunks 設定快取標頭,因此依賴 CDN 與瀏覽器的啟發式機制來決定快取時間。對已進行內容雜湊(content-hashed)的 chunks 設定明確的 Cache-Control: max-age=31536000, immutable 標頭,並重新啟用 Akamai 遵循來源快取的行為,將會是一項不錯的改進,可確保它們作為不可變(immutable)資源被積極且正確地快取 —— 但以目前的架構,無法保證每個團隊都已正確將建置輸出檔名設定為使用 contenthash。不過,確實存在另一種能同時解決這兩個問題的做法,只是需要較大幅度的架構變更。
替代方案:版本化 URL 與探索服務
以上所有內容都假設 remotes 位於固定且眾所皆知的 URL。這是最簡單的部署模型,但也是造成 remoteEntry.js 快取困難的根本原因,因為當你在原地變更檔案時,就永遠無法安全地長時間快取它。
更穩健的做法是在 URL 本身中加入版本資訊:
https://cdn.example.com/remote-a/v1.4.2/remoteEntry.js使用版本化路徑後,remoteEntry.js 就會像其他 chunk 一樣成為內容定址的檔案。你可以用 max-age=31536000, immutable 來快取它。舊版本會無限期保留在 S3 中,因此部署不會中斷正在使用中的使用者。回復舊版只需將資訊清單指向先前的版本,而非重新部署。
要讓此機制運作,外層框架不能寫死 remote 的 URL。你需要一個 discovery service(探索服務) —— 也就是外層框架在啟動時呼叫、以取得每個 remote 當前 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"
}
然而,我們並未採用此做法,儘管我們早在一年多前就已識別出此模式,並將其納入架構藍圖作為可能的未來方向。它雖然能避免團隊遺漏 contenthash 設定的問題,但那屬於紀律問題,並非遷移 30 個 MFE 的理由。在 CDN 各層進行更積極的快取也有助於提升效能並降低頻寬成本,雖然若未對快取命中率與頻寬成本進行詳細分析,很難量化其影響,而且透過目前的做法為 chunks 設定 immutable 標頭,也能達到類似的效果。
擁有 discovery service 也能支援更複雜的部署模式,例如金絲雀發布(canary releases)或在部署層級的 feature flags,但這些都會增加複雜度與維運負擔。多數情況下,我們能夠在應用程式邏輯本身內進行 feature flag,這讓我們更容易掌握使用者實際執行的程式碼。要讓金絲雀發布真正發揮作用,也需要在自動化監控與告警策略上投入時間。
雖然 discovery service 仍是未來可能的選項,但我們更立即的行動是聚焦於在現有較為簡單的部署方式下,改進目前的快取策略。
隨機一篇部落格