《互联网产品的服务端架构·规模化与架构演进》8.1 环境、制品与发布流水线

「回声」从 3 个服务涨到 12 个服务后,发布仍然靠登录机器执行脚本。本节把环境职责、不可变制品与 dev→staging→prod 的晋级顺序写成可执行规则,给出制品晋级的实现与测试,并复盘一次同一次提交被构建两次、回滚时找不到可用镜像的故障。

周五下午的“再发一次就好了”

「回声」最开始只有 3 个服务:一个 API、一个订单任务、一个后台。发布方式是登录机器,git pull,然后 systemctl restart。三个人的团队,这套方式能跑两年。

到 2026 年第三季度,服务数量涨到 12 个(案例假设值),参与发布的团队变成 4 个,环境有 dev、staging、prod 三套,每套又有自己的数据库和消息队列。发布仍然靠一份 deploy.sh:脚本在目标机器上拉代码、编译、替换二进制、重启进程。

问题不是脚本写得不好,而是脚本没有回答一个最基本的问题:线上跑的到底是什么?

一次周五下午的发布把这个问题摆到了台面上。staging 验证通过,prod 执行同一个脚本;十分钟后,支付回调开始超时,错误率从 0.2% 涨到 9%。值班同学决定回滚,执行 deploy.sh rollback,脚本把上一个备份目录拷回来,服务起来了,但数据库迁移已经执行过,旧代码不认识新字段,错误率反而更高。

更尴尬的是,没人能说清 staging 上验证的二进制和 prod 上出问题的二进制是不是同一份。它们来自同一个 commit,但由两次独立的构建产生;构建机的 Go 缓存状态不同,拉到的依赖小版本也不同。也就是说,staging 通过的是 A,prod 运行的是 B,而“回滚”回到的是 C。

这一节讨论的就是这条链路:环境各自负责提供什么证据,制品如何做到不可变,晋级顺序为什么必须由代码而不是由人的记忆来保证。

环境不是三套服务器,而是三道证据

很多团队把环境理解成“配置不同的同一套服务”。这只说对了一半。环境更重要的职责是提供不同强度的证据:dev 证明代码能跑,staging 证明它能在接近生产的拓扑下跑,prod 证明它能在真实流量和真实数据下跑。

如果每个环境都重新构建一次,那么三道证据指向的是三个不同的对象,证据链就断了。正确的做法是让同一份制品依次经过三个环境,环境之间只改变配置注入和流量规模。

环境要回答的问题进入条件制品来源发布权限
dev代码能否编译、单测是否通过合入主干且 CI 绿灯当前提交构建出的 digest开发者自助
staging在接近生产的拓扑下是否可用同一 digest 已在 dev 稳定运行复用 dev 的 digest,不重新构建团队负责人
prod真实流量与真实数据下是否可用同一 digest 已在 staging 完成验证复用 staging 的 digest,不重新构建发布值班 + 审批
回滚能否在故障时回到已知良好状态保留最近 N 个 digest 及其元数据历史 digest,不允许现场重编发布值班

这张表的核心不是权限,而是“制品来源”那一列。只要出现“在目标环境重新构建”,前一个环境的验证就自动失效。配置可以不同,二进制不能不同;数据库迁移脚本、feature flag 默认值、证书和密钥属于配置或独立的发布物,也必须跟 digest 建立可追溯的关联。

还有一个容易忽略的点:环境数量不是越多越好。每多一套长期存在的环境,就多一份配置漂移、数据过期和权限维护的成本。案例中的团队曾经有 6 套环境,其中两套半年没人登录,里面的证书早已过期,真正出事时没有人敢用它们做验证。后来收敛到 3 套常驻环境加按需创建的临时预览环境,维护成本反而下降。

制品必须是不可变的

“制品”不只是容器镜像。对服务端来说,一次可发布的制品至少包含四样东西:

  • 可执行文件或镜像,用内容摘要(digest)标识,而不是用 latest、stable 这类可变标签;
  • 构建元数据:commit、构建时间、构建机、依赖锁定文件;
  • 数据库迁移包,以及它预期的 schema 版本;
  • 配置模板的版本号,但不包含各环境的实际值。

