NestJS 教程 Part 3 — 质量保障与上线运维

4 阅读24分钟

本册涵盖:07 测试金字塔 · 08 可观测性 · 09 DevOps · 10 安全与上线 Checklist


07 - 测试金字塔:单元 / 集成 / E2E / 契约

目标:写出真有用的测试。每种测试的价值、边界、工具、写法、CI 集成。

1. 心智模型:测试金字塔(改良版)

                    /\
                   /  \         E2E (Playwright)
                  /----\        慢、贵、易碎,关键链路 < 30 个
                 /      \
                /--------\      集成 (Vitest + testcontainers)
               /          \     中等速度,**主战场**
              /------------\
             /              \   单元 (Vitest)
            /----------------\  飞快,纯逻辑
           /------------------\
          /契约(Pact / OpenAPI)\  跨服务契约,演进期必备

💡 改良:中间最厚 经典金字塔说"单元最多",但 NestJS + Prisma 项目里集成测试性价比最高:用 testcontainers 起真实 Postgres,业务流跑通比纯单元测试更接近真相,执行时间也能接受(2-5s 一个测试)。纯单元测试留给:计算函数、复杂条件分支、纯逻辑模块。

2. 工具栈

类型工具备选
RunnerVitestJest
HTTP 测试supertest / Fastify inject
数据库隔离testcontainers + Prisma migratesqlite(不推荐)、内存方案
Mockvitest mock + msw(外部 HTTP)nock
E2EPlaywrightCypress(单浏览器内,生态差点)
契约PactOpenAPI 校验

💡 为什么 Vitest 而不是 Jest? 速度快 2-5x(ESM、Vite 转译)、TypeScript 0 配置、watch 模式好、与 Vite/Next 共用生态。Jest 仍然 OK,迁移成本看仓库大小。

3. 单元测试

3.1 例子:纯函数
// src/utils/money.ts
export function formatMoney(cents: number, currency: "USD" | "CNY"): string {
  return new Intl.NumberFormat("en", { style: "currency", currency }).format(cents / 100);
}

// src/utils/money.spec.ts
import { describe, expect, it } from "vitest";
import { formatMoney } from "./money";

describe("formatMoney", () => {
  it("formats USD", () => {
    expect(formatMoney(1099, "USD")).toBe("$10.99");
  });
  it("handles zero", () => {
    expect(formatMoney(0, "USD")).toBe("$0.00");
  });
});
3.2 例子:NestJS Service(mock 依赖)
// users.service.spec.ts
import { Test } from "@nestjs/testing";
import { UsersService } from "./users.service";
import { UsersRepository } from "./users.repository";
import { PasswordHasher } from "../auth/password.hasher";

describe("UsersService", () => {
  let service: UsersService;
  let repo: { findByEmail: Mock; create: Mock };
  let hasher: { hash: Mock };

  beforeEach(async () => {
    repo = { findByEmail: vi.fn(), create: vi.fn() };
    hasher = { hash: vi.fn().mockResolvedValue("HASH") };
    const mod = await Test.createTestingModule({
      providers: [
        UsersService,
        { provide: UsersRepository, useValue: repo },
        { provide: PasswordHasher, useValue: hasher },
      ],
    }).compile();
    service = mod.get(UsersService);
  });

  it("throws when email taken", async () => {
    repo.findByEmail.mockResolvedValue({ id: "u1" });
    await expect(service.create("t1", { email: "a@b.c", name: "x", password: "12345678" }))
      .rejects.toThrow(/already/i);
  });

  it("hashes password and creates", async () => {
    repo.findByEmail.mockResolvedValue(null);
    repo.create.mockResolvedValue({ id: "u2" });
    const r = await service.create("t1", { email: "a@b.c", name: "x", password: "12345678" });
    expect(hasher.hash).toHaveBeenCalledWith("12345678");
    expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ password: "HASH" }));
    expect(r.id).toBe("u2");
  });
});
3.3 单元测试边界

值得:

  • 纯函数(计算、格式化、解析)
  • 复杂条件分支(状态机、权限规则、价格计算)
  • 算法(去重、排序、合并)

不值得(改用集成):

  • "调用 repo.findX 然后返回" 这种 trivial transit
  • 把 Prisma mock 掉测 service —— mock 出来的 mock 都通过,真 SQL 跑不动

4. 集成测试(本教程主战场)

4.1 真数据库:testcontainers
pnpm add -D testcontainers @testcontainers/postgresql
// test/setup.ts
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { execSync } from "node:child_process";
import { PrismaClient } from "@prisma/client";

let container: StartedPostgreSqlContainer;
let prisma: PrismaClient;

export async function setupTestDb(): Promise<{ prisma: PrismaClient; databaseUrl: string }> {
  container = await new PostgreSqlContainer("postgres:16-alpine").start();
  const databaseUrl = container.getConnectionUri();
  process.env.DATABASE_URL = databaseUrl;

  // 跑 migrate(测试库与生产同一份 schema)
  execSync(`pnpm prisma migrate deploy`, { env: process.env, stdio: "inherit" });

  prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
  await prisma.$connect();
  return { prisma, databaseUrl };
}

export async function teardownTestDb() {
  await prisma?.$disconnect();
  await container?.stop();
}

vitest.config.ts:

import { defineConfig } from "vitest/config";
export default defineConfig({
  test: {
    globalSetup: ["./test/global-setup.ts"],
    setupFiles: ["./test/setup-each.ts"],
    pool: "forks",            // 每个测试文件独立进程(避免共享状态)
    poolOptions: { forks: { singleFork: false } },
    hookTimeout: 60_000,      // 容器启动慢
    testTimeout: 20_000,
  },
});
4.2 测试间隔离:事务回滚 or truncate

方案 A:每个测试一个事务,跑完回滚

beforeEach(async () => {
  await prisma.$executeRaw`BEGIN`;
});
afterEach(async () => {
  await prisma.$executeRaw`ROLLBACK`;
});

快,但 业务代码用 $transaction 时会嵌套 —— Prisma 不支持嵌套事务,得用 savepoint,复杂。

方案 B:每个测试 truncate(推荐)

afterEach(async () => {
  // 截断所有业务表(保留 schema)
  const tables = await prisma.$queryRaw<{ tablename: string }[]>`
    SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename NOT IN ('_prisma_migrations')
  `;
  if (tables.length) {
    await prisma.$executeRawUnsafe(
      `TRUNCATE TABLE ${tables.map(t => `"${t.tablename}"`).join(",")} RESTART IDENTITY CASCADE`,
    );
  }
});

简单可靠。10-50ms 一次,100 个测试也只额外 1-5s。

💡 不要用 sqlite 代替 postgres 数据类型、隔离级别、索引行为、扩展(citext / pg_uuidv7)都不一样。用 sqlite 通过的测试在 postgres 可能挂。testcontainers 启动时间一次性,值得。

4.3 NestJS app 启动
import { Test } from "@nestjs/testing";
import { AppModule } from "@/app.module";
import { NestFastifyApplication } from "@nestjs/platform-fastify";

let app: NestFastifyApplication;

beforeAll(async () => {
  const mod = await Test.createTestingModule({
    imports: [AppModule],
  })
    .overrideProvider(SOME_EXTERNAL) // 替换外部依赖,比如 Stripe SDK 用 mock
    .useValue(fakeStripe)
    .compile();
  app = mod.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
  await app.init();
  await app.getHttpAdapter().getInstance().ready();
});
afterAll(() => app.close());
4.4 例子:POST /users 集成测试
import { describe, it, expect } from "vitest";

