代码语言

知识点思维导图

29 个知识节点

Next.js(05) - 构建、部署与性能优化

读完后,你应能完成以下任务:

  • 绘制“Next.js(05) - 构建、部署与性能优化 / 本地能跑,离“可以上线”还差什么”的关键对象与数据流,解释“如果每次部署都在服务器现装依赖、现拉代码,或者出问题后只能“重新构建上一版”,就没有真正的可回滚能力。”,并用源码位置、日志或 Trace 标注证据。
  • 为“Next.js(05) - 构建、部署与性能优化 / 部署的核心是“同一制品逐级晋级””设计正常与异常输入,验证“DIAGRAM_DESCRIPTION:图中必须包含 Git 提交、CI 质量门禁、不可变镜像、安全扫描、灰度实例、指标判断、扩大流量和回滚旧镜像,重点表达测试过的镜像不能在环境之间重新构建。”,输出首个偏差位置与回归测试结果。
  • 实现“Next.js(05) - 构建、部署与性能优化 / 构建时变量和运行时变量不是一回事”的最小代码或配置,检验“浏览器需要的公开配置也要当成产品数据管理,不能因为“不是密钥”就允许任意值进入生产包。”,输出命令、结果与 Diff,并说明不适用边界。

本文面向已经能在本地运行 Next.js、准备部署到生产环境的开发者,示例基于 Next.js 16.3.0。读完后,你能构建可追溯的 standalone 容器制品,区分构建时和运行时配置,设计灰度与回滚,并用 Core Web Vitals 和服务端指标判断瓶颈在哪里。

一、本地能跑,离“可以上线”还差什么

next build 成功只说明当前源码能生成生产产物。真正上线还要回答:

  • 生产运行的代码是不是 CI 构建并测试过的同一个制品?
  • 环境变量哪些在构建时写入,哪些可以在容器启动时修改?
  • 多实例之间的缓存和失效是否一致?
  • 新版本出错时,旧版本的静态资源、数据库结构和镜像还在不在?
  • 页面慢是图片、客户端 JavaScript、服务端查询还是上游接口造成的?

如果每次部署都在服务器现装依赖、现拉代码,或者出问题后只能“重新构建上一版”,就没有真正的可回滚能力。

本文完成后的可验证结果是:

  • 能解释 .next/standalone.next/staticpublic 各自负责什么。
  • 能静态审查一个非 root、多阶段构建的 Next.js Dockerfile。
  • 能设计带健康检查、灰度指标和不可变版本号的发布链路。
  • 能同时使用 LCP、INP、CLS、TTFB、上游耗时和缓存命中率定位性能问题。

二、部署的核心是“同一制品逐级晋级”

flowchart LR
  COMMIT["Git Commit"] --> CI["锁版本 + Lint + Test + next build"]
  CI --> IMAGE["不可变镜像 + Commit SHA"]
  IMAGE --> SCAN["依赖 / 镜像安全扫描"]
  SCAN --> CANARY["灰度实例"]
  CANARY --> CHECK{"健康、错误率、延迟、Web Vitals"}
  CHECK -->|通过| PROD["滚动扩大流量"]
  CHECK -->|失败| ROLLBACK["切回旧镜像"]

DIAGRAM_DESCRIPTION:图中必须包含 Git 提交、CI 质量门禁、不可变镜像、安全扫描、灰度实例、指标判断、扩大流量和回滚旧镜像,重点表达测试过的镜像不能在环境之间重新构建。

2.1 构建时变量和运行时变量不是一回事

配置类型 例子 何时生效 风险
客户端公开变量 NEXT_PUBLIC_ANALYTICS_ID next build 时内联 构建后修改容器环境不会替换;内容对浏览器公开
服务端运行时变量 DATABASE_URL 服务端请求或启动时读取 不能进入客户端模块和构建日志
构建行为配置 output: 'standalone' 构建时 改动后必须重新生成制品
发布元数据 APP_VERSION、Commit SHA 构建或发布注入 缺失后日志和告警无法关联版本

