GitLab CI/CD 隐藏功能揭秘:你可能不知道的用法
从一个真实痛点说起
去年年底,我们团队的流水线膨胀到了一个尴尬的地步:一次完整的 CI 跑下来要 23 分钟,而且其中大部分作业在 80% 的提交里根本不需要执行。更糟的是,有次一个同事在测试分支上误跑了部署作业,把预发环境的数据库覆盖了。那天晚上我在排查事故时翻 GitLab 文档,才发现我用了三年 GitLab CI/CD,居然有一半的功能从未碰过。
这篇文章就是我事后整理的一些“宝藏功能”,每一个都在我们仓库里真实落地了。我按“解决的问题”来组织,你可以对照自己团队的痛点来看。
一、rules + changes:让流水线只跑该跑的东西
我们最早用的是老式 only/except 关键字,规则散落在各处,很难读懂。后来全面迁到 rules,配合路径过滤,效果立竿见影。
lint-frontend:
stage: test
rules:
- changes:
- "frontend/**/*"
when: on_success
- when: never
script:
- cd frontend && npm run lint
这样前端 lint 只在前端代码变更时执行。我们的第一个坑:merge request 首次创建时 changes 的判断基准。默认它对比的是目标分支,这通常没问题,但如果你想更精确,可以加上:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
paths:
- "frontend/**/*"
compare_to: 'refs/heads/main'
迁移之后,日常改文档、改后端的提交,前端相关作业完全不再执行。平均流水线时间从 23 分钟降到了 11 分钟。
二、workflow:rules:在流水线级别做总闸门
上面是控制单个作业,但有时候你想控制整条流水线是否创建。比如我们遇到过一个坑:同事在本地打的 tag 推上去,触发了一整套测试流水线,浪费了大量 runner 资源。解决方法:
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_PIPELINE_SOURCE == "schedule"'
- when: never
只有分支推送、MR 和定时任务会创建流水线,tag push 直接忽略。很多人不知道这个功能藏在顶层的 workflow 关键字里,而不是每个 job 里。
三、interruptible 和自动取消过时流水线
这是我个人的最爱。场景很常见:你推了三个 commit,前两个的流水线还在排队/运行,其实已经没有意义了。
build:
stage: build
interruptible: true
script:
- make build
test:
stage: test
interruptible: true
script:
- make test
然后在项目设置里勾选 Settings → CI/CD → General pipelines → Auto-cancel redundant pipelines,或者在较新版本中用:
workflow:
auto_cancel:
on_new_commit: conservative
注意一个坑:部署类作业千万不要设 interruptible: true,否则部署到一半被取消,环境就卡在中间状态了——我们真的踩过这个雷。
四、resource_group:一行代码防止并发部署
针对开头提到的事故,除了权限,我还加了这层保险:
deploy-staging:
stage: deploy
environment: staging
resource_group: staging-deploy
script:
- ./deploy.sh staging
同一个 resource_group 的作业会自动串行执行——效果类似其他系统里的 mutex。它甚至会等正在运行的作业结束再启动下一个。这比自己在脚本里写文件锁优雅太多了。后来我给每个环境的部署作业都配了一个以其环境命名的 resource group,再也没出现过并发部署冲突。
五、!reference 标签:真正可维护的配置复用
很多人靠 YAML 锚点(& / *)复用配置,但锚点有个致命问题:include 引入的文件之间锚点不互通。!reference 是 GitLab 自己的语法,跨文件有效:
# .common.yml
.default_before_script:
before_script:
- source scripts/setup-env.sh
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
# .gitlab-ci.yml
include:
- local: .common.yml
build:
!reference [.default_before_script, before_script]
script:
- make build
更好的是可以合并多个来源:
before_script:
- !reference [.default_before_script, before_script]
- !reference [.security_before_script, before_script]
- echo "extra step"
不过说实话,!reference 不支持合并嵌套过深的结构,复杂场景下我还是建议用 extends,两者搭配着用。
六、子流水线(Parent-Child Pipelines)与动态生成
这是让我最惊艳的功能。我们有个 monorepo,服务超过 15 个,顶层 YAML 已经接近千行。现在的做法是:一个轻量父流水线,根据变更路径动态生成子流水线:
# .gitlab-ci.yml
generate-children:
stage: trigger
rules:
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
when: never
- when: on_success
script:
- python scripts/generate_pipeline.py > generated.yml
artifacts:
paths:
- generated.yml
run-children:
stage: trigger
needs: [generate-children]
trigger:
include:
- artifact: generated.yml
job: generate-children
strategy: depend
generate_pipeline.py 会检查哪些服务的目录有变更,只把对应服务的作业写进 generated.yml。子流水线在 UI 里是独立的一页,日志清晰得多。
七、一些小而美的细节
CI_MERGE_REQUEST_*变量族:在 MR 流水线里可以直接拿到源分支、目标分支、MR 标题,我甚至用它做了“commit message 不符合规范就 fail”的检查。retry+when:
test:
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
只对基础设施故障重试,代码失败不重试——避免掩盖真实的测试问题。
dotenv报告:下游作业可以继承上游作业导出的变量:
build:
script:
- echo "IMAGE_TAG=$CI_COMMIT_SHORT_SHA" > build.env
artifacts:
reports:
dotenv: build.env
timeout设在作业级:别只依赖全局默认,慢测试单独放宽,其他作业收紧到 10 分钟,失败快速暴露。
实用建议与诚实的局限
建议:
- 迁移
only/except到rules值得专门开一个 MR 做,别混着用。 - 给部署作业加上
resource_group和environment,就两行配置,救过我不止一次。 - 用
gitlab-ci-local这类工具在本地预检查 YAML,别等推上去才发现语法错误。
局限,说实话:
- 子流水线虽然好用,但
strategy: depend会让父流水线阻塞等待,超长的子流水线会拖住整体状态;另外跨子流水线传递变量仍然只能靠 dotenv artifacts,不够顺手。 rules里不能用needs那样的复用逻辑,复杂条件还是得在脚本里自己判断。!reference的报错信息比较隐晦,YAML 结构对不上时只会告诉你引用失败,排查基本靠猜。- 免费版 runner 的并发限制会抵消一部分流水线优化的收益——拆得再细,runner 不够也是白搭。
这些功能没有一个是花哨的技巧,核心思路只有一个:让每次流水线只做必要的事,并且出错时安全可控。如果你也想优化自己那台“越跑越慢”的 CI,我建议从第一节的 rules:changes 开始,那是投入产出比最高的一步。