Next.js 生产部署与发布陷阱
Next.js 的部署问题有个共同点:症状出现在浏览器,根因在构建时刻。变量在 next build 时就被固化,产物在启动时被读进内存,缓存又会在边缘多留一层。不理解这三件事,就会反复遇到"改了不生效""部署完白屏""回滚回不干净"。
本手册按"构建期 → 运行期 → 发布顺序 → 回滚"的顺序组织。
目录
- 何时使用
- 参数与占位符
- 症状索引
- 现场采集脚本
- 心智模型:三个时刻
- 模式 1 — NEXT_PUBLIC_ 变量被内联
- 模式 2 — 运行时覆盖 .next
- 模式 3 — 前后端契约的发布顺序
- 模式 4 — 环境变量改了不生效
- 模式 5 — 依赖与 lockfile 漂移
- 模式 6 — 缓存导致的旧资源
- 模式 7 — 数据库 schema 变更
- 发布检查清单
- 回滚
- 错误处理约定
- 失败模式与恢复
- 完整走查(端到端样例)
- 不适用场景
- 反模式与常见问题
何时使用
自动触发:用户描述与下表场景相关时直接启用,不需要点名本技能。
| 用户会这么说 | 实际场景 |
|---|---|
| 「改环境变量不生效」 | 构建期内联 / 进程缓存(模式 1、4) |
| 「部署完白屏 / chunk 404」 | 运行时覆盖产物(模式 2) |
| 「接口 404 / 字段缺失」 | 前后端顺序(模式 3) |
| 「依赖升级后构建失败」 | lockfile 漂移(模式 5) |
| 「用户看到的还是旧页面」 | 缓存(模式 6) |
| 「迁移要不要停机」 | schema 变更(模式 7) |
| 「怎么回滚」 | 回滚策略 |
手动调用:对话里说「用 Next.js 生产部署与发布陷阱处理」,或直接用 @ 引用本技能(平台的完整 slug 可能带作者后缀,不必写全名)。
不适合:纯页面渲染 bug、CDN 配置本身、纯静态导出到对象存储的站点。
参数与占位符
动手前先替换下表占位符;未替换就跑等于用错环境。
| 占位符 | 含义 | 从哪里取 |
|---|---|---|
<ts> |
构建时间戳 | date +%Y%m%d%H%M |
<app> / <service> |
进程名/compose 服务名 | pm2 list 或 docker compose config --services |
<pid> |
进程 id | pm2 pid <app> |
<pkg> |
依赖清单 | ls package-lock.json pnpm-lock.yaml |
症状索引
| 症状 | 先看 | 关键判据 |
|---|---|---|
| 环境变量改了不生效 | 模式 1 / 模式 4 | 变量名是否 NEXT_PUBLIC_;构建后有没有重启 |
| 白屏 + chunk 404 | 模式 2 | HTML 引用的 chunk 与实际文件是否对得上 |
| 接口 404 | 模式 3 | 前端调用的路径在后端是否存在 |
| 字段 undefined | 模式 3 | 后端新字段是否已上线 |
npm install 后跑不起来 |
模式 5 | lockfile 是否同步、peer 依赖 |
| 部分用户看到旧版 | 模式 6 | 缓存头与 chunk 哈希 |
| 迁移后接口报错 | 模式 7 | 是否有删列/改类型 |
现场采集脚本
python scripts/deploy_triage.py --json 一条命令核对 .next/BUILD_ID、.next/static/chunks 与页面 HTML 引用的资源是否对得上(--html-file 或 --url 取页面)、NEXT_PUBLIC_ 是否已内联进 bundle、package.json 与 lockfile 的顶层依赖、.next-releases/build-* 是否切了 current。
| findings 的 code | 对应本节 |
|---|---|
E-CNF-01 |
模式 2(运行时覆盖 .next:chunk 404 / buildId 不一致) |
E-ENV-02 |
模式 1、4(NEXT_PUBLIC_ 已内联,改环境变量无效) |
E-CNF-04 |
模式 5(依赖与 lockfile 漂移) |
E-CNF-06 |
有多个 build 目录但没切 current |
心智模型:三个时刻
| 时刻 | 发生了什么 | 改什么会生效 |
|---|---|---|
构建时(next build) |
页面、路由、NEXT_PUBLIC_* 被编译进产物 |
只有重新构建才生效 |
| 启动时(进程启动) | 服务端读入构建清单与环境变量 | 重启才生效 |
| 请求时 | 渲染、调用 API、读缓存 | 立即生效 |
一句话判据:先问"这个值是在哪个时刻被固化的",再决定要不要重新构建。绝大多数"改了不生效"都是在构建时固化的东西上改运行时配置。
模式 1 — NEXT_PUBLIC_ 变量被内联
判据:变量名以 NEXT_PUBLIC_ 开头,且改成新值、重启服务后浏览器里用的还是旧值。
根因:NEXT_PUBLIC_* 在构建时被字面替换进客户端代码,不是运行时读取。构建完成后,改 .env 或改容器环境变量都不会影响已经打好的 bundle。
修法(按推荐度):
- 客户端不要用
NEXT_PUBLIC_传服务地址:走同源相对路径(/api/...),由网关或反向代理分流。这样换环境不需要重新构建。 - 必须区分环境时,用运行时配置:服务端渲染时注入(读取
process.env后传给页面),或单独的/config.json接口。 - 确实要用
NEXT_PUBLIC_*,就必须把它当成构建参数:CI 里按环境分别构建,而不是"构建一次到处部署"。
验证:
# 构建产物里能搜到该值,就说明它被内联了
grep -r "api.example.com" .next/static/ | head
搜得到 = 已固化在 bundle 里,改环境变量无效。
模式 2 — 运行时覆盖 .next
判据:部署后白屏,控制台大量 ChunkLoadError / 404;或页面 HTML 引用的 chunk 名与实际文件不一致。
根因:Next.js 进程在启动时把构建清单(build manifest)读进内存。如果你在进程运行期间把新的构建产物覆盖到同一个 .next 目录,进程内存里还是旧清单,浏览器拿到的 HTML 引用的是旧 chunk 名,而磁盘上已经是新文件——两边对不上。具体机制:构建产物里的 chunk 文件名带 content hash,覆盖 .next 后旧 hash 的文件已被删掉,而内存清单仍在按旧 hash 拼 URL,于是每个 <script src=…> 都 404;已经被 CDN / 浏览器缓存的 HTML 还会继续放大这个窗口。
修法:产物不可变 + 原子切换
frontend/
.next-releases/
build-1730000000/ ← 每次构建进新目录,永不覆盖
build-1729900000/
切换步骤:
- 构建到新目录:在
next.config.js里把distDir指到.next-releases/build-<ts>(构建期配置),或next build后把产物整体移到该目录 - 校验产物完整:
BUILD_ID文件存在,chunk 数量非零 - 让运行进程指向新目录:用软链把
.next指向.next-releases/build-<ts>后重启进程。注意distDir只在next.config.js里生效,是构建期配置、不是运行时环境变量——改它必须重新 build,pm2 restart --update-env不会让它生效 - 重启进程,让它重新读清单
- 观察一段时间后再清理旧目录
验证:访问页面,view-source 里的 chunk 路径能在服务器上找到对应文件;控制台无 404。
模式 3 — 前后端契约的发布顺序
判据:前端上线后调接口 404 或字段 undefined,而后端日志显示请求根本没到。
根因:契约变更(新增接口、改路径、改字段)时,前后端不是同时生效的。两种顺序的失败形态不同:
| 顺序 | 失败形态 | 严重度 |
|---|---|---|
| 前端先上 | 调新接口 → 后端还没有 → 404,功能直接坏 | 高 |
| 后端先上 | 新字段/新接口已就绪,旧前端不用它 → 只是没用到 | 低(失败关闭) |
结论:顺序取决于兼容性,不是固定先后。 先判断这次契约变更是否向后兼容:向后兼容(新增可选字段、新增接口、放宽校验)可以前端先上;不兼容(删字段、改语义、换路径)必须后端先上,并且后端要能在旧前端下工作到前端发布完成。
例外:删除字段/接口时反过来——先确认没有前端在用(查访问日志),再删后端。
验证:发布前用旧前端 + 新后端跑一遍主流程;发布后看 404 是否集中在某个路径。
模式 4 — 环境变量改了不生效
判据:改了 .env、重启了服务,值还是旧的。
两类根因(常同时存在):
- dotenv 不覆盖已存在的环境变量:
dotenv的默认行为是"进程环境里已有同名变量就不覆盖"。如果变量在 shell profile、容器编排文件、或启动脚本里已经定义过,改.env会被忽略。 - 进程管理器缓存旧 env:pm2、systemd 一类工具会缓存启动时的环境变量,普通
restart不会重新读取。
修法:
# pm2:显式让它重读环境
VAR=<新值> pm2 restart <app> --update-env
# systemd:daemon-reload 后再重启
systemctl daemon-reload && systemctl restart <service>
排查时先确认进程实际看到的值,而不是猜:
# 进程的环境变量
tr '\0' '\n' < /proc/<pid>/environ | grep VAR
验证:进程环境变量与预期一致,且应用日志里打印的配置值同步变化。
模式 5 — 依赖与 lockfile 漂移
判据:本地能构建、服务器构建失败;或 npm install 后行为变了。
根因:
- 平台锁文件(
package-lock.json/pnpm-lock.yaml)不同步,服务器装到不同版本 node_modules是旧的,安装脚本判断"已存在"就跳过,依赖实际没更新- peer 依赖冲突导致构建期报错
修法:
- lockfile 必须进版本库,CI 用
npm ci(严格按锁文件)而不是npm install - 升级依赖时先删
node_modules再安装,不要依赖增量安装 - peer 冲突如果是历史原因,用
--legacy-peer-deps并在 README 记录原因与移除条件;不要默认开着
验证:服务器上 npm ls <pkg> 的版本与锁文件一致;构建产物哈希在两次构建间稳定。
模式 6 — 缓存导致的旧资源
判据:部分用户看到旧页面,硬刷新后正常;或新版本发布后仍请求旧 chunk。
根因:三层缓存——浏览器、CDN、Next.js 自身的静态产物缓存。
修法:
- HTML 用
no-cache(或短 TTL +ETag),chunk 用长缓存 + 内容哈希(Next.js 默认带哈希,不要手动改成固定文件名) - 发布后让 CDN 刷新 HTML 路径,chunk 不需要刷(哈希变了就是新 URL)
- 给用户的提示写清楚"强制刷新一次",而不是让所有人都怀疑是 bug
验证:curl -I 看 HTML 的 Cache-Control;发布后新 HTML 引用的 chunk URL 变化。
模式 7 — 数据库 schema 变更
判据:发布包含 schema 变更(删列、改类型、加非空列),上线后接口报错或数据异常。
三条纪律:
- 破坏性变更不随发布自动执行:删列/改类型先人工审过 SQL,必要时分两步(先加新列双写,再切读,最后删旧列)。
- schema 同步工具会"修偏":部分工具(如 Prisma 的
db push)以 schema 为唯一事实源,schema 之外的手工索引可能被视为漂移而删除。上线前用工具的预览/--dry-run能力确认将执行什么。 - 同步 schema 后重启应用:ORM 客户端与索引自愈逻辑通常在启动时执行,只同步不重启会出现"数据库已改、进程还按旧结构"的错位。
验证:迁移后在库里核对索引与列是否仍存在;应用日志无 ORM 报错。
发布检查清单
发布前:
- [ ] 契约变更?先判兼容性:向后兼容可先前端,不兼容必须 backend-first
- [ ] 有新
NEXT_PUBLIC_*?确认 CI 按环境构建,不是"一次构建到处部署" - [ ] 依赖变了?lockfile 已提交,用
npm ci - [ ] schema 变更?破坏性的已人工审过;预览确认无意外删除
- [ ] 回滚方案已确认(见下节)
发布中:
- [ ] 构建进新目录,校验
BUILD_ID与 chunk - [ ] 重启进程并指向新目录
- [ ] 观察启动日志(无报错、端口已监听)
发布后:
- [ ] 主流程冒烟:首页、登录、一次写操作
- [ ] 看错误日志与 404 分布
- [ ] 确认 HTML 的 chunk 全部 200
回滚
能回滚的前提是产物留得住。所以发布流程必须满足:
- 每次构建独立目录(不覆盖)
- 上一版目录在验证期内保留
- 回滚 = 切回旧目录 + 重启,不需要重新构建
如果 schema 也变了,回滚要连带考虑数据兼容性——这就是为什么破坏性变更要分两步执行:代码能回滚,数据回滚很难。
错误处理约定
本技能把出错分成四类(输入错误 / 环境错误 / 证据不足 / 结论冲突),处置与重试策略不同,不要混着来;四类判据表与统一输出格式见 references/error-handling.md 第一、二节。
本技能常见错误码:
| 代码 | 现象 | 处置 |
|---|---|---|
E-CNF-01 |
白屏 + chunk 404 | 切回旧目录并重启 |
E-ENV-02 |
改了变量不生效 | 重新构建,或 --update-env 重启 |
E-ENV-03 |
接口 404 | 回滚前端或补后端 |
E-CNF-04 |
构建失败 | 修 lockfile;必要时 --legacy-peer-deps |
E-CNF-05 |
迁移后报错 | 重启应用;核对索引 |
E-CNF-06 |
有多个 .next-releases/build-* 但没切 current |
确认新 build 可用后原子切软链再重启,保留一键回切 |
输入校验清单、重试与退出语义见 references/error-handling.md。
失败模式与恢复
| 失败现象 | 常见原因 | 检查方法 | 恢复动作 | 可否重试 |
|---|---|---|---|---|
| 白屏 + chunk 404 | 运行时覆盖产物 | HTML 引用的 chunk 是否存在 | 切回旧目录并重启 | 可 |
| 改了变量不生效 | 构建期内联 / env 被缓存 | 查进程 environ 与 bundle 内值 | 重新构建,或 --update-env 重启 |
可 |
| 接口 404 | 前端先上 | 后端路由是否存在 | 回滚前端或补后端 | 可 |
| 构建失败 | lockfile 漂移 / peer 冲突 | npm ci 是否通过 |
修 lockfile;必要时 --legacy-peer-deps |
可 |
| 迁移后报错 | schema 与进程不一致 | 库结构 vs ORM 预期 | 重启应用;核对索引 | 可(数据损坏则不) |
| 部分用户看到旧版 | 缓存 | Cache-Control 与 chunk 哈希 |
刷 CDN、提示硬刷新 | 可 |
完整走查(端到端样例)
场景:用户反馈「新版本上线后页面白屏,控制台 ChunkLoadError,但刷新几次偶尔能好」。
输入:
# 1) 看 HTML 引用了哪些 chunk
curl -s https://app.example.com/ | grep -o '/_next/static/chunks/[^"]*' | head -5
# 2) 看这些 chunk 在服务器上是否存在
ls .next/static/chunks/ | head -20
中间输出(示例):
/_next/static/chunks/main-8f2a1c.js ← HTML 引用
/_next/static/chunks/app/page-3d91ba.js
main-8f2a1c.js ✓ 存在
page-3d91ba.js ✗ 不存在(磁盘上只有 page-77c0e4.js)
定位:HTML 引用的是旧清单里的 chunk 名,磁盘上是新构建的 chunk 名——典型的"运行时覆盖 .next"。进程内存里还是上一次启动时读的 build manifest,所以吐出的 HTML 指向已经不存在的文件。刷新偶尔能好,是因为命中了多实例里还没被覆盖的那台。
修复:
- 本次先切回旧目录并重启止血:
ln -sfn .next-releases/build-1729900000 .next-current
ln -sfn .next-releases/build-<ts> .next # 软链切换产物目录
pm2 restart web # distDir 不是运行时环境变量
- 把发布流程改成"构建进新目录 → 校验
BUILD_ID→ 切软链 → 重启",不再覆盖同一目录。
修复后复测:
curl -s https://app.example.com/ | grep -o '/_next/static/chunks/[^"]*' | head -3
# 每个路径都返回 200
for u in $(curl -s https://app.example.com/ | grep -o '/_next/static/chunks/[^"]*' | head -3); do
curl -o /dev/null -s -w "%{http_code} $u\n" "https://app.example.com$u"
done
结论:这轮走查验证了判据顺序——先比对"HTML 引用的 chunk"与"磁盘上的 chunk",对不上就是产物被覆盖;而不是先去怀疑 CDN 或浏览器缓存。
不适用场景
- 纯页面渲染 bug:组件逻辑、样式问题,与部署无关。
- CDN 配置本身:回源、缓存规则、证书属于 CDN 侧运维。
- 纯静态导出:
output: 'export'产物直接放对象存储时,没有 Node 运行时与.next覆盖问题。 - Serverless/边缘平台:构建产物由平台托管,回滚是平台能力。
反模式与常见问题
反模式与高频问答见 references/faq-antipatterns.md。结论先给:这些做法都会让问题更难排查,改法在对应小节。
延伸阅读
references/error-handling.md · references/faq-antipatterns.md