不可变的含义是:digest 一旦生成,对应的内容永远不覆盖。要修一个 bug,就产生新的 digest;要回滚,就引用旧 digest。任何“往同一个标签上重新推一次”的操作,都会让审计记录失去意义。

用标签不是不可以,但标签只能作为人类可读的别名,真正被部署系统引用、被审批记录、被回滚脚本使用的是 digest。比如 echo-api:2026-08-14-7c1f 可以存在,但部署清单里必须写 sha256:...。否则标签被覆盖的那一刻,历史发布记录就变成了谎言。

制品的保留策略也要提前定。案例团队最初只保留最近 5 个镜像,结果一次跨版本回滚需要 3 周前的 digest,仓库里已经被清理,只能从 commit 重新构建。重新构建出来的二进制即使源码相同,也未必字节相同,回滚于是变成了“再发一次新版本”。后来他们把保留策略改成“按发布记录保留 90 天,按季度归档到冷存储”,成本可控,回滚路径也可验证。

一次“同提交、两构建”的回滚事故

事故发生在 2026 年 8 月的一次常规发布(案例假设值)。时间线如下:

  1. 16:10,合并 commit 7c1f,CI 构建出镜像并打标签 echo-api:latest,推送到 registry。
  2. 16:40,staging 部署 latest,跑完冒烟测试和 30 分钟观察,通过。
  3. 17:20,prod 执行 deploy.sh。脚本没有拉取 staging 用过的镜像,而是在 prod 机器上执行 docker build,理由是“prod 的构建参数不同”。
  4. 17:35,支付回调 p99 从 180ms 涨到 2.4s,错误率升到 9%。事后定位,prod 构建时基础镜像被上游更新,gRPC 的一个默认超时行为发生变化。
  5. 17:42,值班执行回滚。脚本把 latest 指向的镜像重新部署,但 latest 在 17:20 已经被 prod 的构建覆盖,回滚部署的正是出问题的镜像。
  6. 18:07,团队从 commit 重新构建并回滚,故障持续约 47 分钟,期间约 23% 的支付回调失败。

这次事故有三层根因,层层叠加:

  • 制品可变:latest 被覆盖,标签不再是稳定引用;
  • 环境间重新构建:staging 的证据无法覆盖 prod 的二进制;
  • 回滚依赖“上一个备份目录”,而不是“上一个已验证的 digest”,而备份目录里的二进制没有元数据,无法判断它对应哪个 commit。

修复不是写一个更聪明的回滚脚本,而是把三件事分开:构建只发生一次;环境之间只做晋级;回滚只引用历史 digest。数据库迁移也要版本化,并且向前兼容至少一个版本,否则“代码回滚、schema 不回滚”仍然会失败。

用晋级函数把顺序写死

下面的代码只做一件事:给定一个制品和目标环境,判断它能否沿既定顺序向前走一格。它不碰 registry、不读配置中心、不触发部署,因为这些副作用会让“顺序是否正确”难以单独验证。

// file: internal/pipeline/artifact.go
// tier: L1
// verify: go test ./internal/pipeline/

package pipeline

import (
	"fmt"
	"strings"
)

// Artifact 是一次构建产物的身份。
// Name 是逻辑名(例如 "echo-api"),Digest 是内容摘要(例如 "sha256:9f2c…"),
// Env 是它当前被允许部署到的环境。三者一起描述“哪一个二进制、可以去哪里”。
type Artifact struct {
	Name   string
	Digest string
	Env    string
}

// Promote 把一个制品从它当前的环境推进到 to。
// order 是环境晋级顺序,例如 []string{"dev", "staging", "prod"}。
//
// 规则只有三条,但每条都对应一类真实事故:
//   - Digest 为空说明构建没有产出可验证的内容地址,禁止晋级;
//   - 只能沿 order 向前走一格,跳级会让 staging 的验证被绕过;
//   - 不能回退或原地重放,回退要走单独的回滚流程并留下审计记录。
func Promote(a Artifact, to string, order []string) (Artifact, error) {
	if strings.TrimSpace(a.Digest) == "" {
		return a, fmt.Errorf("pipeline: artifact %q has empty digest", a.Name)
	}
	if len(order) == 0 {
		return a, fmt.Errorf("pipeline: promotion order is empty")
	}

	from, target := -1, -1
	for i, env := range order {
		if env == a.Env {
			from = i
		}
		if env == to {
			target = i
		}
	}
	if from < 0 {
		return a, fmt.Errorf("pipeline: artifact %q is in unknown env %q", a.Name, a.Env)
	}
	if target < 0 {
		return a, fmt.Errorf("pipeline: unknown target env %q", to)
	}
	if target == from {
		return a, fmt.Errorf("pipeline: artifact %q is already in %q", a.Name, to)
	}
	if target < from {
		return a, fmt.Errorf("pipeline: cannot move %q back to %q", a.Env, to)
	}
	if target != from+1 {
		return a, fmt.Errorf("pipeline: cannot skip from %q to %q", a.Env, to)
	}

	next := a
	next.Env = to
	return next, nil
}