describe("POST /users", () => {
  it("creates a user", async () => {
    const res = await app.inject({  // Fastify inject 无 socket,飞快
      method: "POST",
      url: "/users",
      headers: { authorization: `Bearer ${adminToken}` },
      payload: { email: "alice@x.com", name: "Alice", password: "long-enough-password-123" },
    });
    expect(res.statusCode).toBe(201);
    expect(res.json()).toMatchObject({ email: "alice@x.com" });
    expect(res.json()).not.toHaveProperty("password");

    const row = await prisma.user.findFirst({ where: { email: "alice@x.com" } });
    expect(row).toBeTruthy();
    expect(row?.password).not.toBe("long-enough-password-123"); // 已 hash
  });

  it("rejects duplicate email", async () => {
    await prisma.user.create({ data: { ... } });
    const res = await app.inject({ method: "POST", url: "/users", payload: { email: "alice@x.com", ... } });
    expect(res.statusCode).toBe(409);
    expect(res.json()).toMatchObject({ code: "USER_EMAIL_TAKEN" });
  });
});

💡 Fastify app.inject 比 supertest 快得多(无 socket,纯函数调用)。NestJS Express 用 supertest。

4.5 外部依赖 mock:MSW

业务调外部 HTTP(支付、邮件)→ 用 MSW 拦截:

import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

export const server = setupServer(
  http.post("https://api.stripe.com/v1/charges", () =>
    HttpResponse.json({ id: "ch_fake", status: "succeeded" }),
  ),
);

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

onUnhandledRequest: "error" 让任何未 mock 的外部调用测试失败,逼你显式声明所有外部接触。

5. E2E 测试:Playwright

5.1 安装
pnpm add -D @playwright/test
pnpm dlx playwright install --with-deps chromium
5.2 配置
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [["html", { open: "never" }], ["github"]],
  use: {
    baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
  webServer: process.env.CI ? undefined : [
    { command: "pnpm --filter @my-app/api start:prod", port: 3001 },
    { command: "pnpm --filter @my-app/web start", port: 3000 },
  ],
});
5.3 写法:页面对象模式(轻量)
// e2e/pages/login.page.ts
export class LoginPage {
  constructor(private page: Page) {}
  async goto() { await this.page.goto("/login"); }
  async login(email: string, password: string) {
    await this.page.getByLabel("Email").fill(email);
    await this.page.getByLabel("Password").fill(password);
    await this.page.getByRole("button", { name: "Sign in" }).click();
    await this.page.waitForURL("/dashboard");
  }
}
// e2e/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/login.page";

test("user can login", async ({ page }) => {
  const login = new LoginPage(page);
  await login.goto();
  await login.login("alice@x.com", "password123!");
  await expect(page.getByText("Welcome, Alice")).toBeVisible();
});
5.4 选择器优先级(必背)

按可靠性从高到低:

  1. getByRole(button / link / textbox),最贴近用户和无障碍
  2. getByLabel(表单字段)
  3. getByText(可见文本)
  4. getByTestId(data-testid)—— 最后才用
  5. ❌ CSS / XPath —— 易碎
// 优先
page.getByRole("button", { name: "Submit" });
// 备选(动态文本)
page.getByTestId("submit-btn");
// 避免
page.locator(".btn-primary > span:nth-child(2)");
5.5 数据准备

E2E 测试不要走 UI 注册 准备数据 → 慢且脆弱。 直接走 API seed endpoint:

// 仅 staging/test 环境可用的 endpoint(env guard)
@Post("__test__/seed")
async seed() {
  return this.users.create({ email: "alice@x.com", ... });
}

或在测试中用 prisma 直接插数据(连同一个 testcontainer)。

5.6 网络模拟与稳定性
test("works under slow network", async ({ page, context }) => {
  await context.route("**/*", (route) => {
    setTimeout(() => route.continue(), 500);
  });
  // ...
});

等待:

  • 永远用 await expect(locator).toBeVisible(),Playwright 自带等待
  • 不要 page.waitForTimeout(1000) —— flake 源
  • 长动画用 toHaveScreenshottoBeVisible + 自定义 timeout
5.7 a11y 测试
import AxeBuilder from "@axe-core/playwright";

test("home is accessible", async ({ page }) => {
  await page.goto("/");
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
});

6. 契约测试(可选,跨团队必需)

如果前后端是两个团队,API 变更难协调:

  • 后端改了 schema,前端不知道 → 上线挂
  • 前端依赖某字段,后端"以为没人用"删了 → 上线挂
6.1 OpenAPI 契约

最低限度:用 OpenAPI 作为唯一契约

  • 后端从 schema 生成 OpenAPI(05 章)
  • CI 比较 OpenAPI 变化,破坏性 diff 必须有 reviewer 批
  • 前端用 openapi-typescript 生成类型,改前端时 TS 编译挡住不兼容

工具:openapi-diffoasdiff

6.2 Pact(消费者驱动契约)

前端定义"我期望 API 返回 X" → 生成契约 → 后端在 CI 验证。

// 前端 pact 测试
const provider = new PactV3({ consumer: "web", provider: "api" });
await provider.addInteraction({
  states: [{ description: "有用户 u1" }],
  uponReceiving: "get user by id",
  withRequest: { method: "GET", path: "/users/u1" },
  willRespondWith: {
    status: 200,
    body: { id: "u1", email: like("a@b.c") },
  },
});

后端 CI 拉契约文件,跑验证测试。

💡 Pact 学习曲线陡,只在跨团队 / 微服务多时上 单团队 monorepo 用 OpenAPI + TS 自动生成的客户端足够,Pact 是 overkill。

7. 测试覆盖率与门槛

// vitest.config.ts
test: {
  coverage: {
    provider: "v8",
    reporter: ["text", "html", "lcov"],
    exclude: ["**/*.config.*", "**/dist/**", "**/test/**"],
    thresholds: {
      lines: 70,
      branches: 70,
      functions: 70,
      statements: 70,
    },
  },
},

⚠️ 不要追求 100% 覆盖率 70-80% 是合理目标。剩下 20-30% 是 trivial getter、防御性代码、错误兜底分支。100% 的代价是写废测试,这些测试会拖累 refactor。

💡 看分支覆盖比看行覆盖重要 一个 if (x && y) 行覆盖 100% 不代表四种组合都测了。branches 阈值是更严格的护栏。

8. CI 集成

.github/workflows/test.yml:

jobs:
  test-backend:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_USER: test, POSTGRES_PASSWORD: test, POSTGRES_DB: test }
        ports: ["5432:5432"]
        options: --health-cmd pg_isready --health-interval 5s
      redis:
        image: redis:7-alpine
        ports: ["6379:6379"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: ".nvmrc" }
      - run: corepack enable
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter @my-app/api prisma migrate deploy
        env: { DATABASE_URL: postgresql://test:test@localhost:5432/test }
      - run: pnpm --filter @my-app/api test
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/test
          REDIS_URL: redis://localhost:6379

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: ".nvmrc" }
      - run: corepack enable
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm test:e2e
      - if: failure()
        uses: actions/upload-artifact@v4
        with: { name: playwright-report, path: playwright-report/ }

💡 CI 用 service 容器比 testcontainers 更快 testcontainers 本地很爽,CI 上可以直接用 GitHub Actions 的 services —— 容器复用、网络快、启动早。

9. Flaky 测试零容忍

Flaky(有时过有时不过)= 团队对测试失去信任 = 测试无用。

9.1 常见原因
  • 时间相关:用 mock clock(vi.useFakeTimers)
  • 顺序依赖:每个测试独立 setup
  • 随机数据:不要 Math.random() 出未约束的值,用 seed faker
  • 异步竞争:E2E 用 web-first assertion,不用 sleep
  • 并发跑写同一行:测试间隔离(4.2 节)