不要把密钥写进 next.config.tsenvNEXT_PUBLIC_。浏览器需要的公开配置也要当成产品数据管理,不能因为“不是密钥”就允许任意值进入生产包。

三、做一个可以交给容器平台的 standalone 制品

3.1 开启 standalone 输出并提供健康入口

// next.config.ts
import type { NextConfig } from 'next'

/** 生产构建和响应安全使用的 Next.js 配置。 */
const nextConfig: NextConfig = {
  output: 'standalone', // 裁剪 Node 运行时真正依赖的文件。
  poweredByHeader: false, // 删除无业务用途的 X-Powered-By 响应头。
  images: {
    formats: ['image/avif', 'image/webp'] // 优先协商现代图片格式。
  }
}

export default nextConfig
// app/api/health/live/route.ts
import { NextResponse } from 'next/server'

/** 仅证明 Next.js 进程能够处理请求的存活探针。 */
export function GET(): Response {
  return NextResponse.json({ status: 'ok' })
}

存活探针不要每次都访问数据库,否则数据库抖动会让平台同时重启全部健康实例。就绪探针可以检查关键依赖,但必须有严格超时和降载策略,用来决定是否接收新流量,而不是替代业务监控。

3.2 用多阶段 Dockerfile 固定运行边界

项目需要提交 pnpm-lock.yaml,并保留脚手架生成的 public 目录:

# syntax=docker/dockerfile:1
FROM node:20-alpine AS dependencies
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

FROM node:20-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000

RUN addgroup --system --gid 1001 nodejs \
  && adduser --system --uid 1001 nextjs

COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

三类文件缺一不可:standalone 包含最小 Node 服务和依赖,.next/static 包含带哈希的客户端资源,public 保存原样公开文件。基础镜像在正式项目中还应固定到经过安全扫描的 digest;只写浮动标签会让同一提交在不同日期构建出不同系统层。

静态审查清单:安装使用冻结锁文件;构建和运行分层;最终镜像不携带完整源码与开发依赖;进程使用非 root 用户;运行时没有把密钥写在 Dockerfile;启动命令是 standalone 生成的 server.js

构建和本地验收命令应写入项目文档,但默认不因文章优化而执行:

docker build -t next-app:git-<commit-sha> .
docker run --rm -p 3000:3000 --env-file .env.production next-app:git-<commit-sha>
curl -i http://localhost:3000/api/health/live

预期探针返回 200 和 {"status":"ok"}。镜像标签应使用 Commit SHA 或制品摘要,不使用无法追溯内容的 latest 作为唯一发布依据。

四、多实例部署最容易漏掉哪些状态

4.1 缓存不能默认依赖单机磁盘

多实例自托管时,要明确 Data Cache、增量静态生成、图片优化缓存和标签失效如何共享。如果每个实例只保留本地状态,同一个 URL 可能随负载均衡命中不同版本。解决方案可能是共享缓存处理器、外部 CDN、统一失效消息或业务数据版本校验,具体取决于一致性要求。

不要用“会话粘滞”掩盖缓存不一致。它只能暂时让同一用户命中同一实例,扩缩容、故障切换和后台任务仍会暴露问题。

4.2 发布时要保留上一版静态资源

用户打开旧页面后再加载分包,可能仍请求上一 Build ID 的资源。CDN 如果在新版本上线时立刻删除旧静态文件,就会出现间歇性 Chunk 404。带内容哈希的静态资源适合长期不可变缓存,旧版本资源应保留超过页面和 CDN 的最大存活窗口。

4.3 数据库迁移必须兼容新旧应用共存

滚动发布期间,新旧版本会同时运行。安全做法是 expand-contract:先增加兼容字段或表,让新旧版本都能工作;再切流量和数据;最后在确认没有旧实例后删除旧结构。把破坏性迁移绑定到每个应用容器启动,会导致并发执行、扩容失败和无法回滚。

