⚠️ 法律与用途声明:本文讨论基于 Docker、Conan 2、Artifactory CE 的交叉编译构建架构,用于技术学习与工程研究。所有企业名、组织名、域名、路径前缀均已脱敏。读者须遵守所在地区法律法规。

一、为什么"一个容器干所有事"行不通
上一篇我们让 self-hosted runner 只做调度、把构建交给 Docker 容器。但紧接着会冒出下一个问题:容器里该怎么构建?
最直觉的做法是:起一个容器,在里面 conan install + cmake,构建完销毁。听起来很干净,但实际跑起来会撞三堵墙:
- 冷启动慢:每次容器都从空 Conan cache 开始,所有依赖(工具链、第三方库)都要重新从远端拉取、编译。一次交叉编译动辄十几分钟,CI 排队排到怀疑人生。
- 缓存没处放:Conan cache、ccache 这些加速产物,放容器里随容器销毁就没了;放宿主机又回到上一篇"污染宿主机"的老路。
- 并发互相覆盖:多个 job 想共享一份缓存,直接读写同一个目录,最后写的覆盖先写的,缓存成了公共垃圾桶。
根因还是同一个:把"基础环境""加速缓存""本次构建"这三件生命周期完全不同的事,塞进了同一个容器目录。基础环境几乎不变,加速缓存慢慢积累,本次构建用完即弃——它们的写频率、共享范围、清理策略完全不同,硬塞一起必然冲突。
解法是把它们拆成三层,各层独立目录、独立生命周期、独立更新策略。

