GitLab CI 测试流水线实战——.gitlab-ci.yml 构建、缓存、Docker 服务
GitLab CI 测试流水线实战——.gitlab-ci.yml 构建、缓存、Docker 服务
适读人群:DevOps 工程师、后端工程师、测试工程师 | 阅读时长:约 14 分钟 | 核心价值:掌握 GitLab CI 完整配置体系,用 services、cache、artifacts 构建高效测试流水线
小李是一家创业公司的 DevOps 负责人,公司代码全在自建 GitLab 上。有一次他们接了一个合规要求很严的金融客户,对方要求提供完整的 CI/CD 记录,证明每次代码变更都经过了自动化测试。
小李翻出他们的 GitLab,发现 .gitlab-ci.yml 只有 20 行,就是个简单的 go build,连 go test 都没写。他一边冒冷汗,一边来找我:"老张,GitLab CI 我会一点,但感觉一直在用最简单的方式——能跑就行。你帮我系统讲一下,怎么做到真正的工程化?"
我说:"GitLab CI 其实很强大,关键是三个核心:services(依赖服务)、cache(缓存加速)、artifacts(产物传递)。把这三个搞透,你的流水线就脱胎换骨了。"
1. .gitlab-ci.yml 核心结构
# .gitlab-ci.yml 核心结构
stages: # 定义阶段,同阶段内的 job 并行运行
- build
- test
- coverage
- deploy
variables: # 全局变量
GO_VERSION: "1.22"
DOCKER_DRIVER: overlay2
default: # 默认配置(所有 job 继承)
image: golang:1.22-alpine
before_script:
- go version
- go env2. Go 项目完整流水线
# .gitlab-ci.yml
stages:
- prepare
- lint
- test
- report
variables:
GOPROXY: "https://goproxy.cn,direct"
GOFLAGS: "-mod=readonly"
COVERAGE_THRESHOLD: "80"
default:
image: golang:1.22-alpine
# 全局缓存配置
cache:
key:
files:
- go.sum
paths:
- .go-cache/
policy: pull # 默认只拉取缓存,不推送
before_script:
- export GOPATH="$CI_PROJECT_DIR/.go-cache"
- export GOCACHE="$CI_PROJECT_DIR/.go-cache/build-cache"
- go mod download
# ========== 阶段 1:依赖准备 ==========
prepare:
stage: prepare
script:
- go mod download
- go mod verify
cache:
key:
files:
- go.sum
paths:
- .go-cache/
policy: push # 这个 job 负责更新缓存
rules:
- changes:
- go.sum
- go.mod
# ========== 阶段 2:静态检查 ==========
lint:
stage: lint
script:
- go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
- golangci-lint run --timeout=5m --out-format=checkstyle ./... > gl-code-quality-report.xml || true
- golangci-lint run --timeout=5m ./...
artifacts:
reports:
codequality: gl-code-quality-report.xml
expire_in: 1 week
# ========== 阶段 3:单元测试 ==========
unit-test:
stage: test
script:
- apk add --no-cache gcc musl-dev # -race 需要 CGO
- |
go test \
-race \
-covermode=atomic \
-coverprofile=coverage.out \
-v \
./... \
2>&1 | tee test-output.txt
- go install github.com/jstemmer/go-junit-report/v2@latest
- go-junit-report -in test-output.txt -out junit-report.xml
artifacts:
when: always
reports:
junit: junit-report.xml
paths:
- coverage.out
- junit-report.xml
expire_in: 30 days
# ========== 阶段 3:集成测试(带 Docker 服务)==========
integration-test:
stage: test
services:
- name: postgres:15-alpine
alias: postgres
variables:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
- name: redis:7-alpine
alias: redis
variables:
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: testdb
DB_USER: testuser
DB_PASSWORD: testpass
REDIS_URL: "redis://redis:6379/0"
# 关闭 Testcontainers(使用 services 提供的容器)
INTEGRATION_TEST: "true"
script:
- |
go test \
-run Integration \
-timeout 120s \
-v \
./... \
2>&1 | tee integration-output.txt
- go-junit-report -in integration-output.txt -out integration-junit.xml
artifacts:
when: always
reports:
junit: integration-junit.xml
expire_in: 30 days
# ========== 阶段 4:覆盖率检查 ==========
coverage-report:
stage: report
dependencies:
- unit-test
script:
- go tool cover -html=coverage.out -o coverage.html
- |
COVERAGE=$(go tool cover -func=coverage.out | grep "^total:" | awk '{print $3}' | sed 's/%//')
echo "Coverage: ${COVERAGE}%"
echo "COVERAGE=${COVERAGE}" >> coverage.env
if awk "BEGIN {exit !($COVERAGE < $COVERAGE_THRESHOLD)}"; then
echo "Coverage ${COVERAGE}% is below ${COVERAGE_THRESHOLD}% threshold"
exit 1
fi
coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'
artifacts:
paths:
- coverage.html
- coverage.env
reports:
dotenv: coverage.env
expire_in: 30 days
# 生成 coverage badge
environment:
name: coverage3. Docker in Docker:构建镜像并推送
# 构建 Docker 镜像(需要 Docker in Docker)
build-image:
stage: deploy
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build -t $IMAGE_TAG .
- docker tag $IMAGE_TAG "$CI_REGISTRY_IMAGE:latest"
- docker push $IMAGE_TAG
- docker push "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_BRANCH == "main"4. 多环境条件触发
# 按分支和标签差异化配置
.test-base: &test-base
stage: test
script:
- go test ./...
unit-test-mr:
<<: *test-base
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # 只在 MR 时跑
unit-test-main:
<<: *test-base
script:
- go test -race -covermode=atomic ./... # main 分支跑完整测试
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy-prod:
stage: deploy
script:
- echo "Deploying to production..."
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ # 只在 tag 时部署
when: manual # 手动触发5. Artifacts 传递与依赖
# build 阶段生成二进制
build:
stage: build
script:
- go build -o bin/app ./cmd/app
artifacts:
paths:
- bin/app
expire_in: 1 hour
# test 阶段使用 build 产物
smoke-test:
stage: test
dependencies:
- build # 下载 build 阶段的 artifacts
script:
- ./bin/app --version
- ./bin/app healthcheck6. 踩坑实录
踩坑记录 1:services 里的数据库启动时机问题
GitLab CI 的 services 容器和 job 容器是并行启动的,不是顺序启动的。你的测试代码在 before_script 运行时,PostgreSQL 可能还没完全启动好。
解决方案:在 before_script 里加等待逻辑:
before_script:
- |
echo "Waiting for PostgreSQL to be ready..."
for i in $(seq 1 30); do
pg_isready -h postgres -U testuser && break
echo "Waiting... ($i/30)"
sleep 2
done
pg_isready -h postgres -U testuser || exit 1或者让代码自己重试连接(推荐,代码层面更健壮)。
踩坑记录 2:cache 缓存失效导致每次重新下载依赖
cache 的 key 设置不当时,每次 CI 都重建缓存,完全没有加速效果。正确做法:
cache:
key:
files:
- go.sum # 只有 go.sum 变化时才重建缓存
paths:
- .go-cache/注意:key 里的文件路径是相对于仓库根目录的,不是 working directory。
踩坑记录 3:coverage 正则不匹配导致 GitLab 无法显示覆盖率
GitLab 通过 coverage: 字段的正则从 job 日志提取覆盖率数值,显示在 MR 界面。如果正则写错,什么都不显示。
正确的 Go 覆盖率正则:
coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'用 GitLab 的 CI Lint 工具(Settings > CI/CD > CI Lint)可以提前验证 YAML 语法,但验证不了正则。建议先手动运行 go tool cover -func=coverage.out 看真实输出格式,再写正则。
7. includes:模块化复用配置
大项目可以把通用配置抽取到独立文件:
# .gitlab/ci/go.yml(通用 Go 配置)
.go-test-template:
image: golang:1.22-alpine
cache:
key:
files:
- go.sum
paths:
- .go-cache/
before_script:
- export GOPATH="$CI_PROJECT_DIR/.go-cache"
- go mod download
# 主 .gitlab-ci.yml
include:
- local: '.gitlab/ci/go.yml'
- local: '.gitlab/ci/docker.yml'
unit-test:
extends: .go-test-template
script:
- go test -race ./...8. GitLab CI 的高级特性:父子 Pipeline 与动态 Pipeline
对于复杂的 mono-repo 或多模块项目,GitLab 的父子 Pipeline(Parent-Child Pipelines)和动态 Pipeline 生成能大幅减少不必要的 CI 运行:
# 父 Pipeline:.gitlab-ci.yml
stages:
- trigger
# 根据变更路径,选择性触发子 Pipeline
trigger-go-service:
stage: trigger
trigger:
include: services/go/.gitlab-ci.yml
strategy: depend # 等待子 Pipeline 完成
rules:
- changes:
- services/go/**/* # 只有 go 服务有变更时才触发
when: always
trigger-java-service:
stage: trigger
trigger:
include: services/java/.gitlab-ci.yml
strategy: depend
rules:
- changes:
- services/java/**/*
when: always每个子服务目录里维护自己的 .gitlab-ci.yml,既实现了关注点分离,又避免了"改了一个服务,其他服务的 CI 也全跑一遍"的浪费。
动态 Pipeline(Matrix using YAML anchors)
GitLab 没有像 GitHub Actions 那样的原生 matrix,但可以用 YAML anchors 模拟:
.test-template: &test-template
stage: test
script:
- go test ./...
test-go-1.21:
<<: *test-template
image: golang:1.21-alpine
test-go-1.22:
<<: *test-template
image: golang:1.22-alpine或者更灵活地,用 GitLab 的 parallel: matrix 语法(GitLab 15.9+):
test:
image: golang:${GO_VERSION}-alpine
parallel:
matrix:
- GO_VERSION: ["1.21", "1.22"]
script:
- go test ./...9. GitLab CI 的合规审计与流水线可观测性
金融、医疗等强合规行业对 CI/CD 的要求不只是"能自动化测试",还要求完整的变更审计链路——谁在什么时候触发了哪个流水线,每个步骤的结果是什么,是否有人工审批。GitLab CI 在这方面有几个原生能力值得了解。
审批门禁(Manual Jobs 与 Protected Environments)
通过 when: manual 和受保护环境(Protected Environments)组合,可以强制关键部署步骤需要特定角色人员手动审批:
deploy-production:
stage: deploy
environment:
name: production
# 在 GitLab Settings > Environments 里设置只有 Maintainer+ 角色可以操作
script:
- ./deploy.sh production
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual # 打了版本 tag 才出现,且需要手动点击每次手动触发都会记录操作人和时间,这些记录在 GitLab 的 Audit Log 里可查,满足"每次生产部署都有人工审批记录"的合规要求。
Pipeline 可观测性
GitLab 的 Pipeline 仪表板(CI/CD > Pipelines)提供了每条流水线的历史记录,包括触发来源(push/MR/schedule/手动)、每个 job 的用时、产物、日志。结合 GitLab 的 Analytics > CI/CD Analytics,可以查看团队 Pipeline 成功率和运行时长的趋势。
小李的团队后来把 GitLab CI 的 Pipeline 历史导出作为合规报告的一部分,对方审计人员认可了这份记录的完整性。这说明一件事:CI/CD 流水线记录本身就是质量保证体系的一部分,不只是工程效率工具。从这个角度理解 CI,你会发现它和测试、监控、审计是一个整体,共同构成了软件可信度的基础。
9. GitLab CI 与 GitLab 生态的深度集成
GitLab 的核心优势在于代码管理、CI/CD、项目管理在同一平台,深度集成减少了工具链切换的摩擦。理解这些集成点,能让你把 CI 的价值发挥到最大。
Merge Request 质量信息展示
GitLab CI 可以在 MR 界面直接展示测试结果、代码质量报告、覆盖率变化,让 code reviewer 不需要离开 MR 界面就能看到完整的质量信息:
# 测试报告:在 MR 里展示每个测试用例的通过/失败
unit-test:
script:
- pytest --junitxml=junit-report.xml
artifacts:
reports:
junit: junit-report.xml # MR 界面会展示测试通过率
# 代码覆盖率:MR 界面展示覆盖率变化(相比 target branch)
coverage-test:
script:
- go test -coverprofile=coverage.out ./...
- go tool cover -html=coverage.out -o coverage.html
coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml # 展示哪些新代码没有覆盖到
# 代码质量:展示新增的代码质量问题
code-quality:
script:
- golangci-lint run --out-format=code-climate ./... > gl-code-quality-report.json || true
artifacts:
reports:
codequality: gl-code-quality-report.json这些 reports 字段是 GitLab 的原生特性,配置好后,MR 界面会显示"新增了 X 个代码质量问题"、"覆盖率从 82% 变为 84%"等信息,大大降低了 reviewer 的判断成本。
与 GitLab Container Registry 的无缝集成
GitLab 内置容器镜像仓库(GitLab Container Registry),CI 里推送镜像不需要额外的认证配置:
build-image:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHORT_SHA
before_script:
# GitLab 自动提供这些变量,不需要手动配置
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build -t $IMAGE_NAME .
- docker push $IMAGE_NAME
# 为 main 分支额外打 latest tag
- |
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
docker tag $IMAGE_NAME $CI_REGISTRY_IMAGE:latest
docker push $CI_REGISTRY_IMAGE:latest
fiCI_REGISTRY_USER、CI_REGISTRY_PASSWORD、CI_REGISTRY 这些变量 GitLab 会自动注入,不需要手动配置 secrets,这是 GitLab 生态集成的一个典型例子。
GitLab Release 与 Changelog 自动化
打 tag 时自动创建 Release,并从 MR 描述生成 changelog:
create-release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- echo "Creating release for $CI_COMMIT_TAG"
release:
name: "Release $CI_COMMIT_TAG"
description: "$(cat CHANGELOG.md | head -50)"
tag_name: $CI_COMMIT_TAG
assets:
links:
- name: 'Docker Image'
url: 'https://gitlab.example.com/my-group/my-project/container_registry'GitLab CI 的长期维护经验
小李那个项目最后稳定运行了两年,我从中总结了几条长期维护经验值得分享:
一是 .gitlab-ci.yml 要定期 Review。CI 配置和业务代码一样,会随着项目演进而变得复杂和冗余。建议每季度回顾一次,删除不再使用的 job,合并相似的 job,保持配置的简洁性。CI 配置的复杂度增长会直接影响新人上手速度。
二是严格管理 CI 时间预算。每个 job 都要有 timeout 限制(timeout: 10 minutes),防止某个 job 卡死导致整条流水线超时。同时要监控 CI 平均时间,如果某次变更导致时间明显增加,要立即排查。
三是 Protected Branch 和 Protected Tag 要和 CI 权限联动。能向 main 分支推代码的人,才能触发 main 分支的完整 CI(包括部署步骤)。这是基本的安全实践,很多团队忽略了权限和 CI 触发之间的关联。
GitLab CI 的价值不在于它有多少花哨的特性,而在于它和代码、MR、审计记录深度绑定在同一平台上。当你的代码质量信息、测试结果、部署记录都在一个地方可查,团队协作和合规审计的摩擦都会大幅降低。这就是小李的金融客户认可他们流水线的原因——不是因为技术多先进,而是因为信息完整、可追溯、可信任。
10. GitLab CI 的演进:从 CI 到平台工程
GitLab 的野心从来不只是 CI/CD,而是一个完整的 DevSecOps 平台。了解 GitLab 的演进方向,有助于你在技术选型和规划上做出更前瞻的决策。
GitLab 的 DevSecOps 能力矩阵
除了本文讲到的 CI/CD 核心功能,GitLab 还内置了许多常见的工具链功能:
代码质量方面,GitLab 内置 SAST(静态应用安全测试)、依赖漏洞扫描、Secret 检测,可以通过简单配置启用:
include:
- template: Security/SAST.gitlab-ci.yml
- template: Security/Dependency-Scanning.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml这三行配置就启用了三类安全扫描,结果直接显示在 MR 界面的安全报告标签里。对于中小团队来说,不需要额外部署 SonarQube 或 Snyk,GitLab 内置的扫描已经能覆盖大部分场景。
GitLab Catalog:CI/CD 组件市场
GitLab 17.x 引入了 CI/CD Catalog,类似于 GitHub Actions 的 Marketplace,但专为 GitLab CI 设计。你可以把可复用的 CI 组件(类似 Reusable Workflows)发布到 Catalog,让团队或社区共享:
include:
- component: gitlab.com/my-org/ci-components/go-test@1.0.0
inputs:
coverage-threshold: 85
go-version: '1.22'这标志着 GitLab 生态正在走向"CI 组件化"的方向,未来的 CI 配置可能越来越像"声明式配置 + 可复用组件"的组合,而不是从零写 YAML。
工程师个人的成长视角
我跟小李有过一次深入的对话,他说:"老张,感觉 CI/CD 工具的功能越来越多,跟不上了。"
我跟他说:工具会变,但思维框架不会变。无论是 Jenkins 还是 GitLab CI 还是未来某个新工具,核心的工程诉求是一致的:代码提交后如何快速得到质量反馈,如何在部署前排除已知风险,如何保留完整的变更审计链路。理解了这些诉求,任何工具学起来都只是配置语法的差异。
真正值得投入时间学习的,不是某个工具的所有配置项,而是工具背后的工程思想:为什么要并行化,为什么要缓存,为什么安全扫描要写进流水线而不是独立运行。这些思想跨工具通用,是真正的长期资产。
小李最终不只是修好了那条 GitLab CI 流水线,他开始理解"为什么要这样配置"。两年后他成了公司的 DevOps 负责人,带着团队把整个交付链路系统化地优化了一遍。这个转变的起点,就是那次被金融客户问到"你们的 CI 记录在哪里"时的一身冷汗。
11. 小结:GitLab CI 的核心价值
GitLab CI 的三个关键概念——services(测试依赖管理)、cache(构建加速)、artifacts(产物传递)——构成了一套完整而简洁的流水线抽象。理解了这三个概念,90% 的 CI 需求都能优雅实现。
对于使用 GitLab 的团队,最大化利用 GitLab 生态的深度集成(代码质量报告、测试结果、覆盖率趋势直接显示在 MR 界面),能显著提升 code review 的效率和 PR 合并的决策质量。
CI/CD 的最终目标不是"流水线能跑",而是"每次代码变更都有明确的质量信号"。GitLab CI 提供了实现这个目标的完整工具集,剩下的是团队是否愿意把这些工具用起来,让质量信号真正驱动开发决策。
12. 团队 GitLab CI 的成熟度评估
衡量一个团队 GitLab CI 实践成熟度的几个维度:
基础层(Level 1):有 .gitlab-ci.yml,代码提交自动触发测试,有 artifacts 保存测试报告。
提升层(Level 2):有 cache 优化构建速度,有 services 支持集成测试,有 coverage 指标显示在 MR 界面,有质量门禁(测试不通过不能合并)。
成熟层(Level 3):有 includes 实现配置复用,有父子 Pipeline 实现按需触发,有安全扫描(SAST、依赖扫描)集成,有 Protected Environments 保护生产部署,有 CI/CD Analytics 监控流水线健康度。
卓越层(Level 4):有 CI Catalog 组件共享,有完整的 DevSecOps 链路,CI 配置有 Code Review 流程,定期进行 CI 健康度回顾,CI 指标和业务指标关联分析。
大多数团队在 Level 1 和 Level 2 之间。升到 Level 2 通常只需要 1-2 周的工程投入,带来的效率提升是显著的。小李那个项目最终达到了 Level 3,花了三个月时间,但那次金融客户审计让他们意识到:这个投入的回报,不只是技术效率,还有业务信任。
13. 给 GitLab CI 新手的入门路径
如果你是第一次接触 GitLab CI,最快的上手路径是:
第一步,从一个最简单的 .gitlab-ci.yml 开始,只有一个 stage 和一个 job,跑一下 echo "Hello CI" 确认流水线能触发。
第二步,把项目实际的构建命令写进去,确认编译通过。
第三步,加上测试命令,配置 artifacts 保存测试报告,把 coverage 正则配上,让 MR 界面能显示覆盖率。
第四步,按本文介绍配置 cache,观察 CI 时间变化。
第五步,加上 services,把集成测试也纳入 CI。
每一步都是一个小目标,可以独立交付价值。不要试图一步到位搭建完美的流水线,先跑起来,再迭代优化。GitLab CI 的学习曲线并不陡,关键是动手实践,在真实项目中理解每个配置项的意义。
GitLab CI 与 GitLab 的深度集成使得代码质量、测试结果、安全扫描信息能在同一平台统一呈现,这种一体化的工程视角,是推进团队质量文化的重要基础设施。
GitLab CI 的成功,不在于配置有多复杂,而在于它和 GitLab 的代码管理、MR 流程深度集成,让质量信息自然地融入工程协作流中。用好这些原生集成,让质量数据触手可及,是 GitLab CI 能为团队带来的最大价值。
写在最后
GitLab CI 的精髓在于 YAML 的声明式配置和与 GitLab 生态的深度集成——代码质量报告、测试结果、覆盖率徽章都能直接显示在 MR 界面,让 code review 的信息密度大幅提升。
小李后来把他们的流水线改造完成,覆盖了单测、集成测试、覆盖率门禁和代码质量检查。金融客户那边审计的时候,他把 CI 记录导出来,对方看了说"这个比他们预期的要完善很多"。
下一篇我们进入质量门禁专题——覆盖率、代码扫描、安全扫描三位一体,如何在 CI 里系统性落地。
