site logo

Marico's space

部署只是 Manifest 的结果

编程技术 2026-09-17 14:50:50 3

最近折腾了部署流水线,踩了几个坑,这篇把问题说清楚。

一个服务到线上只有一条路。流水线不需要知道自己在部署哪个服务——它只读 Manifest(服务声明文件)。剩下的事情,人要做三个决定,外加验证一下版本字符串真的在二进制里。

那个永远绿着的缺陷

先说失败案例,因为正是这个坑塑造了后面的所有东西。

Go 通过链接器标志(linker flags)把构建元数据压入二进制文件:

-ldflags "-X example.com/platform/lib/go/platform.Version=$(VERSION) \ -X example.com/platform/lib/go/platform.Commit=$(COMMIT)"

问题来了:链接器会静默忽略指向不存在符号的 -X。不报警,不报错非零退出。构建是绿的,镜像推送了,registry 里的 tag 也看着对,但二进制里的变量还是默认值——dev

 -X points at a symbol that does not exist │ ▼ build is green — no warning, no non-zero exit │ ├──▶ version = dev in the audit trail └──▶ version = dev in the trace attribute The error is invisible everywhere except the version field.
Three boxes: -X points at a symbol that does not exist, arrow to

然后 dev 就跟着跑到各个消费版本的地方去了:

  • 审计日志——每条记录都说自己是被 dev 写的
  • trace 的 version 属性——三次发布的 span 混在一起分不开

什么都不崩,什么都不报警。错误在除了版本字段之外的所有地方都不可见——然后你在最需要版本字段的那天发现它。那天,恰恰是按设计来说最糟糕的一天。

我们这里有两个加重因素:

  1. 模板历史性地带了个死目标。 每个从模板生成的服务都继承了一个指向不存在符号的 -X。所以这不是一个服务的错误——这是默认值。
  2. 有两处要改,只改一处比不改更糟。 flags 同时在 MakefileDockerfile 里。只改 Makefile,本地构建能正确 stamp,但镜像——真正跑的那个——不能。现在本地和生产不一致,而你会去检查的恰恰是本地。

flags 指向平台的符号,必须在两个文件里一起改,否则修了等于没修。

一个检查,而不是多加小心

面对"模板带了个死目标"这个问题,显而易见的回答是定个规矩:始终指向平台符号,始终在两个文件里都写。我写过这种规矩。管用——直到写下一个服务的人没看过规矩,这是一定的。

真正管用的是检查。有一个构建目标:

  1. 一个故意伪造的版本字符串构建两个二进制
  2. 从生成的二进制里 grep 出那个字符串
  3. 找不到就失败

大概长这样:

verify-version: go build -ldflags "-X <platform-symbol>.Version=vX.Y.Z-probe" -o bin/server ./cmd/server strings bin/server | grep vX.Y.Z-probe

输出为空意味着符号错了。就这么简单。不在乎符号错是因为有人改了包名,还是模板本来就是错的,还是复制粘贴漏了一段路径——probe 字符串要么在字节里,要么不在。

这和这个系列其他地方的思路是一样的:当某件事可能无声无息地出错时,答案不是更加小心,而是让错误产生输出。一个你违反但构建仍然通过的规则不是控制。

 build both binaries with a deliberately fake version string │ ▼ strings bin/server | grep vX.Y.Z-probe │ ├──▶ the string is found ──▶ the symbol exists └──▶ empty output ──▶ the symbol is wrong A rule you can violate while the build stays green is not a control.
A probe build stamped with a deliberately fake version string, grepped back out of the binary: the string found means the symbol exists, empty output means the symbol is wrong

三条流水线,所有服务通用

问题说完了,来看整个交付面。三条流水线,对所有服务都一样。

 ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ │ build │ │ tests │ │ deploy │ │ on tag vX.Y.Z │ │ on PR · on push · │ │ manual │ │ │ │ nightly │ │ │ │ one Dockerfile, │ │ │ │ two parameters: │ │ one build-arg per │ │ 1 build │ │ │ │ binary │ │ 2 linter │ │ version │ │ │ │ 3 containers up │ │ server / worker / │ │ images to the │ │ (one script) │ │ all │ │ registry under the │ │ 4 run with coverage │ │ │ │ version tag │ │ 5 time budget │ │ then one step, the │ │ │ │ 6 teardown │ │ same step for every │ │ │ │ if: always() │ │ service │ │ │ │ 7 coverage report │ │ │ │ │ │ 8 threshold check │ │ │ │ │ │ 9 ratchet │ │ │ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘
