返回博客

部署不是发布按钮:把「上线」这件事做成工程

有一段时间,我的「部署」就是 ssh 进服务器、git pull、重启进程。出了事再 git reset --hard 滚回去。能用,也确实出过几次事。

后来我开始认真想这件事:部署本身应该是无聊的。上线某个功能,你最理想的心态是「就这样了,应该没事」,而不是盯着监控屏屏幕祈祷。把上线变得无聊,是一套可以设计的工程能力。

策略先于工具:三种部署模式的本质

很多人上来就问「用 GitHub Actions 还是 Jenkins」,但工具选哪个其实是末端决策。先要想清楚流量切换方式

**滚动部署(Rolling)**是最常见的默认选择:逐台替换实例,旧版和新版同时在线。代价是你得保证两个版本的代码、数据库 schema、API 都能兼容共存。所以「先加列不加约束、再加约束、最后删旧列」这类迁移哲学,本质上是滚动部署逼出来的。

**蓝绿部署(Blue-Green)**是双环境原子切换——新环境跑起来验证完再切流量,旧环境留存备用。最大的好处是回滚等于切一个开关,几乎零代价。代价是 2x 基础设施成本,以及切换那一刻的状态一致性(比如 WebSocket 长连接、正在处理的请求)需要额外处理。

**金丝雀(Canary)**是把风险摊开来:先让 5% 的真实用户跑新版,观察指标,再逐步扩大比例。特别适合「逻辑改动有信心但担心规模效应」的场景。它要求你有足够完善的监控——没有可观测性,金丝雀是盲飞。

这三种策略不是高低之分,是对「出错代价」和「基础设施投入」的不同取舍。个人项目和小团队大多数时候用滚动就够,但要有意识地做这个选择,而不是「随手就这样了」。

Docker 多阶段构建:把「可重现」当作第一目标

我见过不少 Dockerfile 就是一个 FROM node:latest,把整个仓库 COPY . .,然后在里面又是装依赖又是跑构建。镜像几百 MB,还把测试文件、开发工具、.git 目录全打进去了。

多阶段构建解决的核心问题是:构建时需要的东西,运行时不应该出现。三段式是个稳定的模型:

# deps 层:只装依赖,利用层缓存
FROM node:22-alpine AS deps
COPY package.json package-lock.json ./
RUN npm ci --production=false
 
# builder 层:拿依赖、跑构建、裁剪 devDeps
FROM node:22-alpine AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --production
 
# runner 层:最小化镜像,非 root 用户
FROM node:22-alpine AS runner
RUN addgroup -S appgroup && adduser -S appuser
USER appuser
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
HEALTHCHECK CMD wget --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]

几个细节值得强调:固定版本标签(不用 latestlatest 是不可重现的)、非 root 用户运行(容器逃逸的第一道屏障)、HEALTHCHECK 指令内置到镜像里(让 orchestrator 知道服务是否真正就绪)。

可重现性是部署工程的基本要求

同一个 commit SHA 在任何机器、任何时间构建,应该产出行为完全相同的制品。版本标签不固定、构建依赖外部网络的 latest 资源,都是在破坏这个保证。

健康检查:部署系统的感知神经

健康检查端点是整条部署链路的神经系统。没有它,负载均衡不知道实例是否就绪,Kubernetes 不知道该不该把流量导过来,滚动部署可能把请求打到一个还在启动中的进程。

我倾向于暴露两个粒度的端点:

  • /health:轻量,只返回 {"status":"ok"},供 liveness probe 和负载均衡使用,绝不做数据库查询
  • /health/detailed:重一些,查数据库、缓存、下游依赖,返回各子系统状态和延迟,供内部监控和排障使用,不对外暴露

Kubernetes 的三种探针对应三个不同语义:startupProbe 给启动慢的应用争取时间(防止被误判为挂了)、livenessProbe 判断进程是否该重启、readinessProbe 判断是否该接收流量。三者要区分,不能用一个探针打天下。

配置验证:在进程启动时暴露问题

「环境变量漏配了」是我踩过最多次的生产事故之一。应用起来了,某个功能调用时才发现 API_KEYundefined,然后莫名其妙地报 500。

Twelve-Factor 的思路是对的:所有配置走环境变量,代码里不出现环境相关的硬编码。但还有一步更关键——在进程启动时做 schema 验证,配置不合法就立即 crash

import { z } from "zod";
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  PORT: z.coerce.number().default(3000),
});
export const env = envSchema.parse(process.env); // 启动时失败好过运行时失败

fail-fast 胜过 fail-late。启动时崩溃,部署流水线能立即感知并阻止流量切换;运行时崩溃,用户先感知到了。

回滚:预演比文档更值钱

很多团队的回滚方案只存在于 wiki 里,真正需要用的时候才发现:数据库 migration 不可逆、旧镜像没打 tag 找不到了、没人知道上一个版本的 commit SHA。

有几件事要提前做好:

  • 每次部署打不可变的版本标签(用 commit SHA,不要只有 latest
  • 数据库迁移设计成向前兼容,优先加列而非改列,用 feature flag 切换新逻辑
  • 在 staging 环境真实演练过一次回滚,而不只是相信理论上能行

「回滚在生产环境从未被测试过」是一种技术债,和从未被触发过的备份脚本一样危险。

一句话心法

好的部署工程,是让「上线」这件事无聊到你不需要在场盯着它。