发布日期

第 29 讲:Node Server / Standalone / Adapter 三种部署形态

Node Server、Standalone 输出与 Adapter 接口三种部署形态的原理对比

阶段五开始我们离开 build,进入 runtime。生产 Next.js 部署有 4 种主流形态:

  1. next start Node Server(自管 Node 进程)
  2. output: 'standalone' 自包含目录(容器化必备)
  3. Vercel / Cloudflare 平台 Adapter(serverless function)
  4. 自定义 server(用户用 express/koa 接住 Next.js request handler)

本讲拆这 4 种形态的内部实现:进程模型、port binding、HTTP 接入点、graceful shutdown,以及它们之间的取舍。

学习目标

读完本讲,你能:

  1. 复述 next start 的 9 个启动阶段:bin → cli → startServer → http.createServer → router-server worker → render-server worker。
  2. 解释 output: 'standalone' 的 server.js 内部如何重新实现"轻量级 next start"。
  3. 知道 Adapter(Vercel/Cloudflare/Deno)的核心契约:required-server-files.json + functions-config-manifest.json + <page>.nft.json
  4. 实战写一个 custom server,用 express 接住 Next.js handler。
  5. 排查启动失败、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

91:packages/next/src/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,
  })
}

干的事:

  1. 解析 --port / --hostname / --inspect
  2. 校验端口(不能用 0、1024 以下保留端口)
  3. 启用 inspector(如果 --inspect
  4. 注册 CPU profile 信号处理
  5. 调用 startServer({ isDev: false })

阶段 3:startServer 起 HTTP server

283:packages/next/src/server/lib/start-server.ts
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
  }

读懂三个细节:

  1. process.title:把进程名设为 next-server (vX.X.X)top/ps 容易识别。
  2. handlersPromise 延迟解析:HTTP server 先 listen,handler 还在并行加载;第一个请求进来时等 promise resolve。这能让 startup 更快被外部探测到。
  3. keepAliveTimeout:HTTP keep-alive 超时(默认 5s)。在 K8s + ALB/Nginx 前置时常调大到 60s+,避免连接抖动。

阶段 4-9:fork router-server + render-server

startServer 内部 fork(Worker Threads 或 cluster)出:

  1. router-server(必有):handle resolveRoutes、middleware、rewrites
  2. render-server(默认同进程,dev 时独立):跑 NextNodeServer 渲染 page

第 15 讲已经详细讲过这两个 worker 的请求接入路径。

三、graceful shutdown

470:packages/next/src/server/lib/start-server.ts
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:

  1. server.close() 停止接受新连接,等已开 connection 自然结束。
  2. 生产模式不强制断连:等存量请求自然完成,避免数据丢失(dev 模式才 closeAllConnections)。
  3. 关 nextServer:让 base-server 跑完所有 after() 回调、flush IncrementalCache。
  4. flushAllTraces:OpenTelemetry trace 写盘。
  5. exit code 128 + signal:SIGINT → 130、SIGTERM → 143,符合 POSIX 约定。

业务案例:K8s 滚动更新时,SIGTERM 触发 pod terminate;terminationGracePeriodSeconds(默认 30s)内未结束的请求会被强杀。Next.js 的 graceful close 让长请求(流式渲染、SSE)平稳收尾。配合 K8s 的 preStop hook 关闭 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 startstandalone
依赖完整 node_modulesnft 选中的子集
next.config.jsruntime 加载build 时嵌入 __NEXT_PRIVATE_STANDALONE_CONFIG
文件大小巨大200-500MB
启动方式pnpm startnode server.js
适用场景本地 / VMDocker / K8s

docker 多阶段 build 经典模式(第 28 讲讲过):第一阶段 build,第二阶段只 COPY .next/standalone/.next/static/

五、形态 3:Vercel / Cloudflare Adapter

Adapter 模式不需要 next.js 自己起 HTTP server,平台代为接入。

Vercel adapter

Vercel build step 后:

  1. .next/required-server-files.json 知道哪些文件 runtime 需要
  2. .next/server/functions-config-manifest.json 知道每个 route 的 runtime/region/memory
  3. 把每个 route 打成一个 lambda zip:
    • nodejs runtime → 包含完整 server bundle + nft 依赖
    • edge runtime → 单独打到 Cloudflare-style worker bundle
  4. 写 Vercel 的 routing rules(基于 routes-manifest.json 转换)

请求进来:

  1. Vercel Edge Network 接到请求
  2. 匹配 routing rule → 决定调哪个 function
  3. invoke lambda:
    • lambda 内部跑 NextNodeServer.handleRequest(不通过 HTTP,而是用 @vercel/node 的 adapter API 把 lambda event 转成 IncomingMessage)
  4. 拿到 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

技术上可行,但有坑:

  1. memory cache 不共享:每个 worker 独立 IncrementalCache(in-memory),ISR 数据不一致
  2. multiple ports:默认所有 worker 抢一个端口,cluster 模式下要靠 Node cluster.fork() 内置负载均衡
  3. dev 模式不支持:HMR 状态多 worker 间无法同步
  4. 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 --from=deps /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 --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /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_ENVNode 模式production
NEXT_TELEMETRY_DISABLED关 telemetry 上报1(CI/生产建议)
NEXT_SHARP_PATHsharp 二进制路径自定义 sharp 位置
KEEP_ALIVE_TIMEOUTHTTP keep-alive 超时60000(K8s 配合 ALB)
NEXT_RUNTIME当前 runtime(build 时设)nodejs / edge
__NEXT_PRIVATE_STANDALONE_CONFIGstandalone 嵌入的 config(build 自动设)JSON

注意 HOSTNAME=0.0.0.0:standalone 默认绑定 0.0.0.0,但有些 Dockerfile 显式设 localhost,导致容器外访问不到。

十一、生产排障实战清单

现象排查
EADDRINUSE :::3000端口已被占用;用 lsof -i :3000
启动 1 秒后就 100% CPUusually webpack require 还在加载,正常;> 30s 才正常表明大概率有 sync require 卡住
Ready in 10s 但访问 502router-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

十三、本讲小结

  1. 4 种部署形态:next start / standalone / Adapter / custom server,95% 场景选 standalone(容器)或 Vercel Adapter
  2. startServer 的核心是 http.createServer + requestListener,handler 异步就绪。
  3. graceful shutdown 顺序:停 listen → 等存量请求 → 关 nextServer → flush trace → process.exit。
  4. standalone 嵌入 next.config,nft 选 node_modules,docker 部署的最佳实践。
  5. custom server 是逃生通道,会失去 Vercel 平台优化能力,能不用就不用。

下讲预告

第 30 讲《ISR、on-demand revalidation 与 Tag 失效》。深入 IncrementalCache 的 server-side 实现、revalidateTag / revalidatePath 的事件传播、外部 cache handler(redis、s3)的接入方式、以及多实例部署下的失效一致性。