更快的微前端:最佳化 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。
在本文中,我將特別聚焦於此次遷移如何影響我們的微前端架構,以及我們同時為改善快取行為所做的一些調整。
遷移前
遷移後
來源路由:以明確邏輯取代 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 發行版,我們開始在各項頁面載入相關指標上看到全面的改善。
| 指標 | p50 | p75 | p90 |
|---|---|---|---|
| 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 快取載入。
| 指標 | 遷移前(Akamai) | 遷移後(CloudFront) |
|---|---|---|
| TTFB | 2484ms | 1074ms |
| FCP | 3524ms | 1988ms |
| LCP | 6040ms | 3788ms |
我們可以從 Faro 資料拆解 TTFB 請求的各個組成部分,觀察到 request_duration 大幅下降。遺憾的是,Faro 預設不會為 <script> 標籤資源檢測請求時間,因此我們無法準確得知資源快取對請求載入時間改善了多少。
| 指標 | 遷移前(Akamai) | 遷移後(CloudFront) |
|---|---|---|
| DNS | 56ms | 58ms |
| Connection | 47ms | 33ms |
| Cache | 4ms | 4ms |
| Request | 1056ms | 184ms |
| Waiting | 11ms | 22ms |
雖然我們無法在 Faro 中看到資源請求時間,但可以從 CloudFront 依物件區分的快取統計資料看到快取的效果。篩選微前端資源後,可見邊緣快取命中率從 52% 提升至 95%。兩種可快取的資源類型皆接近 100%:remoteEntry.js 從 63% 提升,內容雜湊的不可變程式碼片段則從 48% 提升。
| 指標 | 遷移前(Akamai) | 遷移後(CloudFront) |
|---|---|---|
| remoteEntry.js | 63% | 100% |
| Immutable chunks | 48% | 100% |
| All MFE requests | 52% | 95% |
大部分的提升來自於消除重新驗證。過去在沒有明確快取標頭的情況下,約 40% 的資源請求屬於重新驗證。CloudFront 雖然持有檔案,但幾乎每次請求都會回源檢查(304 Not Modified)。在內容雜湊的程式碼片段上設定 Cache-Control: immutable,並在 remoteEntry.js 上設定 s-maxage,將這些往返轉變為真正的快取命中。這有助於減少來源頻寬,不僅提升效能,也降低了成本。
Faro 會為每個工作階段標記使用者前一次工作階段的參照,讓我們能區分首次(冷快取)載入與回訪(暖快取)載入。我們預期回訪使用者最能從新的快取策略中受益。從 First Contentful Paint(首次內容繪製) 的數據可見,遷移前回訪使用者比新使用者快約 17%,遷移後差距擴大至約 29%。
| 指標 | 遷移前(Akamai) | 遷移後(CloudFront) |
|---|---|---|
| New users | 2296ms | 1156ms |
| Returning users | 1912ms | 816ms |
我們在 LCP 上未看到類似的變化,LCP 主要由應用程式的算繪邏輯與資料擷取 API 呼叫所主導,而非 JS 載入速度。在此,新使用者與回訪使用者皆改善了約 38%。
下一步
我們在前端應用程式的效能方面仍有許多可改善之處。Web Vitals 建議 p75 的「良好」LCP 應低於 2.5 秒。以約 3.8 秒來看,我們仍有進步空間。
我們的架構是為分散式團隊的功能開發速度而設計,雖然 SPA 與微前端能幫助我們達成此目標,卻是以效能為代價。在平台層面,我們仍可在 index.html 快取以及將骨架載入畫面內嵌至 HTML 檔案以改善 FCP 等方面進一步最佳化。最終,要進一步降低 LCP,也需要產品團隊共同投入,從前端與後端邏輯兩方面最佳化初始算繪的關鍵路徑。
此外,我們透過引入快取失效步驟,為部署流程增加了一些複雜度。CloudFront 的快取標籤為我們提供了一種靈活的方式來組織快取失效邏輯,我們正研究運用它來簡化邏輯,並提升按需失效快取資源的能力。
隨機一篇部落格