Three vertical columns: build on tag, tests on PR/push/nightly, deploy manual with two parameters

1. 构建——打 tag 时触发

推送 tag vX.Y.Z 时触发。是个矩阵任务,遍历所有二进制:每个都从同一个 Dockerfile出来,用参数指定构建哪个二进制:

docker build --build-arg BINARY=server .
docker build --build-arg BINARY=worker .

镜像以版本 tag 推到 registry。版本和 commit 用上面的 ldflags stamp——这就是上一节那个检查存在的原因。

注意这里没有的东西:没有 per-service Dockerfile,没有 per-binary Dockerfile,没有"server 有自己的构建因为它需要 X"。一个文件,一个参数。

2. 测试——PR、push 和夜间

这是长的那个。每个 PR、每次 push 到 main 分支、还有定时夜间任务。

# Step What it's for
1 build fail fast before spending anything on infrastructure
2 linter style and static checks
3 bring up test containers one script, shared containers, before the run
4 run tests with coverage the actual pass
5 time budget a separate step that fails when the run gets too slow
6 tear down containers if: always() - runs even when the tests failed
7 coverage report produce the number
8 threshold check compare against the declared minimum
9 coverage ratchet the number may not go down

有两个值得多说几句。

容器是共享的,每次运行一套。 在 pass 之前由一个脚本启动,不是 per test,不是 per package,不是 per worker。测试之间的隔离靠数据和 schema,不是靠再起一个数据库。teardown 步骤带 if: always(),因为你要防备的失败场景恰恰是那个有意思的:测试失败了,job 短路了,容器泄漏了,下一次运行变慢了,runner 最后倒了。只在成功时跑的 teardown 不是 teardown。

ratchet。 阈值检查拿 coverage 和声明的最低值比。ratchet 拿声明的最低值和它自己的上一个值比:你可以往上加,不能往下减,想减就 PR 失败。这是一个单向阀,否则这个数字会悄悄下降,一次一个"之后我再修测试"。

还有一个单独的定时任务:带 race detector 和 volume 测试的运行,加 -p 1。最后这个 flag 不是装饰——package 串行跑,这样没有别的东西和它抢机器,这是唯一能让跨 run 比 ns/op 有意义的情况。race 和 volume 不属于 PR 路径;它们属于 schedule,在那里想跑多久跑多久。

3. 部署——手动,两个参数

部署是手动触发的流水线运行,只需要两个输入:

Parameter Values
version the tag to ship
which services server, worker, all

之后一步,通用:把这个版本的镜像推入选定的环境。

"手动"的意义不是仪式感。发布那一刻是个决策,我想要一个人来做——但我希望这个人做的事情只是填两个字段,不是记一串命令。

流水线从哪里知道要部署什么

现在到了让"所有服务通用"从口号变成实际的那部分。

这些流水线里没有任何服务特定的东西。它们不可能有,因为它们不知道任何不在 Manifest 里的东西:

service: name: <service> version: 0.1.0 daemons: - name: server handlers: [grpc] - name: worker handlers: [scheduler]

这个声明是三个流水线的信息来源:

Pipeline What it reads off the manifest
build the list of daemons → the build matrix (--build-arg BINARY= per daemon)
deploy the same list → the allowed values of the "which services" parameter
tests nothing service-specific at all - it's the same pass everywhere

声明一个 daemon,构建矩阵就多一行,部署参数就多一个选项。没人编辑流水线。没人per service编辑流水线——这才是让"一条路对所有服务"从口号变成属性的关键。

A service manifest on the left and what each pipeline reads off it on the right: build takes the list of daemons as its matrix, deploy takes the same list as its allowed values, tests read nothing service-specific

反过来想是检验这个是否真的成立的有用测试:如果某个服务在部署流水线里需要特殊处理,那一条路就废了。所以特殊处理要么被整合进通用形式,要么不被构建——是的,这是真实的约束,不是白捡的。后面还会说这个。

