
一句话:部署形态决定了「你的代码能做什么」——静态托管没有服务端、Serverless 有冷启动与执行时间限制、容器要自己管伸缩、Edge 有 API 白名单;而工程上的关键是「产物形态(standalone)、环境变量的构建期/运行期区分、健康检查与优雅关闭、以及可回滚」。
一、四种部署形态:能力与代价
| 形态 | 冷启动 | 伸缩 | 成本模型 | 能做服务端吗 | 典型 |
|---|---|---|---|---|---|
| ⭐ 静态托管 | ⭐ 0 | CDN(无限) | ⭐ 最便宜(按流量) | ❌ 不能 | S3+CloudFront / Netlify / Vercel Static |
| ⭐ Serverless 函数 | ⚠️ 100ms~1s | ⭐ 全自动 | 按「调用次数 + 执行时长」 | ✅ 能(但受限) | Vercel Functions / AWS Lambda / Netlify Functions |
| ⭐ 容器(长驻) | ⭐ ~0(预热后) | ⚠️ 要配置(HPA) | 按「实例时长」(⚠️ 即使没人访问也付费) | ✅ 完全能(无限制) | Docker + ECS/K8s / Fly.io / Render |
| ⭐ Edge | ⭐⭐ ~5ms | ⭐ 全自动(全球) | 按「请求数(+ CPU 时间)」 | ⚠️ 受限(见 9.8) | Cloudflare Workers / Vercel Edge |
⭐⭐⭐ 五种「部署形态的取舍」(按「要不要服务端」分两派):
【纯静态(没有服务端)】
✅ 最便宜、最快、最稳(没有「服务端挂了」这种事)
❌ 不能「按用户动态渲染」(⭐ 除非「在客户端做」)
⭐ 适合:文档站、营销页、博客(⭐ 配合 SSG/ISR:构建时生成静态文件)
⚠️ 「ISR 需要服务端」(因为「按需重新生成」)→ 所以「纯静态」只能 SSG
【Serverless】
✅ 「按量付费」+「全自动伸缩」+「不用管服务器」(⭐ 最省心)
⚠️ 代价:**冷启动**(首次请求慢)、**执行时间上限**(⚠️ 长任务不行)、
**无持久内存**(⚠️ 「内存缓存/连接池」不可靠,见 9.8)、**包体积上限**
⭐ 适合:绝大多数「有服务端但不重」的应用(⭐ Next.js 的默认部署形态)
【容器(长驻)】
✅ 「无冷启动」(预热后)+「没有执行时间限制」+「可以有持久连接与内存状态」
(⭐ 所以「WebSocket 服务」「连接池」「后台任务」都适合)
⚠️ 代价:「要自己管」(扩容、健康检查、滚动发布)+「空闲也要付费」
⭐ 适合:「有长连接/长任务/需要状态」的服务;「团队有运维能力」
【Edge】
✅ 「就近 + 极低冷启动」(见 9.8)
⚠️ 代价:**API 白名单**(没有 fs/net)+「CPU 时间受限」+「实例不保证存活」
⭐ 适合:「中间层」(鉴权/路由/改写/A-B)
⭐⭐ 实践结论(⭐ 多半是「混合」):
· 静态资源(HTML/JS/CSS/图片)→ ⭐ **CDN(静态托管)**
· 页面渲染与业务 API → ⭐ **Serverless(或容器)**
· 中间层(鉴权/路由/A-B)→ ⭐ **Edge**
· 长连接/长任务/后台作业 → ⭐ **容器**(或「专门的 worker/队列」)
二、产物形态:先解决「构建出什么」
// next.config.js —— ⭐ 三个关键的产物形态选项
/** @type {import('next').NextConfig} */
const nextConfig = {
// ⭐⭐ `standalone`:「生成一个『最小可运行』的服务端产物」
// 它会把「需要的依赖」挑出来(⭐ 而不是「整个 node_modules」)
// ⭐ 用途:「自托管/Docker」(镜像体积从 ~1GB 降到 ~200MB)
output: 'standalone',
// ⚠️ `export`:「纯静态导出」(⭐ 与 `standalone` 互斥)
// ⚠️ 用了它会「失去」:Server Actions、Route Handlers、ISR、Middleware、动态渲染
// output: 'export',
// ⭐ 「图片优化」在自托管时要指定(否则用的是 Next 的托管优化服务)
images: {
// ⚠️ 自托管时要么「用内置的优化」(需要 sharp + 一个路由)
// 要么「交给图片 CDN」(⭐ 推荐,见 5.15)
remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }],
},
// ⭐ 生产环境「不暴露 source map」(见 8.7)
productionBrowserSourceMaps: false,
// ⭐ 「编译期」注入的值(⚠️ 会「内联进产物」→ 改了要「重新构建」)
env: {
BUILD_TIME: new Date().toISOString(),
},
// ⭐ 实验特性(按需开)
experimental: {
// ppr: 'incremental', // 部分预渲染(见 4.14)
},
};
module.exports = nextConfig;
⭐⭐⭐ 「产物形态」的三个「必须想清楚」的问题:
① 【服务端产物是「standalone」还是「完整 node_modules」】
· ⭐ `output: 'standalone'` → 「只带需要的依赖」→ 镜像小、启动快
⚠️ 但它「不会自动带 public/ 与 .next/static」→ 要「手动 copy」
(⭐ 这是「自托管 Next.js」最常见的坑:页面能开但「样式/图片全丢」)
· ⚠️ 不用 standalone → 镜像巨大(⭐ 上传/拉取时间长,冷启动更慢)
② 【构建期 vs 运行期:环境变量在哪读】
· ⭐ **构建期(inline 进产物)**:`NEXT_PUBLIC_*`、`define` 的值
⚠️ **改了必须重新构建**(⭐ 这是「改环境变量没生效」的原因)
· ⭐ **运行期(服务端读 process.env)**:服务端专用变量(密钥、DB 地址)
⚠️ 但要注意「客户端代码里读不到」(⭐ 只有 `NEXT_PUBLIC_*` 会到客户端)
· ⭐ **判据**:「这个值会「进客户端产物」吗」——
会 → 构建期(⚠️ 且「绝不能是密钥」);不会 → 运行期
③ 【静态 vs 动态:哪些页面是「静态产物」】
· 构建时会「把静态页面预渲染成 HTML/RSC」(⭐ 见 9.9)
· ⚠️ 「哪些页面被静态化了」在构建日志里能看到(`○` / `ƒ`)
· ⭐ 「纯静态托管」时「所有页面必须都是静态的」(否则要用 Serverless)
三、完整实现:一个「可部署」的配置(220 行)
# ✅ Dockerfile(多阶段 + 层缓存友好 + standalone)
# ⭐⭐ 关键:把「变化频率低」的层放前面(见 10.14 的层缓存)
FROM node:20-alpine AS base
# ⭐ 用 corepack 锁定包管理器版本(见 10.9)
RUN corepack enable
# ============ 1. 依赖层(只依赖 lock 文件变化)============
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
# ⭐ 用 BuildKit 的缓存挂载(⭐ 跨构建复用 pnpm store)
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --prod=false
# ============ 2. 构建层 ============
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# ⭐⭐ 构建期环境变量(⭐ 会「内联进客户端产物」→ 必须在这里给)
ARG NEXT_PUBLIC_API_BASE
ARG NEXT_PUBLIC_RELEASE
ENV NEXT_PUBLIC_API_BASE=$NEXT_PUBLIC_API_BASE
ENV NEXT_PUBLIC_RELEASE=$NEXT_PUBLIC_RELEASE
# ⚠️ 关掉遥测(见 10.14)
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# ============ 3. 运行层(⭐ 只带产物)============
FROM base AS runner
WORKDIR /app
# ⭐ 非 root 用户(⭐ 安全基线)
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# ⭐⭐⭐ standalone 产物(⭐ 注意路径:它生成在 `.next/standalone`)
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# ⭐⭐ 关键:下面两行「必须手动 copy」——否则「页面能开但样式/图片全丢」
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000
# ⭐ 监听 0.0.0.0(⭐ 否则容器外访问不到)
ENV HOSTNAME="0.0.0.0"
# ⭐ 健康检查(见下文)
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# ⭐ 用 `node server.js`(⭐ standalone 生成的入口)
CMD ["node", "server.js"]
// ============ 环境变量的「启动时校验」(⭐ fail fast) ============
// lib/env.ts
import { z } from 'zod';
/**
* ⭐⭐ 「启动时校验环境变量」的价值:
* ① 「配置错了」→ **启动就失败**(而不是「用户访问时 500」)
* ② ⭐ 「文档化」(⭐ 看这个文件就知道「要配哪些变量」)
* ③ 「类型安全」(⭐ 用 `env.DATABASE_URL` 而不是 `process.env.DATABASE_URL!`)
*/
const serverEnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.string().url('DATABASE_URL 必须是合法 URL'),
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET 至少 32 位(⚠️ 太短不安全)'),
// ⭐ 可选的「第三方」
REDIS_URL: z.string().url().optional(),
SMTP_URL: z.string().url().optional(),
// ⭐ 「运行期才有的」平台变量(如 Vercel)
VERCEL_URL: z.string().optional(),
});
/** ⭐⭐ 只在「服务端」调用(⭐ 避免「客户端 bundle 里带服务端变量」) */
function parseServerEnv() {
const result = serverEnvSchema.safeParse(process.env);
if (!result.success) {
// ⭐⭐ 「fail fast」:启动就报错,并「把问题列清楚」(⭐ 而不是「运行时才发现」)
const problems = result.error.issues.map((i) => ` · ${i.path.join('.')}: ${i.message}`).join('\n');
// ⚠️ 注意「不要打印变量的值」(⭐ 可能含密钥)
throw new Error(`环境变量配置错误:\n${problems}`);
}
return result.data;
}
// ⭐ 模块级「只校验一次」(⭐ 与 8.10 的「初始化防重」同理)
export const env = parseServerEnv();
/** ⭐ 客户端的公开变量(⭐ 只有 `NEXT_PUBLIC_*` 能到这里) */
export const publicEnv = {
apiBase: process.env.NEXT_PUBLIC_API_BASE ?? '/api',
release: process.env.NEXT_PUBLIC_RELEASE ?? 'dev',
/** ⭐ 用于「特性开关的兜底」等(见 8.9) */
environment: (process.env.NEXT_PUBLIC_ENV ?? 'development') as 'development' | 'staging' | 'production',
};
// ============ 健康检查 + 就绪检查(⭐ 容器/负载均衡的必备) ============
// app/api/health/route.ts
/** ⭐⭐ 「健康检查」要分两种(⭐ 这是「容器编排」的标准做法):
* · **liveness(存活)**:进程「还活着吗」(⭐ 失败 → 重启容器)
* ⚠️ 它「不该检查依赖」(否则「DB 挂了 → 容器被反复重启」→ 更糟)
* · **readiness(就绪)**:能「接待流量吗」(⭐ 失败 → 从负载均衡摘掉)
* ⭐ 它「应该检查依赖」(DB/缓存/必需的下游)
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const kind = url.searchParams.get('kind') ?? 'live';
if (kind === 'live') {
// ⭐ 存活:只证明「进程在跑」
return Response.json({ status: 'ok', uptime: process.uptime() }, {
headers: { 'cache-control': 'no-store' }, // ⚠️ 健康检查绝不能缓存
});
}
// ⭐ 就绪:检查「关键依赖」(⚠️ 要「设超时」,否则「健康检查自己会挂住」)
const checks: Record<string, { ok: boolean; ms: number; error?: string }> = {};
checks.database = await timed(() => db.$queryRaw`SELECT 1`);
if (env.REDIS_URL) checks.redis = await timed(() => redis.ping());
const allOk = Object.values(checks).every((c) => c.ok);
return Response.json(
{ status: allOk ? 'ready' : 'not-ready', checks },
{
status: allOk ? 200 : 503, // ⭐ 「就绪失败」用 503
headers: { 'cache-control': 'no-store' },
}
);
}
/** ⭐ 带超时的检查(⭐ 避免「健康检查自己挂住」) */
async function timed(fn: () => Promise<unknown>, timeoutMs = 2000) {
const start = performance.now();
try {
await Promise.race([
fn(),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)),
]);
return { ok: true, ms: Math.round(performance.now() - start) };
} catch (err) {
return { ok: false, ms: Math.round(performance.now() - start), error: String(err).slice(0, 200) };
}
}
// ============ 优雅关闭(⭐ 容器滚动发布时「不丢请求」) ============
/**
* ⭐⭐ 「优雅关闭」在「长驻容器」里是必须的:
* 滚动发布时,K8s/负载均衡会「先停止把新请求发给这个实例」,
* 然后「等一段时间」再「杀掉进程」(`terminationGracePeriod`)。
* ⚠️ 如果进程「立刻死」→ 「正在处理的请求被断开」(用户看到「网络错误」)
*
* ✅ 做法:
* ① 捕获 `SIGTERM`
* ② 「停止接受新请求」(让「就绪检查」返回 503 —— ⭐ 这一步由平台做)
* ③ ⭐ 「等待『正在处理的请求』完成」(用一个「在途计数」)
* ④ 「关闭资源」(DB 连接、队列消费者)
* ⑤ 退出
*/
let inFlight = 0;
let shuttingDown = false;
function trackRequests() {
// ⭐ 在「请求入口」加计数(⭐ Next.js 里可以用 `instrumentation.ts`(见下))
return {
onStart: () => { inFlight += 1; },
onEnd: () => { inFlight -= 1; },
};
}
export function setupGracefulShutdown(options: {
/** 最多等多久(⭐ 要「小于平台的 terminationGracePeriod」) */
timeoutMs?: number;
onShutdown: () => Promise<void>;
}) {
const { timeoutMs = 10_000, onShutdown } = options;
const tracker = trackRequests();
const handler = async (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[shutdown] 收到 ${signal},开始优雅关闭(在途请求:${inFlight})`);
const deadline = Date.now() + timeoutMs;
// ⭐ 「等在途请求完成」(⭐ 轮询比「监听事件」简单且够用)
while (inFlight > 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 100));
}
if (inFlight > 0) {
console.warn(`[shutdown] 超时,仍有 ${inFlight} 个请求未完成(强制退出)`);
}
try {
await onShutdown(); // ⭐ 关闭 DB/队列等资源
} catch (err) {
console.error('[shutdown] 资源清理失败', err);
}
console.log('[shutdown] 完成');
process.exit(0);
};
process.on('SIGTERM', () => void handler('SIGTERM')); // ⭐ 容器编排通常发 SIGTERM
process.on('SIGINT', () => void handler('SIGINT')); // ⭐ 本地 Ctrl+C
return tracker;
}
// instrumentation.ts —— Next.js 的「启动钩子」(⭐ 适合放「env 校验 + 优雅关闭」)
export async function register() {
// ⭐ 只在「服务端」执行
if (process.env.NEXT_RUNTIME === 'nodejs') {
// ⭐⭐ 启动时校验环境变量(fail fast)
const { env } = await import('./lib/env');
console.log('[boot] 环境校验通过', { nodeEnv: env.NODE_ENV });
// ⭐ 设置优雅关闭
const { setupGracefulShutdown } = await import('./lib/graceful-shutdown');
setupGracefulShutdown({
onShutdown: async () => {
// ⭐ 关闭资源(⚠️ 按「依赖顺序」逆序关)
await db.$disconnect();
await redis?.quit();
},
});
}
}
四、部署流水线:四件必须做的事
⭐⭐⭐ 部署流水线的四件事(⭐ 「有它们」和「没它们」是「工程」与「玩具」的分界):
① 【环境矩阵要清晰】⭐
· dev(本地)→ staging(预发,⭐ 与生产「尽量一致」)→ production
· ⚠️ 「staging 与 production 不一致」是「预发没问题、上线出问题」的根源
(⭐ 至少「数据库版本、Node 版本、环境变量结构」要一致)
· ⭐ 「同一份构建产物」 promote 到各环境(⭐ 而不是「各环境重新构建」)——
⚠️ 因为「重新构建」= 「产物可能不同」(⭐ 见下文的坑)
② 【环境变量的「构建期/运行期」要分清】⭐
· ⭐ 「构建期变量」(`NEXT_PUBLIC_*`)→ **「同一份产物只能对应一套值」**
→ ⚠️ 于是「不同环境要不同值」时「只能重新构建」(⭐ 与 ① 冲突)
✅ 缓解:把「真正随环境变的东西」改成「运行期读」(如「服务端从 env 读,再注入到 HTML」)
· ⭐ 「密钥」绝不进构建期(⭐ 那会「内联进客户端产物」)
③ 【灰度与回滚】⭐
· 「灰度」:先给「小流量/内部用户」(⭐ 见 8.9 的分桶)
· ⭐ 「回滚要能「秒级」」——两条路:
· **平台级**(Vercel/容器编排的「上一版本」)→ ⭐ 最快
· **特性开关**(见 8.9)→ ⭐ 「不用回滚代码,关掉开关即可」
· ⚠️ 「回滚要能回滚「数据」吗」——如果「新版本改了数据结构」→ 回滚会「炸」
→ ✅ 「迁移要向后兼容」(⭐ 「先加字段、再写、再切读、最后删旧字段」)
④ 【可观测性要在部署时就位】⭐
· ⭐ 版本号(release)必须「贯穿」:前端上报、请求头、后端日志(见 7.13/8.7)
· ⭐ 健康检查(见上文)+ 「关键指标的告警」
· ⚠️ 「上了新版本但「不知道效果」」= 「裸奔」
⭐⭐⭐ 一个「自托管 Next.js」的典型架构(⭐ 生产里最常见):
[用户]
│
├─ 静态资源(/_next/static/*、图片)→ ⭐ CDN(⭐ 直接从 CDN 出,不经过 Node)
│
└─ 页面/API 请求 → ⭐ 反向代理(Nginx/Cloudflare)
├─ ⭐ Edge/中间件(鉴权、A/B、改写)—— 见 9.8
└─ ⭐ Node 服务(Next.js standalone)
├─ DB(连接池)
├─ Redis(缓存/会话/限流)
└─ ⭐ 后台任务(⭐ 「用「队列」而不是「在请求里做」」)
⭐ 三个「必须做」的:
① ⭐ 「静态资源走 CDN + 长缓存」(⭐ 见 5.16 的「hash 文件名 + immutable」)
② ⭐ 「Node 服务的「实例数」要能横向扩」(⭐ 所以「不要在内存里存状态」,见 9.8)
③ ⭐ 「重活放队列」(⚠️ 「在请求里做长任务」会「超时 + 占用实例」)
五、本篇特有的坑
① ⭐ 「standalone 产物没 copy `public/` 与 `.next/static`」(页面能开但样式/图片全丢)⭐ 最常见
✅ 三行 COPY 都要有(见 Dockerfile)
② ⭐ 「改了 `NEXT_PUBLIC_*` 但没重新构建」(值没变)
✅ 理解「它是「构建期内联」」→ 「改了要重新构建」;⭐ 或「改成运行期注入」
③ ⭐ 「把密钥放进 `NEXT_PUBLIC_*`」(⭐ 会「内联进客户端产物」→ 泄露)
✅ 密钥只能在服务端读(`process.env` + 只在 Server Component/Route Handler 里用)
④ ⭐ 「各环境「重新构建」」(产物可能不同 → 「staging 与 prod 表现不一致」)
✅ 「同一份产物 promote」(⭐ 用「运行期配置」区分环境)
⑤ 「健康检查「检查了依赖」当作 liveness」(⭐ DB 抖动 → 容器被反复重启)
✅ liveness 只查「进程活着」;readiness 才查依赖
⑥ 「健康检查「没设超时」」(自己挂住 → 被判定「不健康」)
✅ 每次检查「设 2s 超时」
⑦ ⭐ 「没有优雅关闭」(滚动发布时「正在处理的请求被断开」)
✅ 捕获 `SIGTERM` + 「等在途请求完成」+ 关资源
⑧ ⭐ 「在「请求里」做长任务」(超时 + 占用实例)
· 生成大报表、发批量通知、处理上传的大文件
✅ 「丢进队列 + 异步处理 + 通知结果」(⭐ 用「任务状态表 + 轮询/推送」告知用户)
⑨ ⭐ 「在「内存里」存状态」(多实例/Serverless 下不可靠)
· 会话、限流计数、内存缓存
✅ 「放 Redis」(⭐ 或「用「无状态 + JWT」」)
⑩ ⭐ 「数据库连接池没配好」(Serverless 下「每个实例一个池」→ 连接数爆炸)
✅ 「用「连接池代理」(PgBouncer/Prisma Accelerate)」或「把实例数限住」
⑪ 「构建产物「包含 source map」并公开」(源码泄露)
✅ `productionBrowserSourceMaps: false` + 「上传到监控平台」(见 8.7)
⑫ ⭐ 「环境变量「缺一个」但「直到用户访问才 500」」
✅ 「启动时校验」(见 `lib/env.ts`)+ fail fast
⑬ 「部署「没有版本号」」(出问题「不知道是哪个版本」)
✅ ⭐ 「release 贯穿前端/请求/后端」(见 7.13)
⑭ ⭐ 「回滚「改了数据结构的版本」」(回滚后「代码不认识新数据」)
✅ 「迁移向后兼容」(⭐ 加字段→双写→切读→删旧,见 9.9 的「版本迁移」思路)
⑮ 「CDN 缓存了「HTML」」(用户看到旧版本)
✅ HTML 用 `no-cache` + ETag;⭐ 带 hash 的静态资源用 `immutable`(见 5.16)
⑯ ⭐ 「Serverless 的「执行时间」超了」(函数被强制中断)
⚠️ 症状:「请求 504」+「日志里没有错误」
✅ 「看平台的时间限制」+ 「把长任务移出请求」
⑰ 「Docker 镜像「没有固定基础镜像的 digest」」(构建不可复现)
✅ 固定版本(⭐ 或 digest);⭐ 并「定期更新基础镜像」(安全补丁)
⑱ ⭐ 「多阶段构建「没利用层缓存」」(每次构建都重装依赖)
✅ 「先 COPY lock 文件 → install → 再 COPY 源码」(见 10.14)
⑲ 「忽略「平台的冷启动优化」(⭐ 如「保持实例预热」)」
· Vercel:`fluid compute` / 「最小实例数」
· 容器:⭐ 「保持 N 个预热实例」(⚠️ 但「空闲也付费」)
✅ 「按「延迟预算」决定」(⭐ 用真实数据,见 7.13)
⑳ ⭐ 「部署「没有「回滚演练」」」(真出问题时「手忙脚乱」)
✅ ⭐ 「定期演练回滚」(⭐ 并在「runbook」里写清步骤)
// ⑧ 的完整处理(「长任务」用「队列 + 状态表 + 轮询」)
// ① 「提交任务」(⭐ 立刻返回)
export async function POST(request: Request) {
const { type, params } = await request.json();
// ⭐ 创建任务记录(⭐ 「状态可查」是关键)
const job = await db.job.create({
data: {
type,
params,
status: 'queued',
createdBy: (await requireUser()).id,
},
});
// ⭐⭐ 丢进队列(⭐ 「不在请求里做」)
await queue.enqueue({ jobId: job.id, type, params });
return Response.json({ jobId: job.id }, { status: 202 }); // ⭐ 202 Accepted
}
// ② 队列消费者(⭐ 独立进程/容器)
export async function consumeJobs() {
for await (const msg of queue.consume()) {
const job = await db.job.update({
where: { id: msg.jobId },
data: { status: 'running', startedAt: new Date() },
});
try {
const result = await runJob(job.type, job.params);
await db.job.update({
where: { id: job.id },
data: { status: 'done', result, finishedAt: new Date() },
});
} catch (err) {
// ⭐ 「失败要记录 + 可重试」(⭐ 见 7.12 的队列可靠性)
await db.job.update({
where: { id: job.id },
data: { status: 'failed', error: String(err).slice(0, 500), finishedAt: new Date() },
});
}
}
}
// ③ 前端「轮询状态」(⭐ 或「用 SSE/WebSocket 推送」,见 7.14)
export function JobStatus({ jobId }: { jobId: string }) {
const { data } = useQuery({
queryKey: ['job', jobId],
queryFn: () => fetch(`/api/jobs/${jobId}`).then((r) => r.json()),
// ⭐ 「还在跑就继续轮询,完成就停」
refetchInterval: (query) => {
const status = (query.state.data as { status?: string } | undefined)?.status;
return status === 'done' || status === 'failed' ? false : 2000;
},
});
// ...渲染
void data;
return null;
}
六、面试延伸
- 「前端应用有哪些部署形态?怎么选?」
四种:① ⭐ 静态托管(CDN)——冷启动 0、最便宜、最稳(没有「服务端挂了」),❌ 不能按用户动态渲染,适合「文档站/营销页/博客(SSG)」;⚠️ ISR 需要服务端(因为「按需重新生成」)→ 纯静态只能 SSG;② ⭐ Serverless 函数——按量付费 + 全自动伸缩 + 不用管服务器(最省心),⚠️ 代价是冷启动(100ms~1s)、执行时间上限(长任务不行)、无持久内存(内存缓存/连接池不可靠)、包体积上限;③ ⭐ 容器(长驻)——无冷启动(预热后)+ 无执行时间限制 + 可以有持久连接与内存状态(⭐ 所以 WebSocket 服务、连接池、后台任务适合它),⚠️ 代价是「要自己管」(扩容/健康检查/滚动发布)+ 「空闲也付费」;④ ⭐ Edge——就近 + 极低冷启动,⚠️ 但 API 受限(见 9.8)。⭐ 实践结论通常是「混合」:静态资源走 CDN、页面与业务 API 走 Serverless(或容器)、中间层走 Edge、长连接与长任务走容器。 -
「
output: 'standalone'是什么?有什么坑?」
它让 Next.js 生成一个「最小可运行」的服务端产物——⭐ 只挑出「需要的依赖」(而不是「整个node_modules」)→ 镜像从 ~1GB 降到 ~200MB(⭐ 上传/拉取更快、冷启动更好)。⚠️ 最大的坑是「public/与.next/static不会自动带」——⭐ 必须手动 COPY,否则页面能打开但「样式与图片全丢」(⭐ 这是「自托管 Next.js」最经典的坑)。另外:① 「产物在.next/standalone」(⭐ 路径要写对);② 「启动命令是node server.js」(⭐ 不是next start);③ 「用standalone与export(纯静态导出)是互斥的」(export会失去 Server Actions/Route Handlers/ISR/Middleware)。 -
「环境变量为什么会有『构建期/运行期』的坑?」
因为 ⭐NEXT_PUBLIC_*是「构建期被『内联』进客户端产物」的(不是「运行时读」)——所以:① ⚠️ 「改了环境变量但没重新构建」→ 值没变(⭐ 这是「改配置没生效」的经典原因);② ⚠️ 「各环境重新构建」→ 「产物可能不同」(⭐ 与「同一份产物 promote 到各环境」的期望冲突)。而且更重要的是安全:③ ⭐⭐ 「NEXT_PUBLIC_*会进客户端产物」→ 绝不能放密钥(⚠️ 「标签页里就能看到」)。正确做法:① ⭐ 分清「构建期」与「运行期」——「进客户端产物的」= 构建期(且必须公开);「服务端密钥」= 运行期(只在 Server Component / Route Handler 里process.env);② ⭐ 真正随环境变的公开配置(如 API 地址)→ 「服务端运行期读到,再注入到 HTML」(⭐ 这样「同一份产物可以用在不同环境」)。 -
「健康检查怎么写?」
⭐ 要分两种(这是容器编排的标准做法):① liveness(存活)——「进程还活着吗」(失败 → 重启容器),⚠️ 它「不该检查依赖」——否则「DB 抖一下 → 所有容器被反复重启」(把小故障放大成大故障);② readiness(就绪)——「能接待流量吗」(失败 → 从负载均衡摘掉,⭐ 不重启),⭐ 它应该检查关键依赖(DB/缓存/必需下游),失败返回 503。两个细节:① ⭐ 每次检查要「设超时」(如 2s)——否则「健康检查自己会挂住」;② ⚠️ 健康检查绝不能缓存(cache-control: no-store)。另外:⭐ 「优雅关闭」与就绪检查配合——收到SIGTERM时「先让就绪检查失败」(平台会停止发新请求),「再等在途请求完成」,最后「关资源退出」(⭐ 这样「滚动发布不丢请求」)。 -
「『长任务』该怎么部署?」
⭐ 绝不要「在请求里做长任务」——因为:① ⚠️ 会超时(Serverless 有执行时间上限,容器也会被负载均衡断开);② ⚠️ 会「占用实例」(一个长任务占住一个实例 → 「别人访问就慢/排队」);③ ⚠️ 「重试困难」(用户重新提交 = 又跑一遍)。正确做法(异步任务模式):① ⭐ 「提交任务」立刻返回 202 + jobId(⭐ 把任务写进「状态表」+ 丢进「队列」);② ⭐ 「独立消费者进程/容器」处理队列(⭐ 可「独立扩容 + 独立部署」);③ ⭐ 「状态可查」——前端用「轮询」或「SSE/WebSocket 推送」(见 7.14)拿到进度;④ 失败要「记录 + 可重试」(⭐ 见 7.12 的队列可靠性:幂等键、指数退避、死信)。适合跑消费者的形态:⭐ 容器(⭐ 长驻、无执行时间限制)或「平台的队列 + 函数」(如 SQS + Lambda)。 -
「部署时怎么保证『能回滚』?」
三条:① ⭐ 「产物要能秒级回到上一版本」——用「平台的能力」(Vercel 的「上一版本」、容器的「滚动发布 + 镜像 tag」)→ 这是最快的;② ⭐ 「用特性开关代替回滚代码」(见 8.9)——⭐ 「关掉开关」比「回滚部署」更快、更安全(⭐ 而且「不用重新构建」);③ ⚠️ 「数据回滚更难」——如果「新版本改了数据结构」,回滚后「旧代码不认识新数据」→ ✅ 「迁移必须向后兼容」(⭐ 「加字段 → 双写 → 切读 → 删旧字段」的四步法)。另外:⭐ 「定期演练回滚」(否则真出事时「手忙脚乱」)+ runbook 写清步骤。 -
「『同一份产物 promote 到各环境』和『各环境分别构建』哪个好?」
⭐ 「同一份产物 promote」更好(⭐ 这是「十二要素应用」的原则之一)——因为:① ⭐ 「构建产物不同」意味着「你测的不是你上线的」(⚠️ 「staging 通过、production 出问题」的经典原因之一:环境差异 + 产物差异双重叠加);② ⭐ 「构建只做一次」→ 「流水线更快、更省」。但难点是「构建期变量」:⚠️ 如果「环境相关的配置在构建期被内联」(如NEXT_PUBLIC_API_BASE)→ 「同一份产物只能对应一套值」(⭐ 与 promote 冲突)。解法:① ⭐ 把「真正随环境变的东西」改成「运行期读」——「服务端运行期读环境变量 → 再注入到 HTML」(如「window.__ENV__ = {...}」,⭐ 与我们讲过的「SSR 注入 flag」是同一手法,见 8.9);② 「构建期只放『真的与环境无关的』」(如「不带环境的常量」)。⭐ 一句话:「构建产物应该与环境无关,环境差异应该由「运行期配置」表达」。 -
「如果让你把一个 Next.js 应用部署到自托管环境,你会怎么做?」
六步:① ⭐ 产物形态——用output: 'standalone'(镜像小、启动快),⚠️ 记得 COPYpublic/与.next/static;② ⭐ Dockerfile 用多阶段 + 层缓存(⭐ 「先 COPY lock → install → 再 COPY 源码」,见 10.14)+ 非 root 用户 + 固定基础镜像版本;③ ⭐ 环境变量——「启动时用 zod 校验(fail fast)」+ 分清「构建期(NEXT_PUBLIC_*,且不能放密钥)」与「运行期(服务端密钥)」;④ ⭐ 健康检查分 liveness/readiness(⚠️ liveness 不查依赖、都要设超时)+ 优雅关闭(SIGTERM→ 等在途请求 → 关资源);⑤ ⭐ 横向扩展的前提是无状态——「会话/限流/缓存放 Redis」(⭐ 不依赖内存)+ 「DB 连接池要配好或走代理」;⑥ ⭐ 静态资源走 CDN + 长缓存(hash 文件名 + immutable)+ 「长任务进队列」(⭐ 别在请求里做)。另外:「release 版本号贯穿前后端」(见 7.13)+「定期演练回滚」。
一句话速记
四种部署形态:静态托管(冷启动 0、最便宜、❌ 无服务端)/ ⭐ Serverless(全自动伸缩 + 按量付费,⚠️ 冷启动 + 执行时间上限 + 无持久内存)/ ⭐ 容器(无冷启动 + 无时间限制 + 可有状态与长连接,⚠️ 要自己管 + 空闲付费)/ Edge(就近 + 极低冷启动,⚠️ API 白名单)——⭐ 实践是「混合」:静态走 CDN、页面与 API 走 Serverless/容器、中间层走 Edge、长连接与长任务走容器;⭐ 产物形态三个关键:
output: 'standalone'(镜像 1GB→200MB,⚠️⭐ **必须手动 COPYpublic/与.next/static,否则「页面能开但样式图片全丢」)/ 构建期 vs 运行期环境变量(⭐NEXT_PUBLIC_*是「构建期内联」→ 改了要重新构建 + ⚠️⭐ **绝不能放密钥(会进客户端产物))/ 哪些页面被静态化(构建日志的○/ƒ);⭐ 健康检查必须分两种:liveness 只查「进程活着」(⚠️ **不查依赖,否则「DB 抖动 → 容器被反复重启」)/ readiness 查依赖并返回 503(从 LB 摘掉),且都要设超时、都不能缓存;⭐ **优雅关闭:捕获SIGTERM→ 等在途请求完成 → 关资源(⭐ 容器滚动发布不丢请求);⭐ 长任务绝不放在请求里(会超时 + 占用实例)→ 「提交返回 202 + jobId + 队列 + 独立消费者 + 状态可查(轮询/推送)」;⭐ 能横向扩展的前提是「无状态」(会话/限流/缓存放 Redis,⭐ 别依赖内存)+ 连接池要配好或走代理;⭐ 回滚三招:平台级「上一版本」(最快)/ ⭐ 特性开关(比回滚更快更安全,见 8.9) / ⚠️ 数据要用「加字段→双写→切读→删旧」的向后兼容迁移;⭐ 「同一份产物 promote 到各环境」优于「各环境重新构建」(⭐ 否则「你测的不是你上线的」)——代价是「环境相关配置要改成运行期注入」(如window.__ENV__,与 SSR 注入 flag 同手法);自托管的三个必备:静态资源走 CDN + hash 长缓存、release 贯穿前后端、定期演练回滚。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!