持续交付做了两年,我发现最难的从来不是流水线,是那几句 SQL

🔑 关键词:持续交付,Argo CD,金丝雀发布,数据库迁移,特性开关

📖 摘要:一篇不带清单体的持续交付踩坑记录:从 Jenkins 到 Argo CD 的选型对比、一次锁表事故、扩展-收缩式数据库迁移的具体 SQL、Argo Rollouts 金丝雀配置,以及一个不太主流的观点——回滚是失败的策略。

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: 0maxSurge: 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 天。这个洞我还没填上。

🏷️ 标签: