胡萝卜烤地瓜
胡萝卜烤地瓜
发布于 2026-07-25 / 1 阅读
0
0

NO.A2 Docker 三层构建模型:纯净镜像 + 模板缓存 + 即时容器

⚠️ 法律与用途声明:本文讨论基于 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 profile
  • metadata/:固化审计日志

它的定位非常关键:加速层,不是制品真源

  • 制品真源是 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.dbcache.sqlite3profiles/settings.ymlremotes.json、包 metadata/)会被断开硬链接独立副本,避免本次构建改了数据库又通过硬链接污染公共缓存。(硬链接快照的实现细节是下一篇的主题。)


六、一次构建的数据流

把三层串起来,一次 CI 构建的数据流是这样的:

  1. GitHub Actions job 路由到 self-hosted runner,runner 拉起一个 clean-linux 容器。
  2. 脚本从 template-cache/<target>/ 做一份 hardlink 快照,放到 build-runs/<run_key>/.../conan2(持共享读锁)。
  3. 容器在 run 目录里执行 conan create,所有写入只落 run 目录。
  4. 构建成功,导出 result.jsonpackage-info.txtpackage-folder.tar.gzcache-snapshot-info.json
  5. 容器销毁,run 目录的日志和产物保留。
  6. 人工评审通过后,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


评论