CI 流水线提速实战:缓存设计与依赖代理的工程实践

一条企业级流水线的执行时间去哪了?把日志拉出来做时间分布分析,结论往往惊人地一致:代码编译本身可能只占 30%,剩下的 70% 花在了「每次都从零下载」上 ------依赖包(npm、Maven、Gem)、基础镜像、系统包,每个作业都重复一遍完整下载。更麻烦的是,团队规模一大,Docker Hub 的匿名拉取限额(以 6 小时为窗口限制清单 GET 请求数)就会成为隐形的天花板:白天高峰期作业排队失败,排查半天发现是 toomanyrequests

本文以极狐GitLab CI/CD 为载体,拆解两个直接决定流水线速度的机制------缓存(Cache)依赖代理(Dependency Proxy),给出可落地的配置方案与踩坑复盘。技术细节均出自极狐GitLab 官方文档。

一、先分清:缓存还是产物?

这是最容易混淆、也最容易配错的一组概念:

维度 缓存(cache) 产物(artifacts)
用途 存放从网络下载的依赖包 在阶段之间传递中间构建结果
生命周期 可跨流水线复用 默认 30 天过期,随流水线保留
存储位置 Runner 本地或分布式存储(如 S3) 极狐GitLab 服务器
跨项目共享 不可 通过 dependencies 控制获取范围

一句话原则:依赖走缓存,构建结果走产物 。把 node_modules/ 声明成 artifacts、把 dist/ 声明成 cache,是两类典型配置错误------前者会让流水线产物体积膨胀、下载变慢;后者会导致关键产物在缓存被清理后丢失。

二、缓存键设计:命中率的生命线

缓存生效的前提是「键找得对」。极狐GitLab 支持三级键策略:

1. 基于依赖清单文件生成键:cache:key:files

yaml 复制代码
build-job:
  stage: build
  cache:
    - key:
        files:
          - package-lock.json
      paths:
        - node_modules/
  script:
    - npm ci

键由 package-lock.json 的内容派生:锁文件不变 → 键不变 → 命中缓存,npm ci 秒级完成;锁文件一变,自动切到新缓存。这是命中率与正确性兼顾的首选方案 ,另有 key:files_commits 变体,基于文件最近提交生成键,适合「按提交粒度隔离」的场景。

2. 回退键:fallback_keys

新分支首次流水线没有缓存,直接裸跑全量下载?可以配置最多 5 个回退键,按顺序降级查找:

yaml 复制代码
test-job:
  stage: test
  cache:
    - key: cache-$CI_COMMIT_REF_SLUG
      fallback_keys:
        - cache-$CI_DEFAULT_BRANCH
      paths:
        - node_modules/

新特性分支先尝试自己的键,找不到就回退到默认分支的缓存------依赖大概率高度重合,比冷启动快得多。

3. 每个作业最多四个缓存

yaml 复制代码
test-job:
  cache:
    - key:
        files: [Gemfile.lock]
      paths: [vendor/ruby]
    - key:
        files: [yarn.lock]
      paths: [.yarn-cache/]
  script:
    - bundle install
    - yarn install

Ruby 与 Node 的依赖分开成两个键,互不干扰,清理与命中都更精准。

三、policy:让「只读作业」不写缓存

默认策略是 pull-push:作业开始时拉缓存、结束后推回。但大量作业(如测试、安全扫描)只消费缓存,结束后原样推回是纯粹的浪费------打包、上传一个没人会用的 zip。此时显式声明 policy: pull

yaml 复制代码
default:
  cache: &global_cache
    key:
      files: [package-lock.json]
    paths: [node_modules/]
    policy: pull-push

test:
  cache:
    <<: *global_cache
    policy: pull      # 只消费,不回写
  script:
    - npm test

四、Runner 侧的架构问题:分布式缓存

键设计得再好,架构不匹配也白搭。缓存命中有一个隐含前提:执行当前作业的 Runner,能拿到之前作业存储的缓存。官方文档给出了三种达标架构:

  1. 所有作业固定使用单个 Runner(缓存落在本机);
  2. 多 Runner + 分布式缓存:缓存放 S3 等对象存储,Runner 弹性伸缩也能命中(JihuLab.com 的实例 Runner 即采用此方式);
  3. 多个同架构 Runner + NFS 网络挂载共享缓存目录,且 Runner 处于自动伸缩模式。

如果团队用了多个独立 Runner 却没配分布式缓存,就会出现典型的「缓存不匹配」:键没变、缓存却像被清空了------因为作业这次跑在了另一台机器上。排查方法与解法在官方「缓存不匹配」表中列得很清楚。

五、依赖代理:镜像层面的缓存

