一、本文环境
| 项目 | 版本 / 说明 |
|---|---|
| 平台 | 极狐GitLab(JihuLab.com 与私有化部署均适用) |
| CI 语法参考 | .gitlab-ci.yml(官方文档 ci/yaml) |
| 仓库结构 | 单仓多服务(monorepo):services/order、services/payment、services/gateway、packages/common |
| Runner | Kubernetes executor,Docker 24.0 |
| 验证方式 | 同一条合并请求分支,改造前后各跑 3 次取中位数 |
二、问题复现
单仓多服务最容易出现的症状:改一行前端文案,后端三个服务全量编译 + 全量测试。
我先把它复现出来。新建一个分支,只改 services/gateway/README.md 一行,然后推送:
git switch -c docs/touch-readme
echo "update deploy note" >> services/gateway/README.md
git commit -am "docs(gateway): update deploy note"
git push -u origin docs/touch-readme
流水线跑起来后看作业列表,12 个作业一个没少:
Pipeline #48213 status: running
├── build:order running (6 min)
├── build:payment running (7 min)
├── build:gateway running (4 min)
├── test:order pending
├── test:payment pending
├── test:gateway pending
├── test:common pending
├── lint:order pending
├── lint:payment pending
├── lint:gateway pending
├── e2e:checkout pending
└── security:scan pending
整条流水线耗时 18 分 42 秒,其中真正与本次改动相关的作业只有 lint:gateway。其余 11 个作业全是"陪跑"。
再看看流水线里的资源占用(Runner 侧统计):
$ kubectl top pod -n gitlab-runner --sort-by=memory | head -5
NAME MEMORY
runner-xxxxx-build-order-7d9k2 3.1Gi
runner-xxxxx-build-payment-2m4pz 2.8Gi
runner-xxxxx-build-gateway-9xq7c 1.9Gi
runner-xxxxx-test-order-5b8vn 2.4Gi
这不是"流水线慢",这是把算力花在了不可能出错的地方。
三、原因定位
极狐GitLab 的默认行为很简单:只要 .gitlab-ci.yml 里定义了作业,且没有规则把它排除,它就会进流水线。
很多团队的配置长这样:
# 改造前的写法
stages:
- build
- test
build:order:
stage: build
script: make build ORDER=1
test:order:
stage: test
script: make test ORDER=1
没有任何 rules,于是每次推送都全量触发。要让作业"按需出现",需要用 rules:changes 做路径过滤——它的作用是检查特定文件是否发生变更,再决定作业是否加入流水线。
这里有两个必须先搞清楚的语义,否则改完会发现"有的跑有的不跑,说不清为什么":
- 合并请求流水线中,
rules:changes把变更与目标分支比较; - 分支流水线中,
rules:changes把变更与该分支上的上一个提交比较。
还有一个最容易踩的点:对于新分支流水线,或者没有 Git push 事件的流水线(标签流水线、计划流水线、手动流水线),rules:changes 始终评估为真——也就是说,刚推上去的新分支,过滤规则是不生效的,第一次必然全量跑。
四、解决步骤
第 1 步:给整条流水线装总开关 workflow:rules
先控制"要不要创建流水线",再控制"流水线里有哪些作业"。workflow:rules 与作业级 rules 类似,但作用于整条流水线,且 when 只能是 always 或 never。
workflow:
rules:
- if: $CI_COMMIT_TITLE =~ /-draft$/
when: never
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
踩坑 1:同时匹配"分支流水线"和"合并请求流水线"会产生重复流水线——一个提交跑两条。要么只保留 MR 流水线,要么用 $CI_OPEN_MERGE_REQUESTS 做互斥判断。
第 2 步:给每个服务作业加 rules:changes
.order_template: &order_rule
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- services/order/**/*
- packages/common/**/*
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
changes:
- services/order/**/*
- packages/common/**/*
build:order:
<<: *order_rule
stage: build
script: make build SERVICE=order
test:order:
<<: *order_rule
stage: test
script: make test SERVICE=order
通配符支持三种写法,注意区别:
changes:
- services/order/* # 只匹配该目录一层
- services/order/**/* # 该目录及其所有子目录
- "**/*.{go,mod,sum}" # 任意目录下指定扩展名
踩坑 2:rules:changes 的 glob 用 Ruby File.fnmatch 解释,路径是精确字符串比较,不是 shell 求值。写 ./services/order/**/*、services//order/* 这类相对或双斜杠路径匹配不到。另外每个 rules:changes 段最多 50 个模式,超过会报错;模式匹配检查次数上限是 50,000 次,超了以后规则一律视为匹配(即退化为全量)。
第 3 步:用 compare_to 给"没有 push 事件"的流水线兜底
计划流水线、标签流水线没有 Git push 事件,rules:changes 会恒为真。用 compare_to 显式指定比较基准:
nightly:order:
stage: test
script: make test SERVICE=order
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
changes:
paths:
- services/order/**/*
compare_to: 'refs/heads/main'
compare_to 支持分支名、标签名、提交 SHA,17.2 起也支持 CI/CD 变量。
踩坑 3:compare_to 在合并结果流水线下可能给出意外结果,因为比较基准是系统创建的内部提交;在 fork 项目中也有已知问题。如果你们的 MR 必须跑合并结果流水线,这里的过滤要额外验证。
第 4 步:用 include:rules 把配置也拆开
只过滤作业还不够,配置本身也可以按需加载。include 支持 rules:if、rules:exists、rules:changes:
include:
- local: ci/services/order.yml
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- services/order/**/*
- local: ci/services/payment.yml
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- services/payment/**/*
好处是流水线配置解析阶段就少加载文件,配置复杂时解析耗时也会下降。
第 5 步:公共库变更时,用下游流水线精确展开
packages/common 变了,理论上所有依赖它的服务都要测。与其写一堆 changes 模式,不如生成一条下游流水线:
# ci/generate-children.yml
generate:children:
stage: build
script:
- |
SERVICES=$(scripts/affected-services.sh)
echo "affected: $SERVICES"
for s in $SERVICES; do
echo " - project: $CI_PROJECT_PATH" >> children.yml
echo " file: ci/services/$s.yml" >> children.yml
done
- cat children.yml
artifacts:
paths:
- children.yml
trigger:children:
stage: test
needs: [generate:children]
trigger:
include:
- artifact: children.yml
job: generate:children
strategy: depend
scripts/affected-services.sh 自己实现依赖推导即可,最简版本:
#!/usr/bin/env bash
set -euo pipefail
changed=$(git diff --name-only "$CI_MERGE_REQUEST_DIFF_BASE_SHA" "$CI_COMMIT_SHA")
for s in order payment gateway; do
if echo "$changed" | grep -qE "services/$s/|^packages/common/"; then
echo "$s"
fi
done
第 6 步:本地自检,别等推上去才发现规则写错
# 先做语法检查
curl -s --noproxy '*' \
-H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/ci/lint" \
--form "[email protected]" | jq '.status'
# 再模拟一次路径匹配
git diff --name-only origin/main...HEAD | head -20
五、验证结果
同一条 MR 分支,改造前后各执行 3 次,取中位数:
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 作业总数 | 12 | 3 | -75% |
| 流水线时长 | 18 分 42 秒 | 4 分 16 秒 | -77% |
| Runner Pod 峰值内存 | 9.2 Gi | 2.4 Gi | -74% |
| 每月 CI 计算时长 | 4,180 分钟 | 1,260 分钟 | -70% |
| 开发者平均等待反馈 | 19 分钟 | 5 分钟 | -74% |
需要注意:这里的比例取决于你的仓库结构。服务越多、耦合越低,rules:changes 的收益越大;如果 packages/common 天天改,收益会被大幅稀释。
踩坑 4:改造后第一次全量回归别省。路径过滤一旦写漏(比如忘了 go.mod、Dockerfile、.gitlab-ci.yml 本身),会出现"改了依赖却没跑测试"的静默漏测。建议给 .gitlab-ci.yml 与根构建文件单独加一条兜底规则:
build:fallback:
stage: build
script: make build-all
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- .gitlab-ci.yml
- go.mod
- go.sum
- Dockerfile
六、什么时候别这么做
- 仓库服务少于 3 个:过滤的维护成本高于收益,直接全量更简单。
- 服务之间大量共享代码且高频改动:
packages/common一改就全量,过滤形同虚设,应该先拆依赖。 - 发布要求"每次提交都全量回归"(如强合规行业):按需构建与这个诉求冲突,不要为了省算力牺牲审计口径。
- 没有流水线负责人:规则写在 YAML 里,没人维护就会腐化,半年后没人敢动。
七、小结
按需构建不是"给每个作业加个 changes"就完事。真正生效的顺序是:先用 workflow:rules 决定要不要建流水线,再用 rules:changes 决定作业进不进,接着用 compare_to 覆盖计划与标签场景,最后用下游流水线处理公共库扩散,同时留一条兜底规则防漏测。
本文用到的完整配置模板与 affected-services.sh 脚本,我整理成了一份可复制的仓库骨架,放在评论区置顶和我主页的资源合集里,需要的自取。如果你在改造过程中遇到"新分支第一次总是全量""compare_to 结果和预期不一致"这类问题,也欢迎在评论区贴出你的 rules 片段,一起排查。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_44749269/article/details/166317503