9.2 处理
  • 第一次 flake 立刻修,不要 retry 掩盖
  • 反复 flake 还修不好 → 临时 skip + 加 ticket → 一周内必须解决
  • CI 加 flaky test detection(同 commit 跑两次,差异即 flaky)

10. 测试要不要写注释?

每个测试块的结构就是文档:

it("rejects login with wrong password", async () => {
  // Arrange
  await prisma.user.create({ data: { email: "a@b.c", password: hashed } });
  // Act
  const res = await app.inject({ method: "POST", url: "/auth/login", payload: { email: "a@b.c", password: "wrong" } });
  // Assert
  expect(res.statusCode).toBe(401);
});

不需要 // Arrange 注释,空行即可。测试名要描述行为,不是描述代码。

11. 速记卡

想测
纯逻辑单元(Vitest)
Controller → Service → DB集成(Vitest + testcontainers / GH services)
完整用户流程E2E(Playwright)
跨服务 schema契约(OpenAPI / Pact)
外部 HTTPMSW
视觉回归Playwright screenshot
性能基线k6 / Lighthouse CI(不在金字塔内,单独跑)

延伸阅读


08 - 可观测性:Pino + OpenTelemetry + Prometheus + Sentry

目标:让任何线上问题在 5 分钟内定位到代码行。三大支柱:Logs / Metrics / Traces,加 Errors。

1. 心智模型:三柱 + 一关联

支柱回答工具
Logs发生了什么?Pino + Loki(或 ELK)
Metrics多少 / 多快 / 多稳?Prometheus + Grafana
Traces一个请求经过了哪些组件,各花了多久?OpenTelemetry + Tempo/Jaeger
Errors这个 5xx 长什么样,什么用户、什么浏览器?Sentry

关联键:所有日志、metrics、trace span、error 都带 traceIdrequestId。一个出错请求,从 Sentry 跳到 Trace,再下钻到 Logs,5 分钟从现象到根因

2. 日志:用 Pino

2.1 为什么不是 Winston?
  • Winston 比 Pino 慢 5-10 倍(JSON 序列化、prototype-heavy)
  • Pino 异步写,不阻塞 event loop
  • Pino 对 child logger 友好
2.2 NestJS 集成 nestjs-pino
pnpm add nestjs-pino pino-http
// app.module.ts
import { LoggerModule } from "nestjs-pino";

LoggerModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (cfg: ConfigService) => ({
    pinoHttp: {
      level: cfg.get("LOG_LEVEL") ?? "info",
      formatters: {
        level: (label) => ({ level: label }),
      },
      timestamp: () => `,"time":"${new Date().toISOString()}"`,
      base: { service: "api", env: cfg.get("NODE_ENV") },
      genReqId: (req, res) => {
        const id = (req.headers["x-request-id"] as string) ?? randomUUID();
        res.setHeader("x-request-id", id);
        return id;
      },
      customProps: (req) => ({
        userId: (req as any).user?.sub,
        tenantId: (req as any).user?.tid,
        traceId: getActiveTraceId(),
      }),
      redact: {
        paths: ["req.headers.authorization", "req.headers.cookie", "*.password"],
        censor: "[REDACTED]",
      },
      ...(cfg.get("NODE_ENV") === "development" && {
        transport: { target: "pino-pretty", options: { colorize: true, singleLine: false } },
      }),
    },
  }),
}),

main.ts:

const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));  // nestjs-pino 的 Logger
2.3 字段约定

每条日志都应该有:

  • time:ISO 8601 字符串
  • level:fatal | error | warn | info | debug | trace
  • service:服务名(api / worker / web)
  • env:development | staging | production
  • requestId:同请求所有日志关联
  • traceId:跨服务关联
  • 业务字段:userId / tenantId / orderId / ...(用一致命名)
  • msg:简短描述,结构化为主,消息为辅

💡 不要把所有上下文塞 msg 字符串logger.info("user 123 paid 50 USD")logger.info({ userId, amount, currency }, "user paid") 结构化字段可被 Loki/Elasticsearch 索引、过滤、聚合。字符串只能 grep。

2.4 日志等级使用
等级用途
fatal进程将退出(几乎不用)
error业务异常、5xx、需要人介入
warn接近阈值、降级、慢查询、4xx 中的可疑
info关键业务事件(用户注册、订单创建)
debug开发期细节
trace几乎不用,深度排查临时开

生产默认 info,临时排查可热切换到 debug(通过 SIGHUP 或配置中心)。

2.5 不该打的日志
  • 高频小事件(每个 fetch、每次缓存命中)→ 改用 metric
  • 完整 body(可能含敏感信息)→ 摘要 + 关键字段
  • 密码、token、信用卡 → redact 配置兜底

3. Metrics:Prometheus

3.1 NestJS 集成
pnpm add @willsoto/nestjs-prometheus prom-client
PrometheusModule.register({
  defaultMetrics: { enabled: true }, // Node 指标(GC、内存、event loop lag)
  path: "/metrics",
}),

⚠️ /metrics 端点必须内网/受限访问。它暴露大量内部信息,公网开放 = 信息披露。Ingress 上配 IP allowlist 或加 Basic Auth。

3.2 业务 metric 三种类型
@Injectable()
export class OrderMetrics {
  constructor(
    @InjectMetric("orders_created_total") private created: Counter<string>,
    @InjectMetric("order_amount_usd") private amount: Histogram<string>,
    @InjectMetric("active_carts") private carts: Gauge<string>,
  ) {}

  recordOrder(amountUsd: number, tenantId: string) {
    this.created.inc({ tenant: tenantId });
    this.amount.observe({ tenant: tenantId }, amountUsd);
  }
}
类型何时用例子
Counter单调递增的事件数请求总数、错误总数
Histogram分布(p50/p95/p99)延迟、payload 大小
Gauge当前值队列长度、活跃连接、内存
Summary分布(客户端预计算)现在多用 Histogram
3.3 Histogram buckets 怎么选
new Histogram({
  name: "http_request_duration_seconds",
  help: "...",
  labelNames: ["method", "route", "status"],
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});

💡 buckets 决定 p99 精度 p99 = 99% 请求在哪个 bucket 内。如果你的 p99 是 480ms 但 buckets 跳过 0.5s,你只能看到 "p99 落在 0.5-1s",精度差 2x。在你关心的延迟区间放密集 buckets。

3.4 标签(labels)的禁忌
  • 不要把高基数字段做 label(userId、orderId)→ 时间序列爆炸,Prometheus OOM
  • 限制 label values 在 100 以内(method、status_code、route 名)
  • 真要按 user 看 → 用 trace / log,不用 metric
3.5 自动 HTTP metric

nestjs-pino 的请求日志已经有耗时;但要 metric:

@Injectable()
export class HttpMetricsInterceptor implements NestInterceptor {
  constructor(@InjectMetric("http_request_duration_seconds") private hist: Histogram<string>) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const start = process.hrtime.bigint();
    const req = ctx.switchToHttp().getRequest();
    return next.handle().pipe(
      finalize(() => {
        const sec = Number(process.hrtime.bigint() - start) / 1e9;
        const res = ctx.switchToHttp().getResponse();
        const route = req.routerPath ?? req.url;  // 注意 routerPath 是模板(/users/:id),不是实际 URL
        this.hist.observe(
          { method: req.method, route, status: res.statusCode },
          sec,
        );
      }),
    );
  }
}

⚠️ route 必须是模板(/users/:id),不是实际 URL(/users/abc)。否则每个用户 ID 一个时间序列 → 内存爆。req.routerPath (Fastify) 或 req.route?.path (Express) 是模板。

