Faster micro-frontends: optimising CDN behaviour for performance

Alex O'Callaghan

更快的微前端:最佳化 CDN 行為以提升效能

原文由 Alex O'Callaghan 發布,訂閱此部落格

我們剛完成將 CDN 層從 Akamai 遷移至 CloudFront,並投入時間改進微前端架構的快取策略。效能指標已有顯著改善,特別是 Time To First Byte(TTFB)在 p90 降低了 54%,也讓 Largest Contentful Paint(LCP)在 p90 下降了約 36%。

幾個月前我曾寫過關於我們微前端架構的快取策略。當時我們在一次服務中斷後停用了 Akamai 的快取,採用三跳式架構(Akamai -> CloudFront -> S3),並仰賴 CDN 層上明確的快取規則來控制快取。大多數資源並未明確設定快取控制,快取行為分散在 S3 中繼資料、Akamai 規則以及 CDN/瀏覽器的啟發式判斷之間。

移除 Akamai:從三跳到兩跳

Akamai 長期以來是我們架構中一直想移除的額外複雜度。我們的其他基礎設施大多已在 AWS 上,自從透過 CloudFront 導入微前端架構後,Akamai 就成了多餘的一跳,徒增延遲與複雜度。

基礎設施團隊著手處理將多年來累積的各種 Akamai 規則移植到 CloudFront 的複雜工作,陸續解決從舊版 TLS 支援到預設安全行為變更等一系列問題。流量在三週內逐步從 Akamai 轉移至 CloudFront。

在這篇文章中,我將特別聚焦於這次遷移對微前端架構的影響,以及我們同步為改善快取行為所做的一些調整。

遷移前

UserAkamaiCloudFrontPython servicesDjango MPAS3 bucket

遷移後

UserCloudFrontS3 bucketPython servicesDjango MPA

來源路由:用明確的邏輯取代 404 遞補

我們先前仰賴 CloudFront 的自訂錯誤回應,將單頁應用程式的路由遞補到 index.html 檔案。這意味著每當使用者請求的路由不符合任何靜態資源時,請求都會先打到來源端,發現物件不存在後,再以 200 回應碼遞補回 index.html

由於我們本來就要建置一個 CloudFront Function 來處理既有的眾多 Akamai 邊緣規則,我們決定將 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 服務中斷時就曾影響我們

這也有助於改善 TTFB 效能,因為每個 SPA 路由請求都少了兩次對來源端的請求。

依資源類型設定明確的快取標頭

在我們的微前端部署中,有三種從 S3 提供的資源類型:

  • index.html —— 主殼層(host shell)的進入點,屬於可變資源,每次殼層部署都會異動。
  • remoteEntry.js —— 各個微前端的進入點,同樣是可變的,微前端更新時就會變動。
  • assets —— 各個微前端的 JS chunk 與其他資源,屬於不可變資源,檔名包含內容雜湊。

與其讓 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],代表只要 chunk 內容有異動,就會產生新的檔名。因此我們可以為這些資源設定較長的快取時間,而不必擔心提供過時的內容。

remoteEntry.js

remoteEntry.js 是每個微前端的進入點,指向需要載入的各種 JS chunk。由於每個微前端都是獨立部署,檔名必須固定在眾所皆知的路徑上。我們為終端使用者設定較短的快取時間,避免瀏覽器長時間快取舊版本。s-maxage 指令則讓共享快取(如 CloudFront)可以快取較長時間,以減輕來源端的負載。

我們在部署流程中加入了 CloudFront 失效步驟,確保 remoteEntry.js 更新時,CloudFront 上的快取版本會被失效,使用者在下一次請求時就能取得新版本:

aws cloudfront create-invalidation \
  --distribution-id $DISTRIBUTION_ID \
  --paths '/path/to/remoteEntry.js'

註:部分客戶是透過自家的 Proxy 存取我們的應用程式,這些 Proxy 可能會依據 s-maxage 指令進行快取。我們會在 CloudFront 回應中移除 Cache-Control 標頭裡的 s-maxage,讓只有我們自己的 CDN 能以較長時間快取該檔案,下游的使用者則一律取得較短的快取時間。

index.html

