某次代码合入高峰期,核心业务的 GitLab CI Pipeline 出现大面积排队,单次构建从 3 分钟恶化至 40 分钟。核心原因是全局 cache 滥用导致 Runner 节点磁盘 IO 打满(iowait > 65%),且 Docker 构建层缓存(BuildKit)被错误穿透。通过将缓存后端迁移至 MinIO S3、引入 BuildKit 挂载缓存,并严格分离 Cache 与 Artifacts,最终将 P99 构建耗时稳压在 2 分钟内。
案发现场:Pipeline 假死与 IO 风暴
排查过程中,研发反馈提交 PR 后 Pipeline 迟迟不执行。登录 GitLab Runner 宿主机(4C16G,普通 SSD),直接看系统负载:
$ uptime
14:22:10 up 45 days, 10:13, 2 users, load average: 32.41, 28.14, 15.02
$ iostat -dxz 1
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.00 12.40 145.00 380.00 45210.00 125044.0 648.59 14.20 25.30 15.10 29.20 1.90 100.00
%util 持续 100%,load average 飙到 32。查看 Runner 容器的执行日志,发现大量 Job 卡在拉取和解压缓存阶段:
Checking cache for default-3...
Downloading cache.zip from https://storage.googleapis.com/gitlab-com-runners-cache/...
WARNING: Downloaded cache is 3.2 GB
Extracting cache is taking 12m 45s...
一个包含前端 node_modules 和后端 .go/pkg/mod 的巨型 Cache 被挂载到了全局 default 阶段。当 10 个 Job 并发启动时,单台 Runner 需要瞬间下载并解压超过 30GB 的碎文件,直接把磁盘 IO 打穿。
为什么全局 Cache 滥用会引发 Runner IO 雪崩?
很多开发写 .gitlab-ci.yml 时,图省事喜欢把所有依赖丢进全局 cache,并且配置 paths 覆盖整个项目根目录。这在底层机制上是个灾难。
GitLab Runner 处理 Cache 的默认逻辑是:基于 ZIP 压缩,在 Job 开始前下载解压,在 Job 结束后压缩上传。 如果配置不当(例如未配置分布式缓存,使用本地文件系统或远端低速 OSS),会产生以下连环爆炸:
-
网络与 CPU 双重瓶颈:每次 3GB 碎文件的 ZIP 压缩/解压,吃光 Runner 节点的 CPU 资源。
-
多 Job 踩踏:在同一个 Pipeline 中,
build、test、lint三个 Job 并发执行,会产生 3 次冗余的 3GB Cache 下载动作。 -
缓存穿透:只要有一个文件发生变化,缓存的 Hash Key 就会变动(或者使用 fallback key),导致全量重新上传。
此外,Docker 构建环节也存在严重的缓存穿透。传统 docker build 遇到 COPY . . 时,只要源码树里任何一个无关文件(如 README.md)改动,其后的所有构建层(包括耗时的 go mod download)缓存全部失效。
防御性流水线重构与多级缓存落地
针对上述架构缺陷,实施分层缓存防御改造。环境基于 GitLab Runner 16.3 和 Docker 24.0.5。
1. 阻断本地 IO 踩踏:引入 MinIO S3 分布式缓存
放弃 Runner 本地文件缓存,在内网独立部署 MinIO 集群承接 Cache 流量,避免 Runner 磁盘成为瓶颈。修改 /etc/gitlab-runner/config.toml:
[[runners]]
name = "high-perf-runner"
url = "https://gitlab.example.com/"
token = "glrt-xxxxxxxxxx"
executor = "docker"
[runners.docker]
tls_verify = false
image = "alpine:latest"
privileged = true
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/cache"]
shm_size = 0
[runners.cache]
Type = "s3"
Path = "gitlab-runner-cache"
Shared = true
[runners.cache.s3]
ServerAddress = "minio.internal.lan:9000"
AccessKey = "admin"
SecretKey = "StrongSecret123!"
BucketName = "ci-cache"
Insecure = true
2. 精准外科手术:拆分 Cache 与 Artifacts,按需声明
在 .gitlab-ci.yml 中,严禁使用全局 Cache。Cache 用于加速依赖下载,Artifacts 用于阶段间传递制品。
stages:
- deps
- build
- test
# 只在依赖拉取阶段更新 Cache
go-deps:
stage: deps
image: golang:1.21-alpine
cache:
key:
files:
- go.sum
paths:
- .go/pkg/mod/
policy: pull-push # 唯一允许 push 的 Job
script:
- go mod download -x
# 编译阶段:只读 Cache,通过 Artifacts 传递二进制
go-build:
stage: build
image: golang:1.21-alpine
cache:
key:
files:
- go.sum
paths:
- .go/pkg/mod/
policy: pull # 阻断重复压缩上传
script:
- go build -o myapp ./cmd/main.go
artifacts:
paths:
- myapp
expire_in: 1 hour # 防御制品磁盘打满
3. 终极杀器:BuildKit 挂载缓存与内联镜像缓存
针对 Docker 镜像构建,废弃旧版 docker build,全面启用 BuildKit (v0.12+) 的挂载缓存机制,将编译时的 .cache 独立持久化到 Runner 宿主机。
修改 Dockerfile:
# syntax=docker/dockerfile:1.4
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
# 利用 BuildKit 挂载远端/本地模块缓存,避免每次下载
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
# 挂载构建缓存
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -ldflags="-s -w" -o server .
FROM alpine:3.18
COPY --from=builder /app/server /server
CMD ["/server"]
配合 CI 脚本中的内联缓存(Inline Cache),实现多分支复用:
docker-pack:
stage: build
image: docker:24.0.5-dind
variables:
DOCKER_BUILDKIT: 1
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- >
docker build
--build-arg BUILDKIT_INLINE_CACHE=1
--cache-from $CI_REGISTRY_IMAGE/cache:latest
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
.
常见问题
Q1: Artifacts 和 Cache 到底怎么选?边界在哪?
Cache 没有绝对保证,Runner 可能会清理它,它是为了速度(如 npm, go mod)。
Artifacts 是为了 Job 间的数据完整传递(如 build 产出的 .jar 传给 deploy)。绝对不要用 Cache 来传递编译产物,否则遇到并发 Pipeline 或者 Fallback Key 命中失败,会导致后续 Job 找不到文件而直接报错退出。
Q2: DIND (Docker-in-Docker) 模式下,怎么复用宿主机的镜像层缓存?
DIND 每次启动都是独立的守护进程,默认不共享宿主机 /var/lib/docker。如果非要复用,可以通过 Runner 的 volumes = ["/var/run/docker.sock:/var/run/docker.sock"] 改为 Docker-out-of-Docker (DOOD) 模式。但注意,这会带来严重的并发污染问题(多个 Job 同时强删镜像)。更推荐的做法是坚持 DIND,但引入 Registry Cache (--cache-from 和 --cache-to=type=registry),将缓存推送到私有 Harbor。
Q3: GitLab CI 并发太高导致 MinIO S3 节点网络打满怎么限流?
首先检查 Cache 包是否过大,剔除不必要的文件。其次,在 GitLab Runner 配置中使用 [runners.limit] 限制单 Runner 的并发 Job 数。最后,如果是特定大项目引发的,在 gitlab-ci.yml 里使用 rules 控制无关代码提交(如 Markdown 修改)不触发构建,或者引入 needs: (DAG 依赖) 取代按 Stage 批量阻塞,错开 Cache 拉取的时间峰值。