3.6 必备 dashboard(Grafana)
panel公式(PromQL)
QPS 全站sum(rate(http_request_duration_seconds_count[1m]))
错误率sum(rate(http_request_duration_seconds_count{status=~"5.."}[1m])) / sum(rate(http_request_duration_seconds_count[1m]))
p95 延迟histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route))
event loop lagnodejs_eventloop_lag_seconds
进程内存process_resident_memory_bytes
队列堆积自定义 gauge,worker 上报
3.7 告警规则
## Prometheus alert
groups:
  - name: api
    rules:
      - alert: HighErrorRate
        expr: sum(rate(http_request_duration_seconds_count{status=~"5.."}[5m])) / sum(rate(http_request_duration_seconds_count[5m])) > 0.02
        for: 5m
        annotations:
          summary: "API 错误率 > 2% 持续 5min"

      - alert: HighLatency
        expr: histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) > 1
        for: 10m

💡 for: 5m 是去抖:瞬间 spike 不报警,持续 5 分钟才报。没有去抖 = 警报疲劳

4. Tracing:OpenTelemetry

4.1 概念速通
  • Trace = 一个完整请求的全过程
  • Span = trace 中的一段(一次 DB 查询、一次外部调用、一段计算)
  • 每个 span 有 traceId(整 trace 同一个)、spanIdparentSpanId
  • W3C traceparent header 在跨服务调用时传播
4.2 装配(API 端)
pnpm add @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-http

apps/api/src/tracing.ts:

import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: process.env.SERVICE_NAME ?? "api",
    [SemanticResourceAttributes.SERVICE_VERSION]: process.env.APP_VERSION ?? "dev",
    [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV ?? "development",
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + "/v1/traces",
  }),
  instrumentations: [getNodeAutoInstrumentations({
    "@opentelemetry/instrumentation-fs": { enabled: false }, // fs 噪声大
  })],
});

sdk.start();

process.on("SIGTERM", () => sdk.shutdown());

main.ts 必须在所有 import 前 引:

import "./tracing"; // 必须第一行(除了 reflect-metadata)
import "reflect-metadata";
// ... 其他 import

⚠️ import 顺序极其重要 Auto-instrumentation 通过 monkey-patch 各种库的 export。如果别的库先 import 了,patch 失败 → 没有 trace。

4.3 自动 instrumented 的库

auto-instrumentations-node 包含:

  • HTTP / HTTPS / Fastify / Express
  • Prisma / pg / mongodb / ioredis
  • gRPC / Kafka / SQS
  • AWS SDK / GraphQL

几乎不用写 span —— 自动注入。需要时手写:

import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("my-app");

async function reconcileBilling(orgId: string) {
  return tracer.startActiveSpan("reconcile_billing", { attributes: { "org.id": orgId } }, async (span) => {
    try {
      // ...
      span.setAttribute("invoices.count", n);
      span.setStatus({ code: SpanStatusCode.OK });
    } catch (err: any) {
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
      throw err;
    } finally {
      span.end();
    }
  });
}
4.4 把 traceId 关联到日志
import { trace } from "@opentelemetry/api";

function getActiveTraceId(): string | undefined {
  const span = trace.getActiveSpan();
  return span?.spanContext().traceId;
}

Pino 的 customProps 已用过(2.2 节)。日志和 trace 共享同一个 traceId,Grafana 里一键跳转

4.5 Trace 前端

@vercel/otel(Next 内置)或:

pnpm add @opentelemetry/sdk-trace-web @opentelemetry/auto-instrumentations-web

浏览器 OTel 数据量大、可控性差,折中方案:

  • 前端用 Sentry 的 performance(Sentry.startSpan),把 sentry-trace 头发到后端
  • 后端把 sentry-trace 转成 W3C traceparent,Sentry 和 OTel 后端都能用
4.6 采样

100% 采样成本高,通常 5-20%。关键路径 + 错误请求 100%:

import { ParentBasedSampler, TraceIdRatioBasedSampler, AlwaysOnSampler } from "@opentelemetry/sdk-trace-base";

const sdk = new NodeSDK({
  sampler: new ParentBasedSampler({
    root: new TraceIdRatioBasedSampler(0.1),  // 10% 默认
  }),
  // ...
});

服务端"父优先"采样:如果上游决定要 trace,这一段也 trace;否则按比例。保证一个 trace 是完整的

错误请求强制采样(tail-based sampling)需要 OTel Collector 配合,见 5.3。

5. OpenTelemetry Collector(强烈推荐)

不要让你的应用直连后端(Tempo/Jaeger/Sentry),中间夹一个 OTel Collector:

[apps] → OTel Collector → [Tempo / Loki / Prometheus / Sentry / ...]

好处:

  • 批处理、压缩、重试 —— 应用不操心
  • 采样策略集中(tail-based 在这层做)
  • 后端替换零应用改动
  • 协议适配:OTLP in → 任何 backend out
5.1 最小 Collector 配置
receivers:
  otlp:
    protocols:
      grpc: { endpoint: "0.0.0.0:4317" }
      http: { endpoint: "0.0.0.0:4318" }

processors:
  batch:
    timeout: 5s
    send_batch_size: 1000
  memory_limiter:
    check_interval: 1s
    limit_mib: 1024
  tail_sampling:
    decision_wait: 30s
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow
        type: latency
        latency: { threshold_ms: 1000 }
      - name: sample
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls: { insecure: true }

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, batch]
      exporters: [otlp/tempo]

tail_sampling 关键:在 Collector 处暂存 30s 内的 span,等 trace 完整后再决定要不要保留。错误和慢请求 100% 保留,其他 5% 采样。

6. Sentry(错误监控)

OTel 管所有"成功 + 失败"的可观测,Sentry 专注"失败",优势在:

  • 自动聚合相似错误(指纹)
  • 关联用户、浏览器、source map
  • Release tracking(回归识别)
  • Issue tracking 与告警
6.1 NestJS 集成
pnpm add @sentry/nestjs
// instrument.ts —— 第一个 import
import * as Sentry from "@sentry/nestjs";

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  tracesSampleRate: 0.05,
  environment: process.env.NODE_ENV,
  release: process.env.APP_VERSION,
  integrations: [Sentry.httpIntegration(), Sentry.prismaIntegration()],
  beforeSend(event) {
    // 脱敏
    if (event.request?.headers) {
      delete event.request.headers["authorization"];
      delete event.request.headers["cookie"];
    }
    return event;
  },
});

main.ts:

import "./instrument";
import "reflect-metadata";
// ...
app.useGlobalFilters(new SentryGlobalFilter(app.getHttpAdapter())); // @sentry/nestjs

💡 Source map 上传 没 source map = Sentry 只能给你看打包后的代码。@sentry/cli sourcemaps upload 在 CI/CD 里自动上传。释放时一定要跟 release 标签关联,Sentry 才能"这是 v1.2.3 引入的新错误"判断。

6.2 上下文丰富
Sentry.withScope((scope) => {
  scope.setUser({ id: userId, email });
  scope.setTag("tenant", tenantId);
  scope.setContext("order", { id: orderId, amount });
  Sentry.captureException(err);
});

7. 健康检查 vs 可观测性

别混淆:

  • 健康检查(/health/live/health/ready)是 K8s 用的,布尔回答
  • Metrics 是趋势,Logs 是事件,Traces 是路径

健康检查不应该:

  • 查 DB(DB 抖一下整个集群被杀)
  • 调外部依赖(下游挂 = 你被杀)

健康检查应该:

  • /live:进程在跑就返回 200(几乎不会失败)
  • /ready:简单依赖(DB ping)成功才 200

8. 成本控制

可观测性容易花大钱。两个关键:

8.1 日志成本
  • 不要 INFO 日志洪水(每个 fetch 都打 → 每月几 TB)
  • 高频内部事件用 metric,不用 log
  • 采样:DEBUG 全采,INFO 100%,但某些"心跳"事件按 1% 采
