Substrate 节点级 OCI 镜像层缓存(imagecache)深度解析:从逐次解包到一次 overlayfs 挂载
2026/9/23 14:46:14 网站建设 项目流程

Substrate 节点级 OCI 镜像层缓存(imagecache)深度解析:从逐次解包到一次 overlayfs 挂载

【免费下载链接】substrateAgent Substrate: the core system项目地址: https://gitcode.com/GitHub_Trending/substrate7/substrate

internal/imagecache是 Agent Substrate(atelet/ateom 架构)的节点级 OCI 镜像缓存模块:它把镜像以已解包层(unpacked layer)的形式内容寻址地存储在节点磁盘上,供该节点上的所有 actor 共享,并在 actor 启动/恢复时用一次 overlayfs 挂载代替原先的整镜像解包来组装 rootfs。本文以 internal/imagecache/README.md 为主体,结合 imagecache.go、unpack.go、gc.go、bundle_linux.go 等源码,系统讲解它的设计动机、拉取路径、挂载组装路径、垃圾回收策略与测试验证方式,读完可以完整掌握 Substrate 中"镜像缓存一次、节点共享、毫秒级启动"的实现原理与全部相关配置参数。

它解决了什么问题

在引入 imagecache 之前,atelet 采用内存中的 LRU 扁平化镜像 tarball 缓存:每次 actor 启动/恢复都要把整个镜像重新解包进每个 bundle。这种设计带来四个问题:

  • 启动慢:GB 级镜像的完整解包耗时数十秒;Restore timing breakdown日志显示旧的ate.actor.restore.duration.oci_unpack指标在热节点上也要 15–20 秒。
  • 重复下载/重复解包:层在不同镜像间共享时,每个镜像都要各自下载并解包一次。
  • 缓存易失:内存缓存随 atelet 重启即丢失,且无界的内存驻留可能把 atelet 拖到 OOM(issue #437)。
  • 内存峰值高:旧的mutate.Extract路径会整体缓冲扁平化镜像,内存占用随镜像体积线性增长(issue #120)。

imagecache 模块把这些问题逐一解决,其收益可以概括为:

  • actor 启动/恢复只需一次 overlayfs 挂载(毫秒级),热节点上oci_unpack耗时从 ~15–20 秒降到个位数毫秒;
  • 层按 diffID 去重,每个节点每个层只下载、解包一次,共享层的 actor 还能共享 page cache;
  • 缓存落在磁盘上,atelet 重启乃至节点重启后依然有效;
  • tag 引用只需一次HEAD请求解析为 manifest digest 即可缓存(可变 tag 唯一安全的缓存键是 digest);digest 引用直接命中缓存,零网络 I/O
  • 拉取内存占用为 O(流缓冲),与镜像体积无关。

该模块是 issue #463 的 Phase 1 实现,对应解决了 #120、#166、#228、#437 等历史问题。

特权边界驱动的双半设计

这个模块的设计被 Substrate 既有的一条边界塑形:atelet 以普通 root 运行,丢弃了所有 Linux capability("atelet 不做任何挂载",见 manifests/ate-install/atelet.yaml),而 ateom worker pod 是特权容器,拥有节点上的所有挂载权。因此模块被拆成两半:

一半运行位置主要文件需要的特权
Store:拉取、解析、解包、记录ateletimagecache.go、unpack.go、spec.go(可移植)仅文件 I/O
Consumer:finalize、挂载、卸载ateom-gvisor / ateom-microvmbundle_linux.go(//go:build linuxCAP_MKNODCAP_SYS_ADMIN

两半之间只通过文件系统通信:共享缓存目录(位于/var/lib/ateom-gvisorhostPath 上,因此每个 pod 中解析出的绝对路径一致)以及每个 bundle 旁边的一小份 spec 文件。

之所以需要特权拆分,是因为 overlayfs 的 whiteout 是 0:0 字符设备(需要CAP_MKNOD创建),opaque 目录需要trusted.*xattr(需要CAP_SYS_ADMIN),而 atelet 故意丢弃了这些能力。所以 atelet 在解包时不落地 whiteout,而是记录到每层的whiteouts.json,交给有特权的 ateom 在挂载前一次性物化。

一个关键设计点:consumer 在自己的挂载命名空间中挂载 overlay——正好是工作负载解析它的位置(gVisor 的 runsc gofer、微 VM 的 virtiofsd)——因此任何地方都不需要 Kubernetes 的 mount-propagation 配置

磁盘布局

缓存根目录默认是/var/lib/ateom-gvisor/image-cache(atelet 的--image-cache-dir可覆盖,见 cmd/atelet/main.go)。其布局如下:

<cache-root>/ 默认: /var/lib/ateom-gvisor/image-cache version 布局版本标记 ("1") layers/sha256/<diffid-hex>/ fs/ 已解包的层树(overlay 的 lowerdir) whiteouts.json 解包时记录的 whiteout 状态 finalized FinalizeLayer(consumer 侧)写入的完成标记 size 解包时记录的字节数(旧层惰性回填), 使池容量估算无需遍历目录树 layers/sha256/.tmp-*/ 进行中的解包(启动时清扫) layers/sha256/.rm-*/ 已退役待异步删除的层(启动时清扫) manifests/sha256/<digest-hex>.json 镜像 config + 有序 diffID 列表;文件 mtime 同时充当镜像的最后使用时间戳

存在的层目录一定是完整的:解包流先进.tmp-*兄弟目录,再以一次原子 rename 移入正式位置。启动恢复(New)会清扫残留的.tmp-*.rm-*目录、校验布局版本(imagecache.go 中版本不匹配会直接报错拒绝启动,而不是静默混用布局),并回收孤儿层。"镜像"本质上只是一条 manifest 记录——按顺序列出层的 diffID;被 N 个镜像共享的层在磁盘上只存在一份。

拉取路径:atelet 的 Store.EnsureImage

EnsureImage(imagecache.go)是拉取路径的入口,分为五步:

  1. 解析引用。digest 引用直接解析;tag 引用花一次remote.Head把 tag 钉到不可变的 manifest digest 上。localhost/loopback 注册表会按--localhost-registry-replacement重写(对齐 kind 本地注册表的 containerd mirror 配置),并允许明文 HTTP 拉取;gcr.io / pkg.dev 注册表会附加配置好的 GCP authenticator。
  2. 缓存检查。若 manifest 记录存在且每个层目录都在,直接返回,零网络 I/O;只重新拉取缺失的层。
  3. 按解析出的 digest 拉取。各层并行下载(并发上限 4,见 imagecache.go 的layerPullConcurrency),每层都是"流式下载 → 解压 → 直接 untar 进池"。同一镜像或同一层的并发拉取用 singleflight 合并,因此同时启动的多个 actor 不会重复干活;每完成一层就独立落地,中断的拉取在重试时能增量续传
  4. 解包unpackLayer,unpack.go)。这是本仓库加固过的 untar:
    • os.Root约束:路径穿越、符号链接/硬链接逃逸一律拒绝(validateTarNamefilepath.IsLocal校验);
    • 层内"后到条目胜出"(真实 ko 镜像会重复目录条目);
    • 只读目录处理不依赖CAP_DAC_OVERRIDE:解包期间目录先以属主可写模式创建(mode|0700),全部写完后按"深层优先"顺序恢复真实权限;
    • 补建层 tar 遗漏的父目录(它们可能只存在于更低层);
    • whiteout(.wh.*)不写入树——overlayfs whiteout 是 atelet 无法创建的字符设备——而是记入whiteouts.json供 consumer 物化;whiteoutSet还记录Opaques(需要trusted.overlay.opaque=y的目录)和ImplicitDirs(tar 未声明但被隐式创建的父目录)。
  5. 记录。镜像 config + diffID 列表写入请求 digest 名下;对 multi-arch 引用,还额外写入按平台解析出的子 digest 记录(twin record),使两种引用方式都能命中。

之后 atelet 的prepareOCIDirectory(cmd/atelet/oci.go)会调用imagecache.WriteSpec,在 bundle 的config.json旁写入rootfs-overlay.jsonOverlaySpec,见 spec.go),自底向上列出层目录,外加ExtraDirs(rootfs 内的 bind-mount 目标,例如 actor 身份挂载点/run/ate),并创建 bundle 本地的空rootfs/upper/work/目录。

拉取重试与超时的工程细节

源码中还体现了对注册表限流的精细处理(imagecache.go):

  • 专用注册表退避(Artifact Registry、ECR、Harbor 等自建配额):Duration 200ms / Factor 2 / Jitter 1 / Steps 4 / Cap 2s——服务端能吸收重试突发,且拉取位于 Run/Restore 关键路径,所以快速高频重试。
  • 共享注册表退避(registry.k8s.io、Docker Hub、quay.io、ghcr.io、public.ecr.aws、mcr.microsoft.com、registry.gitlab.com、cgr.dev 等):Duration 1s / Factor 2 / Jitter 1 / Steps 4 / Cap 10s——匿名限流是共享的,重试必须熬过限流波,且全抖动用来错开整支舰队。
  • 每次拉取受--pull-timeout(默认 10 分钟)约束;拉取在 detached context 上运行,某个等待者取消不会中断正在进行的拉取(这由 singleflight 的DoChan+context.WithoutCancel实现),等待者只会在自己 ctx 结束时停止等待。

组装路径:ateom 的 SetupBundleRootfs

SetupBundleRootfs(bundle_linux.go)在runsc create/runsc restore(gVisor)之前、staging virtio-fs lower(微 VM)之前调用,分四步:

  1. FinalizeLayer(bundle_linux.go)——把记录在案的 whiteout 物化为 0:0 字符设备(mknodat),把 opaque 目录标记为trusted.overlay.opaque=yfsetxattr)。每层全节点只做一次;幂等且并发安全(EEXIST视为成功,finalized标记最后写)。whiteouts.json中的路径会重新校验,伪造文件无法逃出层树。
  2. 挂载 overlay<bundle>/rootfslowerdir是层链反转为 overlayfs 的 top-first 顺序后的结果(镜像合法地重复列出同一 diffID 时,折叠到最上面的出现——否则 overlayfs 会以ELOOP拒绝);upperdir/workdir是 bundle 本地的目录,承载该 actor 的私有写入。挂载使用新挂载 APIfsopen+ 每层一次fsconfiglowerdir+追加)而非mount(2)——后者的单页选项字符串上限在每层路径约 114 字节时,约 34 层就会撞顶。最低内核要求:Linux 6.5lowerdir+);当前所有 GKE 通道都 ≥ 6.6(Stable 通道是 COS 121 LTS)。
  3. ExtraDirs通过挂载点创建(落入 upper),同样在os.Root约束下。
  4. 隐式父目录元数据修复(implicitdirs.go)。层 tar 经常漏掉只在更低层存在的父目录条目;解包会伪造 root:root 0755 并记为ImplicitDirs。因为 overlayfs 合并目录的属性取自包含它的最顶层,这种伪造目录会遮蔽低层声明的真实元数据(/tmp丢失 1777 sticky 位、/root从 0700 变成 0755)。组装时 consumer 从该镜像链中最顶层的非隐式层解析被遮蔽目录的真实 mode/owner,并通过挂载点施加——copy-up 落在 actor 私有 upper,共享池永不被修改。残余缺口:目录 mtime 与 xattr 不修复;在链的每一层都是隐式的目录保留伪造属性。

