一、背景:流水线"能跑"和"敢用"之间隔着什么
大部分团队的第一版 .gitlab-ci.yml 长这样:
stages:
- build
- test
- deploy
build:
stage: build
script:
- mvn package
artifacts:
paths:
- target/*.jar
test:
stage: test
script:
- mvn test
deploy:
stage: deploy
script:
- scp target/*.jar root@prod:/opt/app/
- ssh root@prod "systemctl restart app"
only:
- main
这段配置能跑,也确实把"手工部署"变成了"点一下按钮"。但它离"敢用"还有很远的距离:
问题一:串行长龙。 build → test → deploy 严格串行,一个 40 分钟的单测把所有后续阶段堵死。开发者提了 MR,等 50 分钟才看到结果,于是开始绕过流水线直接合并。
问题二:环境不一致。 mvn package 在 Runner 宿主机上跑,依赖宿主机的 JDK 版本、Maven 版本、~/.m2 缓存。今天能构建,明天 Runner 换台机器就构建不出来。
问题三:没有制品概念。 scp 直接传 jar,传到一半网络断了,生产上就是半个文件。没有版本号、没有校验、没有回滚依据。出了问题只能"重新打一次包",但打出来的包和上次不一定一样。
问题四:发布没有闸门。 only: main 意味着任何人 push 到 main 就直接上生产。没有审批、没有灰度、没有验证、没有回滚。
问题五:凭证裸奔。 ssh root@prod 用的密钥要么在 Runner 的 ~/.ssh,要么在 CI 变量里明文存着。一旦 Runner 被攻破,生产环境全线失守。
这篇文章要解决的就是从"能跑"到"敢用"的这段距离。核心不是堆功能,而是把流水线当成一个有明确契约的系统来设计:每个阶段输入什么、输出什么、失败怎么办、谁能触发、怎么回滚。
二、环境假设与版本差异
| 项 |
假设 |
说明 |
| GitLab |
17.x / 18.x / 19.x(自建或极狐) |
版本差异见下表 |
| Runner |
GitLab Runner 17+,docker executor |
shell executor 只用于特殊场景 |
| 容器运行时 |
Docker 27+ 或 containerd |
影响 dind / kaniko 选择 |
| 编排 |
Kubernetes 1.29+ |
部署章节用 K8s 举例,非 K8s 环境原理相同 |
| 镜像仓库 |
GitLab Container Registry 或 Harbor |
私有仓库需配置认证 |
| 代码托管 |
单仓库(monorepo 或多仓库均可) |
多仓库用 trigger 串 |
版本差异,这些直接影响 YAML 写法:
| 特性 |
引入版本 |
说明 |
rules 替代 only/except |
12.3 起主推 |
新项目一律用 rules,only/except 只做兼容维护 |
include:component + spec:inputs |
17.0 GA |
组件化复用的基础 |
id_tokens(OIDC) |
15.7+,16.x 完善 |
免密钥访问云资源 |
needs:parallel:matrix |
15.9+ |
并行矩阵的依赖 |
environment:action: prepare/verify |
14.x+ |
环境生命周期的前置/后置钩子 |
release-cli |
18.0 弃用 |
改用 glab release create |
| Merge trains |
15.x 完善 |
高频合并场景降低冲突 |
rules:changes:compare_to |
15.3+ |
和指定 ref 比较变更文件 |
不确定的地方要说清楚: 极狐 GitLab(GitLab 中国发行版)的版本号和上游不完全同步,2026 年 10 月的补丁版本是 19.5.x 系列。如果你的实例是极狐,include:component 的路径规则、$CI_SERVER_FQDN 的取值要以实际实例为准,不要直接抄上游文档的示例域名。
先确认自己的版本:
curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
"https://gitlab.example.com/api/v4/version"
{"version": "19.4.2-ee", "revision": "a1b2c3d4"}
-ee 是企业版,-ce 是社区版。有些特性(比如 Merge trains 的部分能力、价值流分析)只有 EE 有,写 YAML 之前先确认。
三、核心机制:先搞懂这九件事
3.1 两种流水线结构:stage 串行 vs needs DAG
默认结构是按 stage 串行:同一 stage 的 job 并行,不同 stage 严格先后。
stages: [build, test, deploy]
build 全部成功,test 才开始;test 全部成功,deploy 才开始。这种结构简单,但有一个致命问题:最慢的 job 决定整个 stage 的时长。
needs 关键字把流水线从"阶段图"变成"依赖图"(DAG):
test:unit:
stage: test
needs: ["build:image"] # 只等 build:image,不等 build 阶段的其他 job
一旦用了 needs,job 可以在依赖满足时立即开始,不必等整个 stage 结束。一个典型的三阶段流水线,DAG 化之后能省 30%~50% 的时间。
needs: [] 表示"不等任何东西,立刻开始",适合 lint 这种不依赖构建产物的检查。
rules 决定单个 job 跑不跑,workflow:rules 决定整条流水线跑不跑。两者分工明确,混用会出问题。
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
- if: $CI_COMMIT_TAG
这三条规则的顺序不能乱,它解决的是重复流水线问题:当你在一个开了 MR 的分支上 push,GitLab 默认会同时创建"分支流水线"和"MR 流水线",两条都跑,浪费一半资源。第二条 when: never 就是掐掉分支流水线。
rules 的求值规则:从上到下,第一个匹配的生效,后面的不再看。 所以顺序即优先级,这一点和 if/else if 一样,但很多人写成"把所有条件列一遍",结果永远只命中第一条。
3.3 cache 与 artifacts:一个是共享,一个是传递
这两个概念最容易被混用,但它们的语义完全不同:
| 维度 |
cache |
artifacts |
| 目的 |
加速(复用上次的依赖) |
传递(把产物交给下游) |
| 存储位置 |
Runner 本地或对象存储 |
GitLab 服务端(对象存储) |
| 作用范围 |
同项目、同 key 的 job 之间共享 |
同一条流水线内,通过 needs 或 stage 顺序传递 |
| 是否保证存在 |
不保证,可能被清理 |
保证(在 expire_in 内) |
| 失败时 |
不影响 |
失败 job 的 artifacts 默认仍上传(可配) |
| 典型内容 |
node_modules、~/.m2、vendor |
编译产物、测试报告、镜像 digest |
判断标准: 这个文件是"下次能复用就快一点"还是"下游必须要它才能干活"?前者用 cache,后者用 artifacts。
把 jar 包放 cache 是错的——cache 可能被清掉,下游拿到空目录直接失败。把 node_modules 放 artifacts 也是错的——每个 job 都上传下载几百 MB,浪费带宽和存储。
3.4 variables 的层级与优先级
从低到高:
.gitlab-ci.yml 的全局 variables
- job 级
variables
- 项目/组 CI/CD 变量(Settings → CI/CD → Variables)
- 流水线手动触发时传入的变量(Run pipeline)
- 触发 API 传入的变量
同名变量,高优先级覆盖低优先级。 但有一个例外:如果项目变量被标记为 "Protected",它只在受保护分支/tag 上注入,其他分支根本看不到这个变量(不是空值,是不存在)。
这个特性经常被用来做"生产凭证只在 main 上可用",但要注意:它不会阻止非保护分支的流水线运行,只会让变量缺失。 脚本里如果用 ${PROD_TOKEN} 而不做检查,会拿到空字符串,然后带着空凭证去调 API,报一个莫名其妙的 401。
正确写法:
: "${PROD_TOKEN:?PROD_TOKEN 未设置,可能不在受保护分支上}"
${VAR:?msg} 在变量为空时直接退出并打印消息,比后面报 401 好排查一百倍。
3.5 environment 与 deployment
environment 不只是给 UI 分组用的,它带来三个实际能力:
- 部署记录:谁在什么时候部署了哪个 commit 到哪个环境,可追溯。
- 一键回滚:GitLab 提供 "Re-deploy" 按钮,重新跑一次历史 job。
- 环境停止:
on_stop 可以定义"销毁环境"的动作,适合 review app 这类临时环境。
deploy:staging:
stage: deploy
environment:
name: staging
url: https://staging.example.com
script:
- ./deploy.sh staging
environment:name 支持变量,可以做"每个 MR 一个临时环境":
review:
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.review.example.com
on_stop: stop:review
3.6 resource_group:并发控制
同一个环境同时只允许一个部署在跑,这是刚需。否则两个人同时点"部署生产",后一个会把前一个覆盖掉,而且没人知道最终是哪个版本。
deploy:prod:
resource_group: production
resource_group 的语义是互斥:同一 group 的 job 串行执行,后来的排队。配合 environment 使用,效果最好。
3.7 id_tokens:不要再往 CI 变量里塞长期密钥
传统做法是把云厂商的 AK/SK 存进 CI 变量,所有 job 都能读。一旦有恶意 MR 改了 .gitlab-ci.yml 把变量打印出来,密钥就泄露了。
OIDC 的做法是:GitLab 给每个 job 签发一个短期 JWT,云厂商验证这个 JWT 的签发方和 claims,然后换一个短期凭证。
deploy:prod:
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.example.com
script:
- ./assume-role.sh "${GITLAB_OIDC_TOKEN}"
AWS 侧配置 trust policy,信任 GitLab 的 OIDC provider,并限制 sub(项目路径 + 分支/tag)。这样即使 token 泄露,也只能在有效期内、对特定项目生效。
这是当前最推荐的免密钥方案,比把 AK/SK 存 CI 变量安全一个数量级。
3.8 components 与 include:复用的正确姿势
include 有三种来源:本地文件、远程 URL、另一个项目的文件。include:component 是 17.0 之后的推荐方式,它带版本号,可以像依赖包一样管理。
include:
- component: $CI_SERVER_FQDN/platform/ci-templates/build@1.2.0
inputs:
image_name: "order-service"
dockerfile: "docker/Dockerfile"
组件仓库里用 spec:inputs 声明参数:
# ci-templates/templates/build.yml
spec:
inputs:
image_name:
description: "镜像名称"
dockerfile:
default: "Dockerfile"
---
build:image:
stage: build
script:
- echo "building $[[ inputs.image_name ]] with $[[ inputs.dockerfile ]]"
关键点: 组件必须打 tag 才能被引用,@1.2.0 里的版本号对应仓库的 tag。用分支名引用(@main)在生产上是危险的——别人改一下 main,你的流水线行为就变了。
3.9 三道保护闸门
| 机制 |
作用 |
配置位置 |
| Protected branch |
限制谁能 push/merge 到 main |
Settings → Repository → Protected branches |
| Protected environment |
限制谁能部署到 production |
Settings → CI/CD → Environments |
| Protected variable |
变量只在受保护分支/tag 上注入 |
Settings → CI/CD → Variables |
三者要配合使用。只设 Protected branch 不设 Protected environment,那么非保护分支的流水线里如果有一个 environment: production 的 job(比如通过 MR 修改了 YAML),照样能部署。
四、设计原则
在写 YAML 之前,先把原则定下来。后面所有具体写法都是这些原则的推导。
4.1 快速失败
最贵的成本不是机器,是开发者的等待。一个 50 分钟的流水线,开发者会去看别的,回来时上下文已经丢了。
做法:
- lint、类型检查放在最前面,
needs: [] 立即开始,通常 1 分钟内出结果。
- 单测按模块拆分,用
parallel:matrix 并行。
- 集成测试只在 MR 和 main 上跑,feature 分支只跑单测。
- 镜像构建用层缓存。
目标:MR 流水线 10 分钟内出结论。超过 15 分钟,团队就会开始绕过它。
4.2 分层触发
不同事件跑不同的流水线,不要一条流水线打天下。
| 触发场景 |
跑什么 |
目标时长 |
| push 到 feature 分支 |
lint + 单测 |
< 5 分钟 |
| 开/更新 MR |
lint + 单测 + 构建 + 扫描 |
< 15 分钟 |
| merge 到 main |
全量 + 构建 + 推镜像 + 部署 staging |
< 25 分钟 |
| 打 tag |
全量 + 生产部署(人工) |
按需 |
4.3 幂等
同一个 commit、同样的输入,跑两次应该得到同样的结果。做不到的地方要显式标注。
做不到的典型:docker build 每次生成的镜像 digest 不同(因为时间戳)。解决办法是固定 SOURCE_DATE_EPOCH,或者接受"内容相同但 digest 不同",用 tag 里的 commit sha 作为唯一标识。
4.4 制品不可变
镜像一旦推送,就不再覆盖同名 tag。latest 是唯一的例外,且只用于开发环境。
版本号用 commit sha(短 8 位)作为主键,语义化版本(v1.2.3)作为别名。回滚时用 sha,因为它是唯一确定的。
4.5 最小权限
CI_JOB_TOKEN 的权限默认很窄,够用就别加。
- 部署凭证按环境隔离,staging 的凭证不能碰生产。
- Runner 按环境打 tag,生产部署只在专用 Runner 上跑。
4.6 可回滚
每一版部署都要能回答三个问题:上一版是什么?怎么回去?回去需要多久?
做不到自动回滚,至少要有一条命令能回滚,并且这条命令被演练过。
4.7 可观测
流水线本身也要有指标:成功率、时长、失败原因分布。否则"流水线不稳定"永远是一句感觉,不是数据。
五、完整流水线:从提交到生产
5.1 仓库结构
order-service/
├── .gitlab-ci.yml
├── .gitlab/
│ └── ci/
│ ├── base.yml # 全局默认、镜像、缓存
│ ├── test.yml # 测试相关
│ ├── build.yml # 构建与推送
│ ├── security.yml # 扫描与签名
│ └── deploy.yml # 部署与验证
├── docker/
│ └── Dockerfile
├── deploy/
│ ├── helm/
│ └── values/
├── src/
└── pom.xml
拆文件不是为了好看,是为了让不同的人负责不同的部分,减少冲突。
5.2 主干:.gitlab-ci.yml
# .gitlab-ci.yml
# 只做三件事:定义 workflow、引入模板、声明 stage 顺序
workflow:
name: "$PIPELINE_NAME"
rules:
# MR 流水线
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
variables:
PIPELINE_NAME: "MR !$CI_MERGE_REQUEST_IID"
# 有开着的 MR 时,不再跑分支流水线(避免重复)
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
# 主干分支
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
PIPELINE_NAME: "main @$CI_COMMIT_SHORT_SHA"
# 打 tag 触发发布
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
PIPELINE_NAME: "release $CI_COMMIT_TAG"
# 其他分支:只跑 lint 和单测(由各 job 的 rules 控制)
- if: $CI_COMMIT_BRANCH
variables:
PIPELINE_NAME: "branch $CI_COMMIT_REF_SLUG @$CI_COMMIT_SHORT_SHA"
stages:
- validate
- test
- build
- security
- deploy
- verify
include:
- local: .gitlab/ci/base.yml
- local: .gitlab/ci/test.yml
- local: .gitlab/ci/build.yml
- local: .gitlab/ci/security.yml
- local: .gitlab/ci/deploy.yml
注意 workflow:name:默认的流水线名字是 #123456,一眼看不出是哪条。用 MR !123 或 main @abc1234 命名,在流水线列表里能直接认出来。
5.3 base.yml:全局默认
# .gitlab/ci/base.yml
# default 里只放"与镜像无关"的配置
default:
# 所有 job 的超时,防止卡死占用 Runner
timeout: 30m
# 允许被新流水线打断(同一分支的新提交会取消旧流水线)
interruptible: true
retry:
max: 1
when:
- runner_system_failure
- stuck_or_timeout_failure
- api_failure
variables:
# 关闭 Maven 的交互式下载进度,日志干净且更快
MAVEN_CLI_OPTS: "-s .m2/settings.xml --batch-mode --no-transfer-progress"
# 镜像地址,统一从这里取,避免各处硬编码
IMAGE_REPO: "${CI_REGISTRY_IMAGE}"
IMAGE_TAG: "${CI_COMMIT_SHORT_SHA}"
# 构建时间戳固定,提升可复现性
SOURCE_DATE_EPOCH: "1700000000"
# Git 浅克隆深度,需要 git describe 时改成 0
GIT_DEPTH: "20"
# Java 相关 job 的公共模板:镜像 + 环境自检 + Maven 缓存
.java-base:
image: registry.example.com/ci/maven-jdk21:3.9.9 # 自建基础镜像,固定版本
before_script:
- export MAVEN_OPTS="-Dmaven.repo.local=${CI_PROJECT_DIR}/.m2/repository"
- java -version
- mvn -v
cache:
key:
files:
- pom.xml
paths:
- .m2/repository/
policy: pull-push
这里有一个必须避开的坑:不要把 image 和 before_script 写进 default。 default 会作用到所有 job,包括用 kaniko、trivy、curl 这些镜像的 job。如果 default.before_script 里写了 java -version,那么 scan:trivy 在 trivy 镜像里执行 java -version 会直接失败——java: command not found。正确做法是把"跟特定工具链绑定"的配置放进 .java-base 这样的模板,由需要的 job 显式 extends。
同理,default.image 设成 Maven 镜像后,build:image(kaniko)如果不显式指定 image,会跑在 Maven 镜像里,找不到 /kaniko/executor。这类错误在流水线第一次跑时就会暴露,但排查起来很费时间,因为报错信息是 command not found 而不是"镜像配错了"。
几个设计说明:
interruptible: true:同一个分支连续 push 两次,旧的流水线会被自动取消。这在开发阶段能省大量 Runner 时间。但部署 job 必须设 interruptible: false,否则部署到一半被取消,环境状态不确定。
retry:when:只重试基础设施故障(Runner 挂了、API 超时),不重试脚本失败。script_failure 重试是自欺欺人——代码有问题,重试一百次还是失败,只是浪费 30 分钟。
GIT_DEPTH: "20":默认是 20(浅克隆),git describe 这类需要历史的命令会失败。如果流水线里要算版本号,改成 0(完整克隆),代价是克隆变慢。
SOURCE_DATE_EPOCH:固定时间戳,让构建产物可复现。Java 的 maven-jar-plugin 支持这个变量。
5.4 test.yml:分层测试
# .gitlab/ci/test.yml
lint:java:
extends: .java-base
stage: validate
needs: []
script:
- mvn ${MAVEN_CLI_OPTS} -q spotless:check
- mvn ${MAVEN_CLI_OPTS} -q checkstyle:check
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
lint:yaml:
stage: validate
needs: []
image: registry.example.com/ci/yamllint:1.35.1
script:
- yamllint -c .yamllint.yml .gitlab-ci.yml .gitlab/ci/
rules:
- changes:
- "**/*.yml"
- "**/*.yaml"
- ".gitlab-ci.yml"
test:unit:
extends: .java-base
stage: test
needs: ["lint:java"]
parallel: 4
script:
- >
mvn ${MAVEN_CLI_OPTS} test
-DexcludedGroups=integration
-Dsurefire.rerunFailingTestsCount=0
-Dspring.profiles.active=test
artifacts:
when: always
expire_in: 7 days
reports:
junit:
- target/surefire-reports/TEST-*.xml
paths:
- target/surefire-reports/
coverage: '/Total.*?([0-9]{1,3})%/'
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
test:integration:
extends: .java-base
stage: test
needs: ["lint:java"]
services:
- name: mysql:8.4
alias: mysql
variables:
MYSQL_ROOT_PASSWORD: testroot
MYSQL_DATABASE: order_test
MYSQL_USER: test
MYSQL_PASSWORD: testpass
- name: redis:7.4-alpine
alias: redis
variables:
SPRING_DATASOURCE_URL: "jdbc:mysql://mysql:3306/order_test?useSSL=false&allowPublicKeyRetrieval=true"
SPRING_DATASOURCE_USERNAME: test
SPRING_DATASOURCE_PASSWORD: testpass
SPRING_DATA_REDIS_HOST: redis
script:
# 等服务就绪,不要用固定 sleep
- |
for i in $(seq 1 60); do
if mysqladmin ping -h mysql -u test -ptestpass --silent 2>/dev/null; then
echo "MySQL 就绪,用时 ${i}s"
break
fi
sleep 1
done
- mvn ${MAVEN_CLI_OPTS} verify -Dgroups=integration
artifacts:
when: on_failure
expire_in: 3 days
paths:
- target/failsafe-reports/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
关于 services 的几个坑:
alias 决定主机名。 不写 alias,主机名是镜像名的变形(mysql__8.4),非常难记。一定要显式写。
- 服务启动不是瞬间的。
services 只保证容器启动,不保证内部进程就绪。MySQL 从容器启动到能接受连接要 10~30 秒。用 mysqladmin ping 轮询,不要 sleep 30(慢且不可靠)。
mysql:8.4 的认证插件变化。 8.4 默认 caching_sha2_password,JDBC 连接串要加 allowPublicKeyRetrieval=true(非 SSL 时),否则报 Public Key Retrieval is not allowed。这个坑每年都有人踩。
services 和 job 在同一网络。 服务名就是 hostname,不用 localhost。
关于 parallel: 4: 它会把 job 拆成 4 个实例,每个实例的 CI_NODE_INDEX 是 0~3、CI_NODE_TOTAL 是 4。但 GitLab 不会自动帮你分片——4 个实例默认跑完全相同的测试,等于白干四份。真正的分片要在脚本里按 CI_NODE_INDEX 挑测试类,方案见 11.4。
5.5 build.yml:镜像构建
# .gitlab/ci/build.yml
build:jar:
extends: .java-base
stage: build
needs: ["test:unit"]
script:
- mvn ${MAVEN_CLI_OPTS} -DskipTests package
- ls -lh target/*.jar
artifacts:
expire_in: 1 day
paths:
- target/*.jar
build:image:
stage: build
needs: ["build:jar"]
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
# 用 CI_JOB_TOKEN 登录 GitLab Registry,不需要额外凭证
- |
cat > /kaniko/.docker/config.json <<EOF
{
"auths": {
"${CI_REGISTRY}": {
"auth": "$(printf '%s:%s' "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64 -w0)"
}
}
}
EOF
- >
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/docker/Dockerfile"
--destination "${IMAGE_REPO}:${IMAGE_TAG}"
--destination "${IMAGE_REPO}:${CI_COMMIT_REF_SLUG}"
--cache=true
--cache-repo="${IMAGE_REPO}/cache"
--cache-ttl=168h
--build-arg "JAR_FILE=target/order-service.jar"
--build-arg "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}"
--label "org.opencontainers.image.revision=${CI_COMMIT_SHA}"
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
--label "org.opencontainers.image.source=${CI_PROJECT_URL}"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
为什么用 kaniko 而不是 docker:dind:
| 方案 |
优点 |
缺点 |
docker:dind |
兼容所有 Dockerfile,生态成熟 |
需要 privileged 模式,安全风险高;共享 Docker daemon 有并发问题 |
| kaniko |
不需要 daemon,不需要 privileged |
不支持所有 Dockerfile 指令(如 RUN --mount 的某些用法);构建速度略慢 |
| buildkit(rootless) |
快,支持 RUN --mount=type=cache |
配置复杂,需要单独的 daemon |
生产环境优先 kaniko 或 rootless buildkit。dind + privileged: true 等于把 Runner 所在节点的 root 权限交出去,一旦有恶意 MR,节点就沦陷了。
--cache-repo 的作用: 把每一层的缓存推到镜像仓库。这样不同 Runner 之间也能共享缓存,而不是只在本机有效。
注意 --destination 推两个 tag: ${IMAGE_TAG} 是 commit sha(不可变),${CI_COMMIT_REF_SLUG} 是分支名(可变,方便开发调试)。生产部署只认 sha。
5.6 security.yml:扫描与签名
# .gitlab/ci/security.yml
scan:trivy:
stage: security
needs: ["build:image"]
image:
name: aquasec/trivy:0.57.1 # 固定版本,勿用 latest
entrypoint: [""]
variables:
TRIVY_CACHE_DIR: "${CI_PROJECT_DIR}/.trivycache"
TRIVY_USERNAME: "${CI_REGISTRY_USER}"
TRIVY_PASSWORD: "${CI_REGISTRY_PASSWORD}"
cache:
key: trivy-db
paths:
- .trivycache/
script:
- >
trivy image
--severity HIGH,CRITICAL
--ignore-unfixed
--exit-code 1
--format template
--template "@/contrib/gitlab.tpl"
--output gl-container-scanning-report.json
"${IMAGE_REPO}:${IMAGE_TAG}"
artifacts:
when: always
expire_in: 30 days
reports:
container_scanning: gl-container-scanning-report.json
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
sign:image:
stage: security
needs: ["build:image"]
image:
name: gcr.io/projectsigstore/cosign:v2.4.1
entrypoint: [""]
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
script:
# keyless 签名,依赖 OIDC,不需要长期私钥
- cosign sign --yes
--identity-token "${SIGSTORE_ID_TOKEN}"
"${IMAGE_REPO}@$(cat image-digest.txt)"
rules:
- if: $CI_COMMIT_TAG
几个现实问题:
第一,--exit-code 1 会让流水线因为一个 CVE 就红。 这在刚上线时几乎必然发生——基础镜像里总有几个 HIGH。合理的做法是分两步走:先 allow_failure: true 跑两周,收集基线,把"当前已知的、暂时无法修的"写进 .trivyignore 并注明原因和到期时间,再改成硬失败。
.trivyignore 格式:
# CVE-2024-12345: 依赖的 netty 版本由上游 SDK 锁定,等待厂商升级
# 到期: 2026-12-31
CVE-2024-12345
# CVE-2025-67890: 仅影响未使用的 HTTP/2 功能,已通过 WAF 阻断
CVE-2025-67890
第二,image-digest.txt 从哪来。 kaniko 默认不输出 digest 到文件。两种办法:构建后用 skopeo inspect 或 registry API 查,或者在 kaniko 加 --digest-file=/workspace/image-digest.txt(kaniko 支持这个参数)。后者更简单:
- /kaniko/executor ... --digest-file /workspace/image-digest.txt
然后 sign:image 里 needs 带 artifacts 拿到这个文件。
第三,keyless 签名需要公网可达的 OIDC 和 Rekor。 内网环境要跑自建的 sigstore(fulcio + rekor),成本不低。如果做不到,退而求其次用 KMS 托管密钥做签名,至少保证密钥不在 CI 变量里。
5.7 deploy.yml:部署、验证、回滚
# .gitlab/ci/deploy.yml
.deploy-base:
image:
name: alpine/k8s:1.31.4
entrypoint: [""]
variables:
KUBE_NAMESPACE: ""
RELEASE_NAME: "order-service"
before_script:
# KUBE_CONFIG 从受保护变量注入,内容是 base64 后的 kubeconfig
- mkdir -p "${HOME}/.kube"
- echo "${KUBE_CONFIG}" | base64 -d > "${HOME}/.kube/config"
- chmod 600 "${HOME}/.kube/config"
- kubectl config use-context "${KUBE_CONTEXT}"
- kubectl -n "${KUBE_NAMESPACE}" get deploy "${RELEASE_NAME}" -o wide || true
deploy:staging:
extends: .deploy-base
stage: deploy
needs: ["build:image", "scan:trivy"]
environment:
name: staging
url: https://staging.example.com
resource_group: staging
interruptible: false
variables:
KUBE_NAMESPACE: "order-staging"
script:
- |
kubectl -n "${KUBE_NAMESPACE}" set image \
deployment/${RELEASE_NAME} \
app="${IMAGE_REPO}:${IMAGE_TAG}"
- kubectl -n "${KUBE_NAMESPACE}" rollout status \
deployment/${RELEASE_NAME} --timeout=300s
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
smoke:staging:
stage: verify
needs: ["deploy:staging"]
image: curlimages/curl:8.10.1
script:
- |
set -euo pipefail
BASE="https://staging.example.com"
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w '%{http_code}' "${BASE}/actuator/health/readiness" || true)
if [ "${code}" = "200" ]; then
echo "readiness OK,第 ${i} 次探测"
break
fi
echo "第 ${i} 次探测返回 ${code},等待 5s"
sleep 5
if [ "${i}" = "30" ]; then
echo "健康检查超时,判定部署失败" >&2
exit 1
fi
done
# 关键业务接口的冒烟
curl -fsS "${BASE}/api/v1/orders/ping" -o /dev/null
echo "冒烟通过"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy:prod:
extends: .deploy-base
stage: deploy
needs: ["deploy:staging", "smoke:staging"]
environment:
name: production
url: https://api.example.com
resource_group: production
interruptible: false
timeout: 20m
variables:
KUBE_NAMESPACE: "order-prod"
script:
# 记录上一版,供回滚使用
- |
PREV_IMAGE=$(kubectl -n "${KUBE_NAMESPACE}" get deploy "${RELEASE_NAME}" \
-o jsonpath='{.spec.template.spec.containers[0].image}')
echo "上一版镜像: ${PREV_IMAGE}" | tee rollback-info.txt
- |
kubectl -n "${KUBE_NAMESPACE}" set image \
deployment/${RELEASE_NAME} app="${IMAGE_REPO}:${IMAGE_TAG}"
- kubectl -n "${KUBE_NAMESPACE}" rollout status \
deployment/${RELEASE_NAME} --timeout=600s
artifacts:
when: always
expire_in: 30 days
paths:
- rollback-info.txt
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
allow_failure: false
verify:prod:
stage: verify
needs: ["deploy:prod"]
image: curlimages/curl:8.10.1
script:
- |
set -euo pipefail
for i in $(seq 1 20); do
code=$(curl -s -o /dev/null -w '%{http_code}' https://api.example.com/actuator/health || true)
[ "${code}" = "200" ] && break
sleep 5
done
[ "${code}" = "200" ] || { echo "生产健康检查失败" >&2; exit 1; }
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
rollback:prod:
extends: .deploy-base
stage: verify
needs: ["deploy:prod"]
environment:
name: production
variables:
KUBE_NAMESPACE: "order-prod"
script:
- |
set -euo pipefail
PREV=""
if [ -f rollback-info.txt ]; then
# 格式为 "上一版镜像: <repo>:<tag>",取冒号后的部分
PREV=$(awk -F'镜像: ' '/上一版镜像:/ {print $2}' rollback-info.txt | head -n1)
fi
if [ -n "${PREV}" ]; then
echo "回滚到: ${PREV}"
kubectl -n "${KUBE_NAMESPACE}" set image \
deployment/${RELEASE_NAME} app="${PREV}"
else
echo "未找到上一版记录,使用 ReplicaSet 历史回滚"
kubectl -n "${KUBE_NAMESPACE}" rollout undo deployment/${RELEASE_NAME}
fi
kubectl -n "${KUBE_NAMESPACE}" rollout status \
deployment/${RELEASE_NAME} --timeout=300s
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: on_failure
几个关键设计:
resource_group: production 保证同一时刻只有一个生产部署在跑。没有这个,两个人同时点按钮,先点的可能后完成,最终版本不可预测。
interruptible: false 对部署 job 是必须的。默认继承 default 的 true,如果新提交进来,正在跑的部署会被取消——K8s 那边可能已经改了一半。
先记录再变更。 PREV_IMAGE 在改之前取,写进 artifact。回滚时优先用它,而不是 rollout undo。原因:rollout undo 依赖 ReplicaSet 历史(revisionHistoryLimit 默认 10),如果连续部署超过 10 次,最早的版本已经被清理,undo 不一定能回到你想要的那一版。
when: on_failure 的回滚 job。 注意它的 rules 里也要写 when: on_failure,否则 rules 会覆盖 job 级的 when。这是一个非常常见的坑——写了 when: on_failure 但 rules 里没写,结果回滚 job 变成手动或者不触发。
allow_failure: false + when: manual 表示"必须有人点,且点了必须成功"。如果不写 allow_failure: false,手动 job 被跳过时流水线仍然算成功,生产可能压根没部署,但流水线是绿的。
六、关键机制详解与常见坑
6.1 rules 的五种写法
# 1. 按分支
rules:
- if: $CI_COMMIT_BRANCH == "main"
# 2. 按 tag 格式
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
# 3. 按变更文件(只在 MR 里有效)
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- "src/main/java/**/*.java"
- "pom.xml"
# 4. 按文件是否存在
rules:
- exists:
- "Dockerfile"
# 5. 组合 + 变量
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $DEPLOY_ENABLED == "true"
when: manual
坑一:changes 在非 MR 流水线里不可靠。 在 push 流水线里,changes 比较的是"本次 push 的 commit 范围",如果一次 push 了 20 个 commit,行为可能和预期不同;如果是新建分支,所有文件都算变更。需要精确控制时用 changes:compare_to:
rules:
- changes:
paths: ["src/**/*"]
compare_to: "refs/heads/main"
坑二:rules 和 only/except 不能混用。 同一个 job 里同时写,GitLab 会报配置错误。迁移时要么全改,要么全不改。
坑三:rules 里没有 when 时默认是 on_success。 但如果在 workflow:rules 里写了 when: never,整条流水线不创建,job 级的 rules 根本不会执行。调试"为什么流水线没触发"时,先看 workflow。
6.2 needs 与 artifacts 传递
test:unit:
needs:
- job: build:jar
artifacts: true # 默认就是 true,显式写出来更清楚
needs 默认会下载上游 job 的 artifacts。如果不需要(比如只想要执行顺序),设 artifacts: false,能省不少下载时间。
坑:needs 指向的 job 被 rules 跳过了,下游会失败。
ERROR: Job 'test:unit' needs 'build:jar', but 'build:jar' was not run
解决办法是让上下游的 rules 保持一致,或者用 needs:optional: true:
needs:
- job: build:jar
optional: true
optional: true 表示"如果它没跑,我也不报错"。但要小心:这意味着下游可能在缺少产物的情况下运行,脚本里要做检查。
6.3 cache key 的设计
# 错误:所有分支共用一个 key,feature 分支的依赖会污染 main
cache:
key: "maven"
paths: [".m2/repository/"]
# 正确:按 lock 文件哈希,依赖变了自动失效
cache:
key:
files: ["pom.xml"]
paths: [".m2/repository/"]
# 更好:加分支前缀,避免不同分支互相覆盖
cache:
key:
files: ["pom.xml"]
prefix: "${CI_COMMIT_REF_SLUG}"
paths: [".m2/repository/"]
policy 的用法:
pull-push(默认):下载 + 上传。适合构建 job。
pull:只下载不上传。适合测试 job——它们不该修改缓存,避免并发写冲突。
push:只上传。适合专门的"预热缓存" job。
并发写同一个 cache key 会导致缓存损坏(GitLab 用 zip 打包,并发写会产生不完整的包)。所以测试类 job 一律用 policy: pull。
方案一:kaniko(推荐)
优点:无 daemon、无需 privileged、支持层缓存推远端。缺点:不支持 RUN --mount=type=secret 的某些用法;多阶段构建的缓存命中率不如 buildkit。
方案二:buildkit rootless
build:
image:
name: moby/buildkit:v0.16.0-rootless
entrypoint: [""]
variables:
BUILDKITD_FLAGS: "--oci-worker-no-process-sandbox"
script:
- |
buildctl-daemonless.sh build \
--frontend dockerfile.v0 \
--local context=. \
--local dockerfile=./docker \
--opt filename=Dockerfile \
--output type=image,name="${IMAGE_REPO}:${IMAGE_TAG}",push=true
优点:快,支持 RUN --mount=type=cache,缓存粒度细。缺点:配置复杂,rootless 模式对内核版本有要求。
方案三:docker:dind(不推荐用于生产)
build:
image: docker:27.3
services:
- name: docker:27.3-dind
alias: docker
variables:
DOCKER_HOST: "tcp://docker:2376"
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_TLS_VERIFY: "1"
DOCKER_CERT_PATH: "/certs/client"
必须有 privileged: true 才能跑。这意味着容器内的进程可以访问宿主机的所有设备,包括磁盘和内核模块。如果 Runner 是共享的,任何能提交 MR 的人都能拿到节点 root。 只有在自己完全控制的专用 Runner 上才考虑。
6.5 测试报告与覆盖率
test:unit:
artifacts:
when: always
reports:
junit: target/surefire-reports/TEST-*.xml
coverage_report:
coverage_format: cobertura
path: target/site/jacoco/jacoco.xml
coverage: '/Total.*?([0-9]{1,3})%/'
coverage 是正则,从 job 日志里抓覆盖率数字。不同工具的日志格式不同:
| 工具 |
正则 |
| JaCoCo(Maven) |
/Total.*?([0-9]{1,3})%/ |
| Jest |
(原文此处信息不完整,请以实际日志格式为准) |
| pytest-cov |
/TOTAL\s+\d+\s+\d+\s+(\d+%)/ |
| go test |
/coverage: ([0-9.]+)% of statements/ |
正则写错了,覆盖率就显示不出来,但不报错——静默失败。配完之后一定要在 MR 页面确认覆盖率数字真的出现了。
6.6 手动 job 的四种形态
# 1. 手动触发,允许跳过
job1:
when: manual
# 2. 手动触发,不允许跳过(跳过即流水线失败)
job2:
when: manual
allow_failure: false
# 3. 延迟执行(到时间自动跑,但可以提前手动点)
job3:
when: delayed
start_in: 30 minutes
# 4. 只在失败时执行
job4:
when: on_failure
when: manual 的默认 allow_failure 是 true(除了在 rules 里显式写 when: manual 的情况,默认也是 true)。所以"部署生产"这种 job 一定要显式 allow_failure: false,否则跳过就等于成功。
七、制品与镜像管理
7.1 tag 策略
| tag |
用途 |
可变 |
示例 |
<commit-sha> |
唯一标识,部署用 |
否 |
a1b2c3d4 |
v1.2.3 |
语义化版本,对外发布 |
否 |
v1.2.3 |
main |
主干最新 |
是 |
main |
latest |
仅开发环境 |
是 |
latest |
规则:生产部署只认 sha 或语义化版本,绝不认分支名。 分支名是移动的靶子,今天 main 指向 A,明天指向 B,回滚时你都不知道回到哪。
7.2 Registry 清理
镜像仓库会无限膨胀。GitLab 提供清理策略(Settings → Packages and registries → Clean up image tags),也可以自己写:
cleanup:registry:
stage: deploy
image: registry.example.com/ci/curl-jq:1.0
script:
- |
set -euo pipefail
# 保留最近 30 个 tag,其余删除(dry-run 先看)
KEEP=30
DRY_RUN="${DRY_RUN:-true}"
TAGS=$(curl -s --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/registry/repository/${CI_PROJECT_PATH//\//%2F}/tags?per_page=100" \
| jq -r '.[].name' | sort -V)
TOTAL=$(echo "${TAGS}" | wc -l)
DEL=$((TOTAL - KEEP))
[ "${DEL}" -le 0 ] && { echo "无需清理"; exit 0; }
echo "${TAGS}" | head -n "${DEL}" | while read -r tag; do
[ -z "${tag}" ] && continue
if [ "${DRY_RUN}" = "true" ]; then
echo "[dry-run] 将删除 ${tag}"
else
echo "删除 ${tag}"
curl -s -X DELETE --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/registry/repository/${CI_PROJECT_PATH//\//%2F}/tags/${tag}"
fi
done
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
注意 DRY_RUN 默认 true。 清理是不可逆的,第一次必须 dry-run 看清单。确认无误后再手动改成 false 跑一次。
注意 %2F 转义。 项目路径里的 / 在 API URL 里要编码成 %2F,否则 404。这是 GitLab Registry API 的一个坑。
7.3 SBOM 与供应链
sbom:generate:
stage: security
needs: ["build:image"]
image:
name: aquasec/trivy:0.57.1
entrypoint: [""]
script:
- trivy image --format cyclonedx --output sbom.cdx.json "${IMAGE_REPO}:${IMAGE_TAG}"
artifacts:
expire_in: 90 days
paths:
- sbom.cdx.json
SBOM(软件物料清单)的价值在出事的时候体现:某个基础库爆了 0day,你能在 10 分钟内查清"哪些镜像用了它",而不是全公司翻 Dockerfile。
八、部署策略
8.1 三种策略的取舍
| 策略 |
资源开销 |
回滚速度 |
适用场景 |
| 滚动更新 |
低(多 1 个 Pod) |
中(要等新 Pod 起来) |
无状态服务,默认选择 |
| 蓝绿 |
高(双倍资源) |
快(切流量) |
对回滚时间敏感的核心服务 |
| 金丝雀 |
中 |
快(缩容) |
需要按真实流量验证 |
8.2 滚动更新的关键参数
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
minReadySeconds: 10
progressDeadlineSeconds: 600
maxUnavailable: 0 + maxSurge: 1 表示"先起新的,再停旧的",全程容量不下降。代价是需要额外 1 个 Pod 的资源。
minReadySeconds: 10 很重要:Pod 变成 Ready 后还要稳定 10 秒才算成功。没有这个,一个"起来就崩"的版本可能在 readiness 探针的间隙里被判定为成功,然后进入下一个。
8.3 金丝雀的 CI 实现
GitLab 本身不做流量切分,需要配合 K8s 的 Service/Ingress 或者 Istio/Flagger。
最简版本:用两个 Deployment,靠 Service selector 的副本比例控制流量。
canary:10:
stage: deploy
environment:
name: production/canary
script:
- kubectl -n order-prod set image deploy/order-canary app="${IMAGE_REPO}:${IMAGE_TAG}"
- kubectl -n order-prod scale deploy/order-canary --replicas=1
- kubectl -n order-prod scale deploy/order-stable --replicas=9
- sleep 300 # 观察窗口
- ./check-error-rate.sh production 0.01 # 错误率超过 1% 则失败
when: manual
这个方案的缺点是流量比例靠副本数近似,不精确。真正的金丝雀要用 Istio 的 VirtualService 权重:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: order-service
namespace: order-prod
spec:
hosts: ["order-service"]
http:
- route:
- destination:
host: order-service-stable
weight: 90
- destination:
host: order-service-canary
weight: 10
CI 里改权重 + 观察指标 + 自动推进或回退,这套逻辑通常用 Flagger 或 Argo Rollouts 实现,不要自己在 shell 里写。
8.4 数据库变更的顺序
这是发布里最容易出事的部分。原则:先兼容,再变更,后清理。
三步走:
- 扩展(expand):加新字段/新表,旧代码不受影响。这一步可以随应用一起发。
- 迁移(migrate):双写或者后台任务回填数据。
- 收缩(contract):删旧字段/旧表。这一步必须等所有旧版本实例都下线之后。
CI 里的体现:
migrate:prod:
stage: deploy
needs: ["deploy:prod"]
script:
- ./migrate.sh --env production --dry-run
- ./migrate.sh --env production
rules:
- if: $CI_COMMIT_TAG
when: manual
environment:
name: production
action: prepare
风险: DDL 在大表上会锁表。MySQL 8.0 的 ALGORITHM=INSTANT 只支持加列(且列必须在最后),改索引、改类型仍然要 rebuild。生产上大表的 DDL 要用 gh-ost 或 pt-online-schema-change,且必须在低峰执行。
回滚: 数据库变更的回滚比应用回滚难得多。删掉的列加不回来(数据没了)。所以第 3 步"收缩"要单独排期,至少等一个完整的发布周期,确认没问题再做。
8.5 部署验证的四个层次
- Pod 就绪:
rollout status 通过,readiness 探针 OK。
- 接口可用:冒烟测试,关键接口返回 200。
- 业务正确:跑一组核心用例(下单、支付回调),验证数据一致性。
- 指标正常:观察 5~15 分钟,错误率、P99、QPS 在基线范围内。
前两层可以自动做,第三层通常要半自动(跑固定用例),第四层靠监控 + 人工判断。
九、安全设计
9.1 变量的三种保护
Settings → CI/CD → Variables
├── Protected:只在受保护分支/tag 上注入
├── Masked:日志里显示为 [MASKED]
└── Expanded:是否展开变量引用
Masked 的限制: 值必须满足特定格式(至少 8 个字符,只含 Base64 字符集,不能有空格和换行)。一个含换行的私钥没法 mask,只能靠 protected + 不在日志里打印。
Protected 的陷阱: 前面提过,非保护分支拿不到变量,但流水线照样跑。脚本里必须用 ${VAR:?} 检查。
9.2 防止恶意 MR 窃取凭证
威胁模型:一个外部贡献者提 MR,修改 .gitlab-ci.yml 加入 cat $PROD_TOKEN。
防护:
- Protected 变量:MR 流水线跑在源分支上,源分支不是受保护分支,拿不到变量。这是最有效的一层。
- 禁止 fork 流水线访问变量:Settings → CI/CD → Token Access,关闭 "Allow Git push requests to your repository"(针对 fork)。
- 合并前必须 review:
.gitlab-ci.yml 加入 CODEOWNERS,改动必须由平台组审批。
# .gitlab/CODEOWNERS
/.gitlab-ci.yml @platform-team
/.gitlab/ci/ @platform-team
/deploy/ @platform-team
- 生产部署 job 只在 tag 上跑:tag 只有 maintainer 能打,MR 改不了 tag。
9.3 审批与冻结
部署审批(Premium 及以上):
Settings → CI/CD → Environments → production → Required approvals: 1
配置后,deploy:prod 在手动触发后还需要一个人审批,形成"双人复核"。
部署冻结窗口(Deploy freeze):
Settings → CI/CD → Deploy freezes
大促、月底结算这类时间窗,直接冻结生产部署,任何 job 都无法部署到该环境。这比"口头通知大家别发"可靠得多。
9.4 Runner 隔离
| 环境 |
Runner 类型 |
tag |
| 开发/MR |
共享 Runner,docker executor |
shared |
| staging |
项目 Runner |
staging |
| production |
专用 Runner,网络隔离 |
prod |
生产 Runner 的关键配置:
# /etc/gitlab-runner/config.toml
[[runners]]
name = "prod-runner-01"
url = "https://gitlab.example.com"
token = "glrt-xxxx"
executor = "docker"
[runners.docker]
image = "registry.example.com/ci/base:1.0"
privileged = false
volumes = ["/cache"]
network_mode = "prod-net"
[runners.cache]
type = "s3"
shared = true
[runners.cache.s3]
server_address = "minio.example.com"
bucket_name = "gitlab-runner-cache"
privileged = false 是底线。生产 Runner 上跑 privileged 容器,等于把生产网络的管理权限开放给任何能提交代码的人。
十、Runner 运维
10.1 并发配置
concurrent = 20 # 全局最大并发 job 数
check_interval = 3
concurrent 是整个 Runner 进程的并发上限,不是每个 executor 的。设太大,机器会被压垮;设太小,job 排队。
经验值:concurrent = CPU 核数 × 1.5(docker executor,job 主要是 IO 等待)。
单个项目的并发限制在 [[runners]] 段:
[[runners]]
limit = 10 # 这个 runner 最多同时跑 10 个 job
10.2 磁盘清理
docker executor 最大的运维问题是磁盘。每个 job 都会拉镜像、建容器、写层,日积月累几十 GB。
# 定期清理(建议加进 cron,每天凌晨)
docker system prune -af --filter "until=72h"
docker volume prune -f
风险: docker system prune -af 会删掉所有未使用的镜像和停止的容器。如果 Runner 上有手工跑的其他容器,会被误删。生产上要么用专用 Runner(只有 CI 在用),要么用更精确的过滤:
# 只清理 72 小时前的构建缓存
docker builder prune -f --filter "until=72h"
# 只清理悬空镜像
docker image prune -f
注意 docker volume prune 会删数据。 如果有容器挂了 named volume,且当前没运行,volume 会被当成"未使用"删掉。执行前先 docker volume ls 看一眼。
10.3 缓存存储
本地缓存(/cache volume)只在一台 Runner 上有效。多 Runner 场景必须用分布式缓存(S3/MinIO/GCS)。
[runners.cache]
type = "s3"
shared = true
[runners.cache.s3]
server_address = "minio.example.com"
bucket_name = "gitlab-runner-cache"
bucket_location = "us-east-1"
insecure = false
authentication_type = "access-key"
凭证通过 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY 环境变量注入 Runner 进程,不要写在 config.toml 里。
shared = true 表示多个 Runner 共享同一个缓存桶。如果不同 Runner 服务于不同安全等级的项目,设成 false,避免缓存串项目。
十一、性能优化
11.1 先测量,再优化
GitLab 提供流水线时长分析(Analyze → CI/CD analytics),但更直接的办法是看 API:
curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
"https://gitlab.example.com/api/v4/projects/${PROJECT_ID}/pipelines/${PIPELINE_ID}/jobs?per_page=100" \
| jq -r '.[] | [.name, .duration, .queued_duration, .status] | @tsv' \
| sort -k2 -rn
duration 是执行时间,queued_duration 是排队时间。如果 queued_duration 很大,问题在 Runner 容量,不在流水线设计。 这两个数要分开看,否则会优化错方向。
11.2 DAG 化
把串行改成 DAG,通常能省 30%~50%:
# 优化前:test 阶段要等 build 阶段全部完成
# 优化后:test:unit 只等 lint,不等 build:image
test:unit:
stage: test
needs: ["lint:java"]
build:image:
stage: build
needs: ["test:unit"] # 单测过了就构建,不等集成测试
test:integration:
stage: test
needs: ["build:image"] # 集成测试用镜像跑,和单测并行
关键洞察:集成测试可以用构建好的镜像跑,不必等 jar 包。这样单测和集成测试可以并行。
11.3 缓存与镜像预热
warm:cache:
stage: validate
needs: []
cache:
key:
files: ["pom.xml"]
paths: [".m2/repository/"]
policy: push
script:
- mvn ${MAVEN_CLI_OPTS} dependency:go-offline
rules:
- changes:
- "pom.xml"
只在 pom.xml 变化时跑,把依赖下载好推上去。后续 job 用 policy: pull 直接拿,省掉下载时间。
11.4 测试分片
parallel: 4 只是起了 4 个实例,要真正分片得让每个实例跑不同的测试。
Maven Surefire 支持按文件分片:
test:unit:
parallel: 4
script:
- >
mvn ${MAVEN_CLI_OPTS} test
-Dsurefire.failIfNoSpecifiedTests=false
-Dtest="$(./scripts/shard-tests.sh ${CI_NODE_INDEX} ${CI_NODE_TOTAL})"
shard-tests.sh 按文件名哈希取模:
#!/usr/bin/env bash
# shard-tests.sh <index> <total>
# 输出逗号分隔的测试类全限定名,供 mvn -Dtest= 使用
set -euo pipefail
INDEX="${1:?缺少 index}"
TOTAL="${2:?缺少 total}"
[[ "${INDEX}" =~ ^[0-9]+$ ]] || { echo "index 必须是整数" >&2; exit 2; }
[[ "${TOTAL}" =~ ^[1-9][0-9]*$ ]] || { echo "total 必须是正整数" >&2; exit 2; }
[[ "${INDEX}" -lt "${TOTAL}" ]] || { echo "index 必须小于 total" >&2; exit 2; }
OUT=""
while IFS= read -r f; do
# cksum 给出稳定哈希,同一个文件在任何机器上都落到同一个分片
H=$(cksum < "${f}" | awk '{print $1}')
if [[ $((H % TOTAL)) -eq ${INDEX} ]]; then
CLS=$(printf '%s' "${f}" | sed 's#^src/test/java/##; s#/#.#g; s#\.java$##')
OUT="${OUT:+${OUT},}${CLS}"
fi
done < <(find src/test/java -type f -name '*Test.java' | sort)
if [[ -z "${OUT}" ]]; then
echo "NO_TESTS"
else
printf '%s\n' "${OUT}"
fi
对应 job:
test:unit:
parallel: 4
script:
- TESTS=$(./scripts/shard-tests.sh "${CI_NODE_INDEX}" "${CI_NODE_TOTAL}")
- if [ "${TESTS}" = "NO_TESTS" ]; then echo "本分片无测试类,跳过"; exit 0; fi
- mvn ${MAVEN_CLI_OPTS} test -Dtest="${TESTS}" -Dsurefire.failIfNoSpecifiedTests=false
注意: 分片是按类分,不是按方法分。如果某个类里有 500 个测试方法,它会全部落在一个分片上,分片不均。要更均匀,得用 JUnit 5 的 @Tag 手工分组,或者用 junit-platform 的动态分片。
十二、排障
12.1 job 一直 pending
This job is stuck because you don't have any runners online assigned to it.
三个原因:
- 没有匹配 tag 的 Runner。 job 写了
tags: [prod],但没有任何 Runner 注册了这个 tag。
- Runner 并发已满。
concurrent 或 limit 到了上限,其他 job 在排队。
- Runner 被暂停。 管理员在 UI 上 pause 了。
排查:
# 在 Runner 机器上
gitlab-runner list
gitlab-runner verify
# 通过 API 查项目可用的 Runner
curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
"https://gitlab.example.com/api/v4/projects/${PROJECT_ID}/runners?per_page=100" \
| jq -r '.[] | [.id, .description, .status, (.tag_list|join(","))] | @tsv'
12.2 缓存不命中
# 检查点 1:key 是否一致(files 的哈希变了 key 就变)
# 检查点 2:policy 是否允许下载(push-only 的缓存别人拿不到)
# 检查点 3:Runner 是否共享缓存(本地缓存换台机器就没有)
在 job 里打印调试:
script:
- echo "cache key files hash: $(sha256sum pom.xml)"
- ls -la .m2/repository/ | head
12.3 artifacts 下载失败
FATAL: invalid argument
常见原因:artifacts 超过大小限制(默认 100MB,可在 Admin 调整)、对象存储配置错误、expire_in 已过期。
# 通过 API 确认 artifacts 是否存在
curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
"https://gitlab.example.com/api/v4/projects/${PROJECT_ID}/jobs/${JOB_ID}/artifacts" \
-o artifacts.zip -w '%{http_code}\n'
12.4 Docker Hub 拉取限流
toomanyrequests: You have reached your pull rate limit
解决办法:
- 用私有镜像仓库做代理缓存(Harbor 的 proxy cache、GitLab Dependency Proxy)。
- 把常用镜像同步到内网仓库。
- 配置 Docker Hub 认证(
docker login),提高限额。
GitLab Dependency Proxy 的用法:
variables:
DOCKER_HUB_PROXY: "${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}"
然后把 image: node:20 改成 image: ${DOCKER_HUB_PROXY}/node:20。需要在 Group 设置里启用 Dependency Proxy。
12.5 权限类错误
remote: You are not allowed to download code from this project.
CI_JOB_TOKEN 的权限问题。GitLab 16.x 之后收紧了 CI_JOB_TOKEN 的默认权限,跨项目访问要在目标项目的 "CI/CD → Job token permissions" 里显式加白名单。
12.6 用 API 看完整日志
job 日志在 UI 上可能被截断(大文件),用 API 拿完整的:
curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
"https://gitlab.example.com/api/v4/projects/${PROJECT_ID}/jobs/${JOB_ID}/trace" \
-o job.log
grep -nE 'ERROR|FATAL|error:' job.log | head -50
十三、风险与回滚
13.1 流水线变更本身的风险
改 .gitlab-ci.yml 是有风险的变更,但它经常被当成"配置"随手提交。
| 变更类型 |
风险 |
防护 |
改 rules |
某条流水线不再触发,或重复触发 |
在 MR 里对比"改动前后的流水线图" |
改 needs |
产物缺失导致下游失败 |
先在一个分支验证 |
改 cache key |
缓存失效,流水线变慢 |
影响可控,注意观察时长 |
| 改部署脚本 |
可能部署错版本 |
必须走 staging 验证 |
| 改凭证引用 |
生产部署失败或权限过大 |
CODEOWNERS + 审批 |
回滚方式: git revert 对应 commit。但要注意,如果流水线已经跑了一半,revert 不会撤销已经部署的版本,需要单独回滚应用。
13.2 应用回滚的三种粒度
- K8s 层面:
kubectl rollout undo。快(秒级),但依赖 ReplicaSet 历史。
- CI 层面:重跑上一次成功的
deploy:prod job(GitLab 的 Re-deploy 按钮)。可靠,但慢(要重新拉镜像、滚动)。
- 数据库层面:通常不可回滚,只能向前修复。
演练要求: 回滚流程必须演练过。很多团队的回滚脚本从来没跑过,真出事时发现 rollback-info.txt 格式不对、权限不够、镜像已经被清理。
13.3 生产变更的四个前置
- 权限控制:只有 maintainer 能打 tag,只有特定角色能审批生产部署。
- 执行窗口:避开业务高峰,大促期间冻结。
- 灰度:先 staging 全量验证,生产先 1 个副本。
- 验证方式:冒烟 + 指标观察 + 业务确认,三者缺一不可。
十四、度量:流水线好不好,用数据说话
DORA 的四个指标,都可以从 GitLab 数据算出来:
| 指标 |
定义 |
数据来源 |
| 部署频率 |
单位时间内部署到生产的次数 |
tag 流水线成功数 |
| 变更前置时间 |
从 commit 到部署生产的时长 |
commit 时间 → 部署 job 完成时间 |
| 变更失败率 |
部署后导致故障的比例 |
部署后 N 小时内是否有回滚/告警 |
| 恢复时长 |
从故障到恢复的时间 |
回滚 job 的时间戳 |
采集方式:
# 拉取最近 100 次生产部署
curl -s --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
"https://gitlab.example.com/api/v4/projects/${PROJECT_ID}/pipelines?ref=main&status=success&per_page=100" \
| jq -r '.[] | [.id, .created_at, .updated_at, .sha[:8]] | @tsv'
除了 DORA,还有两个内部指标值得盯:
- 流水线成功率:低于 90% 说明流水线本身不稳定,团队会开始不信任它。
- 平均排队时长:
queued_duration 的均值。超过 2 分钟说明 Runner 容量不够。
十五、总结
回到开头那个"能跑但不敢用"的流水线,差距在哪?
第一,结构。 串行改 DAG,让每个 job 只等它真正依赖的东西。这一条通常能砍掉一半时长。
第二,契约。 每个阶段明确输入输出。构建产出镜像(不可变、带 sha),测试消费镜像,部署消费镜像。中间不传"某个目录下的某个文件",传有版本号的制品。
第三,闸门。 分支保护、环境保护、变量保护,三道闸门配合。没有闸门的自动化,只是把事故的发生速度提高了。
第四,可回滚。 部署前记录上一版,回滚有明确命令,命令被演练过。做不到这三点,"自动部署"就是"自动事故"。
第五,凭证。 长期密钥逐步换成 OIDC 短期令牌。这一条不是可选项,是必选项——CI 变量里的 AK/SK 是当前最常见的高危资产。
最后说一个反直觉的经验:流水线的价值不在于"自动化了多少步",而在于"让多少人敢提交"。 一条 8 分钟出结果、失败原因清晰、回滚只要一条命令的流水线,会让团队每天多发几次版;一条 50 分钟、失败要人肉排查、回滚靠运气的流水线,会让团队攒一周发一次,然后在那一次里塞进 200 个 commit。
后者的风险不是"发布频率低",而是"每次发布的变更量巨大,出问题时无法定位是哪个改动导致的"。这才是流水线设计真正要解决的问题。