二、三层模型全景
这套方案的执行层分成三个物理层次,从下到上:
| 层 | 形态 | 路径(脱敏) | 生命周期 | 写入者 |
|---|---|---|---|---|
| 纯净镜像 | Docker image | platform/clean-linux:2026.03 |
几乎不变 | 镜像构建 |
| 模板缓存 | 宿主机目录 | /opt/platform/template-cache/<target>/ |
缓慢积累 | promote/warm 脚本(独占写锁) |
| 即时容器 | 容器 + run 目录 | /opt/platform/build-runs/<run_key>/... |
用完即弃 | 本次 CI 独占 |
三层的关键差异在写权限:纯净镜像只读、模板缓存只允许受控脚本写、即时容器只写自己的 run 目录。这种"读写分离"是后面所有特性的基石。
三、第一层:纯净基础镜像——可复现的起点
最底层是一个版本固定的基础镜像,比如 platform/clean-linux:2026.03。它只装两类东西:
- 构建工具链:Conan 2、CMake、Ninja、Python、基础编译工具
- 平台脚本:拉起构建、导出产物的入口脚本
它不装任何业务 Conan 包。业务包(算法库、第三方依赖)由上面两层处理。
为什么这样设计?因为可复现的起点必须是确定性的。基础镜像一旦确定,所有 CI 构建都从这个完全相同的环境出发——同样的编译器版本、同样的 CMake、同样的 Conan。今天和明天、这台 runner 和那台 runner,起点一致,构建结果才可比。如果基础镜像里混进业务包,不同时间构建的镜像里业务包版本会漂移,"可复现"就成了空话。
镜像版本用显式 tag(2026.03)锁定,不用 latest——这也是可复现的基本功。
四、第二层:模板缓存——可重建的加速层
中间层是模板缓存,路径形如 /opt/platform/template-cache/<target>/,按构建目标(target)分目录,每个 target 下保存:
conan2/:该 target 的 Conan 包缓存(工具链、已固化的依赖库)ccache/:C/C++ 编译缓存profiles/:Conan profilemetadata/:固化审计日志
它的定位非常关键:加速层,不是制品真源。
- 制品真源是 Artifactory CE(所有正式发布的 Conan 包归档在那)。
- 模板缓存只是把"高频用到的包"提前放在本地,让 CI 不必每次都从 Artifactory 全量拉取。
- 正因为它是加速层,丢了能从 Artifactory 完全重建——这给了后面大胆清理缓存的底气。
模板缓存的写入是严格受控的:只有三个脚本能写,而且都持独占写锁(.cache-update.lock):
promote_build_run.sh:某次 CI 构建人工晋升后,把它的包合并进缓存promote_package_to_template_cache.sh:把 Artifactory 里已有的包补固化进缓存warm_platform_template_cache.sh:预热工具链等基础包
普通 CI 构建不允许直接写模板缓存。这是污染隔离的第一道闸门。
五、第三层:即时容器——用完即弃的工作区
最上层是每次 CI 构建的临时工作区,路径形如:
/opt/platform/build-runs/<run_key>/<repo>/<target>-<toolchain>/
每次 CI job 启动,就为它创建一个独立的 run 目录;job 结束,容器销毁,但 run 目录里的日志、产物摘要、result.json 保留下来供排障和评审。
这一层有三个铁律:
只写自己的 run 目录。即时容器从模板缓存"快照"一份到自己的 run 目录,之后所有构建写入都落在 run 目录里,绝不回写模板缓存。容器销毁后,本次构建的所有痕迹(除了特意保留的日志产物)随之消失。
强制构建当前包。脚本默认给 conan create 加 --build=<pkg>/*,确保本次正在构建的包一定从源码编译,而不是被缓存里同名同版本的旧二进制"命中跳过"。依赖包才允许走缓存。这保证了"你改的代码一定被编译进去"。
可变状态隔离。从模板缓存快照时,Conan 的可变状态(.conan.db、cache.sqlite3、profiles/、settings.yml、remotes.json、包 metadata/)会被断开硬链接独立副本,避免本次构建改了数据库又通过硬链接污染公共缓存。(硬链接快照的实现细节是下一篇的主题。)
六、一次构建的数据流
把三层串起来,一次 CI 构建的数据流是这样的:

- GitHub Actions job 路由到 self-hosted runner,runner 拉起一个
clean-linux容器。 - 脚本从
template-cache/<target>/做一份 hardlink 快照,放到build-runs/<run_key>/.../conan2(持共享读锁)。 - 容器在 run 目录里执行
conan create,所有写入只落 run 目录。 - 构建成功,导出
result.json、package-info.txt、package-folder.tar.gz、cache-snapshot-info.json。 - 容器销毁,run 目录的日志和产物保留。
- 人工评审通过后,
promote_build_run.sh把包上传 Artifactory(真源),并合并进 template-cache(加速层)。
注意第 2 步和第 6 步对模板缓存的访问方式不同:构建时只读快照共享读锁,晋升时才独占写锁。这种读写分离是并发安全的关键。
七、铁律:即时容器永不直接挂载模板缓存做可写构建
这条值得单独强调,因为它是整套模型最容易踩的坑,也是最核心的设计决策。
反例:让即时容器直接把 /opt/platform/template-cache/<target>/conan2 以可写方式挂载进去构建。看似省了快照这一步,但后果是灾难性的:
- 并发的多个 job 同时写同一个 Conan 数据库,互相破坏。
- 某次构建拉了一个实验性版本的包,污染了公共缓存,所有后续 CI 都受影响。
- 容器里以 root 或异构 UID 写出的文件,留在公共缓存里,下次别的用户构建时权限报错。
- 想清理都不知道该删什么,因为"本次构建的改动"和"公共缓存"混在一起了。
所以铁律是:即时容器只读快照模板缓存,所有写入落 run 目录;模板缓存的更新只能走受控的 promote/warm 脚本(独占写锁、串行化、带审计)。

这条铁律的本质是读写职责分离:读(加速)可以并发、可以廉价;写(变更缓存)必须串行、必须受控、必须可追溯。这和数据库的读写分离、缓存系统的 cache-aside 是同一个道理。
八、run_key:为什么是 run_id-attempt-run_attempt
即时容器的目录键叫 run_key,它的构造是 <run_id>-attempt-<run_attempt>。这里藏着处理 GitHub CI 一个经典坑的设计。
GitHub 的每次 workflow 运行有一个 run_id。但如果有人在网页上点"re-run jobs",run_id 不变,会生成一个新的 run_attempt(递增)。如果目录只用 run_id,re-run 时新构建会复用旧 attempt 的 run 目录——于是旧 attempt 残留的 Conan cache、root 写的文件、受限 ACL 目录会污染新 attempt,导致"明明代码没变,re-run 却失败了"。
所以三个键各司其职:
run_id:业务检索键(人在 GitHub 页面搜的那个号)run_attempt:同一次 run 的第几次尝试run_key=run_id-attempt-run_attempt:物理实例键,目录就用它
这样每次 re-run 都是一个全新的物理目录,旧 attempt 的残留无法污染新 attempt。这是"实例级隔离"在执行层的落地——后面上板测试那篇会看到,同样的三键模型也贯穿了 board bundle 和 watcher。
九、架构哲学:三性统一
回头看,三层模型解决的是嵌入式交叉编译 CI 的三个根本诉求,而且让它们同时成立:

- 可复现(reproducible):纯净镜像锁定起点,每次构建环境确定。
- 可缓存(cacheable):模板缓存积累高频包,避免冷启动;且因可从真源重建,敢大胆治理。
- 可销毁(disposable):即时容器用完即毁,污染不残留,宿主机永远干净。
这三性在"一个容器干所有事"的模式下是互斥的(要可缓存就得留着缓存,留着缓存就和可销毁冲突)。三层模型通过把它们分配到不同生命周期的层,让三性同时成立。这是"高内聚低耦合"在构建系统里的典范:每一层只对自己的生命周期负责,层间靠明确的读写契约协作。
还有一条贯穿的哲学:真源唯一。Artifactory 是制品的唯一真源,template-cache 只是它的"影子"。影子可以删、可以重建、可以激进清理——因为真源还在。这条哲学下一篇(缓存治理)会展开到极致。
下一篇,我们走进缓存治理:hardlink snapshot 怎么让 CI 启动从全量复制降到秒级,以及 build-runs 的大文件缓存怎么被 systemd timer 每日自动治理。

参考链接:
[1] Docker 官方文档(镜像与容器) https://docs.docker.com/
[2] Conan 2 缓存机制(cache 与 lockfiles) https://docs.conan.io/2/reference/commands/cache.html
[3] Conan 2 本地缓存布局 https://docs.conan.io/2/conan1.4/reference/conanfile/attributes.html
[4] ccache(C/C++ 编译缓存) https://ccache.dev/
[5] JFrog Artifactory CE for C/C++ https://jfrog.com/open-source/
[6] GitHub Actions re-running workflows(run_attempt) https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs
[7] rsync --link-dest(硬链接快照基础) https://man7.org/linux/man-pages/man1/rsync.1.html