没有 spec 文件的 bundle 原样保留(兼容 pre-imagecache atelet 准备的 bundle);零层 spec 组装一个只有 ExtraDirs、无挂载的空 rootfs。

actor 语义与 untar 时代完全一致:atelet 的resetActorDirs会在两次运行之间清空 upper,因此每次运行仍从位级精确的原始镜像 rootfs 出发——只是成本从一次解包变成一次挂载。微 VM 路径几乎未变:它把(现在由 overlay 组装的)bundle rootfs bind-mount 进 virtiofsd 的共享目录,guest 继续构建自己的 tmpfs upper。

TeardownUnmountAllUnder(bundleDir)(bundle_linux.go)通过/proc/self/mountinfo惰性分离 actor bundle 目录下的所有挂载(最深层优先),在 atelet 擦除目录之前调用——来自 ateom-gvisor 的 checkpoint 清理路径和 ateom-microvm 的teardownActor

另外两个值得注意的挂载细节(源码注释中的工程经验):

  • overlay 挂载启用volatile标志,跳过 overlayfs 在上层上的同步(包括 umount 时的同步,实测在 GKE 上每个 actor 约 450ms);bundle upper 只承载一次激活期间的 copy-up,atelet 会在两次运行之间用RemoveAllWritable清空。
  • 挂载失败时,fsContextLog会从 fs context fd 排出内核的 "e/w/i " 前缀消息拼进错误——mount(2)时代失败的 overlay 挂载只有一个裸 errno,现在内核会明确指出拒绝了哪个选项。