對於 index.html,我們設定 Cache-Control: no-cache,確保瀏覽器每次都會向伺服器確認是否為最新版本。其實我們或許可以用類似 remoteEntry.js 的方式進一步最佳化,因為現在透過 CloudFront Function 的邏輯,只需要處理單一的 index.html 快取鍵即可失效,但為了確保殼層更新能快速推播,目前仍維持既有的 no-cache 行為。

衡量成效

我們使用Grafana Faro 來收集正式環境的 RUM 資料,並以此監測這些變更對效能指標的影響。隨著流量從 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 — 伺服器回應時間 — 從約 1056 ms 降至約 184 ms,幾乎貢獻了全部的改善幅度。DNS、連線與快取則幾乎沒有變動。
指標遷移前(Akamai)遷移後(CloudFront)
DNS56ms58ms
連線47ms33ms
快取4ms4ms
請求1056ms184ms
等待11ms22ms
TTFB 子項(p50,ms):request_duration — 伺服器回應時間 — 從約 1056 ms 降至約 184 ms,幾乎貢獻了全部的改善幅度。DNS、連線與快取則幾乎沒有變動。

雖然在 Faro 中看不到資源請求時間,但我們可以從 CloudFront 針對單一物件的快取統計數據看到快取的效果。篩選出微前端資源後,可以看到邊緣快取命中率從 52% 提升至 95%。兩種可快取的資源類型皆達到約 100%:remoteEntry.js 從 63% 提升,內容雜湊的不可變 chunk 則從 48% 提升。

微前端資源的 CloudFront 邊緣快取命中率(命中次數/請求數),遷移前後對比。
指標遷移前(Akamai)遷移後(CloudFront)
remoteEntry.js63%100%
不可變 chunk48%100%
所有微前端請求52%95%
微前端資源的 CloudFront 邊緣快取命中率(命中次數/請求數),遷移前後對比。

大部分的提升來自於消除了重新驗證。以往由於沒有明確的快取標頭,約 40% 的資源請求屬於重新驗證。CloudFront 雖然持有檔案,卻幾乎在每次請求時都回源確認(304 Not Modified)。在內容雜湊的 chunk 上設定 Cache-Control: immutable,並在 remoteEntry.js 上設定 s-maxage 後,這些來回確認都變成了真正的快取命中。這也有助於降低來源頻寬,既節省成本又提升效能。

Faro 會為每個工作階段標記使用者前一次工作階段的參照,讓我們得以區分首次載入(冷快取)與回訪載入(熱快取)。我們預期回訪使用者會從新的快取策略中獲益最多。從 First Contentful Paint 來看,遷移前回訪使用者比新使用者快約 17%,遷移後差距擴大至約 29%。

依新使用者與回訪使用者區分的 FCP(p50,ms)
指標遷移前(Akamai)遷移後(CloudFront)
新使用者2296ms1156ms
回訪使用者1912ms816ms
依新使用者與回訪使用者區分的 FCP(p50,ms)

LCP 則沒有看到類似的差異變化,它主要受應用程式的渲染邏輯與資料擷取 API 呼叫所主導,而非 JS 載入速度。在 LCP 上,新使用者與回訪使用者同樣改善了約 38%。

下一步

在前端應用程式的效能方面,我們仍有許多可以改進之處。Web Vitals 建議 p75 的 LCP 低於 2.5 秒才算「良好」。目前約 3.8 秒,仍有改善空間。

我們的架構當初是為了讓分散式團隊能快速開發功能而設計,雖然 SPA 與微前端有助於達成這個目標,卻是以效能為代價。在平台層面,我們仍可在 index.html 的快取,以及將骨架載入畫面以內嵌方式放入 HTML 檔案以改善 FCP 等方面進一步最佳化。歸根究柢,若要進一步降低 LCP,也需要產品團隊聚焦於最佳化從前端到後端邏輯的首次渲染關鍵路徑。

此外,我們在部署流程中加入快取失效步驟,也增加了一些複雜度。CloudFront 的快取標籤提供了一種彈性的方式來組織快取失效邏輯,我們正考慮採用它來簡化邏輯,並提升按需失效快取資源的能力。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言