
最近折腾了部署流水线,踩了几个坑,这篇把问题说清楚。
一个服务到线上只有一条路。流水线不需要知道自己在部署哪个服务——它只读 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.
然后 dev 就跟着跑到各个消费版本的地方去了:
dev 写的什么都不崩,什么都不报警。错误在除了版本字段之外的所有地方都不可见——然后你在最需要版本字段的那天发现它。那天,恰恰是按设计来说最糟糕的一天。
我们这里有两个加重因素:
-X。所以这不是一个服务的错误——这是默认值。Makefile 和 Dockerfile 里。只改 Makefile,本地构建能正确 stamp,但镜像——真正跑的那个——不能。现在本地和生产不一致,而你会去检查的恰恰是本地。flags 指向平台的符号,必须在两个文件里一起改,否则修了等于没修。
面对"模板带了个死目标"这个问题,显而易见的回答是定个规矩:始终指向平台符号,始终在两个文件里都写。我写过这种规矩。管用——直到写下一个服务的人没看过规矩,这是一定的。
真正管用的是检查。有一个构建目标:
大概长这样:
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.
问题说完了,来看整个交付面。三条流水线,对所有服务都一样。
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ │ 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 │ │ │ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘
推送 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"。一个文件,一个参数。
这是长的那个。每个 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,在那里想跑多久跑多久。
部署是手动触发的流水线运行,只需要两个输入:
| 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编辑流水线——这才是让"一条路对所有服务"从口号变成属性的关键。
反过来想是检验这个是否真的成立的有用测试:如果某个服务在部署流水线里需要特殊处理,那一条路就废了。所以特殊处理要么被整合进通用形式,要么不被构建——是的,这是真实的约束,不是白捡的。后面还会说这个。
顺便说一句,二进制怎么组合,不是流水线定的,也不是实现者定的。新的后台工作进已有的 daemon。新增 cmd/* 是 owner 级别的决定,写进给所有人看的指导方针里作为禁止——因为"我就加个二进制"正是让 fleet 里长出没人部署的东西的方式。
三个决定。不是"我能想到三个"——三个,这是完整列表。
| 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 |
第三个附带的规则最严,是关于人的,不是关于工具的:
最后这条是出了事故才学到的。"现在就解开了先用 pseudo-version,之后再修 tag"会产生一个绿的构建,带着没人选的版本。这和文章开头那个 dev 是同类失败:构建是绿的,但在说谎。
两个老实说在前面,因为"一条路对所有服务"太好吹过头了。
部署流水线里没有等健康检查的步骤。没有。平台提供 /health 和 /ready,部署后的检查由环境做,不是由推送镜像的流水线做。所以流水线结束不意味着新版本起来了在服务——意味着镜像被推送了。绿色勾勾暗示的和它保证的之间有真实的 gap,我宁愿说出来,也不让读者以为这事儿被盖住了。
coverage threshold 目前是 0。ratchet 接好了管用——只能往上走,会拒绝试图降低它的 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 │ └──────────────────────────────────────────────────────────────────┘
我不觉得这是免费的,代价是具体的。
特殊处理必须能通用化,否则就不能存在。一旦某个服务需要共享流水线不支持的东西,只有两条路:让流水线为所有人通用化,或者不做这个事。没有第三条路。这是单服务被允许是什么样子的真实限制,是流水线里没有服务特定知识所付的代价。
手动部署意味着链条里有人。两个字段是个小要求,但仍然是个人——得醒着、在线、对着哪个版本去哪里的判断正确。人类在交付链路里带来的所有后续问题——时区、bus factor、在下拉框里输错值——都在这里。
ratchet 不能降低,即使降低是对的。单向阀只有在它挡的方向都是不想去的方向时才是好的。coverage 下降有正当理由——比如删掉一个测试繁重的已废弃子系统——但机制不知道这个区别。它会拒绝 PR,一视同仁。
版本检查是检查,不是正确性的保证。它证明了符号存在、字符串进了二进制。它不能证明字符串是你想要的那个 tag。
如果只记住一句:流水线不应该知道服务没有声明的任何东西。我的流水线知道的一切——有哪些二进制、什么可以被部署——都来自一个文件。而当某处可以无声地出错时,比如链接器 flag 指向空,修复方式是让错误产生输出,不是写条规矩让人小心。
这是我目前的答案和目前的账单。如果你做得更好,如果你已经走过了,或者你看下来想法不一样——我想听听你们那边怎么解决的,特别是中间什么东西坏过。
开箱即用的运维——第四部分。
下一篇:为什么生成和运维不是两个问题——论证一个不能开箱即用的服务,从一开始就没有被正确生成。