垃圾回收:水位线驱动的 eviction 引擎

GC 逻辑分布在 internal/imagecache/gc.go(引擎)和 cmd/atelet/imagegc.go(驱动循环)中。atelet 以周期--image-cache-gc-period(默认 5m;0关闭周期任务,但每次 atelet 启动时的孤儿恢复仍然运行)驱动。每个 tick 用statfs量测缓存卷,用每层的size文件统计池自身大小,然后计算释放目标。

相关命令行参数(cmd/atelet/imagegc.go)

参数默认值说明
--image-cache-gc-period5m周期运行 eviction 的间隔;0关闭周期任务(启动孤儿恢复仍运行)
--image-cache-high-percent85卷使用率达到该百分比后开始驱逐
--image-cache-low-percent80驱逐要释放到的百分比,必须小于 high
--image-cache-max-bytes0缓存层总大小的绝对上限,独立于水位线;0表示无上限
--image-cache-min-age2m比这年轻的层与记录绝不驱逐(保护"已拉取未挂载"的镜像);同样约束启动孤儿恢复
--image-cache-gc-dry-runfalse只计算并记录驱逐决策,不删除任何东西
--image-cache-dir/var/lib/ateom-gvisor/image-cache缓存目录;必须位于与 ateom pod 共享的卷上

