不少团队在搭建部署流水线时,习惯让代码分支直接映射部署环境:qa 分支对应测试环境,staging 分支对应预发环境,main 分支对应生产环境。功能先合并进 qa 验证,测试无误后再合并进 staging,最后合进 main 触发生产发布。

同时在测的功能少的时候这样很直观。拿一个订单服务举例:同一周里,新结算流程 feature/new-checkout 和优惠券改版 feature/coupon-v2 都合进了 qa 在测,周三生产上又发现运费算错,要紧急修。

长期环境分支的维护成本#

环境分支的麻烦集中在三处:分支越分越开,每个环境跑的不是同一个镜像,功能之间互相卡住发版。

分支漂移(branch divergence)最先出现。运费的修复为了赶时间直接提交到 main 发了,没人记得合回 staging 和 qa,测试环境里这个 bug 还在,测试人员又报了一遍。qa 上这几天恰好也改过运费相关的代码,下次从 qa 一路合到 main 时会在同一段代码上冲突,解冲突时稍不注意就把修复盖掉。反方向也一样:测试中发现的小问题直接修在 qa 上,发版时漏拣一个,main 就少了这个修复。

构建也不一致。每个环境监听自己的分支各自打包,新结算流程在 QA 上测的是 qa 分支构建的镜像,上线时生产又从 main 构建一次。两次构建之间,冲突的解法可能不同,没锁版本的依赖和基础镜像也可能已经更新,QA 验过的和上线的并不是同一个制品。

功能之间还会互相卡住。结算流程测完要发,优惠券改版测出缺陷要延后,可两边的提交已经在 qa 上交织在一起,很难只把结算流程推进到 staging,只能回退优惠券的提交或者重新整理分支。

环境分支为什么被当成反模式#

Kostis Kapelonis 在 Stop Using Branches for Deploying to Different GitOps Environments(2021-12-17)里直接把用分支区分环境称为 anti-pattern。他的建议分两层:环境配置放在单独的仓库,每个环境一个目录(Kustomize 的 overlay、Helm 的 values 文件),不按分支分;应用源码怎么分支不做硬性要求,需要按环境开关的功能用主干开发加 feature flag 处理。

换成主干开发也有前提。DORA 的年度调查从 2014 年起每年都观察到主干开发对交付表现是正向的,2022 年的报告第一次出现反例:整体上主干开发拉低了交付表现。按从业年限拆开看,16 年以上经验的受访者用主干开发后交付表现上升、变更失败率下降,经验少的正好相反。报告的解释是主干开发离不开几条配套规则:主干坏了不能放着不管,合入前有门禁,破坏主干的提交自动回滚。

替代环境分支的四个机制#

替代环境分支的是四个机制:主干开发和制品晋级换掉靠合并分支来发布的路径,feature flag 让没做完的功能不再卡住发版,预览环境接手 qa 分支原来承担的合并前测试。

主干开发与短生命周期分支#

长期分支只留一条受保护的 main。特性分支从 main 切出,改动拆小,通过自动化测试和代码评审后尽快合回去,主干始终保持可用。

基于唯一哈希的制品晋级流水线#

代码合入主干后,CI 只构建一次,产出以 commit SHA 标记的制品(Docker 镜像或归档包)。之后在环境之间流转的是这个制品(artifact promotion),不再合并代码。

