部署手冊

Swift Web applier(Nuxt 4 SSR)有兩種部署場景,行程模型相同、前置層不同:

維度測試環境生產環境
伺服器形態一台 VPS 跑多個測試站一台 VPS 可跑多個站
行程模型單行程 fork / instances: 1單行程 fork / instances: 1(同上)
前置層無 CDN,nginx 直連;HTML 快取不強制Cloudflare 純 CDN / 反向代理 + nginx HTML 快取(標配)
部署方式維護者/維運手動部署維護者/維運手動跑 pnpm deploy:prod
行程設定ecosystem.dev.config.cjs(儲存庫根目錄)ecosystem.config.cjs(儲存庫根目錄)

兩環境統一單行程(standards#86)。生產不用 pm2 cluster:nitro 的 ISR 快取在行程記憶體裡,多實例各持一份快照,同一 URL 在 TTL 內會在新舊兩版之間來回(cinebay#59 的殘留根因);扛量本就靠 nginx HTML 快取(命中不進 Node),多實例換不來覆蓋率。單行程後整機只有一份 ISR 快取,framework 把它的上限從 50MB 放到 256MB(條數 1000 / 單條 2MB 不變)。真要多核,前提是先換共享快取驅動(Redis 等,按 P0 報批),別在記憶體快取下開 cluster。

本文正文按運維執行視角組織:只保留「照做即可」的步驟、設定樣例、驗收與排障;每一步背後的原理與設計論證集中在文末「附錄:原理與設計決策」——運維可不讀,維護者(或 AI)修改部署機制前必讀。

測試環境部署(手動 · 單行程 · 無 CDN)

starter 範本的 CI 只做型別、lint、單元測試/覆蓋率和建置校驗,完整瀏覽器 E2E 在本地執行並留證。CI 不部署、不操作服務行程/快取、不發布 npm;合併不代表上線。測試環境由維護者/維運在 CI 之外檢出已確認的提交,核對環境設定後手動執行以下步驟:

  1. 在 獨立建置目錄(例如 /srv/build/<project>)pnpm install + pnpm build,不在服務目錄原地 build;
  2. 只把 .output rsync 到 /var/www/html/<project>,不帶 --delete;
  3. 經 deploy/pm2-apply.sh ecosystem.dev.config.cjs 套用行程形態(形態與在跑的一致才 startOrReload,不一致則 delete + start,末尾 pm2 save);
  4. 按 ecosystem 實際埠號對本機 Nitro 做健康檢查:循環 curl 127.0.0.1:<埠號> 加核驗回答者確是自己(pm2 裡本應用 online 且 pid 穩定)——startOrReload 起不來也返回 0,且埠號被鄰居 app 佔住時本應用 EADDRINUSE 起不來、curl 拿到的是鄰居的 200,純 curl 是假綠;兩者疊加才讓埠號撞車 / 啟動即崩當場紅(失敗時列印 ss -tlnp 佔用者與 pm2 日誌)。

🔴 共機測試每個 applier 必須分配唯一 PORT:寫進自己儲存庫的 ecosystem.dev.config.cjs(儲存庫根目錄)隨程式碼入庫,並與測試機 nginx 該站的 proxy_pass 埠號一致。範本預設 3000 只是佔位——建庫後第一批改動就要把它改成分配值。

ecosystem.dev.config.cjs 與生產同形態(單行程 fork,記憶體參數含義見生產段):

module.exports = {
  apps: [
    {
      name: require('node:path').basename(process.cwd()), // 行程名跟隨服務目錄名(由服務目錄名決定);測試機一台多專案,寫死通用名會互相劫持(starter#56)
      script: '.output/server/index.mjs',
      interpreter: 'node',
      instances: 1,
      exec_mode: 'fork', // 單行程;改這兩項後 startOrReload 不生效,手動經 deploy/pm2-apply.sh 套用
      max_memory_restart: '1280M', // 必須高於 --max-old-space-size + 原生開銷;與生產同檔,別為共機把測試站砍到小站檔
      watch: false,
      env: { PORT: 3000, HOST: '0.0.0.0', NODE_OPTIONS: '--expose-gc --max-old-space-size=1024' },
    },
  ],
}

手動部署時 pm2 不是裸跑 startOrReload,而是 bash deploy/pm2-apply.sh ecosystem.dev.config.cjs:指令稿先用 pm2 jlist 比對在跑行程的 exec_mode / 個數與 ecosystem 宣告,不一致(如老站還是 cluster)就 pm2 delete 後重新 start——startOrReload 不會套用拓撲變化,只會沿用舊形態熱重載;一致才 reload。末尾 pm2 save,機器重啟後 pm2 resurrect 拉起的才是當前形態。

Nuxt 產物 .output 是自包含的,服務目錄無需原始碼 / node_modules——只搬 .output + ecosystem 即可,比「同步整個原始碼再原地 install/build」更快更乾淨。

生產環境部署(單行程 · nginx HTML 快取 · Cloudflare)

生產手動跑隨範本自帶的 pnpm deploy:prod(即 deploy/deploy-prod.sh),禁止從 CI 呼叫;行程模型與測試環境一樣是單行程,扛量靠 nginx HTML 快取(生產標配)。

先分清兩種場景:首次上線要按下面的 checklist 順序做一輪一次性準備;之後每次發版只有一個指令。

第 N 次部署(日常發版):一個指令

cd /srv/build/<project> && pnpm deploy:prod        # 分支預設 main,可傳參;需 root / sudo

指令稿七步全自動(內部邏輯見下節「部署指令稿」):拉程式碼 → 獨立目錄 build → rsync .output → pm2 重啟(fork 下 reload 即 restart,約 1–3 秒切換時間窗)→ 健康檢查(curl + pm2 自證身分)→ 清 nginx HTML 快取 + reload → 列印驗收指令。跑完按列印的三條 curl 走一遍 生產上線驗收清單即完成。需要 root(或 sudo):指令稿內的 chown、清 /var/cache/nginx、reload nginx 都要權限。

首次上線 checklist(按序執行)

步操作順序原因 / 注意
1前置自查:node 20+/22+、pnpm 11+、pm2、nginx、git、rsync 已裝;服務運行使用者存在(與指令稿 RUN_USER 一致,預設 www,沒有則 useradd -r www 或改參數);VPS 已配儲存庫拉取憑證(推薦 SSH deploy key)存量生產 VPS 通常已具備,逐項核一遍即可;RUN_USER 不存在會讓指令稿在 chown 步報錯退出(實機踩過);沒憑證則 clone 與後續每次部署的 fetch 都失敗
2git clone <儲存庫> /srv/build/<project>部署指令稿第 1 步會檢查該目錄是 git 儲存庫,不是就報錯退出
3核對 deploy/deploy-prod.sh 頂部參數:PROJECT(= /var/www/html 下目錄名)、RUN_USER;不上 nginx HTML 快取的站把 NGINX_CACHE_DIR 置空。同時核對 ecosystem.config.cjs(儲存庫根目錄)的 PORT 與 max_memory_restart參數隨程式碼入庫,開發側通常已填好——運維核對即可。PORT 必須與本機其他服務不衝突、且與 nginx proxy_pass 埠號一致(共機部署最易在這撞車);max_memory_restart 別留 500M 這類偏低值(見下節)
4cp .env.prod.example .env 並逐項核對是真實生產值🔴 必須在首次跑指令稿之前。各值由開發在合入 main 前預填在 .env.prod.example;若看到佔位符(example.com 之類)停下找開發確認,別自己猜。NUXT_PUBLIC_SITE_URL 填錯不報錯、只靜默烤錯 canonical / og;多域名 / 防封域名站該值留空——但有 prerender 頁的站不能留空(構建期缺它直接 throw,starter 範本自帶 8 個 prerender 落地頁即中招),多域名場景改為在那些頁 useSeo({ siteUrl }) 逐頁覆蓋。指令稿運行時會回顯關鍵值,看一眼確認
5配 nginx:⓪ 先把域名 DNS 解析到本 VPS(用 Cloudflare 的話此時只解析、不開代理 / 灰雲——certbot 的 HTTP 驗證需要直連本機)① cache zone(conf.d)② 站點 server 塊(排除清單 + 頁面快取段 + Host / X-Forwarded-* 透傳),照下方完整設定樣例抄改 ③ 新域名先 80-only 起站簽 certbot、再補完整 443 設定 ④ nginx -t && nginx -s reload🔴 必須在首次跑指令稿之前:指令稿清快取步驟對不存在的快取目錄會報錯退出(有意 fail-fast,防「以為清了其實沒清」)。nginx 先就緒而 pm2 未啟只是暫時 502,無害
6跑 pnpm deploy:prod(首次即 pm2 首啟)—
7跑一次 pm2 startup,再按它輸出的提示執行pm2 save 已由 pm2-apply.sh 每次部署末尾做掉,但開機自啟指令稿要 pm2 startup 註冊一次,指令稿不做;漏掉則 VPS 重啟後站點不會自啟
8跑 生產上線驗收清單 三條 curl「站不慢」不能當快取生效的證據
9Cloudflare:把第 5 步的 DNS 記錄切為代理(橙雲);確認沒有 Cache Everything 類規則、_nuxt/* 正常被快取開了 Cache Everything 會把 HTML 烙進全球邊緣節點(purge 面向所有節點、比清本機 nginx 難得多)

關鍵順序依賴只有兩條(第 4、5 步的 🔴)——它們做錯不會當場報錯;其餘步驟順序錯了指令稿會自己攔下。

部署指令稿

核心就是黃金法則:獨立目錄 build → rsync .output 不刪 → deploy/pm2-apply.sh 套用形態並重載 → 健康檢查 → 清 nginx 快取 → 探針。

# 1. 在獨立建構目錄拉程式碼 + 建構(完全不碰服務目錄;pnpm 一律 npx 自包含調用,不動全域 bin)
cd /srv/build/<project> && git fetch origin main && git reset --hard origin/main
cp .env.prod.example .env    # 已存在則指令稿會保留不覆蓋
npx --yes pnpm@12.16.1 install --frozen-lockfile
npx --yes pnpm@12.16.1 build

# 2. 只搬 .output 到服務目錄,不帶 --delete(保留舊 hash 資源)+ 拷生產 pm2 設定
rsync -a /srv/build/<project>/.output/ /var/www/html/<project>/.output/
cp /srv/build/<project>/ecosystem.config.cjs /var/www/html/<project>/   # ecosystem 在儲存庫根目錄;拷到服務目錄根,下一步用裸名啟動

# 3. 套用 pm2 形態並重啟:形態沒變 = startOrReload(fork 下即 restart);變了(如老站還是 cluster)= delete + start。
#    兩者都有約 1–3 秒切換時間窗,nginx proxy_cache_use_stale 用舊快取頂住可快取頁;末尾 pm2 save
cd /var/www/html/<project> && bash /srv/build/<project>/deploy/pm2-apply.sh ecosystem.config.cjs

# 4. 健康檢查:curl 通 + pm2 裡自己 online 且 pid 穩定(埠號被鄰居佔住時鄰居會替你答 200,
#    純 curl 是假綠);不通過則中止、不清快取——舊快取還能頂著

# 5. 🔴 清 nginx HTML 反代快取(漏這步 = 使用者最長滯後到「頁面 ISR TTL + 對外 s-maxage 封頂 60s」才看到新版本,見下)
rm -rf /var/cache/nginx/nuxt-html/* && nginx -s reload

# 6. 探針:GET <站點>/ 看回應有沒有 x-cache-status 頭——沒有就 ⚠️ 提醒「nginx HTML 快取沒接上」(只提醒不擋,
#    不判 HIT/MISS:CF 與 nginx 兩層首打的狀態不確定)
bash /srv/build/<project>/deploy/nginx-cache-probe.sh https://<網域>

首次使用前把指令稿頂部的 PROJECT 改成專案名,並把儲存庫 clone 到建構目錄;未上 nginx proxy_cache 的站點把 NGINX_CACHE_DIR 置空以跳過第 5、6 步(生產標配該快取,置空只該出現在測試站)。

為什麼第 5 步是必須的:應用層記憶體快取隨 pm2 reload 清空,但 nginx 磁碟快取不隨 pm2 清(inactive 只清無人訪問的冷條目,熱門頁永遠新鮮)。指令稿已內建該步,別手動跳過。

所有可快取頁對外廣播的 s-maxage 封頂 60 秒(framework#394;內部 ISR TTL 仍是頁面宣告值,兩者有意解耦)。對外那份頭的語義是「反代多久來問一次 nitro」,不是頁面 TTL 的鏡像:nitro 到期後按 SWR 先回陳舊條目再背景渲染,若反代把陳舊頁按完整 TTL 又快取一整段,滯後會疊成兩層 TTL;此前生產還跑 pm2 cluster,多 worker 各持一份快取又再放大到「worker 數 × TTL」(cinebay#59 實測 isr 3600 的首頁舊內容 4h44m,已隨 standards#86 統一單行程根治)。封頂後 nginx 每分鐘問一次(proxy_cache_lock 下每頁每分鐘只放一個請求,nitro 這次是記憶體命中、不打後端)。它不能取代清快取:最壞滯後仍是一個 ISR TTL + 60s,「改完馬上可見」只靠清快取。

ecosystem.config.cjs(生產單行程)

module.exports = {
  apps: [
    {
      name: require('node:path').basename(process.cwd()), // 行程名跟隨服務目錄名(= 專案名);寫死通用名會在共機部署時被 startOrReload 按名劫持(starter#56)
      script: '.output/server/index.mjs',
      interpreter: 'node',
      instances: 1,
      exec_mode: 'fork', // 單行程(standards#86);改這兩項後 startOrReload 不生效,部署經 deploy/pm2-apply.sh 套用
      max_memory_restart: '1280M', // 必須高於 --max-old-space-size + 原生開銷;小站(後台 / 官網)768M
      watch: false,
      env: { PORT: 3000, HOST: '0.0.0.0', NODE_OPTIONS: '--expose-gc --max-old-space-size=1024' },
    },
  ],
}

兩個記憶體參數的含義與取值:

  • --max-old-space-size=<MB>(NODE_OPTIONS):V8 堆上限,超過就 OOM 崩。內容站取 1024——ISR 快取 256MB 帳面 ≈ 256–512MB 實際堆,再留一半給渲染峰值。
  • max_memory_restart:pm2 看到行程 RSS(堆 + 原生記憶體 + Buffer)超過它就重啟。它是單行程失控守衛、不是記憶體分配:取值只由本行程決定——≥ 堆上限 + 約 200MB 原生開銷,與機器上有幾個站無關;低於它是「堆還沒滿、pm2 先誤殺」,每次誤殺都是一次部署抖動。內容站取 1280M(1024 + 200 = 1224 ≤ 1280 ✓;寫 1280M 不寫 1.25G——pm2 的 schema 只認整數 + G/M/K,小數會被拒、pm2 start 直接失敗、走 delete + start 時站會被刪空;單位也不能省——無單位純數字 pm2 按位元組解釋,1280 = 1280 位元組 ⇒ 行程起得來但反覆被殺。starter 的 pm2-apply.sh 動 pm2 前會按 ^\d+(G|M|K)$ 攔下這兩種),別壓到 1G(1024 + 200 > 1024 ✗);小站(營運後台、頁面少 / 極少更新的官網等,ISR 快取裝不滿)堆 512 / restart 768M(512 + 200 = 712 ≤ 768 ✓)。它是每行程上限,不是常駐佔用——正常執行時 RSS 遠低於它。換檔時堆與 restart 要成對改(範本是內容站檔:堆 1024 / 1280M;派生成小站時把 --max-old-space-size 降到 512 同時 restart 降到 768M,只改 restart 就是 768 < 1024 的誤殺形態,starter 的 tests/unit/deploy-topology.spec.ts 會紅);instances: 1 要顯式寫,別依賴 pm2 預設(舊範本派生的儲存庫沒這一行,拓撲契約測試會紅)。
  • 機器夠不夠:看部署日誌裡的常駐餘量,不疊加各站上限。每一條會重啟 pm2 的部署路徑都在 pnpm build 之後、pm2 重啟之前打一行 free -m + nproc(機器規格與餘量隨每次部署進日誌,不用手抄一個會過期的數字)。範本保留獨立手動部署指令稿;每個執行 pm2-apply.sh 的手動路徑,日誌裡都要有這一行(standards#88)。只看那一行的 available:要夠下一次 nuxt build 用(目前量級約 1.5G;按 free 印出的 total 算約等於兩成——標稱 8G 的機器 total 實為 7680M,韌體 / 核心預留了約 512M,別按標稱規格換算;換規格或建置變重時按「夠一次建置」判,不必回來改百分比。2026-09-11 維運實測兩台 4 核 7680M 機器:各 6 個 fork 行程 RSS 合計 1.1–1.2G、available 4.5–5.8G——舊的「Σ 各站上限 ≤ 機器記憶體」公式按真實 total 會判這套設定「超出記憶體」,這正是它被刪的原因)。⚠️ 這一行量到的是常駐餘量,不是建置峰值——pnpm build 是獨立步驟,跑完行程已退出、記憶體已歸還,free 那一刻瞬態已消失;available 還把可回收 page cache 算作可用,讀數偏樂觀。它不涵蓋建置期 OOM,只回答「常駐是否已貼線」。餘量不夠時先找失控行程(單行程 RSS ≥ 同類中位數 3 倍),再考慮遷站,不靠把各站上限擰小。

fork 模式下 pm2 reload 就是 restart:單行程每次部署都有約 1–3 秒的行程切換時間窗(不只是形態變化那次),nginx proxy_cache_use_stale 會用舊快取頂住可快取頁,不可快取的請求可能失敗一次。內容站可接受,這是換「內容不再來回跳」的代價。

Cloudflare 設定要點

CF 作純 CDN / 反向代理時,這套架構對部署是紅利,但要配對:

  • 不快取 HTML、回源:HTML 每次打到源站即時 SSR,拿到的永遠是當前版本(引用當前 hash),不存在「邊緣留舊 HTML」問題。保持 HTML 不快取。
  • 快取 _nuxt/*(帶 hash + immutable):邊緣快取住、扛流量;新版是新檔名,第一次回源拉一次即快取。確認 CF 沒把 _nuxt/* 排除出快取(預設按副檔名會快取)。
  • 不要 purge 靜態快取:hash 化 + immutable 讓 purge 多餘甚至有害(憑空製造回源風暴)。 ⚠️ 但 HTML 快取必須清 —— 這兩件事別混:上了 nginx proxy_cache 後,部署完必須 rm -rf /var/cache/nginx/<zone>/* && nginx -s reload(deploy-prod.sh 已內建為第 6/7 步),否則使用者會停在舊版本(最長滯後到「頁面 ISR TTL + 60s」)。
  • 別開「Cache Everything」類規則:一旦讓 CF 快取 HTML,它會烙進全球邊緣節點——purge 面向所有節點、比清本機 nginx 難得多。保持 HTML 不快取就沒有這個問題。

HTML 快取放在 nginx 層(生產標配)

結論先說:HTML 快取的主力在 nginx proxy_cache,應用層的 nitro ISR 快取只作安全底線。

六個安全要點(配錯任一都可能出事)

  • 實際 API 前綴(含登入;預設 /api/)、/track/ 與 Nuxt 內部路由必須用正則 location 排除 —— 完整清單:實際 API 路徑前綴(預設 /api/)、/track/、/_nuxt/、/_ipx/、/__sitemap__/、/__og-image__/、/__nuxt_island/。前兩者是 nitro routeRules 代理(快取登入介面 = 災難),_nuxt 自帶內容 hash + immutable 無需再快取一層。正則 location 優先級高於前綴 location /
  • 不寫 proxy_cache_valid、不 proxy_ignore_headers Cache-Control —— TTL 的唯一事實源是上游 s-maxage(framework ≥0.92.0 的 ISR 頁自動發)。「不發 cache-control 的回應一律不快取」是有意設計:ssr: true 動態頁 / CSR 空殼 / prerender / 404 天然不入快取。⚠️ 寫了固定 TTL 兜底反而會把 ssr: true 動態頁兜進共用快取(內容錯配風險),並掩蓋 routeRule 漏配——某站的 /movie 曾因零段父路徑漏配而全量 SSR,被固定 TTL 掩蓋了很久,刪掉後當場暴露。⇒ 「某頁突然不被快取」的第一判據永遠是:上游還發不發 s-maxage
  • proxy_cache_methods GET HEAD —— POST 天然不快取,再顯式收窄一次
  • proxy_cache_lock on + proxy_cache_use_stale error timeout updating http_5xx + proxy_cache_background_update on —— 防快取擊穿;過期先回舊內容、後台靜默更新(≈ stale-while-revalidate);上游 5xx 用舊內容兜底
  • proxy_ignore_headers Set-Cookie + proxy_hide_header Set-Cookie + proxy_cache_bypass $http_authorization $cookie_nocache —— 縱深防禦,絕不讓身分進共用快取;帶認證頭的請求直接繞過
  • add_header X-Cache-Status $upstream_cache_status always —— 觀測 HIT/MISS 的唯一手段,別省

cache key 用 path-only

proxy_cache_key "$scheme$host$uri"(忽略全部 query)。這依賴「可快取頁 SSR 禁讀 query」硬規則,詳見 渲染模式選用指南 §4.2。

TTL 由應用層單點決定:ISR 頁會發 cache-control: s-maxage=<min(TTL, 60)>(對外封頂 60s,framework#394;內部 ISR TTL 不變),nginx 預設尊重它 ⇒ 別在 nginx 裡再寫 proxy_ignore_headers Cache-Control + 固定 TTL,那樣 TTL 就要兩處維護。

完整設定樣例(可直接抄改)

快取區定義(conf.d/ 下全域一份)與站點 server 塊的快取相關部分:

# conf.d/proxy-cache-zones.conf —— 快取區(容量按站點規模調;inactive 清長尾冷資料)
proxy_cache_path /var/cache/nginx/nuxt-html
                 levels=1:2 keys_zone=nuxt_html:20m
                 max_size=2g inactive=24h use_temp_path=off;

API 前綴必須與應用設定一致:starter 在 nuxt.config.ts 中用專案常數 API_PREFIX 同時設定 nitro.routeRules 代理路徑和 runtimeConfig.public.apiPrefix;它不是環境變數。下方樣例使用預設 /api。例如將該常數改為 const API_PREFIX = '/h5api' 後,排除規則應為 location ~ ^/(h5api|track|_nuxt|_ipx|__sitemap__|__og-image__|__nuxt_island)/;不能只排除 /api/。

# 站點設定骨架(80 跳 443 + 443 主站;憑證路徑按 certbot 實際輸出改)
server {
  listen 80;
  server_name example.com;
  location /.well-known/acme-challenge/ { root /var/www/certbot; }
  location / { return 301 https://$host$request_uri; }
}

server {
  listen 443 ssl http2;
  server_name example.com;
  ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
  client_max_body_size 100m;

  # ↓↓ 下面兩個 location 塊放在這裡 ↓↓
}

# nuxt.config.ts 專案常數 API_PREFIX 預設 /api;改為 /h5api 時,下行 api 也改為 h5api
# ① 排除清單(正則 location 優先於前綴 location /)
location ~ ^/(api|track|_nuxt|_ipx|__sitemap__|__og-image__|__nuxt_island)/ {
  proxy_pass http://127.0.0.1:3000;
  proxy_set_header Host              $host;
  proxy_set_header X-Real-IP         $remote_addr;
  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_http_version 1.1;
}

# ② 頁面 HTML —— 走反代快取
location / {
  proxy_pass http://127.0.0.1:3000;
  proxy_set_header Host              $host;
  proxy_set_header X-Real-IP         $remote_addr;
  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_http_version 1.1;

  proxy_cache            nuxt_html;
  proxy_cache_methods    GET HEAD;
  proxy_cache_lock       on;
  proxy_cache_lock_timeout 15s;
  proxy_cache_key        "$scheme$host$uri";          # path-only(見上節)
  proxy_cache_use_stale  error timeout updating http_500 http_502 http_503 http_504;
  proxy_cache_background_update on;
  proxy_ignore_headers   Set-Cookie;
  proxy_hide_header      Set-Cookie;
  proxy_cache_bypass     $http_authorization $cookie_nocache;
  # 注意:這裡刻意沒有 proxy_cache_valid / proxy_ignore_headers Cache-Control——
  # TTL 全由上游 s-maxage 決定(見「六個安全要點」第 2 條)
  add_header X-Cache-Status $upstream_cache_status always;
}

生產上線驗收清單(首次上線 / 每次大版本後跑一遍)

⚠️ 「站不慢」不能作為「快取生效了」的證據——反代 / CDN 都可能掩蓋問題,按下面三條逐一 curl:

#驗什麼指令期望
1ISR 翻譯鏈生效(應用層發 s-maxage)VPS 內 curl -sI http://127.0.0.1:<埠>/<某ISR頁> | grep -i cache-controls-maxage=<min(TTL, 60)>, stale-while-revalidate;TTL ≥ 60 的頁看到的是封頂值 60 而非頁面 TTL,屬預期(framework#394);完全沒有該頭 = 該頁 routeRule 沒生效(查建構期護欄輸出 / 零段父路徑漏配)
2nginx 快取生效curl -sI https://<域名>/<某ISR頁> | grep -i x-cache-status,連打兩次第一次 MISS、第二次 HIT;恆 MISS 時先看第 1 條(上游不發 s-maxage 時不快取是設計,別加固定 TTL 去「修」)
3canonical / origin 正確curl -s https://<域名>/<某ISR頁> | grep -o 'rel="canonical"[^>]*'域名正確、絕不含 localhost(含 = NUXT_PUBLIC_SITE_URL / varies 鏈路問題)。多域名 / 防封域名站:換 -H "Host: <另一域名>" 再打一遍,canonical 應各自跟隨訪問域名(收斂到同一個 = 有兜底把「留空」架空了)
4sitemap 域名正確curl -sL https://<域名>/sitemap.xml | grep -o '<loc>[^<]*' | head -3<loc> 域名與訪問域名一致(多域名站各域名各自一致)。🔴 canonical 對 ≠ sitemap 對——兩者走不同解析鏈(useSeo vs nuxt-site-config),歷史上出過「canonical 正確、sitemap 卻是被 env 兜底填錯的域名」,只查第 3 條會漏

另外:排除路徑(/_nuxt/、實際 API 前綴下的介面)的回應不應有 X-Cache-Status 頭;部署一次後重跑第 2 條確認快取被清(回到 MISS)。

排障速查

現象根因處理
部署後頁面內容還是舊的沒清 nginx HTML 快取(清了仍舊則查 CF 邊緣)deploy-prod.sh 第 6/7 步必須執行;手動 rm -rf /var/cache/nginx/nuxt-html/* && nginx -s reload
上游發 s-maxage、nginx 卻恆 MISS回應帶 Set-Cookie,nginx 未配 ignore 時拒絕快取該回應。framework 已在可快取頁渲染期剝離 Set-Cookie(#374),這個根因只剩兩種來路:framework 未升到含該修復的版本、或自訂 server route 在可快取路徑上寫 cookie先核 framework 版本;再照本手冊樣例配全 proxy_ignore_headers Set-Cookie + proxy_hide_header Set-Cookie(樣例已帶;存量自配的 nginx 最容易漏這兩行)
首屏中間區無樣式,重新整理 / 跳轉才好服務目錄原地 build / --delete 刪了舊 hash 資源按黃金法則:獨立目錄 build + rsync 不帶 --delete
部署後偶發裸樣式切換時間窗裡舊行程引用的舊 chunk 已被刪同上(不刪舊資源);時間窗 CF 快取 _nuxt 也能兜
行程頻繁重啟、CPU 抖動max_memory_restart 低於堆上限 + 原生開銷被誤殺按上文公式調高(內容站 1280M),別與 --max-old-space-size 取同一個數
重新整理時內容來回跳變還在跑 pm2 cluster,多實例 ISR 快取各持一份改單行程(pm2 jlist 看 exec_mode;部署經 pm2-apply.sh 自動切);強一致需求再引入 Redis 共享快取
部署指令稿末尾 ⚠️ 沒有 x-cache-status該站 nginx 沒接 proxy_cache / location 分流沒命中照本頁樣例核對 nginx site 設定(生產標配)
部署後 CF 還吐舊版本誤 purge 或快取了 HTMLHTML 不快取、_nuxt 不 purge(hash 化天然多版本共存)
部署成功但某頁 500(回應帶 cache-control: no-cache)該頁 SSR 取數失敗(後端不可達 / 端點不存在),不是部署問題;錯誤回應有意不進快取先在 VPS 上 curl 後端連通性,再對照頁面資料鏈;用不依賴後端的頁(terms / privacy)驗證部署鏈本身

附錄:原理與設計決策(運維可不讀)

核心原理:為什麼會「首屏中間區無樣式,重新整理才好」

這是部署做錯時最常見的現象,根因務必理解。

Nuxt 建構的靜態資源(_nuxt/entry.<hash>.css、各元件 chunk)檔名帶內容 hash,由 Nitro 行程提供、並帶長期 immutable 快取標頭。而 nuxt build 會先清空 .output 再重新產生。

如果在正在對外服務的目錄裡直接 pnpm build:

build 開始 ──────────────────────── build 結束
   │  舊 .output 被清空              │  新 .output 就緒
   │  ← 這段時間(數十秒~數分鐘)─→  │
   │  舊行程仍在服務,吐出的 HTML    │
   │  引用舊 hash 資源,但檔案已刪   │
   │  → 瀏覽器請求這些 .css/.js 404  │
   │  → 首屏中間區無樣式             │

重新整理或客戶端跳轉後恢復,是因為新 .output 已就緒 / 客戶端導航走 JS 動態載入當前 chunk。行程切換時間窗、CF 回源與還開著舊頁面的使用者都會撞上它。

黃金法則(兩條鐵律)

  1. 絕不在「正在服務的目錄」裡 build。在獨立目錄(CI 工作目錄 / 專用建構目錄)build 好,產物就緒後再把 .output 搬到服務目錄——服務目錄全程沒有「資源缺失」時間窗。
  2. 同步 .output 時不帶 --delete。保留舊 hash 資源,給切換時間窗裡的舊行程、CF 回源、還開著舊頁面的使用者命中。舊孤兒 chunk 無害(Nitro 只讀當前清單),磁碟緊再週期清理。
# ❌ 錯誤:在服務目錄原地 build + --delete,build 時長內舊資源全 404
rsync -a --delete src/ /var/www/html/app/ && cd /var/www/html/app && pnpm build

# ✅ 正確:獨立目錄 build,再只搬 .output 且不刪舊資源
cd /srv/build/app && pnpm build
rsync -a /srv/build/app/.output/ /var/www/html/app/.output/    # 無 --delete

為什麼應用層扛不動(實測帳)

nitro 的 ISR 快取在行程記憶體裡,條數封頂 1000。某站實測:

  • 詳情頁 67 KB/頁、1.9 萬部影片 ⇒ 全量需 1.27 GB
  • framework 預設上限 256 MB / 1000 條 ⇒ 只裝 1000 頁 = 覆蓋率 ~5%
  • 加行程也換不來覆蓋:此前生產跑 8 worker cluster,各持一份 ⇒ 花 400 MB 記憶體換不來任何額外覆蓋(請求隨機分發,worker A 快取的頁在 B 上是 miss)——這也是改單行程的理由之一

nginx 層的三個決定性優勢

所有 worker 共用一份、內容落磁碟(GB 級容量)、命中的請求根本不進 Node(CPU 收益比應用層快取更大)。POC 實測(VPS 內直連,排除網路延遲):

頁面無快取命中提升
列表頁0.213–0.294s0.0043s~50×
詳情頁0.176–0.184s0.0040s~44×
首頁(4.7MB)4.29–5.70s0.0066s~650×

⚠️ 測收益必須在 VPS 內直連測 —— 從外網測 TTFB 被網路往返主導(都是 0.9–1.1s),完全看不出差別,會誤判成「沒效果」。

🔴 應用層快取驅動別用 fs

歷史上曾建議「單機優先 Nitro fs driver(零依賴)」,該建議已作廢:fs driver 把快取 key 直接映射成檔案路徑,而父路徑與子路徑同時可快取時必然衝突:

/scandals      → 快取落成檔案  …/payload/scandals.json      ← 這是個檔案
/scandals/97   → 快取要落      …/payload/scandals/97.json   ← 這要求它是目錄
⇒ ENOTDIR,子路徑整頁 500

而「參數提到 path 段」是可快取頁的首選修法 ⇒ 這個形狀已成常態。換 URL 結構繞不開。

  • framework ≥0.92.0 內建有界 LRU 記憶體驅動(1000 條 / 256MB / 單條 2MB,??= 掛載 ⇒ 顯式設定仍優先),零設定即安全
  • framework ≥0.93.0 在建置期擋下 fs 驅動 × 父子路徑同時可快取的組合
  • 容量顧慮交給 nginx 那層(磁碟 GB 級),應用層只要「有上限 + KV 語意」

多機場景才考慮 Redis(需 ioredis,按 P0 報批);單機 VPS 用 nginx 磁碟快取即可。

⚠️ ISR 快取不一致是新鮮度問題,與首屏裸樣式(靜態資源 404)無關——裸樣式靠黃金法則解決,別指望快取後端。

影片轉碼建議:H.265(HEVC)配 H.264 雙 codec 變體

內容站後端轉碼輸出 HLS 時,若想用 H.265/HEVC 省頻寬(同畫質比 H.264 小 30-50%),必須在同一份 master playlist 裡同時掛 H.264 變體做兜底——這是業界主流做法,權威依據是 Apple 官方《HLS Authoring Specification》(向 Apple 裝置分發 HEVC 時要求同時提供 H.264 變體做後向相容),Netflix / YouTube 等大流量站同樣是「新 codec + AVC 兜底」的多檔策略。

為什麼:HEVC 有專利費,瀏覽器普遍不帶軟解、全靠裝置硬解——Safari(iOS/macOS)原生支援,Chrome/Edge 105+ 僅當裝置有硬解(老集顯 / 老安卓沒有),Firefox 基本不支援。SwVideoPlayer(Artplayer + hls.js ≥1.6)軟體棧本身不設限:hls.js 與 Safari 會按 MediaSource.isTypeSupported() 自動在變體間選支援的 codec,雙檔時不支援 HEVC 的裝置自動落 H.264,全相容。

时序图渲染中…
# master playlist 雙 codec 變體(轉碼側輸出示意)
#EXT-X-STREAM-INF:BANDWIDTH=2500000,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",RESOLUTION=1920x1080
hevc_1080p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4200000,CODECS="avc1.640028,mp4a.40.2",RESOLUTION=1920x1080
avc_1080p/index.m3u8

前端契約 = 一條 URL:雙變體打包在同一份 master playlist 裡,前端(SwVideoPlayer 的 url,或多線路 links 裡每條線路的 m3u8_url)拿到的就是 master.m3u8 這一個位址——選檔由 hls.js / Safari 在 HLS 協議層自動完成,前端零改動。

反例——後端給兩條獨立 URL 讓前端自己判斷:把協議層已解決的選檔問題搬到業務層——前端得自己探測 codec、維護挑選邏輯,探測錯了就黑屏,且每個消費端(Web / App / TV)都要各寫一遍。正確契約是轉碼側輸出一份雙變體 master playlist,任何標準 HLS 播放器天然會選。

反例——只出 H.265 單變體:不支援硬解的裝置直接播放失敗(元件 emit error,使用者看到黑屏),且這種「部分使用者播不了」在自己裝置上極難重現。現場驗證某裝置是否支援:console 跑 MediaSource.isTypeSupported('video/mp4; codecs="hvc1.1.6.L93.B0"')。

預告:將來加 AV1 變體,同一姿勢

公司當前的轉碼要求是 HEVC + H.264 雙檔;將來若引入 AV1,仍是同一份 master playlist 裡再加一行 CODECS="av01..." 變體的事,前端與播放器依舊零改動(hls.js 1.5+ 已支援 fMP4 容器的 AV1 分片,framework 用的是 1.6.x)。AV1 的吸引力:免專利費 + 瀏覽器支援全面(Chrome / Firefox 連軟解都內建;Safari 需 iPhone 15 Pro / M3 及以上硬解)+ Netflix / YouTube 已大規模驗證。相比之下 H.266/VVC 目前全瀏覽器零支援、專利池比 HEVC 更亂——Web 側「下一代省頻寬」的現實選項是 AV1。屆時轉碼側同樣保留 H.264 兜底檔即可,本節契約不變。

相關連結