五、性能优化先定位瓶颈,别先加技巧

5.1 浏览器看 Core Web Vitals

指标 回答的问题 “良好”参考线 常见优化方向
LCP 主要内容多久可见 ≤ 2.5 秒 首屏图片、字体、TTFB、阻塞资源
INP 交互后多久有视觉反馈 ≤ 200 毫秒 长任务、客户端 JS、事件处理
CLS 页面是否发生意外位移 ≤ 0.1 图片尺寸、字体替换、动态插入内容

这些阈值应看真实用户第 75 百分位,并同时覆盖移动端和桌面端。实验室工具适合复现,RUM 才能告诉你真实网络、设备和地区下发生了什么。

next/image 可控制尺寸和响应式图片,next/font 减少字体请求与布局偏移,动态导入可以延后低频重组件,Server Component 可以减少客户端 JavaScript。但每个优化都有边界:图片转换会消耗 CPU 和缓存,拆包过碎会增加请求与 loading 闪烁,把组件改成服务端也不能消除慢数据库。

5.2 服务端指标解释 TTFB 为什么慢

至少按路由模板和版本记录:

  • TTFB、总耗时和流式首块时间。
  • 数据库、上游 API、缓存读取各自耗时。
  • 缓存命中率、回源量和失效失败数。
  • 错误率、超时率、连接池等待和事件循环延迟。

相互独立的数据请求应尽早启动并并行等待,存在依赖的数据保持顺序。慢请求要有超时和取消边界;盲目重试非幂等请求可能把一次故障放大成重复写入和连接池耗尽。

5.3 把 Web Vitals 发回自己的监控入口

// app/web-vitals.tsx
'use client'

import { useReportWebVitals } from 'next/web-vitals'
import type { NextWebVitalsMetric } from 'next/app'

/** 把单条浏览器性能指标发送到站内采集接口。 */
function reportMetric(metric: NextWebVitalsMetric): void {
  /** 只包含性能字段和发布版本的监控载荷。 */
  const payload = JSON.stringify({
    id: metric.id,
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    version: process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown'
  })
  navigator.sendBeacon('/api/vitals', payload)
}

/** 注册一次稳定的 Web Vitals 上报回调。 */
export function WebVitals() {
  useReportWebVitals(reportMetric)
  return null
}

接收端至少要限制请求体并校验指标名。下面只演示契约,生产环境应把指标写入监控系统,而不是依赖应用日志长期存储:

// app/api/vitals/route.ts
/** 允许采集的核心性能指标。 */
const ALLOWED_METRICS = new Set(['LCP', 'INP', 'CLS'])

/** 单次性能上报允许的最大字符数。 */
const MAX_PAYLOAD_CHARACTERS = 2048

/** 性能采集接口使用的 HTTP 状态码。 */
const HTTP_STATUS = {
  badRequest: 400, // JSON 或指标字段不符合契约。
  contentTooLarge: 413, // 请求体超过采集接口上限。
  noContent: 204 // 指标接收成功且无需响应正文。
} as const

/** 浏览器上报的性能载荷。 */
interface WebVitalsPayload {
  /** 单次指标测量的 ID。 */
  id?: unknown
  /** 性能指标名称。 */
  name?: unknown
  /** 性能指标数值。 */
  value?: unknown
  /** 产生该指标的应用版本。 */
  version?: unknown
}

