前面的课程里,我们已经把 BoardFlow 做成了一个能用的任务看板:页面可以读取数据,表单可以提交,登录状态可以被 Proxy 检查,图片也能正常显示。现在产品经理发来一句很朴素的话:“能不能今天给大家一个正式地址?”
很多部署事故就从这里开始。开发者在服务器上执行 npm install && npm run build && npm start,看到首页打开,便以为上线结束了。第二天才发现:浏览器仍在请求测试环境接口;两台实例展示不同的数据;滚动发布后 Server Action 偶尔失败;回滚了代码,数据库却已经不兼容;镜像里甚至还留着 .env。
这不是 Next.js “难部署”,而是我们把构建成功误当成了生产可用。一次可靠发布至少要回答这些问题:谁构建、用哪个 Node 版本构建、什么配置会进入浏览器、产物是否可复现、多个实例如何共享缓存、流量如何切换、失败后怎样回到旧版本。
这一章不追求把某个平台的按钮点一遍。我们会沿着 BoardFlow 从提交、CI、镜像、预览环境、灰度、监控到回滚的路径,建立一套能迁移到 Vercel、自托管容器和其他 Next.js 平台的发布方法。

我们先把“部署”拆开。构建是把源码、依赖和构建时配置变成可运行产物;发布是给产物一个版本并保存它;部署是把这份产物放进目标环境;上线则是让真实流量开始访问它。四件事可能在同一条流水线里完成,但它们不是同一个动作。
对 BoardFlow 来说,一次完整发布可以写成下面这条链路:
开发者提交代码,CI 在固定的 Node.js 与 lockfile 环境中安装依赖,先完成 lint、测试和构建检查。
CI 只构建一次生产镜像,为它记录 Git SHA、镜像 digest 和部署版本;后面的预览、灰度与生产使用同一份不可变产物。
预览环境接入隔离的数据库和环境变量,执行冒烟测试。数据库变更先做向前、向后都兼容的 expand migration。
新版本先接收少量流量。日志、错误率、延迟和缓存命中率稳定后再逐步放量,旧版本暂时保留。
如果指标越过阈值,流量切回旧镜像;如果发布稳定,再清理旧版本并完成不兼容字段的 contract migration。
这里最关键的一句是“构建一次,逐级推广”。如果测试环境、灰度环境和生产环境各自重新执行一次 next build,它们可能读取不同的 CMS 数据、得到不同的 build ID,甚至因为浮动依赖和基础镜像而生成不同产物。你以为测试通过的是生产版本,实际测试的是它的一个近亲。
我们可以先给本章定一个完成标准:
部署平台可以帮我们完成其中一部分,但不会替我们定义数据兼容性、密钥边界和回滚条件。
讨论“最佳实践”之前,先看 BoardFlow 仓库现在到底是什么状态。下面这些结论来自项目文件和本地构建产物,而不是一份通用模板。
还有一个很隐蔽的脚本问题。项目当前写法类似:
{
"scripts": {
"build": "set NODE_OPTIONS=--max-old-space-size=8192 && next build --webpack"
}
}set NAME=value 是 Windows CMD 的环境变量写法。npm 在 macOS 和 Linux 上通常通过 /bin/sh 执行脚本,此时 set NODE_OPTIONS=... 只会改变 shell 的位置参数,并不会把变量导出给 Node。构建仍可能成功,所以错误很容易藏起来,但预期的 8 GB 内存上限没有生效。
如果团队需要跨 Windows、macOS 和 Linux,可以先执行 npm install --save-dev cross-env,让依赖与 lockfile 一起进入评审,再使用下面的脚本:
{
"scripts": {
"dev": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next dev --webpack",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build --webpack",
"start": "next start",
"lint": "eslint ."
}
}如果构建只发生在 Linux CI,也可以使用 NODE_OPTIONS=... next build --webpack。本章只给出修正方向,不会替你修改真实脚本;上线前应把这项修复放进单独提交并在所有开发环境验证。
当前 .gitignore 排除了 .env,但这不能保护 Docker 构建。Docker 只读取 .dockerignore;只要本地文件存在且没有被它排除,COPY . . 就可能把 .env 放进镜像层。即使后续执行 rm .env,旧层仍可能保留文件内容。
这次体检的目的不是批评旧配置,而是确定发布工作的起点。没有这一步,我们很容易花时间优化一个项目根本没有使用的功能,同时错过真正的泄密风险。
next build 到底做了什么执行下面的命令时,Next.js 不只是把 TypeScript 转成 JavaScript:
npm run build对 App Router 项目来说,构建阶段大致会完成配置加载、模块编译、类型检查、客户端与服务端产物拆分、路由分析、可静态页面预渲染和清单生成。构建日志通常会把路由标为静态或动态,帮助我们判断哪些页面可以在 CDN 长期复用,哪些请求需要交给运行中的服务器。
项目现有的一次构建快照里有数百条预渲染页面和少量动态路由。这种比例并不代表“项目是纯静态的”。只要登录检查、动态 API、Server Action 或请求相关逻辑仍存在,生产环境就需要相应的 Next.js 运行能力。
这是最容易被忽略的事实。静态页面、generateStaticParams、generateMetadata 以及构建时的数据请求,可能在 next build 期间访问 CMS、数据库或外部 API。因此构建机器需要必要的网络权限和配置,外部数据的变化也可能改变最终 HTML 与 RSC 输出。
假设 BoardFlow 在构建时读取公开课程目录:
type Course = {
id: string;
title: string;
};
export default async function CatalogPage() {
const response = await fetch(`${process.env.CMS_API_URL}/courses`);
if (!response.ok) {
throw new Error(`课程目录构建失败:${
如果这个页面被静态生成,构建时返回的数据会进入产物。即使变量没有 NEXT_PUBLIC_ 前缀,也不能把私人数据塞进预渲染结果。所谓“服务端变量”只表示它不会被 Next.js 自动内联进客户端代码,并不保证应用逻辑不会把它输出到 HTML、RSC、日志或镜像。
Next.js 16 已把 lint 从 next build 中移除。发布门禁至少应显式执行:
set -eu
npm ci
npm run lint
npm run build项目如果有单元测试、端到端测试和迁移检查,也要单独加入。不要根据旧版本教程假设构建命令会自动运行 ESLint。
Next.js 16.1.6 提供了 --debug、--debug-prerender、--profile 和 --experimental-debug-memory-usage 等诊断选项。例如,预渲染只在生产构建失败时,可以单独运行:
npx next build --webpack --debug-prerender内存问题则可以在隔离的诊断构建中运行:
NODE_OPTIONS=--max-old-space-size=8192 \
npx next build --webpack --experimental-debug-memory-usageTurbopack 的实验性 analyzer 不能直接套在当前强制 webpack 的脚本上。继续使用 webpack 时,应选择与 webpack 匹配的分析方案;准备切换 Turbopack 时,再单独建立基线和回归测试。不要为了“用上新工具”顺手改变生产构建器。
.next:产物、缓存与诊断构建完成后,.next 里既有运行文件,也有为了加速下次构建而保存的缓存。把整个目录一股脑塞进镜像,往往是镜像膨胀的开始。

一个经过简化的目录可以这样理解:
.next/
├── BUILD_ID # 当前构建标识
├── server/ # 服务端路由、RSC 与运行代码
├── static/ # 带内容哈希的 JS、CSS 等静态资源
├── cache/ # 编译和数据相关缓存,不是完整运行产物
├── routes-manifest.json # 路由相关清单
├── prerender-manifest.json # 预渲染与重验证清单
└── standalone/ # 仅在 output: 'standalone' 时生成当前工作区的 .next 一度达到约 12 GB,其中 .next/cache 约 8.3 GB、.next/dev 约 3 GB,而 .next/static 只有约 19 MB。这个数据不能直接当成生产镜像体积,因为本地目录混入了开发历史;但它清楚地说明,缓存和运行产物必须分开管理。
CI 恢复 .next/cache 是为了加快下一次构建,不是把旧产物当成新产物。缓存键至少应包含 lockfile 和相关配置的变化;命中失败只应变慢,不能导致构建结果错误。
不要把 .next/static 和 public 混为一谈。前者通常带内容哈希,适合长期 immutable 缓存;public 中的文件名未必变化,缓存策略需要由你自己设计。standalone 输出也不会默认把这两个目录都复制到运行目录。
项目的 package.json 使用版本范围,lockfile 则记录了实际解析到的 Next.js 16.1.6。生产 CI 应执行 npm ci,而不是让 npm install 在发布时重新解依赖。
两者的差别很实际:
npm ci 要求 package.json 与 lockfile 一致,不一致就失败。node_modules 开始,更适合 CI。Node 也要锁。Next.js 16.1.6 的最低要求是 Node 20.9.0,但“达到最低版本”不等于“适合今天的生产环境”。截至本章编写时,Node 20 已结束支持,Node 22 与 24 仍在 LTS 周期。BoardFlow 可以选择团队已经验证的 Node 22,或者升级到 Node 24;无论选谁,都要在本地、CI 和运行镜像中保持一致。
推荐在项目中同时留下几层约束:
{
"engines": {
"node": "^22.9.0 || ^24.0.0"
},
"packageManager": "npm@11.6.2"
}这个范围只接受当前仍在 LTS 线上的 Node 22 或 24,不会因为写成宽泛的 <25 而把已经结束支持的 Node 23 放进来;22.9.0 也满足 npm 11 的运行要求。再用 .nvmrc、.node-version 或 CI 配置固定团队实际采用的精确版本。容器基础镜像也应固定到经过验证的补丁版本,成熟流水线还会记录镜像 digest。浮动的 node:22-alpine 可能在没有代码变更时带来系统库变化。
原生依赖需要额外检查。项目安装了 Sharp,容器使用 Alpine 时会涉及 musl,使用 Debian slim 时通常是 glibc。架构从开发机的 ARM64 变成生产的 AMD64,也可能影响原生包。构建镜像和运行镜像应使用兼容的系统与架构,不要直接复制开发机的 node_modules。
可复现发布不要求世界永远不变,而是要求我们能回答“这份产物由什么生成”。至少保存 Git SHA、Node 版本、lockfile、构建时间、镜像 digest 和部署版本。出了问题,这些信息比“我本地刚才没问题”有用得多。
环境变量最容易因为名字相同而被误认为同一件事。实际上,BoardFlow 要经过构建进程、服务器运行时和浏览器三个环境,变量在每一段的行为都不同。

NEXT_PUBLIC_* 为什么会“改了却不生效”假设预览环境构建时使用:
NEXT_PUBLIC_API_URL=https://preview-api.example.com随后我们把同一镜像部署到生产,并通过 Compose 或 Kubernetes 注入:
NEXT_PUBLIC_API_URL=https://api.example.com浏览器仍会请求预览 API,因为公开变量早已写进构建产物。当前项目的 Compose 使用运行时 env_file,它可以给服务端进程提供变量,却不能改写已经生成的客户端 JavaScript。
如果确实需要“同一镜像、不同浏览器运行时配置”,可以让动态服务端提供一个受控的公开配置端点,或者在 HTML 启动数据中注入白名单字段。不要把整个 process.env 序列化给浏览器。反过来说,如果客户端配置不是环境中立的、又没有这类运行时配置机制,那么“预览与生产推广同一镜像”就不成立:每套公开变量都必须产生一份新的浏览器 bundle。
最安全的情况是构建完全不需要生产密钥。如果静态预渲染必须读取 CMS,可以使用 BuildKit secret,让密钥只在某个 RUN 步骤可见:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=CMS_BEAR_TOKEN,env=CMS_BEAR_TOKEN,required=true \
npm run build构建命令示例:
docker buildx build \
--secret id=CMS_BEAR_TOKEN,env=CMS_BEAR_TOKEN \
--tag registry.example.com/boardflow:"$GIT_SHA" \
.BuildKit secret 避免把值写进 ARG、ENV 和普通 COPY 层,但它不能阻止应用主动把密钥打印到日志,也不能阻止构建结果包含由密钥读取到的私人数据。权限最小化和输出审计仍然需要做。
下面这个交互实验可以帮助你观察变量在三段环境中的最终去向。
部署方式没有统一排名。我们应该先列出应用需要的能力,再判断平台是否完整实现这些能力。

Vercel 对 Next.js 的框架语义支持通常最完整,Git 分支也容易生成预览部署。但这不表示环境变量、数据库迁移和业务回滚自动正确。其他平台即使能启动 next start,也应核对功能保真度,而不是只看“支持 Next.js”四个字。
对 BoardFlow 当前形态,Node Server 或 Docker 是最直接的选择:它有 Proxy、动态路由、服务端接口和登录态。Serverless 也可能可行,但要逐项验证所选 adapter。Edge 不应只因为“听起来更快”就使用;如果代码依赖 Node API、Sharp 或数据库驱动,迁移成本会很真实。
当前 Dockerfile 在一个阶段里安装依赖、复制源码、执行构建,再用 npm run start 启动。它还通过镜像加速地址引用浮动的 node:22-alpine 标签。这样能运行,却会把开发依赖、源码和构建缓存一起留在最终镜像中,也可能在代码没有变化时因为基础镜像更新而改变结果。Next.js 的 output file tracing 可以生成更小的 standalone 目录,更适合作为生产运行边界。
先在现有 next.config.mjs 中合并输出配置,不要覆盖项目已有的 MDX 和图片设置:
const nextConfig = {
output: 'standalone',
// 保留项目已有的 pageExtensions、images、webpack 等配置
};
export default nextConfig;下面是一份面向 Linux 容器的参考 Dockerfile。示例选用 Node 24 的 Debian slim 系列;真实仓库应把基础镜像固定到团队验证过的补丁版本或 digest。
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN --mount=type=secret,id=CMS_BEAR_TOKEN,env=CMS_BEAR_TOKEN,required=true \
npm run build
FROM node:24-bookworm-slim AS runner
WORKDIR /app

这份示例有几个容易漏掉的细节:
public 和 .next/static,如果没有单独放到 CDN,就要显式复制。USER nextjs 避免应用以 root 身份运行。PORT 和 HOSTNAME。CMD 让 Node 进程正确接收终止信号。HEALTHCHECK 依赖轻量的 /healthz,该端点不能被登录 Proxy 拦截;Docker 的健康状态本身不会自动替代 Kubernetes readiness 或负载均衡摘流量规则。配套 .dockerignore 至少应包括:
.git
.next
node_modules
.env*
!.env.example
.tmp
tmp
app/local
*.log
Dockerfile*
docker-compose*.yml是否排除 app/local 要根据正式构建是否需要它决定;当前目录属于本地自动化内容,不应无意进入生产上下文。.env.example 只能保存变量名和无敏感默认值。
上面的代码是本章参考实现,不会自动修改当前 Dockerfile。把它落地时,要先修正项目的跨平台 build 脚本,再在 CI 中验证 Sharp、MDX、健康端点、运行用户和文件权限,最后比较镜像体积与启动行为。
设置 output: 'export' 后,next build 会生成 out 目录,可以放到对象存储或普通静态服务器。对于文档站、营销页和构建时即可确定的内容,它很省心;但它不是把完整 Next.js 服务“压成静态文件”。
以下能力通常不适用于完整静态导出:
静态 GET Route Handler 在满足构建时可执行条件时可以被导出;图片也不一定只能设置 unoptimized: true,还可以使用真正支持变换的外部 loader。但这些例外不会改变 BoardFlow 的结论:当前项目有根级 proxy.ts、动态接口和用户状态,不能整体改成静态导出。
如果未来希望把公开课程介绍页静态化,可以把它拆成独立站点,或在仍有 Next.js Server 的应用里让特定页面静态生成。部署模型应服从页面能力,不要为了便宜的静态托管删掉业务所需功能。
自托管时,请求通常不会直接撞上 next start。真实链路更像这样:浏览器先经过 DNS、CDN 或 WAF,再进入反向代理和负载均衡,最后到某个 Next.js 实例。

每一层都可能改变行为:CDN 缓存了旧 HTML,代理缓冲了流式响应,负载均衡把同一个旧页面请求分到新实例,WAF 拦截了较大的 Server Action 请求。排查部署问题时,要沿链路逐层验证。
生产入口通常应负责 TLS、请求体限制、基础限流和异常流量过滤,并正确转发主机与协议。一个简化的 Nginx 片段如下:
upstream boardflow_next {
server boardflow-1:3001;
server boardflow-2:3001;
}
server {
listen 443 ssl;
server_name boardflow.example.com;
location / {
proxy_pass http://boardflow_next;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
这不是可以原样上线的完整 TLS 配置,但它表达了一个关键点:React Server Components、Suspense、PPR 或 Server Actions 的流式响应需要端到端传输。只要 CDN 或代理把整个响应缓冲完再发送,用户就看不到渐进式内容。proxy_buffering off 只关闭这一层 Nginx 的缓冲;应用也可以返回 X-Accel-Buffering: no 提醒支持该响应头的 Nginx 层,但仍要逐层验证 CDN、WAF 和负载均衡没有再次聚合响应。托管平台同样要核对 streaming,而不是只看函数能否返回响应。
Next.js 的 proxy.ts 与这里的反向代理不是同一层。前者在框架路由流程中运行,负责应用级重写、跳转或访问检查;后者位于应用外面,负责网络入口。Next.js 16 的 Proxy 使用 Node.js runtime,旧教程中把它固定描述成 Edge runtime 已不准确。
如果 Server Actions 经过不同域名或代理,需要按真实入口配置 serverActions.allowedOrigins,不能用宽泛通配符换取“先跑起来”。
Next.js 会根据响应类型生成 Cache-Control。动态个性化响应应保持私有或不缓存;静态和 ISR 响应可能带共享缓存指令;/_next/static 则适合长期 immutable。CDN 若无条件改成统一的长缓存,可能把某位用户的页面交给另一位用户。示例里的 proxy_cache off 也只表示 Nginx 本层不缓存,不能证明位于它之前的 CDN 没有缓存。
App Router 导航不只有一份 HTML。CDN 缓存键必须保留完整查询参数,尤其不能丢掉 _rsc;还要正确区分或遵守 RSC、Next-Router-State-Tree、Next-Router-Prefetch、Next-Router-Segment-Prefetch,以及响应实际声明的 Vary。把这些请求合并成一个缓存对象,可能让普通页面、预取结果和 RSC payload 彼此串用。若 CDN 位于应用 Proxy 前面,登录态或按请求判断权限的路由应绕过共享缓存,否则命中 CDN 时根本不会执行 Proxy。
revalidatePath、revalidateTag 等按需失效首先作用于 Next.js 服务端缓存,不会自动清理外部 CDN。需要缓存页面响应时,发布与内容更新流程还要调用 CDN purge,并同时覆盖受影响的 HTML 与 RSC 变体。/_next/static 的哈希资源则应长期保留,不要用“全站清空”代替精确失效。
静态资源部署看起来只是“传文件”,但路径和缓存键一旦配置错误,常见结果就是新页面找不到旧 chunk,或者 CDN 缓存了错误图片格式。
assetPrefix 与 basePath 解决不同问题const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX,
basePath: '/boardflow',
};
export default nextConfig;assetPrefix 主要改变 /_next/static 等 Next.js 静态资源地址,用于把 chunk 放到独立 CDN 域名。它不会自动给 public 文件加 CDN 前缀。basePath 表示整个应用部署在 /boardflow 子路径下,会影响路由和资源引用,并在构建阶段写入客户端 bundle。改变它通常需要重新构建。.next 暴露给 CDN。服务端代码、清单和缓存并不是公开资源。项目全局自定义 loader 只是在 URL 后面追加 w 和 q。下面的代码保留了仓库当前实现的关键逻辑,包括源地址已经带查询参数时使用 &:
export default function myImageLoader({
src,
width,
quality,
}: {
src: string;
width: number;
quality?: number;
}) {
return `${src}${src.includes('?') ? '&' : '?'}w=${width
对现有媒体源站实测,原始图片与附加 ?w=640&q=75 的响应都是 image/png、374427 字节,ETag 相同,响应体 SHA-256 也同为 cafca32507e8c48aad5b042a3a20c15382fbb296ecb03841be3030b8302026ad。这说明该源站对这张图片忽略了参数;浏览器虽然看到多个 srcset 候选,却可能始终下载同一张原图。这个结论只描述当前源站实测,媒体服务升级后应重新验证。
上线前可以用下面的方法做黑盒验证:
set -eu
IMAGE_URL='https://media.example.com/uploads/boardflow-card.png'
curl -fsS -D original.headers -o original.bin "$IMAGE_URL"
curl -fsS -D resized.headers -o resized.bin "${IMAGE_URL}?w=640&q=75"
wc -c original.bin resized.bin
shasum -a 256 original.bin resized.bin真正的图片服务应该根据尺寸、质量和格式返回对应变体,并让 CDN 缓存键包含这些差异。当前自定义 loader 绕过了 Next 内置图片优化器,因此代理转发 Accept 不是当前问题的核心;将来恢复默认优化器时,才要确保 Accept 正确到达优化服务,并按 WebP、AVIF 等输出格式分开缓存。
给 URL 加上 ?w=640 不代表图片已经变成 640 像素。判断优化是否生效,要看响应尺寸、像素尺寸、Content-Type、ETag 和传输体积。部署验收应把这项检查自动化。
BoardFlow 只有一台实例时,本地缓存看起来一切正常。扩容到三台后,管理员在 A 实例更新课程并触发失效,B 和 C 仍可能返回旧页面;重启某一台后,它的缓存状态又和其他实例不同。

需要关注的不只有浏览器缓存:
Next.js 的自托管部署可以配置自定义缓存 handler。Next.js 16.1.6 中,单数 cacheHandler 覆盖传统服务端缓存路径,例如 ISR/页面与路由响应、被框架缓存的 fetch、unstable_cache 和图片缓存;实现需要正确处理 get、set、revalidateTag 与请求级缓存重置。复数 cacheHandlers 则属于 Cache Components,用来给 use cache 的默认、远程或命名缓存配置处理器,涉及 updateTags、refreshTags 与 getExpiration 等标签同步语义。名称很接近,却不是可以随意互换的同一配置。
当前项目的 cacheComponents 仍是默认的 false,因此不能只复制一段 cacheHandlers 配置就假定所有 ISR 与数据缓存已经共享。无论使用哪套 handler,都要按锁定版本的类型接口实现,并用两个以上实例验证写入、读取、标签失效、并发重建和后端故障;外部 CDN purge 仍是另一条独立链路。
无论采用 Redis、数据库还是平台缓存,设计时都要回答:
revalidateTag 或其他失效动作能否传播到所有实例?当前项目尚未启用 Cache Components,也没有自定义共享缓存。这并不表示现在必须立刻引入 Redis。先根据真实流量决定是否横向扩容;一旦需要多个副本或 ISR,就要把共享缓存和失效传播作为部署设计的一部分,不能等线上出现“偶尔旧数据”再补。
滚动发布不会在同一毫秒替换所有实例。几分钟内,v1 页面、v1 静态资源、v1 服务和 v2 服务可能同时存在。用户在部署前打开的标签页也可能数小时后才提交表单。

常见故障包括:
最稳妥的方式仍是构建一次镜像,再复制到所有副本。如果平台不得不在多个阶段重新构建,可以用稳定的发布标识生成 build ID:
const releaseId = process.env.RELEASE_ID;
if (process.env.CI && !releaseId) {
throw new Error('CI 构建缺少 RELEASE_ID');
}
const nextConfig = {
generateBuildId: async () => releaseId ?? 'local-development',
deploymentId: releaseId,
};
export default nextConfig;也可以不在配置里写 deploymentId,改由构建环境提供 Next.js 内置的 NEXT_DEPLOYMENT_ID;两者同时存在时,以配置值为准。生产中应让同一份发布标识贯穿镜像、日志、指标和部署清单,避免又引入一个含义相近却不一致的自定义变量。
deploymentId 会给框架资源增加 ?dpl=... 缓存破坏参数,客户端导航也会携带部署标识;当客户端与服务器返回的部署标识不一致时,框架可以退回整页导航。但服务端不会读取传入的 ?dpl 来自动选择旧实例,因此它不是完整的流量路由系统。平台或负载均衡仍需理解版本并保留对应部署,普通自建 Nginx 不会因为看见一个版本参数就自动找到旧实例。
Next.js 会使用密钥保护 Server Action 闭包数据。多实例或多次独立构建时,可以提供统一的 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY。它需要是 Base64 编码、解码后为 16、24 或 32 字节的值,例如由密钥系统生成 32 字节随机值:
openssl rand -base64 32这把密钥会进入构建产物,因此应在 next build 阶段安全提供给同一发布集;由同一个镜像启动的副本会自然共享产物中的密钥,只有多地独立构建时才更需要显式统一。轮换时还要考虑仍在运行的旧部署和仍打开旧页面的客户端,不能只替换新实例的运行时变量。它不等于业务授权:Action 内仍要验证用户身份和权限,敏感数据也不应只依赖闭包加密。
一次稳妥的滚动发布会保留旧静态资源,让新实例先通过 readiness,再逐步接流量;老实例停止接新请求后继续排空已有连接。数据库采用 expand/contract 迁移,确保 v1 与 v2 共存期间都能工作。
下面的实验会把副本比例、静态资源保留、Action key 和排空时间放到同一条时间线上。
“容器进程还在”只能说明 PID 存在,不能说明它已经准备好接收用户请求。生产平台通常需要三种不同探针:
一个最小健康端点可以放在 app/healthz/route.ts:
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET() {
return Response.json(
{
status: 'ok',
deployment: process.env.NEXT_DEPLOYMENT_ID ?? 'unknown',
},
{
headers: {
'Cache-Control': 'no-store',
},
},
liveness 不应每次都检查所有外部依赖,否则数据库短暂抖动会让整组应用重启。上面的 /healthz 适合作为最小进程健康检查;如果 readiness 需要表达“当前是否能接业务流量”,可以另建 /readyz 做有严格超时的必要依赖检查,并根据业务决定数据库故障时是否摘流量。两个端点都要排除在登录跳转、CDN 缓存和限流规则之外。
Kubernetes 中可以这样表达探针和停机窗口:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: boardflow
image: registry.example.com/boardflow@sha256:REPLACE_WITH_DIGEST
ports:
- containerPort: 3001
startupProbe:
httpGet:
path: /healthz
port: 3001
timeoutSeconds: 1
failureThreshold: 30
periodSeconds
Kubernetes 删除 Pod 时,终止宽限期计时、EndpointSlice 把端点标成 terminating 且 ready=false,以及 kubelet 的本地停机流程会相继发生,不能假设所有负载均衡器一定先完成摘流量、随后才发信号。kubelet 会先执行 preStop,再向容器主进程发送 TERM;示例中的 5 秒 sleep 只是给入口层传播摘流量状态的兼容窗口,而且会占用 30 秒宽限期。standalone 的 Node 进程必须成为容器主进程,不能被不转发信号的 shell 包住;应用要在剩余时间内排空流式响应和现有连接,超过宽限期仍未退出会被强制终止。
它表示状态不应只存在某一台可随时替换的实例里:
Next.js 的 after 可以把一些工作放到响应之后,但它不是耐久队列。付款、发信、媒体处理等不能丢失的任务应写入持久队列,由 worker 重试和记录幂等键。跨地域部署还要考虑数据库延迟与数据主区域,不能只把 Web 实例“铺得更远”。
现在把前面的零散要求收进一条流水线。它不必一开始就复杂,但每个阶段都应有明确输入、输出和失败条件。
CI 在固定 Node 版本下执行 npm ci,再运行 lint、测试和不产生正式发布物的代码检查。lockfile 不一致时立即失败。
使用 BuildKit 构建一次 runner 镜像,临时挂载构建所需密钥。镜像推送后记录不可变 digest,并扫描密钥与已知漏洞。
把同一 digest 部署到预览环境,注入预览环境的服务端配置,执行登录、列表读取、表单提交、图片变体和健康端点冒烟测试。
数据库先执行与旧、新代码都兼容的 expand migration。发布操作通过受保护环境审批后,新版本以少量流量进入生产。
一个简化的 CI shell 可以表达基本门禁:
#!/usr/bin/env sh
set -eu
: "${GIT_SHA:?缺少 GIT_SHA}"
: "${REGISTRY:?缺少 REGISTRY}"
: "${CMS_BEAR_TOKEN:?缺少构建所需 CMS_BEAR_TOKEN}"
npm ci
npm run lint
docker buildx build \
--secret id=CMS_BEAR_TOKEN,env=CMS_BEAR_TOKEN \
--label org.opencontainers.image.revision="$GIT_SHA
正式流水线还应运行项目真实存在的测试脚本,并使用镜像仓库返回的 digest 部署,不能只依赖可被覆盖的 tag。
假设 v2 把 tasks.title 拆成 summary 和 details。如果上线前直接删除 title,仍在运行的 v1 会立即报错,代码回滚也无法恢复字段。更安全的顺序是:
回滚平台只能把域名或流量指回旧代码。它不会自动回滚数据库、环境变量、队列消息、对象存储或 CMS 内容,所以“点击 Instant Rollback”从来不是完整的数据恢复方案。
Vercel 可以自动识别 Next.js、为非生产分支和 Pull Request 创建 Preview、管理框架构建和切换不可变部署。Instant Rollback 能把域名重新指向旧部署,但旧部署绑定的配置可能已经过期,外部数据库、CMS 和队列也不会随之回滚。启用 Skew Protection 后,平台可以在保留窗口内保护框架管理的资源、导航和 Action 请求;应用自己发起的普通 fetch 不会自动绑定旧部署,具体可用范围和保留时间仍要核对当前套餐与文档。普通 Next.js 项目不需要手工把输出目录配置成 .next。自托管则要自己实现镜像、共享缓存、反向代理、探针和排空。
两条路都要由团队负责:环境变量分类、预览数据隔离、数据库迁移、业务 smoke test、发布阈值与回滚后的数据检查。
安全配置和监控经常被放到“上线后再补”,但许多问题只有在请求入口、镜像和构建阶段才能正确解决。
BoardFlow 至少应检查这些项目:
.env、云凭据和私钥不进入 Docker 上下文、镜像层、日志和客户端 bundle。poweredByHeader: false 减少无意义的技术暴露。max-age 验证。X-Content-Type-Options: nosniff、合理的 Referrer Policy、Permissions Policy 和 CSP frame-ancestors。基础响应头可以从较保守的配置开始:
const securityHeaders = [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
];
const nextConfig = {
poweredByHeader: false,
async headers() {
return [
{
source:
CSP 不能从博客复制一行就上线。Next.js 需要执行框架脚本,应用还可能加载 CMS 图片、字体、分析脚本和 API。nonce CSP 会让页面在请求时生成 nonce,通常迫使页面动态渲染,从而影响静态生成、ISR、CDN 和 Cache Components。较稳妥的流程是先整理资源清单,在预览环境使用 Content-Security-Policy-Report-Only 收集违规,再选择静态 CSP、nonce 或经过验证的其他方案。
Next.js 的 instrumentation.ts 可以在服务启动时注册 OpenTelemetry,也可以通过 onRequestError 统一记录框架错误:
import type { Instrumentation } from 'next';
export function register() {
console.info(
JSON.stringify({
event: 'next_server_start',
runtime: process.env.NEXT_RUNTIME ?? 'unknown',
deployment: process.env.NEXT_DEPLOYMENT_ID ?? 'unknown',
}),
);
}
export const onRequestError: Instrumentation
上面的版本不依赖额外监控包,先把启动和请求错误变成结构化日志。选择 OpenTelemetry 后,可以在 register 中只针对 Node runtime 动态导入单独的 provider 初始化模块;自托管时通常还需要 Collector 接收、采样和转发 trace。当前项目没有安装原章节示例中的 @vercel/analytics、@vercel/speed-insights、@vercel/otel 或相关 OpenTelemetry 包,所以不能直接复制 import;应先选平台和观测方案,再安装依赖。
日志至少要包含时间、请求 ID、路由、状态、耗时和 deployment ID,但不要记录 Cookie、Authorization、完整请求体或带敏感查询参数的 URL。指标则应覆盖:
只有把部署标识写入日志、指标和 trace,才能比较 v1 与 v2,而不是在总平均值里猜测新版本是否出问题。

我们最后把 BoardFlow 的发布过程写成可执行 runbook。它的价值不在于文档看起来完整,而在于凌晨出现问题时,值班者不用临时猜下一步。
.dockerignore 已排除密钥文件和本地临时目录。下面的控制台会把这些门禁放进一次模拟发布中。
你的团队把同一个 BoardFlow 镜像先部署到预览,再部署到生产。生产容器通过 env_file 把 NEXT_PUBLIC_API_URL 改成正式 API,但浏览器仍请求预览地址。同时,两台生产实例使用本地 ISR 缓存,管理员更新课程后只有一台显示新内容。
请先写下你的处理顺序:哪些问题需要重新构建,哪些需要运行时配置,哪些需要共享基础设施?
当你能用同一个镜像 digest 完成预览、灰度与生产,能解释每个环境变量在哪个阶段生效,能在多实例下保持缓存和版本一致,并能按阈值排空或回滚流量,部署才算真正进入可操作状态。下一次发布不需要依赖某个人的记忆,而是按 runbook 留下证据。
观察错误率、P95 延迟、Server Action 失败、缓存命中率和资源占用。满足观察窗口后逐步放量,否则把流量切回旧 digest。
等旧实例和旧客户端退出后,再执行删除旧字段等 contract migration,并按保留策略清理旧镜像和静态资源。