flowchart TB subgraph S1["代码集成阶段"] A["Feature 分支提 PR"] --> B["自动化测试与代码评审"] B --> C["合入 main 分支"] end subgraph S2["单次构建阶段"] C --> D["CI 编译构建唯一镜像
app:sha-7f3b89"] end subgraph S3["环境晋级阶段"] D --> E["自动部署至 QA 环境
(加载 QA 配置)"] E --> F["QA 验收通过,流水线触发审批"] F --> G["部署同一镜像至 Staging 环境
(加载 Staging 配置)"] G --> H["预发回归通过,触发生产审批"] H --> I["部署同一镜像至 Production
(加载 Prod 配置)"] end S1 ~~~ S2 S2 ~~~ S3

图里的 sha-7f3b89 是 tag,tag 可以被覆盖,要确保三个环境跑的真是同一个镜像,部署时最好引用 digest,或者在镜像仓库里打开 tag immutability;VM 场景下按 digest 部署的做法见 Jenkins 部署从 push 改成 pull。

在 Kubernetes 上,这一步通常交给 GitOps:主干构建出新镜像后,流水线改配置仓库里对应环境目录下的镜像版本(例如 overlays/staging/kustomization.yaml),Argo CD 或 Flux 检测到变更后同步到集群。改哪个环境的版本、什么时候改,这部分可以交给专做晋级的 Kargo(v1.0.0 于 2024-10-19 发布)。不用 GitOps 的流水线里,GitLab CI 用 when: manual 的 job、GitHub Actions 用 environment 上的 required reviewers,上一个环境通过后由人确认,再部署下一个环境。

用 feature flag 解耦部署与发布#

未完成的功能照样合入主干、随镜像一起部署,只是在代码里用 feature flag 包起来,部署(deployment)和发布(release)就分开了:

if (featureFlags.isEnabled("NEW_CHECKOUT_FLOW", userContext)) {
  return renderNewCheckout();
}
return renderLegacyCheckout();

开关默认关闭。测试人员可以在 QA 或 Staging 环境中按请求头、用户 ID 或内部 IP 打开开关做专项测试,普通用户继续走原来的逻辑。新功能上线后发现问题,运维人员在开关后台关掉,等 SDK 下一次拉取配置后生效,不用回滚代码,也不用重新打包部署。

在 K8s 里用 OpenFeature 加 flagd 让 Gateway、微服务和 React 共用一套开关的过程见 Spring Boot 3.5 + Java 25 + React:在 K8s 里跑通一套跨链路 OpenFeature flag,flag 多了以后的维护成本见 Feature flag 的隐藏成本。

临时预览环境与流量泳道#

环境分支原来还承担一步:功能合进 main 之前,先合到 qa 上给测试看。停掉 qa 分支以后,这一步要么推迟到合入之后,在 QA 环境里打开 feature flag 测;要么挪到合入之前,也就是常说的测试左移(shift-left testing)。后者有两种做法。

一种是每个 PR 自动拉起一套临时预览环境(ephemeral environment):流水线在独立的 namespace 或 vcluster 虚拟集群里部署这个 PR 的版本,给出访问地址供测试验收,PR 合入或关闭后销毁。用 Argo CD 的话,ApplicationSet 的 Pull Request generator 按 PR 列表生成 Application,某个 PR 不再满足筛选条件时,它的 Application 随之删除,见 ArgoCD 应用组织方式的选择和权衡。

服务多的时候每个 PR 拉一整套环境成本太高,另一种做法是泳道(也叫流量染色):只维护一套公共基准环境,PR 只部署改动过的那个服务,网关或服务网格按请求头里的标识把测试流量路由到新实例。前提是链路上每个服务都把这个请求头透传给下游,否则后面的调用会回到基准环境。只用 API Gateway 按请求头分流的例子见 没有 Service Mesh,用 API Gateway 做用户级灰度。

订单服务的几件事在主干开发下怎么处理#

回到开头的订单服务,看四件事在主干开发下怎么走。前两件是开头那一周遇到的,后两件是换成主干开发以后常见的情况:

结算流程上线,优惠券改版延后#

两个功能都带着默认关闭的 flag 合进 main:NEW_CHECKOUT_FLOW 和 COUPON_V2。同一个镜像晋级到生产后,只打开 NEW_CHECKOUT_FLOW,COUPON_V2 继续关着,优惠券的代码随镜像上了生产,用户走不到。延后的功能不用从哪条分支里抽出来,代价是新旧两套逻辑要在代码里并存,直到 flag 删掉。

运费 bug 的紧急修复#

修复照常走一个 PR 合进 main,合入后构建一次,自动部署到 QA,测过之后按 digest 晋级到 Staging 和生产。修复只有一份,就在 main 上;QA 跟随 main,测试环境自然也带着它,开头那种生产修了、测试环境还在报的情况不会再出现。

前提是 main 上从上次发版以来的其他提交也能一起上线。它们都在关闭的 flag 后面时没有问题;还没接入 flag 的阶段,main 上可能已经有这次不能发的改动,这时从生产正在跑的版本切一条短期分支,把 main 上的修复拣过去:

git switch -c release/v1.2.1 v1.2.0
git cherry-pick -x <main 上修复的 commit>
git push -u origin release/v1.2.1

-x 会在提交信息末尾记下原提交的 SHA,以后查得到这次修复来自 main 的哪个提交。这条分支只为这一次发版存在,它的新提交同样只构建一次,镜像从 Staging 开始晋级,打上 v1.2.1 以后分支就可以删掉。

Staging 验出候选版本有缺陷#

发版时选中的是 main 上的提交 C,它的镜像在 Staging 上测出优惠券有问题。优惠券在 flag 后面的话,生产上不打开就行,C 照样晋级。没有 flag 时,在 main 上把优惠券的提交 revert 掉,同样走 PR:

git switch -c revert-coupon-v2 origin/main
git revert <优惠券合入 main 的 commit>

优惠券如果是以 merge commit 合入的,revert 时要加 -m 1 指明保留 main 这一侧。合入后的新提交构建一次、过 QA,再从头晋级到 Staging。

优惠券改版要做三周#

三周不合并的特性分支,合回 main 时要面对三周的差异,和环境分支的漂移是同一个问题。主干开发下的做法是先合进一个默认关闭的 COUPON_V2 和一个空的入口,之后每个 PR 只做一小块,做完就合。这些代码每天随镜像部署到 QA,flag 关着时不影响别的功能测试,测优惠券的人在 QA 上按自己的用户 ID 打开 flag。

什么情况下还需要长期分支#

上面的做法默认线上只跑一个版本。要同时维护多个已发布版本时情况不同,比如私有化部署的客户停在 1.x 不升级,或者 SDK 要给上一个大版本出补丁。Kapelonis 那篇也提到,Git-flow 适合生产上同时支持多个版本的情况,并补了一句这种情况存在,但不常见。这时 release/1.x 这类版本分支要长期保留,修复仍然先进 main,再 cherry-pick 到每个还在支持的版本分支。这类分支按版本分,环境之间照样走制品晋级:从 release/1.x 构建出的镜像,同样按 QA、Staging、生产的顺序晋级。

分三个阶段停掉环境分支#

第一阶段收敛分支。团队停用常驻的 qa 分支,所有特性开发都向 main 提 PR。main 设为保护分支,要求自动化测试通过,并把测试人员加入 PR 的必要评审人。QA 环境改为跟随 main 的提交自动更新。这一步要先把 DORA 2022 年报告里那几条前提立起来:合入前有门禁,主干坏了先修主干。

第二阶段停用常驻的 staging 分支,改成制品晋级。QA 继续跟随 main,每次合入只构建一次镜像。发版时从 QA 验过的提交里选一个,把它的镜像按 digest 晋级到 Staging,通过后再晋级到生产,中间不重新构建;版本号打成这个提交上的 git tag,镜像只在原 digest 上追加一个 v1.2.0 tag。这一阶段还没有 feature flag,Staging 验出缺陷和紧急修复按前面订单服务例子里的 revert 和短期 release 分支处理。

第三阶段让主干随时可发布。项目里接入 feature flag,大功能拆成小 PR 合入主干。在此基础上按需建设 PR 预览环境或泳道,把大部分功能验证挪到合并之前。

小结#

分支漂移、测的和上线的不是同一个镜像、功能互相卡住发版,这三个问题换成主干开发后,分别由单一主干、按 digest 晋级的同一个镜像和 feature flag 解决。分支怎么分还要看线上同时跑几个版本:只有一个线上版本的服务,一条 main 加偶尔的短期 release 分支就够;要给多个已发布版本出补丁,再加按版本划分的长期分支,修复一律先进 main。迁移从门禁开始:DORA 2022 年的报告里,经验少的受访者用主干开发后交付表现反而下降,报告把原因归到缺少这类配套规则。

参考资料#