缓存解决的是包依赖 ,依赖代理解决的是容器镜像。它是极狐GitLab 提供的群组级拉取缓存,对 Docker Hub 镜像生效,核心机制有两个:

  • 规避速率限制 :Docker Hub 按清单 GET 请求数限流,而依赖代理的请求以 HEAD 开始(不计入限额),只有清单摘要变化时才真正回源拉取。官方文档给了一个量化例子:流水线每 5 分钟拉一次 node:latest,6 小时内仅产生 1 次真实拉取,而不是 360 次;
  • blobs 本地缓存:镜像层缓存在极狐GitLab 服务器,再次拉取时清单信息从上游获取、层文件从本地提供。

CI 中的使用方式几乎零成本------Runner 会自动完成对依赖代理的登录,并提供一组预定义变量:

yaml 复制代码
# 直接作为作业镜像使用
test:
  image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/alpine:latest
  script:
    - echo "镜像经由依赖代理拉取"

配合 Docker-in-Docker 构建镜像时:

yaml 复制代码
build:
  image: docker:27
  services:
    - docker:27-dind
  variables:
    DOCKER_TLS_CERTDIR: ""
  before_script:
    - echo "$CI_DEPENDENCY_PROXY_PASSWORD" | docker login "$CI_DEPENDENCY_PROXY_SERVER" -u "$CI_DEPENDENCY_PROXY_USER" --password-stdin
  script:
    - docker build -t my-app:1.0 .

两个注意点:依赖代理仅在群组级别启用 (设置 > 软件包与镜像仓库 > 依赖代理),且目前上游仅支持 Docker Hub;变量中包含服务器端口,手动写死路径时要带上(如 :443)。

六、改造前后的架构对比

flowchart LR subgraph 改造前[改造前:每个作业都全量回源] A1[作业 1] -->|下载依赖包| HUB1[公共源] A2[作业 2] -->|下载依赖包| HUB1 A3[作业 N] -->|拉取基础镜像| HUB2[Docker Hub] end
flowchart LR subgraph 改造后[改造后:两级缓存拦截] B1[作业 1] --> C1[Runner 分布式缓存<br/>S3] B2[作业 2] --> C1 B3[作业 N] -->|image 拉取| DP[依赖代理<br/>群组级] DP -->|HEAD 探测 + 摘要比对| HUB[Docker Hub] C1 -. 命中即跳过 .-> B1 end

依赖包命中分布式缓存、镜像命中依赖代理,回源请求被压缩到「内容真正变化」的时刻。

七、踩坑复盘:三条真实教训

  1. 同键不同路径,缓存互相覆盖 。两个作业都写了 key: same-key,一个缓存 public/、一个缓存 vendor/------它们会被归档到同一个 cache.zip,后写的覆盖先写的,下次作业拿到的是错的缓存。解法:键跟路径绑定,一作业一键。
  2. 缓存是优化,不是保证。官方文档明确:缓存不保证始终可用(Runner 架构、磁盘空间都会影响)。作业脚本必须兼容「缓存缺失时全量重建」的路径,把缓存当作加速器而非依赖项。
  3. 受保护分支缓存默认隔离 。受保护分支与非受保护分支的缓存键会自动附加 -protected / -non_protected 后缀(一项安全设计)。如果发现「主干有缓存、特性分支死活不命中」,先检查这个设置,而不是反复清缓存。

结语

流水线提速没有银弹,但「缓存键设计 + 分布式架构 + 依赖代理」这套组合拳,是投入产出比最高的第一步:不需要改业务代码,只在 .gitlab-ci.yml 与群组设置层面动手。建议先做一次作业耗时分布分析,找到 Top 3 的下载耗时项,再逐项套用本文方案------数据驱动,比凭感觉改配置可靠得多。

延伸阅读:CI/CD 缓存官方文档 · 依赖代理官方文档 · cache 关键字参考

相关推荐
莫得感情 o2 小时前
Redis 09 · 高可用:Sentinel 如何发现故障并完成切主
redis·缓存·sentinel
吴佳浩 Alben2 小时前
构建企业级 DevOps 排错 Agent:从日志告警到自动化修复 PR
大数据·人工智能·语言模型·架构·自动化·ai编程·devops
harmony&2 小时前
DevOps进阶:SonarQube 代码审计与 Harbor 镜像仓库实战
运维·devops
11路没有终点2 小时前
CI/CD 集成:GitHub Actions
ci/cd·github
harmony&3 小时前
DevOps 实战:从概念到 CI/CD 持续交付全流程
运维·ci/cd·devops
BUG研究员_4 小时前
LangGraph节点缓存-CachePolicy
python·缓存·langraph
tryxr4 小时前
Redis 的背景知识
数据库·redis·缓存
莫得感情 o5 小时前
Redis 08 · 主从复制:全量同步、增量同步与复制延迟
redis·缓存
刃神太酷啦17 小时前
Redis 进阶核心:持久化 (RDB/AOF)、事务与主从复制全解析----《Hello Redis!》(5)
linux·c语言·数据库·c++·redis·缓存·bootstrap