一条企业级流水线的执行时间去哪了?把日志拉出来做时间分布分析,结论往往惊人地一致:代码编译本身可能只占 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,能拿到之前作业存储的缓存。官方文档给出了三种达标架构:
- 所有作业固定使用单个 Runner(缓存落在本机);
- 多 Runner + 分布式缓存:缓存放 S3 等对象存储,Runner 弹性伸缩也能命中(JihuLab.com 的实例 Runner 即采用此方式);
- 多个同架构 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)。
六、改造前后的架构对比
依赖包命中分布式缓存、镜像命中依赖代理,回源请求被压缩到「内容真正变化」的时刻。
七、踩坑复盘:三条真实教训
- 同键不同路径,缓存互相覆盖 。两个作业都写了
key: same-key,一个缓存public/、一个缓存vendor/------它们会被归档到同一个cache.zip,后写的覆盖先写的,下次作业拿到的是错的缓存。解法:键跟路径绑定,一作业一键。 - 缓存是优化,不是保证。官方文档明确:缓存不保证始终可用(Runner 架构、磁盘空间都会影响)。作业脚本必须兼容「缓存缺失时全量重建」的路径,把缓存当作加速器而非依赖项。
- 受保护分支缓存默认隔离 。受保护分支与非受保护分支的缓存键会自动附加
-protected/-non_protected后缀(一项安全设计)。如果发现「主干有缓存、特性分支死活不命中」,先检查这个设置,而不是反复清缓存。
结语
流水线提速没有银弹,但「缓存键设计 + 分布式架构 + 依赖代理」这套组合拳,是投入产出比最高的第一步:不需要改业务代码,只在 .gitlab-ci.yml 与群组设置层面动手。建议先做一次作业耗时分布分析,找到 Top 3 的下载耗时项,再逐项套用本文方案------数据驱动,比凭感觉改配置可靠得多。
延伸阅读:CI/CD 缓存官方文档 · 依赖代理官方文档 · cache 关键字参考