- 发布日期
第 29 讲:Node Server / Standalone / Adapter 三种部署形态
Node Server、Standalone 输出与 Adapter 接口三种部署形态的原理对比
阶段五开始我们离开 build,进入 runtime。生产 Next.js 部署有 4 种主流形态:
next startNode Server(自管 Node 进程)output: 'standalone'自包含目录(容器化必备)- Vercel / Cloudflare 平台 Adapter(serverless function)
- 自定义 server(用户用 express/koa 接住 Next.js request handler)
本讲拆这 4 种形态的内部实现:进程模型、port binding、HTTP 接入点、graceful shutdown,以及它们之间的取舍。
学习目标
读完本讲,你能:
- 复述
next start的 9 个启动阶段:bin → cli → startServer → http.createServer → router-server worker → render-server worker。 - 解释
output: 'standalone'的 server.js 内部如何重新实现"轻量级 next start"。 - 知道 Adapter(Vercel/Cloudflare/Deno)的核心契约:
required-server-files.json+functions-config-manifest.json+<page>.nft.json。 - 实战写一个 custom server,用 express 接住 Next.js handler。
- 排查启动失败、graceful shutdown、健康检查、容器内存等典型问题。
本讲对应代码:
packages/next/src/cli/next-start.ts(next start CLI 入口)packages/next/src/server/lib/start-server.ts(startServer 核心)packages/next/src/server/lib/router-server.ts(router worker)packages/next/src/server/lib/render-server.ts(render worker)packages/next/src/build/index.ts: writeStandaloneDirectory(standalone 产物)
一、四种形态总览
┌──────────────────────────────────────────────────┐
│ 形态 1: next start │
│ ────────────────── │
│ pnpm start │
│ ↓ │
│ bin/next.ts → cli/next-start.ts │
│ ↓ │
│ startServer() 起 http.createServer │
│ ↓ │
│ router-server worker (Node Worker Thread) │
│ ↓ │
│ render-server worker (默认进同进程) │
│ ↓ │
│ NextNodeServer.handleRequest │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ 形态 2: output: 'standalone' │
│ ──────────────────────────── │
│ node .next/standalone/server.js │
│ ↓ │
│ 内嵌一个轻量 startServer │
│ ↓ │
│ 同样起 http.createServer + router-server │
│ 区别: 不依赖外部 node_modules │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ 形态 3: Vercel/Cloudflare Adapter │
│ ────────────────────────────────── │
│ Vercel 部署器读 required-server-files.json │
│ ↓ │
│ 每个 route handler/page 打成一个 serverless fn │
│ ↓ │
│ 平台分配 region/runtime/memory │
│ ↓ │
│ 请求来时 lambda invoke │
│ ↓ │
│ 内部仍是 NextNodeServer.handleRequest │
│ 区别: 没有 http server,request 由平台代为接入 │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ 形态 4: Custom Server (express/koa/...) │
│ ────────────────────────────────── │
│ const next = require('next') │
│ const app = next({ dir: '.', dev: false }) │
│ const handle = app.getRequestHandler() │
│ express().all('*', (req, res) => handle(req, res)) │
│ │
│ 区别: 用户自己起 server,把请求转给 Next handler │
└──────────────────────────────────────────────────┘
二、形态 1:next start 启动 9 阶段
pnpm start
实际执行 packages/next/dist/bin/next start --port 3000。完整调用链:
阶段 1:bin/next.ts
入口脚本(已编译版)。负责:
- 解析命令行参数(commander.js)
- 检查 Node 版本
- 记录
NEXT_PRIVATE_START_TIME(用于"Ready in X ms") - 路由到对应子命令模块
阶段 2:cli/next-start.ts
const nextStart = async (options: NextStartOptions, directory?: string) => {
const dir = getProjectDir(directory)
const hostname = options.hostname
const inspect = options.inspect
const port = options.port
const keepAliveTimeout = options.keepAliveTimeout
if (isPortIsReserved(port)) {
printAndExit(getReservedPortExplanation(port), 1)
}
if (inspect) {
const inspector = await import('inspector')
// ... 启用 --inspect debugger
}
if (options.experimentalCpuProf) {
Log.info(`CPU profiling enabled. Profile will be saved on exit (Ctrl+C).`)
process.on('SIGTERM', () => saveCpuProfile())
process.on('SIGINT', () => saveCpuProfile())
}
await startServer({
dir,
isDev: false,
hostname,
port,
keepAliveTimeout,
})
}
干的事:
- 解析
--port/--hostname/--inspect等 - 校验端口(不能用 0、1024 以下保留端口)
- 启用 inspector(如果
--inspect) - 注册 CPU profile 信号处理
- 调用
startServer({ isDev: false })
阶段 3:startServer 起 HTTP server
export async function startServer(serverOptions: StartServerOptions): Promise<StartServerResult> {
// ...
process.title = `next-server (v${process.env.__NEXT_VERSION})`
let handlersPromise: Promise<void> | undefined = new Promise<void>(...)
let requestHandler: WorkerRequestHandler = async (req, res) => {
if (handlersPromise) {
await handlersPromise // 等待 handler 就绪后再处理请求
return requestHandler(req, res)
}
throw new Error('Invariant request handler was not setup')
}
async function requestListener(req: IncomingMessage, res: ServerResponse) {
try {
if (handlersPromise) {
await handlersPromise
handlersPromise = undefined
}
await requestHandler(req, res)
} catch (err) {
res.statusCode = 500
res.end('Internal Server Error')
// ...
} finally {
// ... dev 模式下内存 monitor
}
}
const server = http.createServer(requestListener)
if (keepAliveTimeout) {
server.keepAliveTimeout = keepAliveTimeout
}
读懂三个细节:
process.title:把进程名设为next-server (vX.X.X),top/ps容易识别。handlersPromise延迟解析:HTTP server 先 listen,handler 还在并行加载;第一个请求进来时等 promise resolve。这能让 startup 更快被外部探测到。keepAliveTimeout:HTTP keep-alive 超时(默认 5s)。在 K8s + ALB/Nginx 前置时常调大到 60s+,避免连接抖动。
阶段 4-9:fork router-server + render-server
startServer 内部 fork(Worker Threads 或 cluster)出:
- router-server(必有):handle resolveRoutes、middleware、rewrites
- render-server(默认同进程,dev 时独立):跑 NextNodeServer 渲染 page
第 15 讲已经详细讲过这两个 worker 的请求接入路径。
三、graceful shutdown
const cleanup = (signal: 'SIGINT' | 'SIGTERM') => {
if (cleanupStarted) {
return
}
cleanupStarted = true
;(async () => {
// first, stop accepting new connections and finish pending requests
await new Promise<void>((res) => {
server.close((err) => {
if (err) console.error(err)
res()
})
if (isDev) {
server.closeAllConnections()
closeUpgraded?.()
}
})
// now that no new requests can come in, clean up the rest
await Promise.all([
nextServer?.close().catch(console.error),
cleanupListeners?.runAll().catch(console.error),
])
await flushAllTraces()
switch (signal) {
case 'SIGINT':
process.exit(130)
break
case 'SIGTERM':
process.exit(143)
break
}
})()
}
读懂这个 cleanup:
server.close()停止接受新连接,等已开 connection 自然结束。- 生产模式不强制断连:等存量请求自然完成,避免数据丢失(dev 模式才
closeAllConnections)。 - 关 nextServer:让 base-server 跑完所有
after()回调、flush IncrementalCache。 flushAllTraces:OpenTelemetry trace 写盘。- exit code 128 + signal:SIGINT → 130、SIGTERM → 143,符合 POSIX 约定。
业务案例:K8s 滚动更新时,SIGTERM 触发 pod terminate;
terminationGracePeriodSeconds(默认 30s)内未结束的请求会被强杀。Next.js 的 graceful close 让长请求(流式渲染、SSE)平稳收尾。配合 K8s 的preStophook 关闭 traffic 5-10s 再 SIGTERM,效果更佳。
四、形态 2:standalone server.js
output: 'standalone' 时 build 产物:
.next/standalone/
├─ server.js # 入口
├─ package.json
├─ .next/server/... # server bundle + manifest
├─ node_modules/ # nft 选中的依赖(不含 dev deps)
└─ public/ # public 资源
server.js 内容(简化):
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify({...})
const path = require('node:path')
const { startServer } = require('next/dist/server/lib/start-server')
const dir = path.join(__dirname)
process.chdir(__dirname)
// 兼容传统环境变量
const currentPort = parseInt(process.env.PORT, 10) || 3000
const hostname = process.env.HOSTNAME || '0.0.0.0'
const keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10)
startServer({
dir,
isDev: false,
config: { /* embedded next.config */ },
hostname,
port: currentPort,
keepAliveTimeout: !Number.isNaN(keepAliveTimeout) ? keepAliveTimeout : undefined,
})
关键差异:
| 维度 | next start | standalone |
|---|---|---|
| 依赖 | 完整 node_modules | nft 选中的子集 |
| next.config.js | runtime 加载 | build 时嵌入 __NEXT_PRIVATE_STANDALONE_CONFIG |
| 文件大小 | 巨大 | 200-500MB |
| 启动方式 | pnpm start | node server.js |
| 适用场景 | 本地 / VM | Docker / K8s |
docker 多阶段 build 经典模式(第 28 讲讲过):第一阶段 build,第二阶段只 COPY
.next/standalone/和.next/static/。
五、形态 3:Vercel / Cloudflare Adapter
Adapter 模式不需要 next.js 自己起 HTTP server,平台代为接入。
Vercel adapter
Vercel build step 后:
- 读
.next/required-server-files.json知道哪些文件 runtime 需要 - 读
.next/server/functions-config-manifest.json知道每个 route 的 runtime/region/memory - 把每个 route 打成一个 lambda zip:
nodejsruntime → 包含完整 server bundle + nft 依赖edgeruntime → 单独打到 Cloudflare-style worker bundle
- 写 Vercel 的 routing rules(基于
routes-manifest.json转换)
请求进来:
- Vercel Edge Network 接到请求
- 匹配 routing rule → 决定调哪个 function
- invoke lambda:
- lambda 内部跑
NextNodeServer.handleRequest(不通过 HTTP,而是用@vercel/node的 adapter API 把 lambda event 转成 IncomingMessage)
- lambda 内部跑
- 拿到 response 返回给客户端
关键 contract:
// 大致
const requestHandler = await createServer({
hostname: 'localhost',
port: 0, // 不绑定端口
conf: { ... },
})
module.exports = async function lambda(req, res) {
return requestHandler(req, res)
}
跟 next start 的差别就是没起 http server。
Cloudflare Workers
Cloudflare 用类似的 adapter,但跑在 V8 isolate 而非 Node lambda:
- 只支持
runtime = 'edge'的 page/route - nodejs runtime 的 route 需要
@cloudflare/next-on-pages这种 wrapper(用 wasm 模拟 Node API) - middleware 天然兼容
六、形态 4:Custom Server
// server.js
const { createServer } = require("http");
const { parse } = require("url");
const next = require("next");
const port = parseInt(process.env.PORT || "3000", 10);
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
}).listen(port);
});
或用 express:
const express = require("express");
const next = require("next");
const app = next({ dev: false });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = express();
// 自定义路由
server.get("/legacy/:id", (req, res) => {
return handle(req, res, { pathname: "/blog/" + req.params.id, query: {} });
});
// 把其它请求转给 Next.js
server.all("*", (req, res) => handle(req, res));
server.listen(3000);
});
使用场景(少见,但确实存在):
- 需要在 Next.js 前加自定义 middleware(如非常自定义的 auth proxy)
- 需要 socket.io 等需要长连 server 的库
- 历史包袱(已有 express app,新加 Next.js 部分)
代价:
- 不能用 Vercel adapter(用了 custom server 就只能 next start 或 standalone)
- 不能用 ISR/SSG 优化(因为 Vercel platform 优化都是基于 next start contract)
- 增加调试复杂度
Next.js 14+ 强烈不推荐 custom server,除非你明确知道为什么。
七、为什么不能用 PM2 / cluster?
经典 Node 部署用 PM2 做 cluster:
pm2 start npm --name 'next' -i max -- start
技术上可行,但有坑:
- memory cache 不共享:每个 worker 独立 IncrementalCache(in-memory),ISR 数据不一致
- multiple ports:默认所有 worker 抢一个端口,cluster 模式下要靠 Node
cluster.fork()内置负载均衡 - dev 模式不支持:HMR 状态多 worker 间无法同步
- standalone 不需要:本身就是单进程 + libuv 事件循环,足够吃满 CPU(Next.js 不是 CPU 密集型 server)
推荐方案:单 process Next.js + 外层 K8s/Nginx 做多实例。每个 pod 一个 Next.js 进程,redis/external cache handler 共享 ISR 数据。
八、健康检查 / readiness
K8s 部署的两个探针:
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
/api/health 实现:
// app/api/health/route.ts
export const dynamic = "force-dynamic";
export async function GET() {
return Response.json({ ok: true, ts: Date.now() });
}
注意:
- 健康检查 必须 force-dynamic,否则 build 时 prerender,runtime 永远返回 200,无法反映真实状态
- 复杂场景可加上"能否连上 DB / cache"等检查,但不要太重(每 5s 调一次)
九、Docker 镜像优化
最佳实践 Dockerfile(基于 standalone):
# 第一阶段:依赖安装
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
# 第二阶段:build
FROM node:20-alpine AS builder
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build # 生成 .next/standalone/
# 第三阶段:runtime
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# 复制 standalone 产物
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/public ./public
# 用非 root 用户
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
CMD ["node", "server.js"]
效果:最终镜像 ~200MB(base 100MB + standalone 100MB),冷启动 < 1s。
十、常见生产环境变量
| 变量 | 作用 | 典型值 |
|---|---|---|
PORT | 监听端口 | 3000 |
HOSTNAME | 绑定地址 | 0.0.0.0(容器内必须) |
NODE_ENV | Node 模式 | production |
NEXT_TELEMETRY_DISABLED | 关 telemetry 上报 | 1(CI/生产建议) |
NEXT_SHARP_PATH | sharp 二进制路径 | 自定义 sharp 位置 |
KEEP_ALIVE_TIMEOUT | HTTP keep-alive 超时 | 60000(K8s 配合 ALB) |
NEXT_RUNTIME | 当前 runtime(build 时设) | nodejs / edge |
__NEXT_PRIVATE_STANDALONE_CONFIG | standalone 嵌入的 config(build 自动设) | JSON |
注意 HOSTNAME=0.0.0.0:standalone 默认绑定 0.0.0.0,但有些 Dockerfile 显式设 localhost,导致容器外访问不到。
十一、生产排障实战清单
| 现象 | 排查 |
|---|---|
EADDRINUSE :::3000 | 端口已被占用;用 lsof -i :3000 查 |
| 启动 1 秒后就 100% CPU | usually webpack require 还在加载,正常;> 30s 才正常表明大概率有 sync require 卡住 |
Ready in 10s 但访问 502 | router-server worker 没就绪;查 __NEXT_PRIVATE_STANDALONE_CONFIG 是否完整 |
| K8s pod 反复重启 | livenessProbe 超时;查 health endpoint 是否 force-dynamic、看 pod log |
| SIGTERM 后请求被强杀 | terminationGracePeriodSeconds 太短;调到 60+ |
| Vercel 部署后 cold start 慢 | function bundle 太大;用 outputFileTracingExcludes 排除大依赖 |
| custom server 后 ISR 失效 | custom server 没启用 res-cache;查 getRequestHandler 是否正确 |
| 多个 K8s 实例数据不一致 | 没配 external cache handler;用 redis-based custom cache handler |
| docker image > 1GB | 没用 standalone;改用 multi-stage build |
启动报 Cannot find module 'sharp' | sharp 是 platform-specific binary;docker base 不匹配,重新 install |
Hostname localhost 容器外访问不到 | 必须 0.0.0.0 |
十二、配套 fixture:四种部署形态对照
fixtures/lecture-29/ 准备:
- 默认
pnpm start跑 next start pnpm build:standalone && node .next/standalone/server.js跑 standalone- 一个 custom-server.js 演示自定义 server
- 一个 Dockerfile.standalone 演示容器化
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-29
pnpm install
# 1. next start
pnpm build
PORT=3029 pnpm start &
curl http://localhost:3029/api/health
# 2. standalone
pnpm build:standalone
PORT=3029 node .next/standalone/server.js &
# 3. custom server
pnpm build
node custom-server.js
# 4. Docker
docker build -f Dockerfile.standalone -t lec29 .
docker run --rm -p 3029:3000 lec29
十三、本讲小结
- 4 种部署形态:next start / standalone / Adapter / custom server,95% 场景选 standalone(容器)或 Vercel Adapter。
startServer的核心是http.createServer+requestListener,handler 异步就绪。- graceful shutdown 顺序:停 listen → 等存量请求 → 关 nextServer → flush trace → process.exit。
- standalone 嵌入 next.config,nft 选 node_modules,docker 部署的最佳实践。
- custom server 是逃生通道,会失去 Vercel 平台优化能力,能不用就不用。
下讲预告
第 30 讲《ISR、on-demand revalidation 与 Tag 失效》。深入 IncrementalCache 的 server-side 实现、revalidateTag / revalidatePath 的事件传播、外部 cache handler(redis、s3)的接入方式、以及多实例部署下的失效一致性。