2022 年 3 月,我们组 Jenkins 上挂着 6 条流水线。最长的一条从 git push 到生产落地要 47 分钟,其中 31 分钟在跑单元测试和 SonarQube 扫描。当时我笃定一件事:持续交付就是把慢的地方搞快。
两年后回头看,这个判断只对了三分之一。我们确实把部署频率从每周 2 次提到了日均 3.5 次,变更前置时间从 11 天压到 5 小时 20 分。但同一时期,变更失败率从 12% 涨到 19%,MTTR 从 38 分钟涨到 1 小时 52 分。加速和稳定没同时拿到,反而互相拖后腿。下面这些是我自己复盘出来的东西,没有最佳实践清单,信不信随你。
先把一个误区拆掉:CD 不等于 CI 加自动部署
CI 解决的是「这段代码能不能合进主干」,CD 解决的是「这次变更能不能安全地暴露给用户」。前者是工程问题,后者是风险和权限问题——这个区别我当年完全没意识到,所以走了很久弯路。
我们最早的做法特别典型:Jenkinsfile 三百多行,mvn package 打完包 scp 到跳板机,ssh 过去 docker restart,加一句 sh 'set -e' 就算有回滚能力了。这套东西跑起来很爽,直到 2022 年 7 月的一个周五下午。
那次发布改了订单表,加了一个 NOT NULL DEFAULT 0 的列。MySQL 5.7 里这个操作会把 800 万行的表锁住重写,前端 504 一片,告警 4 分钟后才响。回滚方案是什么?把镜像 tag 换回上一个。但数据库的列已经加上了,旧版本代码读的时候直接报字段不存在。从告警到恢复一共 2 小时 40 分钟,其中 90 分钟大家在争论到底该回滚还是往前修,因为没人知道往前修要多久。
这件事让我得出一个有点粗暴的结论:持续交付的瓶颈从来不在流水线,在数据层。
数据库变更:扩展-收缩,别想着一步到位
后来我们把所有 schema 变更强制拆成三阶段,叫扩展-收缩,其实思路很朴素:任何时刻,新旧两个版本的代码都必须能同时读同一张表。
阶段一只加列,不带约束。PostgreSQL 11 之后加带默认值的列不会再重写表,MySQL 8.0.12 之后加列在表末尾可以用 ALGORITHM=INSTANT,但这些都有前提条件,稳妥起见我们统一加成 NULL:
ALTER TABLE orders ADD COLUMN status_v2 varchar(32) NULL;
执行前一定先 SET lock_timeout = '3s',拿不到锁就直接失败重试,绝不让一个 DDL 堵住整张表的读写。这个 3 秒是拍脑袋定的,但比默认无限等待好太多。
阶段二应用双写、历史数据分批回填:
UPDATE orders SET status_v2 = status WHERE status_v2 IS NULL LIMIT 5000;
5000 行一批,每批之间 sleep 200ms,主从延迟超过 1 秒就暂停。800 万行跑完大概 40 分钟,这 40 分钟里线上是正常的,因为没有任何一列被改。
阶段三才切读路径、加约束、删旧列。删旧列这事我们吃过亏:原计划发布后 3 天删,结果一个数据报表任务还在读老列,直接把报表跑挂了。现在规定旧列至少保留 14 天或者跨两个发布周期,删之前先 grep 一遍代码仓库和 BI 平台的数据源配置。gh-ost 和 pt-online-schema-change 我们也用过,gh-ost 适合 MySQL 大表,pt-osc 对触发器多的表不太友好,但它们只能解决 DDL 锁表,解决不了「新旧代码同时读一张表」这个问题。
工具对比:别被 Jenkins 的历史包袱带走
我们把主流水线从 Jenkins 迁到 GitLab CI,生产侧换成 Argo CD,中间折腾了大半年。维度摊开大概是这样的:
| 维度 | Jenkins | GitLab CI | GitHub Actions | Argo CD |
|---|---|---|---|---|
| 执行模型 | 长驻 agent,master 是单点 | Runner 主动拉取 | Runner 主动拉取 | Pull,集群内 controller |
| 生产凭证位置 | 常在 Jenkins 凭据里 | 项目变量 | Secrets | 集群内,CI 拿不到 |
| 回滚方式 | 重跑旧构建 | 重跑旧 pipeline | 重跑 workflow | git revert 或 abort |
| 生态 | 1800 多个插件 | 内置为主 | Marketplace 两万多个 action | K8s 原生 |
| 自维护成本 | 高,插件冲突是日常 | 中 | 低 | 中,要管 Git 仓库权限 |
但我更想说的是选型背后那件事。Jenkins 最大的问题不是慢,是生产凭证躺在 master 上。我们当时的 AWS key 就在 Jenkins 凭据里,理论上所有能改 job 配置的人都拿得到。Argo CD 的 pull 模式把这个问题解掉了:CI 只负责把新镜像 tag 写进 Git 仓库,集群里的 controller 自己拉,生产凭证从头到尾没离开过集群。这一步对我们过审计的帮助,比任何流水线提速都大。代价是 Git 仓库变成了唯一真相源,谁手抖改一下 manifest 就是一次线上变更,后来靠 Branch Protection 加强制 review 挡住的。
金丝雀:1% 到 5% 到 25%,每一步步长都别偷懒
我们用 Argo Rollouts,配置大概是这个形状:
strategy:
canary:
steps:
- setWeight: 5
- pause: {duration: 10m}
- setWeight: 25
- pause: {duration: 15m}
- analysis:
templates:
- templateName: success-rate
- setWeight: 100
真正关键的不是这段 YAML,是分析模板里的 PromQL:
sum(rate(http_requests_total{service="order-service",status=~"5.."}[5m]))
/
sum(rate(http_requests_total{service="order-service"}[5m])) < 0.01
失败自动 abort。但这里有个特别阴的坑:如果新版本的问题是「慢」而不是「错」,5xx 率根本不涨,金丝雀会一路放量到 100%。所以 P99 延迟也必须进分析模板,我们的阈值是 P99 小于 800ms,超 15 分钟就 abort。
还有一个更阴的:K8s 滚动更新里 maxUnavailable: 0 配 maxSurge: 1,在小集群会卡死——新 Pod 调度不上去,progressDeadlineSeconds(我们设的 600)到点就标记失败,可旧 Pod 还好好跑着。表面上 Deployment 是 failed 状态,流量其实一点没断。这种「假失败」最容易让人误判成发布事故。readinessProbe 也踩过:我们的服务是 JVM,冷启动 45 到 60 秒,一开始 initialDelaySeconds 设的 5、periodSeconds 10、failureThreshold 3,结果 Pod 活着但一直没就绪,30 秒后被杀,无限重启,滚动更新永远走不完。改成 initialDelaySeconds: 30 / failureThreshold: 6 才稳。
一个我不太主流的观点:回滚是失败的策略
很多团队把「能一键回滚」当成 CD 成熟的标志,我的看法正好相反——回滚次数多,说明你的变更粒度太大了。
我们现在的默认做法是特性开关。新代码上线但不生效,开关在配置中心,按用户 ID 尾号 0 到 9 灰度。出问题时不是回滚代码,是把开关关掉,生效时间大概 3 秒;代码回滚要重新走构建、拉镜像、滚动更新,最快也要 4 分钟。数据上看,特性开关上线后我们的「回滚次数」从每月 6 次降到 1.8 次,但这 1.8 次都是真的必须回滚代码的,配置类的全走开关了。
代价是技术债。开关会腐烂,三个月不清就是一堆死代码加一堆没人记得的 if 分支。我们定了规矩:开关创建 30 天内必须清理,超过 60 天自动告警。执行得也不算好,大概有一半会超期,但比没有规矩强。
什么团队不该上持续交付
说点不合时宜的。三种情况我建议先别折腾:全职开发少于 5 个人、又没有专职运维的,你们维护 Argo CD 加监控加告警的时间可能比手工发布还多;没有自动化测试、关键路径覆盖率低于 30% 的,CD 只是把 bug 更快地送到用户面前;再就是单体应用、一年发版 4 次以内、业务对停机也不敏感的,优化发布流程的投入产出比很低,不如先去修数据库慢查询。
DORA 2023 那版报告里精英团队的数字是变更失败率 5%、失败恢复时间小于 1 小时。我们现在是变更失败率 7.2%、MTTR 26 分钟,还没到精英线,但比两年前好太多。最后说一个我自己都没解决的问题:CD 让发布变便宜之后,产品那边提需求的频率也跟着上去了,需求评审反而成了新瓶颈。我们现在从需求进 backlog 到上线平均 9 天,其中写代码只占 2 天。这个洞我还没填上。