前言
这篇文章整理一下之前工作里 CI/CD 这块做的事情。
为什么要做,做技术的大概都有类似感受。
一开始团队规模不大,发布这件事往往不会被单独拿出来设计。大家知道代码在哪,知道哪台机器要发,靠几个熟悉系统的人就能把事情推进下去。
早期这样其实没什么问题。但项目一多,技术栈一多,环境一多,发布就慢慢从"日常操作"变成"隐性风险"了。
当时工程的情况大概是这样:
- 前端、Java、Kotlin、Python 等不同类型项目并存
- dev、gray、prod 等多套环境都要维护
- 发布动作不只是构建,还包括镜像、配置、部署、通知
- 有些项目需要快速迭代,有些项目又必须稳定可控
继续靠人手动处理,短期还能顶住,长期却是负担。所以后面把发布过程按照 GitOps 的思路做成了一套依赖 GitLab Runner 执行、基于 Git 管理部署的系统流程。
没有统一 CI/CD 之前的问题
发布这个事情,在没有技术经验的人看来可能想象不到会有多少的难度和复杂性,这也往往是基础设施建设所承担的,然而这类事情在技术和组织发展到一定阶段,从长远来看是一定需要做的,事关效率的提升问题。
发布路径越来越多
项目少的时候,每个项目有自己的发布方式还能接受。一个项目登录机器 git pull,一个项目本地打包再上传,另一个项目先改镜像再手动重启,看起来都能跑。
项目一多涉及到的负担就不一样了,比如: - 每个项目有不同的发布命令 - 每个环境有不同的操作步骤 - 有的改了镜像,有的改了配置,有的什么记录都没有
由于团队里没有一条统一的交付路径,随着时间推移,每多一个项目,就多了一套需要记忆和维护的手工流程。
发布依赖个人经验
流程写在文档里是一回事,真正执行时能不能顺是另一回事。
很多发布动作看起来简单,但里面夹杂着经验:这个服务要先发,那个配置要后改,这个环境要等几分钟,那个机器不能随便重启。
一旦这些经验只存在于某几个人脑子里,发布就不是流程,而是经验复用。新人接手成本高,同样的动作不同人执行结果不一致,没有流程记录,出问题后也很难还原当时到底做了什么。
版本和部署对不上
镜像仓库里有新 tag,但部署配置没同步更新,或者线上实际运行版本没落到 Git 管理里,后面就很难回答这几个问题:
- 当前环境到底运行的是哪个版本
- 上一次发布改了哪些服务
- 为什么镜像有了,但环境没生效
- 真要回滚时,应该回到哪个版本
权限和反馈缺失
没有统一发布流程时,每个人都要登录机器、每个项目都要维护一套服务器操作方式、每个人都有全部的权限,或者各种权限等环境配置乱飞在不同的地方。
另外,什么时候开始发布、发布成功没有、失败在哪里、谁触发的,这些信息只停留在执行机器的日志里,甚至都没有,那么后面的问题发现和跟进也无从说起了。
常见的几种 CI/CD 实现方式
市面上实现的方式有不少,不同团队适合的方案并不一样。这里简单过一下常见的选择:
Jenkins
Jenkins 是老牌的方案了,插件生态丰富,可定制能力强。历史系统多、流水线逻辑复杂、需要大量自定义插件或脚本的团队,用 Jenkins 往往比较顺手。
但维护成本也比较高。插件版本、构建节点、凭证、权限、任务模板都要持续治理,否则时间久了很容易从自动化工具变成另一个"脚本堆放处"。
GitHub Actions
GitHub Actions 对 GitHub 生态很友好,配置轻,Marketplace 里可复用的 action 也多。代码本身就在 GitHub 的团队用起来比较自然。
但如果公司用的是自建 GitLab,部署又依赖内网机器、私有镜像仓库和固定构建环境,那就不一定合适了。
云厂商流水线
云厂商的流水线服务通常和自家资源,比如网络、镜像仓库、权限体系等集成得很好,资源如果基本在同一家云上的团队可以一试。
但问题在于绑定,以及成本也需要考虑。只要存在本地机房、自建 GitLab、多云环境或者历史服务器,后续迁移和兼容成本就要提前考虑。
Kubernetes + GitOps 工具
基础设施已经比较云原生的话,Argo CD、Flux 这类 GitOps 工具会很自然。核心思路是把集群期望状态写在 Git 里,由控制器持续对比并收敛实际状态。这一套我也没实际用过,但我认为前提是相对的,公司本身已经到一定规模且有技术储备,相关的服务部署、配置管理、集群治理都已经比较成熟。
GitLab CI + GitLab Runner
目前项目采用的形式。
原因主要在于:
- 代码本身已经在 GitLab
- 分支、tag、用户、变量这些 CI 上下文可以直接使用
- Runner 部署在自己的构建机上,访问内网资源、Docker、SSH 和私有仓库
- GitOps 仓库也在 Git 中,流水线可以在构建后直接回写部署配置
最后方案没有绝对,都是基于当时工程环境下的取舍,它能用相对少的系统,把代码、构建、镜像、部署配置和通知串在一起。
项目整体链路
回到项目,当前整个工程链路如图所示:
- 代码提交后触发 GitLab CI
- GitLab Runner 拉起任务并注入 CI 上下文
- 统一入口脚本
run.sh开始执行 env.sh根据环境、项目、应用解析部署目标- 不同类型项目进入各自的
script/ci/*构建脚本 - 构建完成后推送镜像,并把新版本写回对应环境的 GitOps yml
compose.sh根据更新后的编排执行部署feishu.sh把发布开始、成功、失败的状态反馈出去
核心流程
1. env.sh:先确定发到哪里
script/cicd/env.sh 的职责,是把发布请求按环境、项目、应用逐层路由,最后得到目标主机或目标环境。
环境层只关心 dev、gray、prod 等差异,项目层只关心自己业务域里的应用映射,应用接入时只需要补对应规则,不必改总入口。
2. run.sh:统一入口与总控编排
script/cicd/run.sh 是整套链路的总入口,主要做几件事:
- 解析当前环境、项目、应用和目标主机
- 做一些公共准备动作
- 根据项目类型计算版本 tag
- 按技术栈分发到不同 CI 脚本
- 构建完成后继续进入部署动作
这里针对不同项目会有些差异化处理,比如前端可从 package.json 取版本,Java 项目可从 Maven 取,Gradle 项目可从 gradle.properties 取,正式环境则优先绑定明确的tag。
3. script/ci/*:按技术栈完成构建
构建脚本按技术栈拆开:
webjavajava11java-jibkotlinpython-naked
前端有自己的 Node 版本和构建命令,Java 有 Maven,Kotlin 有 Gradle 和模块名映射,Python也有自己的部署方式,从poetry到uv。
CI 脚本做完构建之后,不只是推镜像,还会把新镜像版本写回 GitOps 配置。
4. compose.sh:按 GitOps 配置完成部署
script/cd/compose.sh 拉取最新 GitOps 配置,根据目标服务执行部署,并把配置变更提交回 Git。
真正驱动部署的不是某个临时命令,而是 Git 中已经更新过的环境描述。后面再回头看某个环境时,不只是知道它"现在跑着什么",还能知道它"为什么变成这样"。
5. feishu.sh:补齐团队感知
测试、产品、上下游系统等相关人员都需要知道当前有没有发布、发布是否成功、失败时是谁触发的。
script/util/feishu.sh 做的就是这层反馈:正在发布、发布成功、发布失败。
GitLab Runner 在这里承担什么角色
在项目的脚本里会使用 CI_COMMIT_REF_NAME、CI_COMMIT_TAG、CI_REPOSITORY_URL、GITLAB_USER_EMAIL 这些变量,然后会通过 GitLab CI token 回写 Git 仓库。
整条链路由 GitLab CI 触发,再由 GitLab Runner 真正执行。简单说:GitLab 负责管理代码、流水线、分支、tag、变量等上下文,Runner 负责把任务拉起来,在指定环境里执行脚本。
它要做的事情包括:
- 拉取代码
- 注入 CI 变量
- 执行统一入口脚本
- 调用构建工具
- 构建并推送镜像
- 修改 GitOps 配置
- 通过 SSH 或 compose 完成部署
- 把发布结果通知出去
关于 Runner 的安装、注册、执行器选择、变量配置,官方文档已经很完整,这里不展开:
- Install GitLab Runner
- Registering runners
- Get started with GitLab CI/CD
- CI/CD YAML syntax reference
- CI/CD variables
.gitlab-ci.yml 怎么写
对于业务方,主要关注的是.gitlab-ci.yml文件,那么相对的设计原则是尽量让业务项目的 .gitlab-ci.yml 保持简单。
业务仓库只描述几件事:有哪些 stage,哪些分支或 tag 触发哪些 job,job 应该跑在哪类 Runner 上,最后调用哪个统一入口脚本。至于具体怎么构建、怎么推镜像、怎么改 GitOps 配置、怎么部署,都沉到统一的 GitOps 工程脚本里维护。
一个简化后的示例如下:
stages:
- format-feature
- test-feature
- scan-feature
- cicd-dev
- cicd-gray
- prepare-prod
- cicd-prod
variables:
RUN_ARGS: "java11 project service-name"
.cicd-dev:
stage: cicd-dev
tags:
- package-build-runner
rules:
- if: $CI_COMMIT_BRANCH == "dev"
job_cicd-dev_service:
extends: .cicd-dev
script:
- sh /opt/gitops/scripts/cicd/run.sh dev $RUN_ARGS
job_cicd-gray_service:
stage: cicd-gray
tags:
- package-build-runner
rules:
- if: $CI_COMMIT_BRANCH == "gray"
script:
- sh /opt/gitops/scripts/cicd/run.sh gray $RUN_ARGS
job_prepare-prod_service:
stage: prepare-prod
when: manual
tags:
- package-build-runner
rules:
- if: $CI_COMMIT_BRANCH == "master"
script:
- sh /opt/gitops/scripts/prepare/release.sh maven11
job_cicd-prod_service:
stage: cicd-prod
tags:
- package-build-runner
rules:
- if: $CI_COMMIT_TAG
script:
- sh /opt/gitops/scripts/cicd/run.sh prod $RUN_ARGS
这里几个变量解释一下:
stages 表达交付顺序。feature 分支做格式化、测试、扫描;dev 分支发开发环境;gray 分支发灰度或预发环境;master 做正式发布前准备;tag 触发正式发布。这样分支模型和发布模型就对应起来了。
variables 只放非敏感参数。应用类型、项目名、服务名可以放这里,token、密码、私钥、Webhook 这类内容放到 GitLab 的 CI/CD Variables 中。
tags 决定 job 跑在哪个 Runner 上。package-build-runner 这类 tag 意味着这个 job 需要被调度到具备对应构建和部署能力的 Runner 上。
rules 控制触发范围。相比早期的 only,rules 的表达能力更强,推荐使用。原则是不要让所有 job 在所有分支上都跑。
extends 用来复用模板。公共的 stage、tags、rules、when 放在模板里,具体 job 只保留自己的 script。
最后,script 尽量只调用统一入口。业务项目的 .gitlab-ci.yml 不要堆太多构建细节,否则每个项目又会慢慢发展出自己的发布逻辑。
发布与回滚
日常环境和正式环境的节奏是不一样的。
dev 和 gray 更偏向快速迭代,目标是让联调、测试尽快拿到结果,所以都是自动构建、自动推镜像、自动更新配置、自动部署。
prod 产线版本,不应该简单拿当前分支状态直接往前推,而是要绑定明确 tag,让发布更像一次版本发布。
回滚上,没有额外做很复杂的平台能力,但因为部署事实已经在 GitOps 配置里,路径是清楚的:找到上一个稳定镜像版本,把对应环境 yml 中的 image 改回去,重新执行部署。比"去服务器上猜上一次运行的镜像是什么"可靠很多。
不过需要注意,单纯的服务功能回滚容易,涉及数据库结构变更的回滚就是另一回事了。
所以这套方案整体上是什么
基于 GitLab Runner 执行,以 Git 仓库承载部署事实,用 GitOps 思路组织环境编排的 CI/CD 流程。
Git 在这里不只是存代码,还承担了三层角色:流水线触发源、版本变更记录源、部署编排事实源。
这套方案的优点和边界
优点:
- 统一了发布入口,项目接入和维护成本更低
- 利用了 GitLab Runner 的执行能力,和现有 GitLab 体系结合自然
- 兼容多技术栈,不强行要求所有项目走同一种构建方式
- 用 GitOps 思想把镜像版本和部署配置串起来
- 发布过程有通知反馈,团队感知更明确
边界也很明显:
- 脚本约定比较重,都是 shell,后期不维护好,重新理解起来很费力
- Runner 节点的环境治理很重要,宿主机差异会影响稳定性
- 项目规模继续扩大后,映射规则和脚本数量还需要进一步平台化
- 当前更像一套工程实践,还不是完整的发布平台产品
这里的工作主要是将工作流程抽象化组织起来,然后再用脚本把规则固化下来,接着看哪些规则值得抽象成平台。
如果结合 AI,还能继续往哪走
。
在上述工程实现后的这两年 AI 在研发流程里的应用越来越多,再看看AI能在这个项目里能做些什么 。
要说明的是,不是说有了 AI 就能替代发布系统。CI/CD 的核心仍然是规则、权限、环境、流程和可追踪性。AI 更适合做辅助层,帮助人更快理解变化、识别风险、整理信息,而不是直接绕过已有流程去"自动决策"。
1. 发布前风险识别
一次发布到底改了哪些模块,是否涉及配置变化,是否有数据库结构调整,是否影响启动参数,是否需要测试重点关注某些链路,这些目前更多还是靠研发或负责人自己判断。
如果结合 AI,可以在发布前基于 MR diff、commit message、变更文件、GitOps 配置差异,生成一份发布风险提示:
- 本次涉及哪些服务和模块
- 是否改动了环境变量、端口、volume 等部署相关配置
- 是否有数据库、缓存、消息队列等中间件相关变更
- 是否建议人工确认后再发布
- 测试回归重点应该放在哪里
提前将风险暴漏出来,可以按等级排列,提前进行发布前的预审以防遗漏信息,和可能出现的很多未曾考虑过的风险情况。
一般在版本发布前本身也会有文档整理和归档,这里可以做比对,甚至可以让各模块负责人做审核确认,机制可以很细,但真正实现也需要兼顾效率。
2. 发布说明自动生成
目前的发布通知比较机械,只有"某某服务正在发布""发布成功""发布失败"。
从团队协作角度看,更希望透明化一些:这次发布到底包含什么,影响什么范围,失败了应该找谁,测试应该验证什么。
AI 可以把 commit、MR、tag、变更文件、发布服务整理成更可读的发布说明:本次包含哪些功能或修复,影响哪些服务,是否有配置变更,是否有兼容性风险,是否需要业务方关注等。
3. 失败日志辅助分析
CI/CD 失败的时候,首先要判断问题在哪一层。是依赖下载失败,还是单测失败;是 Docker build 失败,还是镜像推送失败;是 SSH 不通,还是目标机器环境不对;是脚本参数错了,还是 GitOps 配置没更新。
这些判断除了靠有经验的人,也可以先让 AI 来分析,节省排查时间。尤其是生产环境发布失败,时效性要求很高。
比如 AI 可以基于 Runner 日志、构建日志、部署日志做初步归类:
- 失败阶段:构建 / 推镜像 / 更新配置 / 部署
- 疑似原因:依赖不可达 / 权限不足 / 参数缺失 / 环境不一致
- 建议排查:检查哪个变量、哪个配置、哪个服务日志
核心是减少排查时间,不是要做到自动修复。
最后
这套方案支撑了后续的所有变更和迭代,包括dev环境快速的测试以及生产环境几个大版本的发布,基本都在几分钟之内达到从发布到上线,职能也更加清晰了,业务能够更加专注在业务代码上,而不用自己去提心吊胆各个环境的问题。
当然也有没解决好的地方。脚本积累久了之后,有些历史逻辑已经说不清当初为什么这么写,改起来也会有顾虑。这类问题靠文档是一方面,另一方面也是接下来可以结合 AI 做脚本解释和审查,甚至帮忙重构。
最终就发布流程这件事,能做到稳定、可追踪、出问题有人知道,当前就已经达到目的了。