持续交付落地实录:把 Jenkins 换成 GitLab CI 之后,我们的部署失败率从 8% 涨到了 21%

🔑 关键词:持续交付, CI/CD 流水线, 数据库变更, 金丝雀发布, DORA 指标

📖 摘要:一个 32 人研发团队的持续交付踩坑记录:构建时间从 14 分钟压到 4 分 20 秒,部署失败率却翻了一倍多。217 次失败部署的原因分布、expand/contract 字段变更的三个发布节奏、gh-ost 在线 DDL 的具体耗时、金丝雀发布的流量阈值,以及一个关于「部署频率」不太受欢迎的观点。

持续交付落地实录:把 Jenkins 换成 GitLab CI 之后,我们的部署失败率从 8% 涨到了 21%

图片

先说结论,省得你划走:持续交付的瓶颈,九成不在流水线跑得够不够快,而在你的数据库能不能回滚、你的制品是不是同一个东西、你的 staging 和 prod 是不是真的是同一套环境。我们花了大半年才想明白这件事,代价是把部署失败率从 8% 亲手干到 21%,再花四个月把它压回 3% 以下。

一、2021 年那个夏天,我们做了一件现在想想挺蠢的事

那时候我在一家做 B 端 SaaS 的公司,研发 32 个人,后端 18 个,前端 9 个,剩下是测试和运维。产品线三条,最大的那个服务跑在 12 台 4C8G 的机器上,MySQL 5.7 单实例,4200 万行的 orders 表。

原来的 Jenkins 是 2.289.1,pipeline 写在一个 3100 多行的 Groovy 脚本里,谁都不敢动。一次完整的构建加部署,从点按钮到服务真的起来,平均 14 分钟。开发者提 PR 之后,本地跑单测要 6 分钟,等流水线要 14 分钟,一天下来真正写代码的时间被切得很碎。

图片

于是我们做了个决定:迁到 GitLab CI,把构建时间压下来。这个决定本身没错,错的是我们以为构建快了,交付就快了。

结果:构建时间从 14 分钟压到 4 分 20 秒(Maven 依赖做成本地 nexus 缓存 + Docker 分层镜像 + 单测按 package 切成 4 个 shard 并行)。但上线之后两个月,我们拉了一下数据:部署频率从每周 4.3 次提到了每天 5.6 次,变更失败率从 8% 涨到了 21%,平均恢复时间从 26 分钟涨到了 41 分钟。 DORA 四个指标里,我们只有一个变好了,另外三个全在退步。

二、根因不是流水线,是没人管数据库变更的时序

等等,这里我先插一句。那段时间我在内部群里被人问最多的问题是「Jenkins 和 GitLab CI 哪个好」,我每次都回答「这问题问错了」。工具选型能带来的收益,天花板大概就是构建那十几分钟;真正的坑在下面。

图片

我们把失败原因做了分类统计,217 次失败部署里:数据库迁移冲突或迁移后代码不兼容 89 次,占 41%;镜像 tag 用了 latest 导致回滚回到错误版本 37 次,占 17%;staging 和 prod 环境配置不一致导致 34 次,占 16%;代码本身逻辑 bug 31 次,占 14%;其他(网络、证书过期、磁盘写满)26 次,占 12%。

你看,代码 bug 只占 14%。我们花了两个月优化的构建速度,影响的其实就是那个 14%。剩下的 86%,流水线跑得再快也救不了。

最典型的一次事故:一个后端同学在同一个 PR 里既改了 schema 又改了代码。迁移脚本把 users 表的 nickname 字段从 varchar(32) 改成 varchar(64),同时新代码加了个校验逻辑。迁移在部署时先跑,跑了 2 分 40 秒(4200 万行的表,加索引那步锁了表),这 2 分 40 秒里旧代码还在对外服务,写进来的数据被按老规则截断了——因为旧代码以为字段还是 32 位。三个客户的昵称变成了乱码,客服第二天早上才收到投诉。

修的时候更麻烦。字段长度改回去了,但被截断的数据找不回来,只能从 binlog 里捞。运维同学花了一整个通宵,用 mysqlbinlog 从凌晨的备份加 binlog 里重放出那 2 分 40 秒的写入,恢复了 400 多条记录,还有 11 条确实丢了,最后给客户赔了钱。

图片

三、我们把规则改成了这样(附具体参数)

从那之后我们定了几条硬规则,写进 CONTRIBUTING.md,code review 的时候卡。前三个月有人天天在群里骂,第四个月没人提了。