标志校验(validateImageCacheGCFlags)会拒绝负数 period、越界百分比、low >= high以及负的 min-age(负值会反转否决逻辑,使刚拉取的层变成可驱逐)。

目标计算(imageCacheGCTarget)遵循 kubelet 的公式:usage 达到high-percent时释放到low-percent;同时若池大小超出--image-cache-max-bytes则按超出量独立驱逐;取两者较大者,但上限封顶为池自身大小——因为这个缓存只是共享卷上的一个租户,不加封顶的目标会在外部磁盘压力下把整个缓存清空来修复并非它造成的压力。每次 pass 即使目标为 0 也会运行:枚举门控能及时暴露损坏的记录或 spec,而不是等到磁盘吃紧才暴露。

单次 eviction pass(Store.EvictUnused)

  1. 根集Store.InUse):扫描 actors 目录下每个 bundle 的rootfs-overlay.json。overlay 挂载活在 ateom pod 的挂载命名空间里,atelet 在自己的/proc/mounts看不到它们;而 bundle spec 是 atelet 自己在任何 ateom 被要求挂载之前写入、卸载之后才删除的,所以 spec 就是权威的"当前已挂载"集合。一个 spec 会 root 住它的镜像 digest、它点名的每个层目录、以及它的精确层集合——最后这一项同时保护 multi-arch twin 记录和没有imageDigest的旧 spec 的记录(gc.go)。WithActorsDir为空则禁用扫描(根集为空)。
  2. 引用计数:跨所有镜像记录统计层的 refcount;把未 root 且超过 min-age 的记录列为驱逐候选,按最后使用时间(记录 mtime——每次缓存命中、每次进行中拉取完成的层都会刷新它)LRU 排序。
  3. 驱逐:删记录(在缓存命中路径持有的同一把锁下做新鲜度复查,见hitMu的读写锁契约)直到释放约 targetBytes;然后退役该删除留下的未引用层。若任何层必须保留——仍被另一记录引用、被 spec root、比 min-age 年轻、或退役失败——记录被字节级精确恢复,该镜像本次 pass 不驱逐:磁盘上永远不会留下没有记录解释的层。

pass 只通过记录触达层:层被删除的充要条件是最后一个引用它的记录被删,绝无对池的独立扫描。

一切失败都倾向保留

  • 若镜像记录或 bundle spec 无法完整枚举(有不可读文件/目录),pass什么都不做并以 ERROR 记录罪魁祸首:基于部分数据的 refcount 与根集会退役掉未读记录仍引用、或运行中 actor 仍挂载的层。干跑模式(--image-cache-gc-dry-run)完全不改动任何东西——连惰性 size 回填都不做,这是在线舰队上浸泡验证策略的推荐方式。
  • 进行中的拉取无需额外保护EnsureImage在解包之前先写镜像记录(正如 Go 在 GC 期间先分配黑色对象、containerd 在字节落地前先建 ingest 记录),所以拉取产生的每个层在磁盘上存在之前就被引用并受保护;中断拉取的记录是可续传进度而非垃圾——下次拉同一 digest 只重取缺失层,永不续拉的记录通过普通 LRU 老化掉。
  • 启动恢复:没有记录引用的层只可能是崩溃碎片(驱逐在记录删除与层改名之间被打断)或运维误删;New启动时调用一次Store.RecoverOrphans(此时没有拉取在竞争扫描),若任何记录或 spec 读取失败则保守地跳过整个扫描。没有在线全池扫描(类比 ext4 的分割:挂载时受限恢复、fsck 离线)。
  • 删除是两阶段的:层在自身 singleflight 内被原子 rename 成.rm-*(一次rename(2)——驱逐永远不会拖住拉取),然后异步删除;中间崩溃就把目录留给启动清扫。这很重要,因为内核在这里不提供任何保护:删除一个在另一挂载命名空间中是活动 overlay lowerdir 的目录会静默成功、让 overlay 行为未定义、甚至在挂载消失前都不释放空间。
  • 手工删除缓存根(在无 actor 启动时)依然安全——store 会重新拉取任何缺失内容。

