深入 GitLab CI 阻塞排查:全局 Cache 滥用引发的 Runner IO 饱和与多级构建穿透实战

某次代码合入高峰期,核心业务的 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),会产生以下连环爆炸:

  1. 网络与 CPU 双重瓶颈:每次 3GB 碎文件的 ZIP 压缩/解压,吃光 Runner 节点的 CPU 资源。

  2. 多 Job 踩踏:在同一个 Pipeline 中,buildtestlint 三个 Job 并发执行,会产生 3 次冗余的 3GB Cache 下载动作。

  3. 缓存穿透:只要有一个文件发生变化,缓存的 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 拉取的时间峰值。