8.2 Trace 成本
  • Tail sampling:错误 100%,慢请求 100%,其他 5-10%
  • Collector 端聚合 + 压缩
8.3 Metric 成本
  • 控制 label 基数(3.4 节)
  • 不需要分秒精度的用 1m 采集

9. SLO + Error Budget

把"质量"量化:

SLO: 99.9% 的请求 p95 < 500ms
Error Budget: 300.1% = 43 分钟可"不达标"

定义后:

  • 超预算 → 暂停 feature 推进,聚焦稳定性
  • 在预算内 → 可以承担更多变更风险

Grafana 可以画 burn rate alert(快速消耗 budget 时报警),比单纯阈值告警更有信号。

10. 应急 Runbook 模板

每个关键 alert 应该有 Runbook 链接,内容:

## Alert: HighErrorRate
### 一句话
API 5xx 错误率 > 2% 持续 5 分钟。

### 立刻检查
1. Grafana dashboard `api-overview` 看哪个 route 在涨
2. Sentry → 最近 30min 的 issue
3. 如果是单 route → kubectl logs 看是不是某个 pod 异常
4. 如果是全站 → 看 DB / Redis / 第三方上游

### 临时缓解
- 上游问题:开熔断(feature flag)
- 单 pod 问题:`kubectl delete pod <name>`
- DB 慢:看 pg_stat_activity 是否有锁

### 复盘
- 写 post-mortem,贴 trace、log、metric 截图
- 加 alert / 加 test / 改设计三选一

11. 速记卡

想知道
某请求慢在哪Trace
全站现在怎么样Grafana dashboard(metrics)
某条具体错误Sentry issue
系统昨天 5pm 发生了啥Logs(by traceId / userId / time)
API p99 在涨Histogram percentile
某用户报 bugSentry + log filter by userId
队列堆积Queue gauge metric

延伸阅读


09 - DevOps:Docker / GitHub Actions / Kubernetes / 灰度发布

目标:把代码"自动、可观、可回滚地"送上生产。Docker 多阶段构建、CI/CD、K8s 部署、滚动 + 蓝绿 + 灰度三档发布。

1. Docker 多阶段构建

1.1 NestJS API Dockerfile

infra/docker/api.Dockerfile:

## syntax=docker/dockerfile:1.7

## ---- Stage 1: 基础(共享 layer 缓存)----
FROM node:20.11.1-alpine AS base
RUN apk add --no-cache libc6-compat tini openssl
RUN corepack enable
WORKDIR /app
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

## ---- Stage 2: 安装依赖 ----
FROM base AS deps
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./
COPY apps/api/package.json apps/api/
COPY packages/shared/package.json packages/shared/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm fetch --frozen-lockfile

## ---- Stage 3: 构建 ----
FROM base AS build
COPY --from=deps /pnpm /pnpm
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile --offline
RUN pnpm --filter @my-app/api prisma generate
RUN pnpm --filter @my-app/api build
## 只保留生产依赖
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm --filter @my-app/api --prod deploy /out

## ---- Stage 4: 运行 ----
FROM node:20.11.1-alpine AS runner
RUN apk add --no-cache tini openssl
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /out/node_modules ./node_modules
COPY --from=build /app/apps/api/dist ./dist
COPY --from=build /app/apps/api/prisma ./prisma
COPY --from=build /app/apps/api/package.json ./
USER node
EXPOSE 3001
ENTRYPOINT ["tini", "--"]
CMD ["node", "dist/main.js"]

要点拆解:

  • 多阶段:build 产物干净,runner 镜像 ~150-200MB
  • BuildKit cache mount(--mount=type=cache):pnpm store 跨构建复用
  • pnpm deploy:生成独立的"只含生产依赖 + 链接好"的部署包
  • tini 作 PID 1:正确转发 SIGTERM 到 Node;Alpine 默认没有
  • non-root(USER node)
  • 不复制源码到 runner

💡 pnpm fetch + --offline:fetch 阶段只下载到 store(不解压到 node_modules)。build 阶段 --offline 强制只用 store。pnpm-lock.yaml 没改 → fetch 层缓存命中,install 飞快

⚠️ 不要直接 COPY . . 然后 pnpm install。每次源码变化都会 invalidate install 层。把 install 与源码分离是 Docker 缓存的基本功。

1.2 Worker Dockerfile

Worker 与 API 共代码,但运行入口不同。可以共用同一个 image,只换 CMD:

## 与 1.1 完全一样,只把 CMD 改成:
CMD ["node", "dist/worker.js"]

或者在 K8s 部署时同 image、不同 command:

## api Deployment
command: ["node", "dist/main.js"]

## worker Deployment(用同一个 image:tag)
command: ["node", "dist/worker.js"]

💡 同 image 多用途的好处:

  1. 构建一次,推一次;2) 版本天然同步,永远不会有 API/Worker 版本不一致导致的协议错位;3) CI 时间减半。 唯一注意:Worker 不需要 HTTP probe,K8s 的 livenessProbe 用 exec 或简单的 TCP socket 检查即可。
1.3 .dockerignore(必须)
node_modules
**/node_modules
**/dist
**/.next
**/.turbo
**/coverage
.git
.env*
*.log
.vscode
.idea
playwright-report
test-results

漏掉 .git / node_modules 让 build context 飙到几个 G,极慢且经常 OOM。

1.4 镜像版本与标签
ghcr.io/myorg/api:1.2.3
ghcr.io/myorg/api:1.2.3-abc1234   # SHA 标签(回溯精确版本)
ghcr.io/myorg/api:latest          # 仅本地开发用,生产禁用

CI 同时打多个 tag:

- uses: docker/build-push-action@v6
  with:
    tags: |
      ghcr.io/myorg/api:${{ env.VERSION }}
      ghcr.io/myorg/api:${{ env.VERSION }}-${{ github.sha }}
      ghcr.io/myorg/api:sha-${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

⚠️ 生产部署绝不引用 :latest。流量切到新版本 = 一段时间集群里既有新又有旧,定位问题时不知道是哪个版本。永远用 immutable tag(SHA 或 semver)。

2. GitHub Actions:完整 CI/CD

.github/workflows/ci.yml:

name: CI

on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  prepare:
    runs-on: ubuntu-latest
    outputs:
      affected: ${{ steps.affected.outputs.list }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - id: affected
        run: echo "list=$(pnpm turbo run build --filter='...[origin/main]' --dry=json | jq -c '.tasks')" >> $GITHUB_OUTPUT

  quality:
    needs: prepare
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup
      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm test
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/test
          REDIS_URL: redis://localhost:6379
    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_USER: test, POSTGRES_PASSWORD: test, POSTGRES_DB: test }
        ports: ['5432:5432']
        options: --health-cmd pg_isready
      redis:
        image: redis:7-alpine
        ports: ['6379:6379']

  build-api:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          file: infra/docker/api.Dockerfile
          push: true
          tags: |
            ghcr.io/${{ github.repository_owner }}/api:sha-${{ github.sha }}
            ghcr.io/${{ github.repository_owner }}/api:main
          cache-from: type=gha,scope=api
          cache-to: type=gha,mode=max,scope=api
          provenance: true                  # SLSA provenance
          sbom: true                         # 软件物料清单
      # 漏洞扫描
      - uses: aquasecurity/trivy-action@master
        with:
          image-ref: ghcr.io/${{ github.repository_owner }}/api:sha-${{ github.sha }}
          severity: HIGH,CRITICAL
          exit-code: '1'                    # 高危即失败

  deploy-staging:
    needs: [build-api, build-web]
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - run: |
          helm upgrade --install api ./infra/k8s/charts/api \
            --namespace staging --create-namespace \
            --set image.tag=sha-${{ github.sha }} \
            --wait --timeout 5m

要点:

  • concurrency 取消同 branch 旧 build
  • fetch-depth: 0 让 turbo 能判断 affected
  • composite action 抽取 setup(node + corepack + pnpm install)
  • BuildKit GHA cache(cache-from/to: type=gha)
  • Trivy 漏洞扫描:高危即拒
  • SBOM + provenance:供应链安全(SLSA L2)
2.1 Composite action 抽取

.github/actions/setup/action.yml:

name: Setup
runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with: { node-version-file: .nvmrc }
    - shell: bash
      run: corepack enable
    - uses: actions/cache@v4
      with:
        path: ~/.local/share/pnpm/store
        key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    - shell: bash
      run: pnpm install --frozen-lockfile
2.2 Turborepo 远端缓存
- run: pnpm build
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

跨 CI run 共享构建产物 —— 没改的包不重 build,大仓 CI 时间减半。

3. Kubernetes:最小可用部署

3.1 Deployment

infra/k8s/charts/api/templates/deployment.yaml(Helm):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
  labels:
    app: api
    version: {{ .Values.image.tag }}
spec:
  replicas: {{ .Values.replicaCount }}
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels: { app: api }
  template:
    metadata:
      labels: { app: api, version: {{ .Values.image.tag }} }
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "3001"
        prometheus.io/path: "/metrics"
    spec:
      terminationGracePeriodSeconds: 60
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        fsGroup: 1000
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: api
          image: "{{ .Values.image.repo }}:{{ .Values.image.tag }}"
          imagePullPolicy: IfNotPresent
          ports: [{ containerPort: 3001, name: http }]
          env:
            - name: NODE_ENV
              value: production
            - name: PORT
              value: "3001"
            - name: APP_VERSION
              value: {{ .Values.image.tag }}
            - name: DATABASE_URL
              valueFrom: { secretKeyRef: { name: api-secrets, key: DATABASE_URL } }
            - name: REDIS_URL
              valueFrom: { secretKeyRef: { name: api-secrets, key: REDIS_URL } }
            - name: JWT_PRIVATE_KEY
              valueFrom: { secretKeyRef: { name: api-secrets, key: JWT_PRIVATE_KEY } }
            - name: OTEL_EXPORTER_OTLP_ENDPOINT
              value: "http://otel-collector.observability:4318"
          resources:
            requests: { cpu: 200m, memory: 256Mi }
            limits:   { cpu: 1000m, memory: 512Mi }
          readinessProbe:
            httpGet: { path: /health/ready, port: http }
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet: { path: /health/live, port: http }
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 6
          startupProbe:
            httpGet: { path: /health/live, port: http }
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 30           # 启动最长 150s
          lifecycle:
            preStop:
              exec:
                # 让 LB 先摘流量,再开始优雅退出
                command: ["sh", "-c", "sleep 5"]
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }

要点:

  • maxUnavailable: 0 + maxSurge: 1:零停机滚动(先起一个新的,旧的再下)
  • 三 probe:startup(冷启动慢)、readiness(可接流量)、liveness(进程活着)
  • preStop sleep 5:K8s 摘 endpoint 是异步的,sleep 一会儿等 LB 真摘掉再退,避免"已停止接但仍在收"的请求
  • terminationGracePeriodSeconds: 60:留时间给 NestJS OnApplicationShutdown 完成
  • resource requests/limits:requests 决定调度,limits 防止单 pod 吃光节点
  • security context:non-root、readonly rootfs、drop capabilities

⚠️ Liveness probe 永远不要查 DB。DB 抖 1 秒 → 整集群被 K8s 杀光,雪崩。

💡 HPA 的依据:CPU/Memory 基础够,生产推荐"自定义 metric"(队列长度、p95 延迟、QPS)。

3.2 Service + Ingress
apiVersion: v1
kind: Service
metadata: { name: api }
spec:
  selector: { app: api }
  ports: [{ port: 80, targetPort: http }]
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
    nginx.ingress.kubernetes.io/server-snippet: |
      add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
spec:
  ingressClassName: nginx
  tls: [{ hosts: [api.example.com], secretName: api-tls }]
  rules:
    - host: api.example.com
      http:
        paths:
          - { path: /, pathType: Prefix, backend: { service: { name: api, port: { number: 80 } } } }
3.3 HPA(水平自动扩容)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: api }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300  # 5min 平稳后再缩
      policies: [{ type: Percent, value: 25, periodSeconds: 60 }]
    scaleUp:
      stabilizationWindowSeconds: 0
      policies: [{ type: Percent, value: 100, periodSeconds: 30 }]

要点:扩快缩慢。突发流量赶紧扩,流量回落后慢慢缩(防止抖动)。

3.4 PodDisruptionBudget(防滚动期间挂太多)
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: api }
spec:
  minAvailable: 50%
  selector: { matchLabels: { app: api } }

节点维护时 K8s evict pod 会遵守 PDB,保证服务可用。

3.5 Worker Deployment(注意区别)

Worker 没有 service / ingress。健康检查最简(进程在跑就 OK)。

livenessProbe:
  exec: { command: ["node", "-e", "process.exit(0)"] }
  periodSeconds: 30

队列消费者优雅退出关键:preStop 给 60-120s 让正在处理的任务跑完。

4. Schema 迁移与发布编排

Schema 迁移和应用部署是最容易出生产事故的点。原则:

4.1 迁移与代码分离

不要在 app 启动时跑 migrate deploy。原因:

  • 多副本启动会并发跑迁移 → 失败或锁
  • 失败时迁移已经一半完成,难恢复

推荐:Helm pre-install/pre-upgrade hook 跑一个一次性 Job:

apiVersion: batch/v1
kind: Job
metadata:
  name: api-migrate-{{ .Release.Revision }}
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-delete-policy": before-hook-creation
spec:
  backoffLimit: 1
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repo }}:{{ .Values.image.tag }}"
          command: ["npx", "prisma", "migrate", "deploy"]
          env:
            - name: DATABASE_URL
              valueFrom: { secretKeyRef: { name: api-secrets, key: DATABASE_URL } }
4.2 "扩展-收缩"(Expand-Contract)

破坏性 schema 变更分多次发:

Phase 1(扩展): 加新列/表,旧代码继续工作
Phase 2(回填): 后台 backfill,新旧并存,新代码可读两边
Phase 3(切流): 应用切到新结构,旧结构停写
Phase 4(收缩): 安全后删旧列/表

每两阶段间至少跨一次发布周期 + 监控。

5. 发布策略三档

5.1 滚动(默认)
  • 适用:无破坏性变更,日常发布
  • 风险:1-2 个 pod 同时新旧并存几分钟
  • 回滚:helm rollbackkubectl rollout undo
5.2 蓝绿
  • 适用:破坏性 schema、需要"切流即生效或回滚"
  • 部署:同时跑 blue(旧)和 green(新),通过 Service selector 切流
  • 工具:Argo Rollouts / Flagger
5.3 灰度(Canary)
  • 适用:高风险特性、需要观察生产指标
  • 实现:
    • 流量百分比:Ingress 按 5% / 25% / 50% / 100% 切
    • 用户白名单:基于 cookie / header / userId 哈希,只给指定用户看新版(更可控)

NestJS 内置 feature flag:

const isEnabled = (user: JwtPayload) => {
  if (user.email.endsWith("@internal.com")) return true;
  return hashUserId(user.sub) % 100 < parseInt(process.env.FF_NEW_BILLING_PCT ?? "0");
};

或用 GrowthBook / Unleash / LaunchDarkly。

💡 灰度真正的价值不是"小步发",是指标对比:控制组(旧)vs 实验组(新),P99、错误率、转化率有没有显著差异。光"先 5% 用户看"但没看指标 = 自我安慰。

6. Secret 管理