两个值得警惕的数字

  • 封顶是池的大小而非可驱逐子集(rooted 与新鲜层永远无法释放),所以在持续的外部压力下目标永远达不到,每次 pass 都会驱逐所有未 root 且超 min-age 的东西——命中率降到零直到压力消退。"could not reach target" 的 WARN 就是信号;若在生产中踩到,保留下限(绝不驱逐到 N 字节以下)是既定扩展方向。
  • usage 按卷的原始容量计算(kubelet 的公式,运维直觉可迁移),这会把 ext4 约 5% 的保留块算作已用:驱逐会比df报告的百分比早约 5 个百分点开始。

观测:缓存指标

Store 通过WithMeter绑定 OTel meter,上报ate.imagecache.requests计数器(metrics.go),按 outcome 打标:hit / miss / cancelled / timeout / error。失败会替换掉调用方传入的 hit-or-miss outcome,只有 Error outcome 携带error.type(受限的状态码集合:401、403、404、429、500、502、503、504,其余归入_OTHER,防止注册表或代理铸造无限新序列)。拉取被取消也会上报——它已启动并付出了成本。

阶段性演进与后续规划

README 明确标注了路线:本文描述的是 Phase 2(水位线循环、flags 与缓存指标补齐),Phase 3 将增加控制面(上报已缓存 digest 供调度亲和,以及带过期 pin 的PreloadImageAPI)。同时 layer-materializer 接缝已设计好,未来可以换成 lazy-pull 后端(eStargz/SOCI 风格 FUSE)而无需重构。

测试与验证

测试体系分为三层(README 的 Testing 一节与测试源码相互印证):

  • 可移植单元测试(任何平台包括 macOS 都能跑):解包安全套件(路径穿越、符号链接/硬链接逃逸、whiteout 捕获、后到条目胜出、只读目录、缺失父目录)、spec 往返、overlay 选项组装(含重复层去重)、mountinfo 解析、引用重写、options。端到端拉取测试针对内存注册表(pkg/registry)运行。
  • Linux 专属测试(bundle_linux_test.go):非特权测试覆盖逃逸拒绝与无 spec/零层组装;root 门控测试执行真实的mknod/xattr 物化以及完整的 挂载 → 写隔离 → 卸载 往返(写隔离断言——actor 写入落在 bundle upper、绝不进入共享池——是关键安全属性)。这些 root 门控测试通过roottest.Require自跳过,CI(以及本机hack/run-root-tests.sh)用sudo重跑本包使它们真正执行。
  • 批量验证工具tools/validate-image-cache:批量验证任意注册表镜像能被 store 半拉取、解析、解包——非常适合在依赖大规模镜像语料之前先扫一遍。它不挂载 overlay、不跑工作负载(那一半是 Linux 且需要特权),只回答"这个镜像能否载入缓存"。磁盘占用由缓存自身驱逐引擎约束(低于--min-free-gb时调用EvictUnused回收缺口);与线上 atelet 并行运行时池不会被损坏(记录先写、两阶段退役),但空闲超--evict-idle的层可能恰好被 atelet 复用时退役——应以缓存和 actors 目录的属主用户运行。

回归测试还专门覆盖了几个竞态场景(gc_regression_test.go):启动时孤儿层回收、digestless spec 的层在 bundle 消失后回收、记录先写保护进行中拉取、wedged 拉取的新鲜层不滞留、记录被 root 时恢复、枚举不完整时跳过启动孤儿扫描、非层目录忽略、失败拉取留下可续传记录而非孤儿。

小结

Substrate 的 imagecache 是围绕"atelet 无特权、ateom 有特权"这条既有边界精心拆分的模块:无特权的一半只做纯文件 I/O 的拉取解包与记录,有特权的一半负责 whiteout 物化与 overlay 挂载,双方通过共享 hostPath 上的内容寻址层池和 bundle 旁的rootfs-overlay.json契约协作。它以"一次挂载"换掉"每次解包",用记录先写 + 引用计数 + 倾向保留的驱逐策略在崩溃、并发与磁盘压力下保持数据一致性,并以 dry-run 与完整测试矩阵支撑在真实舰队上的安全演进。理解它的拉取/组装/GC 三条路径与全部--image-cache-*参数,是调优 Substrate 节点镜像缓存行为、诊断启动耗时与磁盘水位问题的基础。

【免费下载链接】substrateAgent Substrate: the core system项目地址: https://gitcode.com/GitHub_Trending/substrate7/substrate

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询