部署手册

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 兜底档即可,本节契约不变。

相关链接