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),並仰賴 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 邊緣規則,因此決定在同一個 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 故障期間就曾因此受到影響

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

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

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

  • 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 快取失效步驟,以確保當 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,以確保瀏覽器每次都會向伺服器確認檔案的最新版本。由於現在透過 CloudFront function 的邏輯,index.html 只有單一快取鍵值需要失效,我們或許可以採用類似 remoteEntry.js 的方式進一步最佳化,但目前仍維持既有的 no-cache 行為,以確保殼層更新能快速發布。

衡量影響

我們使用Grafana Faro 來擷取正式環境的 RUM 資料,並以此監控這些變更對效能指標的影響。隨著流量遷移至新的 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 快取載入。

遷移前後第 75 百分位數的 Core Web Vitals(核心網頁指標)(毫秒)
指標遷移前(Akamai)遷移後(CloudFront)
TTFB2484ms1074ms
FCP3524ms1988ms
LCP6040ms3788ms
遷移前後第 75 百分位數的 Core Web Vitals(核心網頁指標)(毫秒)

我們可以從 Faro 資料拆解 TTFB 請求的各個組成部分,觀察到 request_duration 大幅下降。遺憾的是,Faro 預設不會為 <script> 標籤資源檢測請求時間,因此我們無法準確得知資源快取對請求載入時間改善了多少。

TTFB 子元件(p50,毫秒):request_duration——伺服器的回應時間——從約 1056 毫秒降至約 184 毫秒,貢獻了幾乎全部的增益。DNS、連線與快取幾乎沒有變動。
指標遷移前(Akamai)遷移後(CloudFront)
DNS56ms58ms
Connection47ms33ms
Cache4ms4ms
Request1056ms184ms
Waiting11ms22ms
TTFB 子元件(p50,毫秒):request_duration——伺服器的回應時間——從約 1056 毫秒降至約 184 毫秒,貢獻了幾乎全部的增益。DNS、連線與快取幾乎沒有變動。

雖然我們無法在 Faro 中看到資源請求時間,但可以從 CloudFront 依物件區分的快取統計資料看到快取的效果。篩選微前端資源後,可見邊緣快取命中率從 52% 提升至 95%。兩種可快取的資源類型皆接近 100%:remoteEntry.js 從 63% 提升,內容雜湊的不可變程式碼片段則從 48% 提升。

微前端資源的 CloudFront 邊緣快取命中率(命中次數/請求數),遷移前後比較。
指標遷移前(Akamai)遷移後(CloudFront)
remoteEntry.js63%100%
Immutable chunks48%100%
All MFE requests52%95%
微前端資源的 CloudFront 邊緣快取命中率(命中次數/請求數),遷移前後比較。

大部分的提升來自於消除重新驗證。過去在沒有明確快取標頭的情況下,約 40% 的資源請求屬於重新驗證。CloudFront 雖然持有檔案,但幾乎每次請求都會回源檢查(304 Not Modified)。在內容雜湊的程式碼片段上設定 Cache-Control: immutable,並在 remoteEntry.js 上設定 s-maxage,將這些往返轉變為真正的快取命中。這有助於減少來源頻寬,不僅提升效能,也降低了成本。

Faro 會為每個工作階段標記使用者前一次工作階段的參照,讓我們能區分首次(冷快取)載入與回訪(暖快取)載入。我們預期回訪使用者最能從新的快取策略中受益。從 First Contentful Paint(首次內容繪製) 的數據可見,遷移前回訪使用者比新使用者快約 17%,遷移後差距擴大至約 29%。

依新使用者與回訪使用者區分的 FCP(p50,毫秒)
指標遷移前(Akamai)遷移後(CloudFront)
New users2296ms1156ms
Returning users1912ms816ms
依新使用者與回訪使用者區分的 FCP(p50,毫秒)

我們在 LCP 上未看到類似的變化,LCP 主要由應用程式的算繪邏輯與資料擷取 API 呼叫所主導,而非 JS 載入速度。在此,新使用者與回訪使用者皆改善了約 38%。

下一步

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

我們的架構是為分散式團隊的功能開發速度而設計,雖然 SPA 與微前端能幫助我們達成此目標,卻是以效能為代價。在平台層面,我們仍可在 index.html 快取以及將骨架載入畫面內嵌至 HTML 檔案以改善 FCP 等方面進一步最佳化。最終,要進一步降低 LCP,也需要產品團隊共同投入,從前端與後端邏輯兩方面最佳化初始算繪的關鍵路徑。

此外,我們透過引入快取失效步驟,為部署流程增加了一些複雜度。CloudFront 的快取標籤為我們提供了一種靈活的方式來組織快取失效邏輯,我們正研究運用它來簡化邏輯,並提升按需失效快取資源的能力。

原文由 Alex O'Callaghan 發布

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