陌上人如玉
公子世无双

9.11 部署形态与运行时

9.11 部署形态与运行时

一句话:部署形态决定了「你的代码能做什么」——静态托管没有服务端、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;
}

六、面试延伸

  1. 「前端应用有哪些部署形态?怎么选?」
    四种:① ⭐ 静态托管(CDN)——冷启动 0、最便宜、最稳(没有「服务端挂了」),❌ 不能按用户动态渲染,适合「文档站/营销页/博客(SSG)」;⚠️ ISR 需要服务端(因为「按需重新生成」)→ 纯静态只能 SSG;② ⭐ Serverless 函数——按量付费 + 全自动伸缩 + 不用管服务器(最省心),⚠️ 代价是冷启动(100ms~1s)、执行时间上限(长任务不行)、无持久内存(内存缓存/连接池不可靠)、包体积上限;③ ⭐ 容器(长驻)——无冷启动(预热后)+ 无执行时间限制 + 可以有持久连接与内存状态(⭐ 所以 WebSocket 服务、连接池、后台任务适合它),⚠️ 代价是「要自己管」(扩容/健康检查/滚动发布)+ 「空闲也付费」;④ ⭐ Edge——就近 + 极低冷启动,⚠️ 但 API 受限(见 9.8)。⭐ 实践结论通常是「混合」:静态资源走 CDN、页面与业务 API 走 Serverless(或容器)、中间层走 Edge、长连接与长任务走容器。

  2. 「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)。

  3. 「环境变量为什么会有『构建期/运行期』的坑?」
    因为 ⭐ NEXT_PUBLIC_* 是「构建期被『内联』进客户端产物」的(不是「运行时读」)——所以:① ⚠️ 「改了环境变量但没重新构建」→ 值没变(⭐ 这是「改配置没生效」的经典原因);② ⚠️ 「各环境重新构建」→ 「产物可能不同」(⭐ 与「同一份产物 promote 到各环境」的期望冲突)。而且更重要的是安全:③ ⭐⭐ 「NEXT_PUBLIC_* 会进客户端产物」→ 绝不能放密钥(⚠️ 「标签页里就能看到」)。正确做法:① ⭐ 分清「构建期」与「运行期」——「进客户端产物的」= 构建期(且必须公开);「服务端密钥」= 运行期(只在 Server Component / Route Handler 里 process.env);② ⭐ 真正随环境变的公开配置(如 API 地址)→ 「服务端运行期读到,再注入到 HTML」(⭐ 这样「同一份产物可以用在不同环境」)。

  4. 「健康检查怎么写?」
    ⭐ 要分两种(这是容器编排的标准做法):① liveness(存活)——「进程还活着吗」(失败 → 重启容器),⚠️ 它「不该检查依赖」——否则「DB 抖一下 → 所有容器被反复重启」(把小故障放大成大故障);② readiness(就绪)——「能接待流量吗」(失败 → 从负载均衡摘掉,⭐ 不重启),⭐ 它应该检查关键依赖(DB/缓存/必需下游),失败返回 503。两个细节:① ⭐ 每次检查要「设超时」(如 2s)——否则「健康检查自己会挂住」;② ⚠️ 健康检查绝不能缓存(cache-control: no-store)。另外:⭐ 「优雅关闭」与就绪检查配合——收到 SIGTERM 时「先让就绪检查失败」(平台会停止发新请求),「再等在途请求完成」,最后「关资源退出」(⭐ 这样「滚动发布不丢请求」)。

  5. 「『长任务』该怎么部署?」
    ⭐ 绝不要「在请求里做长任务」——因为:① ⚠️ 会超时(Serverless 有执行时间上限,容器也会被负载均衡断开);② ⚠️ 会「占用实例」(一个长任务占住一个实例 → 「别人访问就慢/排队」);③ ⚠️ 「重试困难」(用户重新提交 = 又跑一遍)。正确做法(异步任务模式):① ⭐ 「提交任务」立刻返回 202 + jobId(⭐ 把任务写进「状态表」+ 丢进「队列」);② ⭐ 「独立消费者进程/容器」处理队列(⭐ 可「独立扩容 + 独立部署」);③ ⭐ 「状态可查」——前端用「轮询」或「SSE/WebSocket 推送」(见 7.14)拿到进度;④ 失败要「记录 + 可重试」(⭐ 见 7.12 的队列可靠性:幂等键、指数退避、死信)。适合跑消费者的形态:⭐ 容器(⭐ 长驻、无执行时间限制)或「平台的队列 + 函数」(如 SQS + Lambda)。

  6. 「部署时怎么保证『能回滚』?」
    三条:① ⭐ 「产物要能秒级回到上一版本」——用「平台的能力」(Vercel 的「上一版本」、容器的「滚动发布 + 镜像 tag」)→ 这是最快的;② ⭐ 「用特性开关代替回滚代码」(见 8.9)——⭐ 「关掉开关」比「回滚部署」更快、更安全(⭐ 而且「不用重新构建」);③ ⚠️ 「数据回滚更难」——如果「新版本改了数据结构」,回滚后「旧代码不认识新数据」→ ✅ 「迁移必须向后兼容」(⭐ 「加字段 → 双写 → 切读 → 删旧字段」的四步法)。另外:⭐ 「定期演练回滚」(否则真出事时「手忙脚乱」)+ runbook 写清步骤。

  7. 「『同一份产物 promote 到各环境』和『各环境分别构建』哪个好?」
    ⭐ 「同一份产物 promote」更好(⭐ 这是「十二要素应用」的原则之一)——因为:① ⭐ 「构建产物不同」意味着「你测的不是你上线的」(⚠️ 「staging 通过、production 出问题」的经典原因之一:环境差异 + 产物差异双重叠加);② ⭐ 「构建只做一次」→ 「流水线更快、更省」。但难点是「构建期变量」:⚠️ 如果「环境相关的配置在构建期被内联」(如 NEXT_PUBLIC_API_BASE)→ 「同一份产物只能对应一套值」(⭐ 与 promote 冲突)。解法:① ⭐ 把「真正随环境变的东西」改成「运行期读」——「服务端运行期读环境变量 → 再注入到 HTML」(如「window.__ENV__ = {...}」,⭐ 与我们讲过的「SSR 注入 flag」是同一手法,见 8.9);② 「构建期只放『真的与环境无关的』」(如「不带环境的常量」)。⭐ 一句话:「构建产物应该与环境无关,环境差异应该由「运行期配置」表达」。

  8. 「如果让你把一个 Next.js 应用部署到自托管环境,你会怎么做?」
    六步:① ⭐ 产物形态——用 output: 'standalone'(镜像小、启动快),⚠️ 记得 COPY public/ 与 .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,⚠️⭐ **必须手动 COPY public/ 与 .next/static,否则「页面能开但样式图片全丢」)/ 构建期 vs 运行期环境变量(⭐ NEXT_PUBLIC_* 是「构建期内联」→ 改了要重新构建 + ⚠️⭐ **绝不能放密钥(会进客户端产物))/ 哪些页面被静态化(构建日志的 ○/ƒ);⭐ 健康检查必须分两种:liveness 只查「进程活着」(⚠️ **不查依赖,否则「DB 抖动 → 容器被反复重启」)/ readiness 查依赖并返回 503(从 LB 摘掉),且都要设超时、都不能缓存;⭐ **优雅关闭:捕获 SIGTERM → 等在途请求完成 → 关资源(⭐ 容器滚动发布不丢请求);⭐ 长任务绝不放在请求里(会超时 + 占用实例)→ 「提交返回 202 + jobId + 队列 + 独立消费者 + 状态可查(轮询/推送)」;⭐ 能横向扩展的前提是「无状态」(会话/限流/缓存放 Redis,⭐ 别依赖内存)+ 连接池要配好或走代理;⭐ 回滚三招:平台级「上一版本」(最快)/ ⭐ 特性开关(比回滚更快更安全,见 8.9) / ⚠️ 数据要用「加字段→双写→切读→删旧」的向后兼容迁移;⭐ 「同一份产物 promote 到各环境」优于「各环境重新构建」(⭐ 否则「你测的不是你上线的」)——代价是「环境相关配置要改成运行期注入」(如 window.__ENV__,与 SSR 注入 flag 同手法);自托管的三个必备:静态资源走 CDN + hash 长缓存、release 贯穿前后端、定期演练回滚。

赞(0) 打赏
未经允许不得转载:陌上寒 » 9.11 部署形态与运行时

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