几个设计点值得说明。

Digest 用 strings.TrimSpace 判空,而不是直接比较空字符串。构建系统、YAML 解析和 shell 变量都可能产生只含空白的值;这类值在日志里看起来“有内容”,实际无法定位任何东西。把它当成空 digest 拒绝,比让它进入 registry 更早暴露问题。

Promote 返回的是 Artifact 的副本,而不是修改入参。Go 的结构体是值类型,这里显式复制并只改 Env,调用方手里的原对象保持不变。这个细节在批量晋级时很重要:一次循环里如果复用同一个变量,原地修改会让后续判断拿到已经被改过的环境。

函数只允许 target == from+1。跳级(dev 直接到 prod)被拒绝,是因为 staging 的证据不能凭空补出来;回退也被拒绝,是因为回退需要单独的审批、迁移兼容检查和审计记录,不能和正常晋级共用一条路径。把“回退”做成另一个函数,比在一个函数里用布尔参数区分两种语义更不容易出错。

还有一个刻意的取舍:函数不校验 order 里是否有重复环境。重复项会让同一个环境名对应多个下标,行为取决于遍历顺序。生产代码应该在加载配置时就去重并校验,而不是在每次晋级时重复检查;这里保持函数小,是为了让测试聚焦在晋级规则本身。

测试:把“不能跳级”变成断言

晋级逻辑最怕的是“今天允许、明天不允许”,所以测试要覆盖正常路径和每一种拒绝路径。

// file: internal/pipeline/artifact_test.go
// tier: L1
// verify: go test ./internal/pipeline/

package pipeline

import "testing"

var order = []string{"dev", "staging", "prod"}

func TestPromoteWalksTheOrder(t *testing.T) {
	a := Artifact{Name: "echo-api", Digest: "sha256:9f2c", Env: "dev"}

	got, err := Promote(a, "staging", order)
	if err != nil {
		t.Fatalf("dev -> staging: %v", err)
	}
	if got.Env != "staging" {
		t.Fatalf("env = %q, want staging", got.Env)
	}
	if got.Digest != a.Digest || got.Name != a.Name {
		t.Fatalf("identity changed: %+v", got)
	}

	got, err = Promote(got, "prod", order)
	if err != nil {
		t.Fatalf("staging -> prod: %v", err)
	}
	if got.Env != "prod" {
		t.Fatalf("env = %q, want prod", got.Env)
	}
	if a.Env != "dev" {
		t.Fatalf("input artifact mutated: %+v", a)
	}
}

func TestPromoteRejectsEmptyDigest(t *testing.T) {
	for _, digest := range []string{"", "   ", "\t\n"} {
		a := Artifact{Name: "echo-api", Digest: digest, Env: "dev"}
		if _, err := Promote(a, "staging", order); err == nil {
			t.Fatalf("digest %q: want error", digest)
		}
	}
}

func TestPromoteRejectsSkipAndBackward(t *testing.T) {
	a := Artifact{Name: "echo-api", Digest: "sha256:9f2c", Env: "dev"}
	if _, err := Promote(a, "prod", order); err == nil {
		t.Fatal("dev -> prod: want skip error")
	}

	b := Artifact{Name: "echo-api", Digest: "sha256:9f2c", Env: "prod"}
	if _, err := Promote(b, "staging", order); err == nil {
		t.Fatal("prod -> staging: want backward error")
	}
}