顺便说一句,二进制怎么组合,不是流水线定的,也不是实现者定的。新的后台工作进已有的 daemon。新增 cmd/* 是 owner 级别的决定,写进给所有人看的指导方针里作为禁止——因为"我就加个二进制"正是让 fleet 里长出没人部署的东西的方式。

剩下给人做的是什么

三个决定。不是"我能想到三个"——三个,这是完整列表。

Three lines under the heading
Decision Why it stays human
environment which contour receives this version
secrets what the service is handed at runtime
tag which version exists at all

第三个附带的规则最严,是关于人的,不是关于工具的:

  • 一个人打 tag。不是 bot,不是谁收尾的。
  • 禁止 pseudo-version。不是"不鼓励"。
  • 服务只能携带被给的那个 tag。如果依赖因为 tag 还不存在而解不开,答案是等 tag,不是用能解开的什么代替。

最后这条是出了事故才学到的。"现在就解开了先用 pseudo-version,之后再修 tag"会产生一个绿的构建,带着没人选的版本。这和文章开头那个 dev 是同类失败:构建是绿的,但在说谎。

流水线里没有的东西

两个老实说在前面,因为"一条路对所有服务"太好吹过头了。

部署流水线里没有等健康检查的步骤。没有。平台提供 /health/ready,部署后的检查由环境做,不是由推送镜像的流水线做。所以流水线结束意味着新版本起来了在服务——意味着镜像被推送了。绿色勾勾暗示的和它保证的之间有真实的 gap,我宁愿说出来,也不让读者以为这事儿被盖住了。

coverage threshold 目前是 0ratchet 接好了管用——只能往上走,会拒绝试图降低它的 PR——但基准本身还没设。实际 coverage 是 86.7%。所以机制是真实的,数字还没生效;老实说就是"阀门装好了,压力还没调"。

还有第三件事值得说明:服务里没有部署 UI。部署就是上面说的参数化流水线运行,就这些——比之前进步的地方是它是两个字段的表单,不是记一串命令。

 ┌──────────────────────────────────────────────────────────────────┐ │ no wait-for-health step in the deploy pipeline │ │ the pipeline finishing means the image was pushed, not that │ │ the new version is up and serving │ ├──────────────────────────────────────────────────────────────────┤ │ the coverage threshold is currently 0 │ │ actual coverage is 86.7% — the valve is installed, │ │ the pressure has not been dialled in │ ├──────────────────────────────────────────────────────────────────┤ │ no deploy UI inside the service │ │ a form with two fields instead of a remembered sequence │ │ of commands │ └──────────────────────────────────────────────────────────────────┘
Three named absences: no wait-for-health step in the deploy pipeline, a coverage threshold still set to zero against actual coverage of 86.7 percent, and no deploy UI inside the service

代价

我不觉得这是免费的,代价是具体的。

特殊处理必须能通用化,否则就不能存在。一旦某个服务需要共享流水线不支持的东西,只有两条路:让流水线为所有人通用化,或者不做这个事。没有第三条路。这是单服务被允许是什么样子的真实限制,是流水线里没有服务特定知识所付的代价。

手动部署意味着链条里有人。两个字段是个小要求,但仍然是个人——得醒着、在线、对着哪个版本去哪里的判断正确。人类在交付链路里带来的所有后续问题——时区、bus factor、在下拉框里输错值——都在这里。

ratchet 不能降低,即使降低是对的。单向阀只有在它挡的方向都是不想去的方向时才是好的。coverage 下降有正当理由——比如删掉一个测试繁重的已废弃子系统——但机制不知道这个区别。它会拒绝 PR,一视同仁。

版本检查是检查,不是正确性的保证。它证明了符号存在、字符串进了二进制。它不能证明字符串是你想要的那个 tag。

一个结论

如果只记住一句:流水线不应该知道服务没有声明的任何东西。我的流水线知道的一切——有哪些二进制、什么可以被部署——都来自一个文件。而当某处可以无声地出错时,比如链接器 flag 指向空,修复方式是让错误产生输出,不是写条规矩让人小心。

这是我目前的答案和目前的账单。如果你做得更好,如果你已经走过了,或者你看下来想法不一样——我想听听你们那边怎么解决的,特别是中间什么东西坏过。

开箱即用的运维——第四部分。

下一篇:为什么生成和运维不是两个问题——论证一个不能开箱即用的服务,从一开始就没有被正确生成。