部署手册
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 之外检出已确认的提交,核对环境配置后人工执行以下步骤:
- 在 独立构建目录(例如
/srv/build/<project>)pnpm install+pnpm build,不在服务目录原地 build; - 只把
.outputrsync到/var/www/html/<project>,不带--delete; - 经
deploy/pm2-apply.sh ecosystem.dev.config.cjs应用进程形态(形态与在跑的一致才startOrReload,不一致则 delete + start,末尾pm2 save); - 按 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 都失败 |
| 2 | git 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 这类偏低值(见下节) |
| 4 | cp .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 | 「站不慢」不能当缓存生效的证据 |
| 9 | Cloudflare:把第 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:
| # | 验什么 | 命令 | 期望 |
|---|---|---|---|
| 1 | ISR 翻译链生效(应用层发 s-maxage) | VPS 内 curl -sI http://127.0.0.1:<端口>/<某ISR页> | grep -i cache-control | s-maxage=<min(TTL, 60)>, stale-while-revalidate;TTL ≥ 60 的页看到的是封顶值 60 而非页面 TTL,属预期(framework#394);完全没有该头 = 该页 routeRule 没生效(查构建期护栏输出 / 零段父路径漏配) |
| 2 | nginx 缓存生效 | curl -sI https://<域名>/<某ISR页> | grep -i x-cache-status,连打两次 | 第一次 MISS、第二次 HIT;恒 MISS 时先看第 1 条(上游不发 s-maxage 时不缓存是设计,别加固定 TTL 去"修") |
| 3 | canonical / origin 正确 | curl -s https://<域名>/<某ISR页> | grep -o 'rel="canonical"[^>]*' | 域名正确、绝不含 localhost(含 = NUXT_PUBLIC_SITE_URL / varies 链路问题)。多域名 / 防封域名站:换 -H "Host: <另一域名>" 再打一遍,canonical 应各自跟随访问域名(收敛到同一个 = 有兜底把「留空」架空了) |
| 4 | sitemap 域名正确 | 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 或缓存了 HTML | HTML 不缓存、_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 回源与还开着旧页面的用户都会撞上它。
黄金法则(两条铁律)
- 绝不在「正在服务的目录」里 build。在独立目录(CI 工作目录 / 专用构建目录)build 好,产物就绪后再把
.output搬到服务目录——服务目录全程没有「资源缺失」窗口。 - 同步
.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.294s | 0.0043s | ~50× |
| 详情页 | 0.176–0.184s | 0.0040s | ~44× |
| 首页(4.7MB) | 4.29–5.70s | 0.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 兜底档即可,本节契约不变。
相关链接
- Nuxt 渲染模式选用指南 —— ISR / 缓存策略(部署后缓存新鲜度差异的来源)
- nginx origin 透传配置 —— 反代 Host / X-Forwarded-* 透传,保证 canonical / OG origin 正确
- 框架配置 Module Options —— 运行时配置 / 构建期环境变量(
NUXT_PUBLIC_SITE_URL等)