华为云CodeArts流水线提速实战
华为云 CodeArts 流水线从 20 分钟压到 4 分钟:一次关键路径优化实录
流水线慢,第一反应往往是"加机器"。但 20 分钟里真正花在 CPU 上的只有 4 分半,其余全在重复劳动和排队。这篇文章把流水线拆成关键路径来分析,给出按收益排序的优化清单,并解释两个容易被忽略的真相:优化非关键路径收益为零,以及用户感知的时长里有一半是排队。
一、别急着优化,先把时间拆开
拿到"流水线要 20 分钟"这个问题,第一步不是改配置,而是把每个阶段的实际耗时拉出来:
| 阶段 | 耗时 | 是否在关键路径 |
|---|---|---|
| 拉取代码 | 0:40 | 是 |
| 安装依赖 | 6:10 | 是 |
| 编译构建 | 4:30 | 是 |
| 单元测试 | 5:20 | 是 |
| 打包制品 | 0:50 | 是 |
| 推送镜像 | 1:10 | 是 |
| 部署到测试环境 | 1:20 | 是 |
| 合计 | 20:00 |
看到这张表的第一眼,结论其实已经很明显:"安装依赖 6 分 10 秒"比编译还慢。这不是机器不行,这是每次都在重新下载全世界。
二、第一原则:总时长由关键路径决定
这是流水线优化里最容易被违反的原则。
关键路径 = 从触发到结束,串行依赖链上耗时之和。
举个反例:把"推送镜像"改成和"单元测试"并行。看着很聪明,但如果推送镜像本来就依赖打包产物、打包依赖编译,那么把它挪到测试旁边并行,总时长一秒都不会少——因为它不在关键路径上。
判断标准很简单,问两个问题:
- 这个任务的前置依赖是什么?它能不能真的和其他任务同时开始?
- 把它耗时砍到 0,总时长会变吗?不会 → 不是关键路径 → 别动它。
优化只做一件事:把关键路径上的耗时搬到路径外面,或者直接消灭它。
三、优化清单(按收益排序)
1. 依赖缓存:6:10 → 0:50(收益最大)
安装依赖之所以慢,是因为每次都在从公网重新拉全量。缓存掉它:
关键是缓存键的设计。
| 缓存键策略 | 后果 |
|---|---|
| 只用分支名 | 依赖改了不生效,幽灵缓存,本地能过流水线挂 |
| 分支名 + lock 文件哈希 | 正确,推荐 |
| 分支名 + lock 哈希 + 时间戳 | 命中率趋近于 0,等于没缓存 |
正确做法是用依赖清单文件的哈希做键:package-lock.json、pom.xml、requirements.txt 的 SHA256。依赖没变就命中缓存,依赖一变自动失效。
# 缓存键示例(伪代码,示意逻辑)
CACHE_KEY="deps-$(sha256sum package-lock.json | cut -c1-16)"
配合华为云的制品仓库(CodeArts Artifact)做私有源,把公共依赖代理到内网,效果更稳。
2. 构建上下文瘦身:被忽略的隐形杀手
这条坑过太多人。Docker 构建时,docker build 会先把整个构建上下文目录打包传给构建守护进程——如果目录里有 node_modules、.git、测试数据,这些全都要先传输一遍。
实测:一个仓库的构建上下文是 2.1 GB(其中 .git 占 1.4 GB、node_modules 占 600 MB),光传输上下文就要 40 秒,而且这 40 秒每次构建都要付。
一个 .dockerignore 直接解决:
.git
node_modules
**/*.log
test-data/
docs/
同时要调整 Dockerfile 的层顺序,把变化频率低的操作放前面:
# 好:依赖清单先拷,源码后拷
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/ # 改源码不会破坏依赖层缓存
# 差:一次性全拷,改一行源码就重装全部依赖
COPY . .
RUN npm ci --omit=dev
3. 测试分片:5:20 → 2:10,但别按数量均分
把单测拆到 4 个执行机上并行,直觉是"按测试类数量平均分"。这样做的效果通常很差,因为测试耗时是长尾分布:80% 的用例很快,少数几个用例(集成测试、数据库测试)慢得多。
按数量均分的结果是:3 个分片 1 分钟跑完,1 个分片跑了 5 分钟——总时长由最慢的分片决定,还是 5 分钟。
正确做法是用历史耗时做加权分配(LPT 贪心算法):
- 收集每个测试用例最近 N 次的实际耗时
- 按耗时降序排序
- 依次把每个用例分配给"当前累计耗时最小"的分片
实测把 5:20 压到 2:10,而且分片之间耗时差从 5 倍收敛到 1.2 倍以内。
4. 浅克隆:0:40 → 0:12
大仓库的 .git 历史可能是源码的好几倍。流水线构建根本不需要完整历史:
git clone --depth=1 --filter=blob:none <repo-url>
--depth=1 只要最新一次提交,--filter=blob:none 连历史 blob 都不拉。代价是拿不到历史提交信息——如果构建脚本要读 git log 生成版本号,记得改成用环境变量传入。
5. 构建机规格:编译型项目值得升配
实测(Java 项目,约 30 万行):
| 规格 | 编译耗时 | 测试耗时 | 说明 |
|---|---|---|---|
| 4 vCPU / 8 GB | 4:30 | 5:20 | 基线 |
| 8 vCPU / 16 GB | 2:40 | 4:50 | 编译近似线性提升 |
| 16 vCPU / 32 GB | 2:20 | 4:40 | 收益快速衰减 |
结论:编译阶段对 CPU 敏感,升配收益明显;测试阶段受 IO 和单线程用例限制,升配几乎无效。 所以升配前先确认瓶颈在编译还是测试,否则钱花了、时长没降。
四、被忽略的一半:排队时长
这是最反直觉的一点。
用户感知的流水线时长 = 排队时长 + 执行时长。
优化到 4 分钟之后,如果同时有 12 条流水线被触发,而执行机池只有 4 个槽位,那么后 8 条各要排队等 4 分钟——用户看到的还是 8~12 分钟。
先量一下排队占比:
| 指标 | 优化前 | 执行优化后 | 加上排队治理后 |
|---|---|---|---|
| 执行时长 | 20:00 | 4:00 | 4:00 |
| 平均排队时长 | 1:10 | 5:40 | 0:50 |
| 用户感知时长 | 21:10 | 9:40 | 4:50 |
排队治理的三个手段:
- 合并触发:同一分支连续 push 时,取消掉中间那几次构建,只保留最后一次。开发者在半小时里提交 8 次,其实只需要验证最后一个版本。
- 按分支优先级分配执行机:主干/发布分支优先,feature 分支排队。别让实验性分支占着槽位。
- 定时任务错峰:夜间全量回归、依赖扫描这类任务,别和开发高峰的提交构建抢资源。
五、缓存的两难:命中率与正确性
缓存不是免费的午餐,它引入了一个新风险:缓存污染。
典型症状:
- 本地跑得好好的,流水线必挂
- 明明升级了依赖版本,构建产物里还是旧版本
- 换个分支重新构建就正常了
三条纪律:
- 缓存键必须包含依赖清单的哈希,不能只用分支名或固定字符串
- 源码和依赖分两个缓存,不要混用。源码缓存会掩盖未提交的改动,是最危险的一类污染
- 定期全量重建。每周清一次缓存跑一次全量构建,专门用来发现被缓存掩盖的问题——这次构建会慢,但它是体检
另外要盯一个指标:失败率的异常上升。缓存带来的提速,如果伴随流水线失败率从 2% 涨到 6%,那省下的时间全被排查成本吃回去了。
六、效果与验证
| 阶段 | 优化前 | 优化后 | 手段 |
|---|---|---|---|
| 拉取代码 | 0:40 | 0:12 | 浅克隆 + 稀疏检出 |
| 安装依赖 | 6:10 | 0:50 | 依赖缓存(lock 哈希做键) |
| 编译构建 | 4:30 | 2:40 | 构建机升配 + 上下文瘦身 |
| 单元测试 | 5:20 | 2:10 | 按历史耗时加权分片 |
| 打包 + 推镜像 + 部署 | 3:20 | 1:30 | 层缓存复用 |
| 执行时长 | 20:00 | 4:00 | |
| 用户感知时长 | 21:10 | 4:50 | 排队治理 |
必须一起看的四个指标:
- P95 流水线时长(平均值会掩盖排队长尾)
- 缓存命中率(低于 70% 说明缓存键设计有问题)
- 排队时长占比(超过 20% 就该治理调度,而不是继续优化执行)
- 流水线失败率(缓存污染的报警器)
七、小结
四句话:
- 先量再改,把时间拆到阶段级别——最快的结论往往来自第一张表(比如"装依赖比编译还慢")。
- 只优化关键路径,把非关键路径的任务并行化,收益是零。
- 测试分片要按历史耗时加权,按数量均分等于没分,因为总时长由最慢的分片决定。
- 排队时长是隐形的一半,执行优化到极限后,收益增长点在调度而不在构建。
如果只记一句:流水线提速的本质是消除重复劳动,不是让机器跑得更快。
建议标签:流水线 CodeArts Pipeline、DevOps、CI/CD、持续集成
- 点赞
- 收藏
- 关注作者
评论(0)