/** 接收经过白名单校验的 Web Vitals 指标。 */
export async function POST(request: Request): Promise<Response> {
  /** 浏览器发送的原始文本,先限制长度再解析。 */
  const rawBody = await request.text()
  if (rawBody.length > MAX_PAYLOAD_CHARACTERS) {
    return new Response(null, { status: HTTP_STATUS.contentTooLarge })
  }

  /** 经过 JSON 解析的未知监控载荷。 */
  let payload: WebVitalsPayload
  try {
    payload = JSON.parse(rawBody) as WebVitalsPayload
  } catch {
    return new Response(null, { status: HTTP_STATUS.badRequest })
  }

  if (typeof payload.name !== 'string' || !ALLOWED_METRICS.has(payload.name) || typeof payload.value !== 'number') {
    return new Response(null, { status: HTTP_STATUS.badRequest })
  }

  // 生产环境在这里写入指标系统,并按版本、设备和路由聚合;不要记录用户身份。
  console.info('web-vital', { name: payload.name, value: payload.value, version: payload.version })
  return new Response(null, { status: HTTP_STATUS.noContent })
}

<WebVitals /> 放进根布局的 body。采集接口还应增加采样、限流和来源控制,不能直接把任意 JSON 打进日志。版本字段用于比较灰度和稳定版,而不是收集用户身份。

六、灰度、回滚和成本怎么权衡

灰度不只是“先上一台”。要提前定义停止扩量的阈值,例如新版本相对基线的错误率、P95/P99、关键转化和 Web Vitals。如果阈值超出,发布系统停止扩大流量并切回已经验证过的旧镜像。

回滚前要确认数据库迁移仍向后兼容、旧静态资源还在、旧镜像可以拉取、旧环境变量仍有效。没有这些条件,按钮叫“回滚”也只能重新构建和碰运气。

成本优化同时看计算、带宽、图片转换、缓存存储和可观测性采样。减少客户端 JS 可能降低浏览器成本,却增加服务端渲染;扩大缓存可能降低数据库压力,却增加存储和一致性复杂度。结论必须基于流量、命中率和延迟数据,不能只比较单次请求。

七、常见故障怎么排查

现象 根因 怎么定位 修复方式 防止复发
新版偶发 Chunk 404 CDN 过早删除旧 Build ID 静态资源 按版本和资源哈希检查 404,复现旧页面延迟加载 恢复旧静态资源并延长不可变资源保留时间 发布验收包含跨版本分包加载
多实例同一 URL 内容不同 缓存和失效只在本机 响应记录实例 ID、数据版本和缓存命中 使用共享缓存或统一失效传播 跨实例一致性冒烟和告警
容器健康却无法服务 存活探针没覆盖就绪条件 分别检查进程、数据库、上游和连接池 增加有超时的就绪探针,摘除而非重启进程 明确 liveness/readiness 职责
LCP 变差但服务端耗时正常 首屏图片、字体或客户端资源变大 对比 RUM、资源瀑布和版本包体 优化首屏资源尺寸、优先级和缓存 Core Web Vitals 按版本设回归门禁
回滚后服务启动失败 数据库已执行不兼容删除 对比旧应用查询和迁移记录 恢复兼容结构或前滚修复 使用 expand-contract 并演练回滚

八、上线前按什么验收

  • Node、pnpm、锁文件和基础镜像版本明确,制品可关联 Commit SHA。
  • 同一个镜像从测试环境晋级生产,没有在生产重新构建。
  • 最终容器使用非 root 用户,不包含开发依赖、源码密钥和构建缓存。
  • public.next/static 和 standalone 服务文件完整,旧静态资源有保留策略。
  • 存活和就绪探针职责分开,依赖检查有超时。
  • 多实例缓存、图片转换和失效传播有明确方案。
  • 数据库迁移兼容新旧版本共存,并实际设计了回滚路径。
  • 灰度阈值覆盖错误率、P95/P99、关键业务指标和 Core Web Vitals。
  • 性能数据按路由、设备、地区和应用版本拆分,能关联 Trace。

学完自测

8.1 场景选择:为什么不能在生产重新构建

测试环境验证过镜像 A,生产部署时服务器重新拉源码构建镜像 B。主要风险是什么?

A. 只要 Git Commit 相同就完全一致。
B. 依赖、基础镜像和构建环境可能变化,生产运行的不是已验证制品。
C. 生产构建一定比 CI 慢。
D. Next.js 不支持在服务器构建。