规则一:schema 变更和代码变更永远不在同一个发布里。 用 expand/contract 模式,一次字段变更拆成三个发布。第一是 Expand:加新列,只加不删不改,比如给 orders 加 status_v2 varchar(16) DEFAULT NULL,用 gh-ost 做在线 DDL,4200 万行的表加列花了 38 分钟,全程不锁表。第二是双写:代码同时写 status 和 status_v2,读还是读老的,这个状态保持至少 7 天;回填脚本按主键分片,每批 5000 条,批与批之间 sleep 200ms,避免主从延迟,整个回填跑了 3 小时 15 分。第三是 Contract:读切到 status_v2,观察 2 周,确认没问题再删 status 列——删列这步我们拖了一个半月,因为有个报表系统的 SQL 是写死的,谁也不知道。

图片

规则二:制品必须不可变,禁止 latest。 Docker 镜像 tag 用 git sha 的前 12 位。我们在 CI 里加了个检查,manifest 里出现 :latest 直接 fail。同时每次部署都落一份 deploy manifest,记录这次上线用的镜像 digest、配置文件对应的 git commit、migration 版本号,存到 S3 上按天分目录。回滚的时候读这个 manifest,而不是靠人回忆「上次到底是哪个版本」。这一条把回滚时间从 40 分钟左右压到了 90 秒,因为回滚不再是「重新构建上一个版本」,而是「把上一个 digest 重新拉起来」。

规则三:feature flag 必须有过期时间。 我们用开源的 Unleash。每个 flag 创建时必须填 owner 和 expires_at,默认 90 天。CI 每天凌晨扫一遍,快过期的在群里 @ owner。有人觉得烦,但清理的时候发现,大约 60% 的 flag 是全量开启状态挂了半年以上的——那其实就是死代码,只是没人敢删。

规则四:金丝雀不是走过场。 用 Argo Rollouts,流量按 5% → 20% → 50% → 100% 四步走,每步观察 10 分钟。观察指标不是「有没有报错」,而是三个:P99 延迟、5xx 比例、以及一个业务指标(我们这个产品是下单成功率,阈值定在 99.2%)。任何一项不达标自动回滚。你可能会问 5% 够不够发现问题——我的经验是,如果一个问题只在 0.1% 的请求里出现,5% 流量跑 10 分钟大概能碰上几十次,够告警了;但如果你的日活本来就只有几千,那这个比例得往上调,否则 10 分钟里可能一个样本都没有。

四、一个不太受欢迎的观点

图片

最后说点可能得罪人的。现在市面上讲持续交付的内容,很喜欢强调「部署频率」这个指标,好像一天不部署个几十次就不算 DevOps。我不同意。部署频率是结果,不是目标。我们后来稳定在每天 8 到 12 次部署,变更失败率 2.7%,MTTR 11 分钟,这个水平在一个 30 多人的团队里我觉得够用了。再往上堆频率,边际收益是负的——因为你每一次部署都要有人盯指标、有人判断要不要回滚,这个注意力成本是真实存在的,而且它不会因为你的流水线快了就消失。

另一个被执行坏的东西是 trunk-based development。原则没错,但它有个前提:你的测试得能给你足够快的真实反馈。我们的集成测试套件跑一遍 19 分钟,这就是为什么我们最初不敢让大家随便往主干合。后来把测试拆成「PR 必跑的 4 分半」和「合并后跑的 19 分钟」两档,PR 那档只跑被改动模块的单测加 12 条核心链路,才慢慢敢这么做。如果你现在的测试套件要跑半小时以上,先别谈主干开发,先谈怎么切它。

还有一点很少有人提:别把 CD 当成一个项目,它更像一份需要持续维护的账本。 我们的 flag 台账、migration 版本表、环境配置的 helm values,都是活的东西,没人管就会烂。我们现在每个月最后一个周五下午留两个小时做「交付债清理」——删过期 flag、核对废弃的 migration 记录、diff 一遍 staging 和 prod 的配置。这俩小时经常被别的事挤掉,但一个月能做一次,就比完全不做强。

如果你现在正准备做持续交付,我的建议是:先别买工具,先花两周老老实实记录数据。每次部署记开始时间、结束时间、失败原因、谁处理的。两周之后你会得到一份属于你自己团队的失败原因分布表。那份表比任何一篇方法论文章都有用,因为它会直接告诉你,你该先动哪一块。

🏷️ 标签: