本册涵盖: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. 工具栈
| 类型 | 工具 | 备选 |
|---|---|---|
| Runner | Vitest | Jest |
| HTTP 测试 | supertest / Fastify inject | — |
| 数据库隔离 | testcontainers + Prisma migrate | sqlite(不推荐)、内存方案 |
| Mock | vitest mock + msw(外部 HTTP) | nock |
| E2E | Playwright | Cypress(单浏览器内,生态差点) |
| 契约 | Pact | OpenAPI 校验 |
💡 为什么 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 选择器优先级(必背)
按可靠性从高到低:
getByRole(button/link/textbox),最贴近用户和无障碍getByLabel(表单字段)getByText(可见文本)getByTestId(data-testid)—— 最后才用- ❌ 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 源 - 长动画用
toHaveScreenshot或toBeVisible+ 自定义 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 编译挡住不兼容
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) |
| 外部 HTTP | MSW |
| 视觉回归 | 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 都带 traceId 和 requestId。一个出错请求,从 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 | traceservice:服务名(api / worker / web)env:development | staging | productionrequestId:同请求所有日志关联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 lag | nodejs_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 同一个)、spanId、parentSpanId - W3C
traceparentheader 在跨服务调用时传播
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转成 W3Ctraceparent,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: 30 天 0.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 |
| 某用户报 bug | Sentry + log filter by userId |
| 队列堆积 | Queue gauge metric |
延伸阅读
- Google SRE Book
- OpenTelemetry 官方文档
- Honeycomb 博客 —— 可观测性思维方式
- Pino 文档
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 多用途的好处:
- 构建一次,推一次;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 旧 buildfetch-depth: 0让 turbo 能判断 affectedcomposite 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:留时间给 NestJSOnApplicationShutdown完成- 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 rollback或kubectl 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 能看到时间线
延伸阅读
- Kubernetes Production Patterns
- Argo Rollouts 文档
- Helm 最佳实践
- SLSA Supply-chain Levels for Software Artifacts
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或 CSPframe-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,服务端禁止访问
localhost、127.0.0.0/8、169.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. 必读书目与资源
- OWASP Top 10
- OWASP Cheat Sheets
- NIST SP 800-63B — 数字身份指南
- SANS Top 25 CWE
- SLSA — 供应链等级
- Web Application Hacker's Handbook(经典)
- Securing DevOps(Manning)
后记
到这里,11 篇 NestJS 后端教程结束。
回看你应该掌握:
- 从零搭出可演进的后端工程结构
- NestJS 七件套的生产用法 + 原理
- 数据层、API、认证、异步的工业级模式
- 测试金字塔(单元 / 集成 / E2E / 契约)
- 可观测性三大支柱 + Sentry
- Docker / K8s / 灰度发布
- 一份能用一辈子的安全 checklist
工程师真正的本事不是"会用 X 框架",而是知道:
- 这个决策的代价是什么
- 什么时候用 A,什么时候用 B
- 这个失败会怎样,我能怎么发现、怎么恢复
- 哪些是不可逆决策,哪些是可以下次再改的
希望这套教程帮你形成判断,而不是教你照搬。代码是工具,判断力才是壁垒。
祝上线顺利,p99 低位震荡。
附录 — 高级进阶
前 11 章解决"怎么把生产系统做出来"。这部分解决"做出来之后,你怎么在这个系统上持续做对决策"。
阅读建议:不必按顺序,按你正在面对的问题挑章。