答案:B。 相同提交不保证依赖元数据、基础镜像和工具链完全相同。正确做法是构建一次不可变制品,经过测试、安全扫描和灰度后逐级晋级。C 不是核心风险,D 也不是事实。

8.2 多选:哪些会造成多实例不一致

A. 每个实例使用独立本地 Data Cache。
B. 标签失效消息只发送给接收写请求的实例。
C. 带哈希静态资源在 CDN 长期缓存。
D. 图片转换结果只存在各实例临时磁盘。

答案:A、B、D。 三者都让相同请求依赖命中的实例。C 对内容寻址的不可变资源是正常优化,前提是文件内容和 URL 哈希一致,并保留旧版本资源。

8.3 故障分析:健康检查为什么制造重启风暴

存活探针每秒查询数据库。数据库短暂抖动后,平台把所有应用容器判死并重启。应该怎么改?

答案: 根因是把依赖就绪状态塞进了进程存活判断。存活探针只判断进程能否处理请求;数据库检查放进带超时和失败阈值的就绪探针,用于摘除流量。数据库故障应单独告警,不能通过同时重启所有应用放大故障。

8.4 架构设计:LCP 变差从哪里查

灰度版本 LCP 变差,但服务端 TTFB 与旧版相同。下一步优先检查什么?

答案: 优先比较首屏资源瀑布、LCP 元素、图片尺寸和格式、字体、阻塞 CSS 以及客户端包体。TTFB 相同说明服务端首字节不是主要变化,但仍需用版本化 RUM 和 Trace 佐证,不能仅凭单次 Lighthouse 结果下结论。

九、总结

  • 本地能跑,离“可以上线”还差什么:生产运行的代码是不是 CI 构建并测试过的同一个制品?
  • 部署的核心是“同一制品逐级晋级”:DIAGRAM_DESCRIPTION:图中必须包含 Git 提交、CI 质量门禁、不可变镜像、安全扫描、灰度实例、指标判断、扩大流量和回滚旧镜像,重点表达测试过的镜像不能在环境之间重新构建。
  • 做一个可以交给容器平台的 standalone 制品:就绪探针可以检查关键依赖,但必须有严格超时和降载策略,用来决定是否接收新流量,而不是替代业务监控。
  • 多实例部署最容易漏掉哪些状态:解决方案可能是共享缓存处理器、外部 CDN、统一失效消息或业务数据版本校验,具体取决于一致性要求。
  • 性能优化先定位瓶颈,别先加技巧:| 指标 | 回答的问题 | “良好”参考线 | 常见优化方向 |
  • 灰度、回滚和成本怎么权衡:灰度不只是“先上一台”。

学完自测

选择所有正确答案;提交后逐项核对判断依据。

1在“构建、部署与性能优化”中,需要同时满足“本地能跑,离“可以上线”还差什么”与“构建时变量和运行时变量不是一回事”。给定正文约束“如果每次部署都在服务器现装依赖、现拉代码,或者出问题后只能“重新构建上一版”,就没有真正的可回滚能力。”,哪些判断保持了原有处理机制?多选
2“构建、部署与性能优化”出现偏差:“在“构建、部署与性能优化 / 开启 standalone 输出并提供健康入口”中,即使不满足“就绪探针可以检查关键依赖,但必须有严格超时和降载策略,用来决定是否接收新流量,而不是替代业务监控”,结果与副作用仍会保持不变。”已成为实际行为。围绕“开启 standalone 输出并提供健康入口”与“用多阶段 Dockerfile 固定运行边界”,哪些判断能定位被改变的职责或边界?多选
3评审“构建、部署与性能优化”方案时,验收条件包含“它只能暂时让同一用户命中同一实例,扩缩容、故障切换和后台任务仍会暴露问题。”。关于“缓存不能默认依赖单机磁盘”与“发布时要保留上一版静态资源”的哪些决策符合正文机制?多选