func TestPromoteRejectsSameAndUnknownEnv(t *testing.T) {
	a := Artifact{Name: "echo-api", Digest: "sha256:9f2c", Env: "dev"}
	if _, err := Promote(a, "dev", order); err == nil {
		t.Fatal("same env: want error")
	}
	if _, err := Promote(a, "canary", order); err == nil {
		t.Fatal("unknown target: want error")
	}

	c := Artifact{Name: "echo-api", Digest: "sha256:9f2c", Env: "qa"}
	if _, err := Promote(c, "staging", order); err == nil {
		t.Fatal("unknown source: want error")
	}
	if _, err := Promote(a, "staging", nil); err == nil {
		t.Fatal("empty order: want error")
	}
}

TestPromoteWalksTheOrder 检查两件事:每一步的环境被正确更新,以及制品身份(名称和 digest)在晋级过程中不变。第二点经常被忽略:如果晋级函数顺手改了 digest,等于在环境之间偷偷重建了制品。

TestPromoteRejectsEmptyDigest 覆盖空字符串、空格和制表符换行。构建脚本里 DIGEST=$(cat digest.txt) 在文件缺失时会得到空值,如果不拦住,这个空值会一路传到部署清单。

TestPromoteRejectsSkipAndBackward 和 TestPromoteRejectsSameAndUnknownEnv 把四条拒绝路径分开断言:跳级、回退、原地重放、未知环境。分开写的好处是失败信息直接指向哪条规则被破坏,而不是笼统的“应该报错”。

测试没有覆盖 registry 的交互,也没有覆盖并发晋级。这些属于集成测试的职责:两个发布任务同时晋级同一个制品时,应该由部署系统用乐观锁或发布单号去重,而不是靠这个纯函数。纯函数负责规则,系统负责并发,这个分工要提前说清楚。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/pipeline/ -run 'TestPromote' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestPromoteWalksTheOrder
--- PASS: TestPromoteWalksTheOrder (0.00s)
=== RUN   TestPromoteRejectsEmptyDigest
--- PASS: TestPromoteRejectsEmptyDigest (0.00s)
=== RUN   TestPromoteRejectsSkipAndBackward
--- PASS: TestPromoteRejectsSkipAndBackward (0.00s)
=== RUN   TestPromoteRejectsSameAndUnknownEnv
--- PASS: TestPromoteRejectsSameAndUnknownEnv (0.00s)
PASS
ok  	webapp/internal/pipeline	0.383s

四个用例全部通过,退出码为 0。-run 'TestPromote' 只筛了晋级相关用例,同包的平台准入和采纳成本测试没有执行;发布前应去掉 -run 跑全量。

小结

环境的价值在于提供逐级增强的证据,而证据只有指向同一个制品时才有效。制品一旦可变,环境之间就不再是晋级关系,而是三次互不相关的构建。发布流水线要做的,是把“构建一次、逐级验证、按 digest 回滚”变成系统强制,而不是流程文档里的建议。

晋级顺序看起来只是三个字符串的排列,但它承载的是责任转移:进入 staging 意味着团队负责人背书,进入 prod 意味着发布值班和审批背书。把这个顺序写进代码,才能让每一次转移留下可核查的记录。

下一节讨论内部平台的能力边界:当发布、日志、缓存都收归平台,平台到底承诺什么、不承诺什么,必须用 SLA 写清楚。

阅读导航:上一节:目录与学习路径 · 下一节:8.2 内部平台的能力边界 。

常见错误与边界

  • 用 latest 或日期标签作为部署引用。标签会被覆盖,历史发布记录随之失真;digest 才是可审计的引用。
  • 在每个环境重新构建。即使 commit 相同,依赖解析、基础镜像和构建缓存都可能不同,验证证据无法传递。
  • 把“回滚”实现成“重新部署上一个备份目录”。备份目录没有元数据,无法确认它对应哪个 commit、哪个 digest。
  • 认为配置不同就必须重新构建。配置应该在启动时注入或挂载,二进制保持同一份;把环境差异编译进制品,等于制造了多个版本。
  • 只保留最近几个制品。跨版本回滚需要更长的保留窗口,保留策略要跟发布频率和合规要求一起定。
  • 让晋级函数承担部署副作用。纯函数负责规则,registry、审批和并发控制放在系统层,否则规则无法单独测试。
  • 忽略数据库迁移的向前兼容。代码可以回滚,schema 往往不能立刻回滚;迁移必须先加后删,至少兼容一个版本。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查