不放 git。两种主流方案:

6.1 External Secrets Operator + 云 KMS
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata: { name: api-secrets }
spec:
  refreshInterval: 1h
  secretStoreRef: { name: aws-secrets-manager, kind: ClusterSecretStore }
  target: { name: api-secrets, creationPolicy: Owner }
  data:
    - secretKey: DATABASE_URL
      remoteRef: { key: prod/api, property: DATABASE_URL }

Secret 在云 KMS 里(AWS Secrets Manager / GCP Secret Manager / Vault),ESO 同步到 K8s Secret。改源 → 自动滚动到集群,不需要再次 deploy。

6.2 SOPS + Git

sops 加密 YAML 文件 + 解密密钥在云 KMS。文件可入 git。简单团队适合。

⚠️ K8s Secret 默认只是 base64 编码,不是加密。要么:1) 集群启用 etcd encryption at rest;2) 用 KMS。

7. 回滚演练

每月至少一次"假装出事 → 回滚"演练:

helm history api -n production
helm rollback api 42 -n production --wait

未演练的回滚 = 不存在的回滚。生产真出事时手抖、命令找不到、密钥过期是常态。

8. 数据库的备份与恢复

  • 每日全量备份(云托管自动)
  • PITR(Point-in-Time Recovery):至少 7 天,有合规要求时 30 天
  • 每季度恢复演练:在测试环境恢复最近一次备份,验证可用

⚠️ 从未恢复过的备份 = 没有备份

9. 蓝绿 + Argo Rollouts 示例

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata: { name: api }
spec:
  strategy:
    canary:
      canaryService: api-canary
      stableService: api-stable
      trafficRouting:
        nginx: { stableIngress: api }
      steps:
        - setWeight: 5
        - pause: { duration: 10m }
        - setWeight: 25
        - pause: { duration: 10m }
        - analysis:
            templates:
              - templateName: error-rate-check
        - setWeight: 50
        - pause: { duration: 5m }
        - setWeight: 100
  template:
    # 同 Deployment.spec.template

analysis template 自动查 Prometheus,错误率超阈值 → 自动回滚

10. 灾难恢复(DR)目标

维度目标
RPO(数据丢失)≤ 5 分钟(用 PITR + 副本)
RTO(恢复时间)≤ 1 小时(单服务)/ ≤ 4 小时(全链路)
跨可用区DB / Redis / API 至少跨 2 AZ
跨区域可选,合规/SLA 要求时

每季度做 DR Drill,把整个 region/AZ 演练失效。

11. 上线 checklist(本章关注的)

  • Dockerfile 多阶段、non-root、tini、镜像 < 300MB
  • CI 含 lint / typecheck / test / build / scan
  • 镜像 SBOM + provenance 已生成
  • K8s 三 probe 配齐,terminationGracePeriod ≥ 30s
  • HPA 配好,min ≥ 2(避免单点)
  • PDB 配好
  • Schema 迁移走独立 Job(pre-upgrade hook)
  • Secret 走 ESO/Vault,git 里没有明文
  • Helm rollback 命令演练过
  • DB 备份验证过(恢复演练)
  • 回滚 SOP 写在 Runbook 里
  • 部署 dashboard:每次 release 都打 annotation,Grafana 能看到时间线

延伸阅读


10 - 安全加固与上线 Checklist

目标:把本教程涉及的安全点收敛成可勾选的清单,并给出威胁建模和事故响应模板。上线前一项一项打勾

1. 安全心智:纵深防御

任何一道防线都不能假设是唯一防线。常用心智:

  • 最小权限:每个组件只拿它需要的权限,绝不多一分
  • 零信任:网络里的任何调用方默认不可信
  • 失败安全:出错时拒绝,而不是放行
  • 可审计:关键操作有不可篡改的日志
  • 侧重防御不可修复:某些漏洞利用后无法挽回(数据泄露、密钥泄露),优先防护

2. 威胁建模:STRIDE 速查

每次设计新模块花 15 分钟过一遍:

字母威胁缓解
Spoofing冒充身份强认证(MFA、token 签名)
Tampering数据篡改输入校验、签名、完整性检查
Repudiation抵赖审计日志
Information Disclosure信息泄露加密、权限控制、错误信息脱敏
Denial of Service拒绝服务限流、降级、隔离
Elevation of Privilege提权RBAC、CASL、纵深防御

3. 传输层

  • 全站 HTTPS(包括内部服务,用 mTLS 或 service mesh)
  • HSTS:Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  • TLS 1.2 最低,优先 1.3;禁 SSL/TLS 1.0/1.1
  • 证书自动续期(cert-manager + Let's Encrypt 或商业 CA)
  • HTTP → HTTPS 强制跳转
  • 证书透明度日志监控(预警证书被恶意签发)

4. HTTP 安全头

// NestJS + @fastify/helmet 或手动 set
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'nonce-{{nonce}}'"], // 不要 unsafe-inline
      styleSrc: ["'self'", "'unsafe-inline'"],     // 折中,如能去掉更好
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", "https://api.example.com"],
      frameAncestors: ["'none'"],
      formAction: ["'self'"],
      objectSrc: ["'none'"],
      baseUri: ["'self'"],
      upgradeInsecureRequests: [],
    },
  },
  referrerPolicy: { policy: "strict-origin-when-cross-origin" },
}));
  • CSP 不含 unsafe-eval,scriptSrc 用 nonce 或 hash
  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY 或 CSP frame-ancestors: 'none'
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy 关闭不用的 API(camera / mic / geolocation)
  • Cross-Origin-Opener-Policy: same-origin(Web 端)

5. 认证(详见 04 章)

  • 密码:argon2id,memory ≥ 19MB / time ≥ 2
  • 登录失败计数 + 锁定/CAPTCHA
  • 邮箱/密码错误返回同一文案 + 同等响应时间
  • 注册/重置密码端点防枚举
  • JWT:EdDSA 或 RS256,禁 alg=none
  • Access token TTL ≤ 30min,Refresh token 一次性 + 旋转 + 家族检测
  • Cookie:HttpOnly + Secure + SameSite=lax 至少
  • CSRF token 保护非 GET(Cookie 模式)
  • MFA 可选,关键操作强制
  • 用户能查看活跃会话 + 远程登出
  • 新设备登录 / 改密 / 改邮箱发邮件通知
  • OAuth:严格 email 验证或绑定流程

6. 授权

  • 默认拒绝 + 显式 @Public() 标注
  • CASL/RBAC 在 Service 层做(API 边界)
  • 查询过滤(accessibleBy)+ controller 资源检查 两层都做
  • 行级安全(RLS) 在 DB 层兜底
  • 所有"按 ID 查"端点检查归属(IDOR 防御)
  • 管理员操作必须 audit log

⚠️ IDOR 是排第一的常见漏洞(GET /orders/123 把别人的订单返回了)。每个"按 id 查"必须显式检查归属。RLS 是兜底,不是替代。

7. 输入校验

  • 每个 API 边界都有 schema(Zod / class-validator)
  • Schema .strict() 拒绝未知字段
  • 字符串字段有长度上限(默认就给上限,防内存爆)
  • 数字字段有范围
  • 文件上传:MIME 校验 + 大小限制 + 病毒扫描(可选)
  • URL 字段:协议 allowlist(http/https,file: javascript: data:)
  • 富文本:服务端用 DOMPurify-server 清洗,不要相信前端

⚠️ SSRF(Server-Side Request Forgery) 防御:用户传入的 URL,服务端禁止访问 localhost127.0.0.0/8169.254.0.0/16(云元数据)、内网 CIDR。用 ssrf-req-filter 或自己写 DNS resolve + IP 检查。

