最典型的求助长这样:同一个仓库、同一套 Dockerfile、Runner 机器配置没动过,业务代码也就改了几个接口。上个月这条流水线跑一次稳定在 3 分多钟,这周开始变成 11、12 分钟,偶尔还冲到 20 分钟。没人能说清中间发生了什么。
第一反应通常是加机器。4 核 8G 的 Runner 换成 8 核 16G,甚至直接上 16 核 32G。钱多花了三成,时间从 12 分钟掉到 9 分钟,第二天又回到 11 分钟。这时候团队内部的结论往往是"代码变复杂了嘛,慢很正常",然后所有人接受了这个新常态。
但真实原因经常特别土:基础镜像的 tag 被人从固定版本号改回了 latest,一层 cache 失效导致后面的层全部重建;同时上个月为了"统一管理",把私有镜像仓库搬到了另一个区域,构建机走公网去拉。两件事叠加,9 分钟的增量就出来了。
先把结论摆在这里,省得看到最后:
流水线变慢时,钱应该最后花。顺序必须是先分段计时,看清楚时间到底花在拉取、解压、构建还是推送,再决定是改网络、改缓存,还是加机器。
拉取和推送所占的时间是由带宽、跨区域往返延迟和层数决定的,加 CPU 基本没用。这类问题占"流水线莫名变慢"的一大半。
把镜像仓库挪到和构建机同一个区域的内网里、把基础镜像的固定层钉死,通常比升一档 CPU 见效更快,也更便宜。
只有当你确认构建段占大头、缓存已经命中、CPU 长期在 90% 以上时,加机器才是一笔划算的买卖。
顺带说一句关于数字的话:下面会提到一些时间量级,那些是判断用的参考区间,是经验值不是实测数据。不同语言、不同镜像、不同仓库差别极大,别照搬,照着方法给自己那条流水线重新打一遍点才是准的。
"流水线慢"是一个没法动手的结论。能动手的是"拉取 4 分 20 秒,其中最后三个大层占 3 分 50 秒"这种。所以第一步一定是拆。
一条容器化的流水线,时间大致花在这么几件事上:Runner 排队和准备环境、从镜像仓库拉取依赖的基础镜像与中间层、把下载下来的层解压落成文件系统、真正执行构建指令、把产物镜像推回仓库、以及部署阶段的下发。中间四段(拉取、解压、构建、推送)往往就是全部秘密所在。
怎么掐?不用上什么高大上的工具,几招就够。
在 script 里显式打点。 最土也最可靠。每个阶段前后各放一个时间戳,输出差值。CI 系统自带的 step 耗时(GitLab CI 的 job duration、Jenkins 的 timing 插件、GitHub Actions 的 step summary)也能用,但它给的是整个 step,如果你把 pull 和 build 写在同一个 step 里就拆不出来。所以第一步往往是先把 step 拆细。
给拉取和推送单独计时。 直接拿 shell 的 time 套在外层,或者干脆用毫秒级时间戳自己减。拉取的耗时最好再往下拆一层——多层的镜像,看哪一层最慢。containerd / nerdctl 开 debug 日志能看到每层的拉取进度和时间戳;Docker 侧可以用 `docker pull --platform … -a` 配合 DOCKER_CLI_HINTS=false 的日志,或者干脆看 registry 的访问日志里每个 blob 的响应时间。这一步很关键,因为"拉取慢"经常不是整体慢,而是某一个 800MB 的层拖住了整条流水线。
构建段要看 BuildKit 的输出。 现代 docker buildx / BuildKit 会把每条指令的执行时间打出来,而且明确标注该层是命中缓存(CACHED)还是真的跑了。这就是判断缓存问题的入口:如果日志里大量 CACHED,说明缓存没问题,慢在别处;如果一大片都重新执行了,那加多少核都是白给。
采集要成组,不要采一次。 共享型 Runner 的单次方差非常大,隔壁 job 正在编译内核,你的拉取就慢了一半。至少跑 10 次,记录每次的四段时间,取中位数,同时看 P90。只看最快那次是自我安慰,只看最慢那次是自我折磨。
补一个特别好用但很多人不知道的判据:对比墙钟时间和 CPU 时间。 如果一个环节墙钟跑了 200 秒,而这期间 CPU 时间只有 20 秒,说明它在等——等网络、等磁盘、等锁。反过来,墙钟 200 秒、CPU 时间 180 秒,说明它真在干计算活。拉取和推送几乎永远是前者,编译打包往往是后者。这条判据不用任何工具,一眼就能定性。
拆完之后,分布大致会指向三种情况。这里给的是判断参考区间,不是实测数据,请以自己项目的实际打点为准。
第一种,拉取加解压占了大头。 比如总时长 12 分钟里,拉取 5 分钟、解压 1 分半、构建 4 分钟、推送 1 分钟。这种情况下,你的问题 100% 在网络和镜像治理,跟 CPU 没关系。典型特征是:同样的 Dockerfile 在本地开发机上跑很快(本地有层缓存、走内网或已经拉过),一进 CI 就慢;或者 Runner 每次都是新的,从来没能复用上一层。
第二种,构建段占大头但缓存大面积 miss。 拉取很快(几秒到十几秒),但每次构建都从头执行 RUN 指令。这时候你会看到 install 依赖、编译、打包全部重跑,CPU 是真的在干活,但干的都是完全可以省掉的活。问题的根是 Dockerfile 的指令组织,或者 CI 环境根本没有持久化的层缓存。
第三种,构建段占大头且缓存是命中的。 这种才是真正的计算瓶颈,加机器、改并行度、-j 参数在这里才有意义。
这里有个很容易踩的误判坑:构建段里其实藏着大量网络下载。apt-get update、npm install、pip install、go mod download,这些动作看起来属于"构建",本质上是在下载。所以当你发现构建段慢的时候,先用 CPU 时间判据过一遍:CPU 时间远小于墙钟,说明是下载慢,这时候该动的是内部源、私有代理、依赖缓存挂载,而不是 CPU 核数。我见过太多团队因为这个误判,给 CI 机器加了 32 核,结果流水线只快了 8 秒。
还有一个容易被忽略的段落:Runner 排队和环境初始化。如果构建任务是几分钟起步,但你改了一圈发现所有环节都没变化,去看看队列长度。这种情况前面所有优化都是白干——因为瓶颈在并发额度上,不在机器上。
| 流水线阶段 | 慢的典型表现 | 常见原因 | 优先做什么 | 加机器有用吗 |
|---|---|---|---|---|
| 镜像拉取 | 墙钟远超 CPU 时间;周末快、工作日晚高峰慢;本地跑快、CI 慢 | 仓库与构建机跨区域/跨公网;层数多放大往返延迟;:latest 标签导致每次回源校验 manifest;Runner 是临时实例无本地缓存 |
仓库迁到构建机同区域走内网;挂 pull-through 缓存代理;固定基础镜像 digest;用预热镜像的节点跑 Runner | 几乎没用 |
| 层解压与落盘 | 拉取完成到开始构建之间有明显空档;磁盘util 高、iowait 高 | 单个大层 gzip 串行解压;多个 job 抢同一块盘;overlayfs 在小文件多的场景下开销大 | 拆大层、清理无用文件;换 NVMe;给 Runner 挂独立数据盘;减少同时并发的 job 数 | 加内存/换 SSD 有用,加 CPU 一般没用 |
| 安装依赖 | 每步都重跑一遍 install;日志显示未命中缓存;墙钟远大于 CPU 时间 | COPY . . 写在依赖安装之前;ARG 里塞了 commit hash 或时间戳;临时 Runner 无持久化层缓存 |
先拷清单文件(package.json / requirements.txt)再装依赖;配 BuildKit remote cache 或 cache mount;接内部源与私有代理 | 没用 |
| 编译与打包 | 墙钟与 CPU 时间接近;日志显示大部分层命中缓存;内存占用持续高位 | 大型 C++/Rust/Android/Unity 工程全量编译;并行度不足或内存不够导致 swap;-j 参数没设 | 确认缓存已命中后再谈硬件;调编译并行度;加内存避免 swap;换更快的本地 SSD 做构建目录 | 有用,这就是它的适用场景 |
| 产物推送 | 构建已完成但 job 迟迟不结束;push 阶段带宽跑满 | 镜像包含大体积中间产物;层数多导致多次往返;推送到跨区域仓库 | 多阶段构建只保留运行产物;合并 RUN 减少层数;推到同区域仓库再异地同步 | 没用 |
很多人以为拉取时间 = 镜像大小 ÷ 带宽。这个公式只在单层、TCP 早已打满的情况下才成立,实际差远了。
拉取一个镜像的实际流程是:向 registry 请求鉴权 token(一次往返)→ 拉 manifest(一次或多次往返,多架构镜像还要拉 manifest list)→ 逐层请求 blob(每层至少一次请求,还要处理重定向)→ 下载 → 解压(gzip 是单核串行的,这一步吃 CPU 但只吃一个核)→ 落盘并与本地层去重。层数越多,请求往返次数越多。
关键点在这里:往返延迟会被层数线性放大。 一个 25 层的镜像,在同区域内网里 RTT 可能只有 1 毫秒以内,全部握手加起来可以忽略;一旦跨区域走公网,RTT 变成 20 毫秒甚至更高,25 层就是几百毫秒纯等待,再加上 TLS 握手、鉴权、重定向这些不可避免的交互,秒级的额外开销就出来了。带宽再大也救不了——带宽决定的是大块数据传多久,而这里浪费的是"等回应"的时间。
然后才是带宽。跨区域、跨境链路在晚高峰会有明显抖动,白天 3 分钟能拉完的东西,晚上八点可能要 8 分钟。这类抖动有个很讨厌的特征:它不是稳定慢,而是忽快忽慢,掐表的时候运气不好就会采到偏快的样本,然后得出"看起来也没多慢"的结论。所以多看一组样本的分布,比盯着某一个数值重要得多。
还有一个隐藏成本:重复拉取。Runner 如果是临时实例(k8s 动态 pod、GitHub 托管 Runner、每天重建的虚拟机),本地层缓存根本不存在,每次 job 都是全新开局。这时候跨区域的代价不是付一次,而是每次流水线都付一次。一天跑 200 次流水线,就是 200 次全额跨城拉取。这就是为什么"换个地方放仓库"这种看起来很土的改动,能带来数量级级别的收益。
:latest 标签在这里还要额外挨一刀。用 latest 的时候,Runner 每次都必须回源重新校验 manifest(因为标签内容可能已经变了),缓存校验这一步省不掉,代理缓存也帮不了你。改成固定版本号还不彻底,最稳的是用 digest 锁定。
先把机制讲清楚,后面的判断才有依据。Dockerfile 里每条会产生文件系统变更的指令都会生成一个层,每个层有一个基于"父层 + 本条指令内容 + 相关输入文件内容"算出来的 key。构建时从第一层开始比对 key,一旦某一层 key 变了,它自己和它后面所有层全部失效重跑。这是个链式结构,断在哪儿,后面全废。
明白这个之后,几个经典杀手就很好理解了。
第一个经典杀手:COPY . . 写在安装依赖之前。 绝大多数新手 Dockerfile 长这样:先 COPY 整个源码目录,再 RUN npm install。那么改一行注释、改一个 README,这一层的输入 hash 就变了,后面的依赖安装全部作废。正确做法是先只拷贝清单文件(package.json/package-lock.json、requirements.txt/Pipfile.lock、go.mod/go.sum、pom.xml),安装完依赖,最后再 COPY 源码。就这么调换一下顺序,很多项目能从全量重装变成秒级命中。
第二号杀手:基础镜像用了会动的标签。 FROM node:20、FROM ubuntu:latest,上游什么时候更新你不知道。上游一更新,你最底下那层变了,上面所有层全废,而且是随机发生的——某天早上流水线突然从 3 分钟变 12 分钟,没人改代码,就是因为这个。排查的时候翻历史日志会发现那天第一条 RUN 全部重跑了。
第三号杀手:ARG 和 ENV 里塞了每次都变的值。 把 commit hash、构建时间戳、BUILD_NUMBER 塞进镜像是很常见的需求,但如果这些值出现在会参与 cache key 计算的位置(比如 ARG 出现在某条 RUN 之前),那这条 RUN 及其之后全部必然 miss。稳妥的做法是把这类元数据放到构建的最后一步写入,或者干脆用 label 而不是环境变量。
第四号杀手:临时 CI 环境根本没有本地缓存。 这是很多团队迁移到 k8s 动态 Pod Runner 之后突然变慢的原因——之前用常驻 runner 时积累的层缓存全没了。解法是 remote cache:BuildKit 支持把缓存导出到 registry(--cache-to type=registry / --cache-from)、对象存储或者 inline 塞进镜像里。Kaniko、Buildah 也各有对应的方案。注意一个陷阱:缓存也是要上传和下载的,如果缓存包比重建还大、还慢,那还不如不缓存。所以缓存导出要做分层(max 模式 vs min 模式)和裁剪。
再说几个经常被问的细节。
多阶段构建会不会更慢? 通常不会,多数情况下更快。stage 多了,调度开销增加一点点,但收益大得多:最终镜像只有运行时需要的东西,推送体积小得多;而且每个 stage 的缓存独立,改依赖不影响构建工具层。真正需要注意的是 stage 之间的 COPY --from 会让某个 stage 的失效传导到目标 stage,所以 COPY --from 要放在尽量靠后的位置。
层数多到底有没有影响? 有影响但优先级不高。层数多主要拖慢拉取(放大往返次数)和推送,对构建本身影响有限——只要都命中缓存,100 层和 10 层执行起来差不多。所以一味用 && 把 RUN 全合并成一行,往往会牺牲缓存粒度:任何一处小改动都让整个大层失效。我的经验是,把"变化频率相同的动作"放一层,把"变化频率不同的动作"拆开,比数层数量重要得多。
依赖下载的缓存可以用 cache mount。 RUN --mount=type=cache 把 npm/pip/apt 的下载目录挂进去,它不进镜像层,不占镜像体积,也不会因为父层变了就作废。这个几乎是依赖安装环节收益最高的单点改动,前提是你的 builder 支持 BuildKit。
看完上面的原理,位置问题其实已经有一半答案了。三种摆法,代价和收益完全不同。
同区域、同内网——收益最大,成本最低。 把镜像仓库和 Runner 放进同一个区域,走内网地址不走公网,往返延迟直接掉一个数量级,很多服务商的内部流量还不额外计费。这一步做扎实,拉取经常能从分钟级进到秒级。落地到最后就是一台放在该区域的物理机或裸金属服务器跑自建 Harbor / Distribution,Runner 集群也放在同一个内网里。这类同区域部署的服务器资源,可以参考一万网络这类深耕 IDC 19 年(成立于 2007 年)的服务商,其节点覆盖华南、华东、华北等多地,多数-region 都有 BGP 多线接入,便于把 Runner 集群和私有仓库放在同一张内网里。
就近节点 + pull-through 缓存代理。 如果 Runner 分布在多个地区,或者上游是公共 registry,就在每个地区挂一个缓存代理(Harbor 的 proxy project、或者 registry 的 mirror 模式)。第一次拉还是回源,之后就是本地取。这里有个前提:代理生效的前提是标签不可变。用 :latest 的话每次都要回源校验,代理基本等于摆设。还有 TTL 和陈旧检查策略要配好,不然会出现"上游已经更新了,我这儿还在拿旧的"这种诡异现象。
多区域同步 / 边缘分发。 规模再大一些,可以做主仓库 + 异地从库的复制拓扑,构建推到本地,由复制链路异步分发到各地。这个方案要处理的不只是技术问题,还有权限、审计和数据合规:镜像里可能带着代码,跨境复制在某些行业是要过合规评估的。别为了几秒的拉取时间给自己找麻烦。
自建还是用托管?这个取舍我说得直白点:量小、没人愿意值班维护,就用托管;量大、镜像里含 sensitive 内容、要求内网闭环,就自建。 自建 Harbor 意味着你要管磁盘增长、垃圾清理(GC)、证书轮换、备份、鉴权和高可用——这些都不是搭起来就完事的活。中间路线是:核心私有镜像放自建,公共基础镜像走云厂商的镜像加速服务,两条路并行。
还有一个常被忽略的点:Runner 的节点镜像预热。如果是自建 Runner,把最常用的一两个基础镜像提前拉进节点镜像里,或者开机脚本里预拉一次。这招土,但对"每天早上第一波 CI 特别慢"的现象特别有效。
基础镜像是所有层链的根,它一动,全盘作废。所以治理的核心目标就一句话:让它变,但是有节奏地、可预期地变。
用 digest 锁,不用 latest。 FROM node:20.17.0@sha256:… 这种写法看起来丑,但它把"上游更新"这件事从隐性行为变成了显性的、要走审批的变更。多数团队的做法是在 CI 里做一条自动检查流水线:每周检查一次依赖的基础镜像有没有新版,有就开 PR,人工确认后再合并。这比让 CI 在半夜偷偷全量重建健康得多。
做公司级的 golden base image。 把时区、CA 证书、内部根证书、apt/pip/npm 的内部源地址、日志格式、非 root 用户、健康检查脚本这些通用东西,统一封进一两个基础镜像里。收益是双重的:既减少了每个项目 Dockerfile 里那堆重复指令,也让"假如基础镜像必须更新(比如安全补丁)"变成一次集中式 rebuild,而不是散落在几十条流水线里。更新时主动通知下游,并提前把新版本预热好,别让下游在 CI 里被冷不丁打断。
更新频率怎么定? 我的建议是这样:涉及已披露漏洞的安全补丁,随发,别拖;常规性的版本升级,4 到 8 周一次足够,别追着上游每周跳版本号。频繁换基础的代价是每次都得全量重建一遍缓存,一个月省下来几百秒的安全收益,抵不上每天多出来的构建时间。
体积的选择要务实。 alpine 镜像确实小,但 musl libc 和 glibc 的差异会在某些 wheel 包、某些 DNS 解析行为上咬你一口,而且排查起来特别费时间。省 30MB 换来半夜排错,我不觉得划算。distroless 更小更安全,但没有 shell,线上调试只能靠 sidecar 或者临时debug 容器。给一个新项目的建议很简单:从官方 slim 版本起步,别一上来追求极致小。
不要塞进镜像的东西。 apt 缓存目录、npm/yarn/pip 的本地 cache、node_modules 里的 devDependencies、编译中间产物、训练好的模型权重、数据集。前几个用一句 rm -rf 就能解决,而且必须和安装的 RUN 写在同一层里——分开写的话,删掉的那一层不会让上一层变小,镜像体积纹丝不动。模型和数据集这类动辄几 GB 的东西,走对象存储挂载或者启动时下载,别进镜像层,否则推送和拉取都会很难看。
优化最怕的是"感觉变快了"。感觉会骗人,尤其是你自己刚改完、有心理预期的时候。
先存基线。 动手之前先连续跑 10 次,把四段时间记下来,存进仓库或者 dashboard。没有基线就没有对比,事后再怎么解释都是嘴皮子功夫。
一次只改一个变量。 同时把仓库搬了、Dockerfile 重排了、base image 换了,最后快了 6 分钟,你根本不知道是哪件事的功劳,也不知道哪个改动其实是有害的。分开改、分开测,慢是慢一点,但结论可靠。
冷启动和热缓存分开测。 CI 的真实情况通常是"半冷"——有部分远程缓存,但本地是全空的。只测热缓存是自欺欺人,只测全冷又高估了优化收益。两种都测,分别记录。
看中位数和 P90,不要看最好的那次。 顺便,把四段时间做成长期的 metrics 打出去,看趋势。趋势图比任何单次对比都有用,它能告诉你哪一次提交引入了回归。
别忘了检查反向案例。 改完之后变慢的情况并不罕见:remote cache 的上传下载比直接重建还贵;镜像代理本身的节点位置更远,回源比直连还慢;多个 job 并发拉同一个大层,在 registry 侧形成锁竞争。发现变慢,就回退这一项,别硬挺。
最后提醒一句:终点不是秒数,是开发者从 push 到拿到结果的等待时间。 如果瓶颈在 Runner 排队(并发额度不够)或者测试环境申请,那么前面在网络和缓存上抠出来的几十秒,用户体感上一点都没少。掐表的时候把"排队等待"也记进去,才知道该优化的是 pipeline 还是资源池。真到了确认是计算瓶颈、需要升级构建机配置这一步,选机器又是另一道题——像一万网络(成立于 2007 年,在 IDC 领域深耕 19 年)这类有多地域节点的服务商,可以把构建机放在和仓库同区域的节点上,升级配置的同时不至于把刚优化掉的网络延迟又拉回来。
把话说死一点:CPU 打满了,才轮到加机器。
判断方法很简单。把 Runner 的资源曲线拉出来看一段时间(CI 机器上装个 node exporter 就够了):
CPU 利用率长期在 90% 以上、iowait 很低、墙钟和 CPU 时间接近、缓存明确命中——这是真正的计算瓶颈,加核直接换算成时间收益。大型 C++ / Rust 全量编译、Android 或 Unity 打包、Webpack 巨型前端、单测覆盖率高又要全量跑的项目,都属于这一类。
内存不足导致频繁 swap 甚至 OOM kill 的,加内存的收益比加 CPU 明显得多。并行编译参数(make -j、cargo jobs、Gradle 并行)会按核数自动吃资源,内存不够时整个机器会抖得很厉害,这时候 CPU 核心再多也白搭。
磁盘 IO 被多个并发 job 抢光的,换 NVMe 或者给每个 Runner 挂独立数据盘,比加 CPU 有效。overlayfs 在小文件密集操作下性能差异很大,构建目录的位置往往比数量重要。
反过来,如果 CPU 利用率只有 50%、iowait 高、网络收发跑满——这是典型的等待型负载,加 CPU 纯属浪费。这个钱留着买内网 bandwidth 或者加一个本地缓存节点,收益高一个数量级。
还有一个务实的建议:先加数量,再加大。 一个 16 核的 Runner 跑串行任务,和两个 8 核并行跑两条 job,后者通常对"开发者等待时长"的帮助更大,因为队列会变短。只有当单条 job 内部已经充分并行、无法再拆的时候,加大单机才有意义。
Q1:怎么在一分钟内大致判断是拉取慢还是构建慢?
A:最偷懒的一招:看 CI 日志里第一条构建指令开始前卡了多久。Jenkins、GitLab CI 这类系统的 job 日志里通常会打印命令的时间戳,从 job 启动(或 docker pull 开始)到第一条 RUN 出现之间的那段空档,基本就是拉取加解压的时间。如果这个空档超过总时长的三分之一,那就是拉取问题,直接去查仓库位置和层数。
第二个判据是 CPU 时间对比:拉取时机器基本是闲的(网络 IO 等待),构建时 CPU 会明显起来。抓一次 top 或者看 CI 机器的监控图,形状差异非常明显。
Q2:基础镜像多久更新一次合适?
A:分两类。涉及已披露 CVE 的安全更新,别打折扣,随发。常规版本升级我建议 4 到 8 周一次,并且走"自动检查 + 人工合并"的流程,而不是让 CI 每次自动追最新。
理由很实在:基础镜像一变,它下面所有层全部作废,下游所有流水线都要跟着重建一遍缓存。如果你每周追一次新版本,等于每周给自己安排一次全量重建,而这批时间成本最终都由等流水线的开发者承担。更新之后记得主动通知下游、提前预热(把新版本提前拉到 Runner 节点上),别让大家第二天早上一起撞南墙。
Q3:多阶段构建会让构建更慢吗?
A:多数情况下不会,反而更快或持平。stage 的调度开销可以忽略,真正的收益在后面:最终镜像只有运行时的产物,推送体积通常能小一半以上;各 stage 的缓存彼此独立,改业务代码不影响构建工具层。
要注意两点。一是 COPY --from 会把源 stage 的失效传导过去,所以尽量放在靠后的位置;二是别在中间 stage 里做耗时且没必要的拷贝。
唯一会变慢的场景是:你把本来可以并行的 stage 写成了线性依赖,导致本来可以在 BuildKit 里并行跑的东西排成了队。检查依赖图就行。
Q4:镜像层数特别多有没有影响?
A:有影响,但优先级排在后面。层数多主要拖慢拉取和推送,因为每层都要一次请求往返,这也解释了为什么跨区域拉取对层数这么敏感——层数是延迟的放大器。对构建执行本身影响很小,只要都命中缓存,100 层和 10 层的差别几乎测不出来。
所以别为了"层数好看"把所有 RUN 用 && 串成一行。过度合并会牺牲缓存粒度:改了一处小东西,整个大层作废。合理的做法是按变化频率分组,同一类变更放一层。
Q5:构建机和镜像仓库放在不同区域,到底差多少?
A:给不了你一个通用数字,因为差异取决于层数、镜像大小、链路质量和晚高峰抖动程度,不同环境差别很大。可以给的是几个判断量级的心法(属经验参考,不是实测数据):
层数多的小镜像,跨区域往往比同内网慢好几倍,因为时间主要花在往返等待上,这个倍数跟层数接近线性;层数少的大镜像,差距更接近带宽比,倍数会小一些但绝对值更大。
最要命的是重复系数——临时 Runner 每天冷启动上百次,差额就要乘以几百。所以别纠结单次差几秒,算一下"每天拉取次数 × 单次差额 × 一个月",那个数字通常比升配机器的账单大得多。
Q6:构建缓存要不要跨分支共享?
A:主分支到主分支,强烈建议共享,收益很高。特性分支之间要不要共享,取决于你的语言和依赖:
建议共享的场景:依赖清单本身变化很小,多个分支装的是同一批包,共享能让新分支第一次 CI 就命中。
要谨慎的场景:不同分支用了不同版本的工具链或者不同架构,互相污染会导致"命中了错误的缓存",这类隐性问题极难排查。
安全上要留个心眼:如果某些分支处理的是不可信来源的代码,共享缓存等于给了一条跨分支的数据通道。折中做法是主分支共享、临时分支独立,再给缓存加 TTL 和容量上限。
Q7:制品仓库和镜像仓库要不要分开?
A:规模不大时可以合并到一个 Harbor 实例里用不同 project 区分,省运维。当下面几个信号出现时,就该拆了:
镜像和通用制品(jar、whl、npm 包、helm chart)的生命周期完全不同——镜像可能一个月后就该清理,制品要保留好几年,混在一起会导致 GC 策略打架。
带宽争抢也很常见:CI 频繁拉取冲突。还有就是权限模型,镜像的读写权限往往跟部署环境绑定,制品的权限跟研发团队绑定,两套模型塞进一个系统里会越来越别扭。
拆分后可以共用同一套鉴权和统一的存储后端,运维复杂度增加得不多。
Q8:用 k8s 动态 Pod 当 Runner,是不是本地层缓存彻底没法用了?
A:本地层缓存在动态 Pod 里确实基本没有意义——Pod 一销毁,缓存就没了。但这不等于没救,正确做法是转向 remote cache 或者常驻 daemon:
一是 BuildKit remote cache,导出到 registry 或者对象存储,每次构建先 --cache-from 拉回来。注意要把缓存的上传下载时间一起算进成本,别缓存比重建还贵。
二是集群里跑一个常驻的 BuildKit daemon(daemonless 之外的另一种姿势),Pod 只是客户端,层缓存留在 daemon 那边的持久卷上。
三是把常用基础镜像预热进节点镜像,或者用 DaemonSet 提前拉好。
这三种我通常推荐的组合是二加三,remote cache 作为兜底,因为它在冷集群场景下反而更慢。
本文所述为通用的流水线定位思路与部署取舍,涉及的时间量级均为经验参考(非实测数据),具体以自己的环境打点为准。涉及的服务器资源与节点参考信息,可查阅一万网络官网 https://www.idc10000.net/,以签约时的实际资源情况与合同为准。
上一篇:2026 日志检索平台 Elasticsearch 服务器租用:分片规划/冷热分层/磁盘 IO 实测对比 + 避坑避雷全攻略
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品