8. 输出处理

  • Response Schema 校验:不让数据库字段意外泄露
  • 错误响应脱敏(05 章):5xx 不包含 stack / SQL / 内部 ID
  • HTML 输出 React 默认转义;dangerouslySetInnerHTML 审计每一处
  • PDF/CSV 导出处理 formula 注入(=cmd|... 开头加单引号)

9. 数据库

  • 应用账号最小权限(不是 superuser)
  • 不同环境(prod/staging/dev)账号 + DB 隔离
  • 连接强制 TLS
  • 静态加密(RDS encryption at rest)
  • 备份加密
  • 敏感字段应用层加密(KMS):PII、token、私钥
  • 永远不要把敏感字段进日志(Pino redact + 业务自检)
  • DB schema migration peer review(看不到的破坏更多)
9.1 Postgres 注入防御
  • Prisma/参数化查询天然防注入,但 $queryRawUnsafe + 字符串拼接是后门
  • 任何拼接 SQL 的地方都要 review

10. 文件 & 对象存储

  • 用户上传到独立 bucket,与系统资产分离
  • 通过签名 URL 下载,不公开 bucket
  • 上传 MIME 校验,也校验文件 magic number(用户可改 content-type)
  • 图像处理走 sandbox(避免 ImageTragick 类漏洞)
  • 文件名 sanitize(防路径穿越:../../etc/passwd)
  • 限制单文件大小、单用户存储配额

11. 第三方 & 供应链

  • pnpm audit / Snyk / Dependabot 每周扫
  • Lock file 必须 commit(避免 typosquatting)
  • CI 拒绝合并含高危依赖的 PR
  • 镜像扫(Trivy / Grype),CI 强制 fail on HIGH+
  • 生成 SBOM(11 章)、SLSA provenance
  • 新依赖加入需要 review:维护活跃度、下载量、签名
  • 绝不动态 npm install 在生产容器内
  • 私有包用私服(GitHub Packages / Verdaccio),不公开发布

12. Secret 管理

  • secret 永远不进 git(预提交钩子 gitleaks 兜底)
  • .env*.gitignore(.env.example 入 git 但用占位)
  • K8s secret 由 KMS 加密(ESO / SOPS / Vault)
  • 每个环境独立 secret
  • 定期轮换:JWT key 季度;DB 密码半年;高敏感 secret 月度
  • 离职/权限变更后立即轮换可能接触过的 secret

13. 速率限制与抗滥用

  • 全局基础限流(IP)
  • 敏感端点:登录、注册、改密、重置密码、OTP、上传 → 严格限流(每分钟 5-10 次)
  • 按账号维度限流(IP 不够)
  • 关键 API key 端点按 key 限流
  • WAF / Cloudflare bot management 抵挡爬虫

14. 日志与审计

  • 所有写操作有结构化日志(actor、action、target、result)
  • 鉴权失败、权限拒绝、4xx 中可疑模式都 warn
  • 审计日志不可篡改(WORM 存储或写到对象存储 + object lock)
  • 日志中无敏感数据(密码、token、信用卡)
  • 日志保留:操作 90 天,审计 1-7 年(看合规)
  • 日志可按 traceId / userId / requestId 检索

15. 监控与告警(参考 09 章)

  • 错误率告警(5xx > 2% 5min)
  • 延迟告警(p95 超 SLO)
  • 队列堆积告警
  • 异常登录告警(同账号多 IP、地理跨度大)
  • secret 即将过期告警(证书、API key)
  • 备份失败告警

16. 隐私与合规

  • 隐私政策、用户协议、Cookie 政策已上线
  • 用户数据导出 / 删除 端点(GDPR 等合规要求)
  • PII 数据加密 at rest
  • 跨境传输合规(看业务地区)
  • 第三方数据共享有用户授权
  • 儿童数据(< 13 岁)单独处理 / 不收集

17. 与前端的协作边界

后端要为前端的安全负责的部分:

  • 后端不返回任何"前端不应看到的字段"(响应 schema 严格白名单)
  • 设好 CORS allowlist(05 章),避免任意来源调用
  • 用 HttpOnly + Secure + SameSite cookie 传 session,不依赖前端把 token 存 localStorage
  • CSP / 反 clickjacking / 反 MIME sniffing 等响应头由 API 设(本章第 4 节)
  • 提供给前端的错误响应模型一致(RFC 7807),不让前端"猜"错误字段
  • 富文本类输入由服务端用 DOMPurify-server 清洗后再入库,不要相信前端清洗

完整前端侧的安全清单见对应的前端教程(Next.js 生产指南的对应章节)。

18. 上线发布的安全门

每次 release 前自动检查:

  • git secrets / gitleaks 无命中
  • 依赖扫描无 HIGH+
  • 容器扫描无 HIGH+
  • SAST(Semgrep / SonarQube)无新增 high
  • 测试覆盖率达标(参考 10 章)
  • 数据库迁移 review pass
  • CHANGELOG / release notes 已更新
  • Sentry release 关联好(便于 sourcemap)

19. 事故响应(IR)Runbook 模板

## Incident Response Runbook

### Severity 分级
- SEV-1:全站不可用 / 数据丢失 / 安全事件
- SEV-2:主要功能受影响
- SEV-3:小功能 bug

### SEV-1 响应流程
1. **5 分钟内**:On-call 收到告警 → 在 #incident-{date} 频道宣布开始
2. **15 分钟内**:确认 scope(用户数、地区、影响)
3. **30 分钟内**:决定:回滚 / 降级 / 修复
4. **同步频率**:每 30 分钟在频道更新
5. **解决后**:发恢复通知
6. **48h 内**:Post-mortem 草稿,组内 review

### Post-mortem 模板
- 影响:多少用户、多长时间、损失估算
- 时间线:精确到分钟,贴 trace/log 截图
- 根因(直接 + 系统性)
- 已生效的缓解
- Action items:加 alert / 加测试 / 改架构,**有 owner 和 deadline**
- **不追责个人,追责系统**

💡 Blameless culture 是工程文化第一战场 追责个人 → 人下次出事会隐瞒 → 系统永远不改进。追责系统 → 大家敢说真话 → 系统持续变好。

20. 周期性安全演练

  • 每月:回滚演练、Pod 删除演练
  • 每季度:DR 演练(假装 region down)、备份恢复演练
  • 每半年:Pentest(内部或外部)
  • 每年:Threat model 全量评审、密钥轮换全量

21. 提交本文档前的最终自检

把这份文档当 checklist,本项目所有勾打完才 ship 1.0:

大类进度
传输层
HTTP 头
认证
授权
输入
输出
数据库
文件
供应链
Secret
限流
日志
监控
隐私
与前端协作边界
发布门
响应流程
演练

22. 必读书目与资源


后记

到这里,11 篇 NestJS 后端教程结束

回看你应该掌握:

  • 从零搭出可演进的后端工程结构
  • NestJS 七件套的生产用法 + 原理
  • 数据层、API、认证、异步的工业级模式
  • 测试金字塔(单元 / 集成 / E2E / 契约)
  • 可观测性三大支柱 + Sentry
  • Docker / K8s / 灰度发布
  • 一份能用一辈子的安全 checklist

工程师真正的本事不是"会用 X 框架",而是知道:

  • 这个决策的代价是什么
  • 什么时候用 A,什么时候用 B
  • 这个失败会怎样,我能怎么发现、怎么恢复
  • 哪些是不可逆决策,哪些是可以下次再改的

希望这套教程帮你形成判断,而不是教你照搬。代码是工具,判断力才是壁垒

祝上线顺利,p99 低位震荡。




附录 — 高级进阶

前 11 章解决"怎么把生产系统做出来"。这部分解决"做出来之后,你怎么在这个系统上持续做对决策"。

阅读建议:不必按顺序,按你正在面对的问题挑章。