- 云原生
【免费下载链接】buildah
A tool that facilitates building OCI images.
本文以 vendored 依赖github.com/vbatts/tar-split的tar/asm包设计文档为主线,讲解该包如何实现 tar 归档的流式解组(disassemble)与重组(assemble),并结合vendor/github.com/vbatts/tar-split/tar/asm/下的真实源码,深入剖析其元数据打包机制、CRC64 完整性校验,以及 README 中重点讨论的“tar 允许同一路径多条记录(clobbering)”场景下为何必须依赖内容寻址存储(CAS)才能保证重组的字节级精确。读完后,你将理解容器工具链中“把一个 tar 流拆成元数据+载荷、再无损还原”这一基础能力的完整实现原理。
asm 包在 buildah 仓库中的位置
本文的骨架文档是 README.md,它位于 buildah 通过 Go modules 引入的第三方库 tar-split 的 vendored 副本中。从仓库可以确认其版本与引入方式:
- go.mod 第 120 行声明
github.com/vbatts/tar-split v0.12.3 // indirect,说明 buildah 主代码并不直接 import 该库,而是经由其他容器生态依赖(如容器镜像/存储相关库)间接引入; - vendor/modules.txt 第 445–449 行列出了 vendored 的三个包:
archive/tar、tar/asm、tar/storage; - 包自身的 doc.go 给出官方定位:“Package asm provides the API for streaming assembly and disassembly of tar archives”,即它专门负责 tar 归档的流式组装与拆解,并依赖
tar/storage完成元数据的打包/解包(Packing/Unpacking)以及文件载荷的存取(Getting/Putting)。
README 原文开宗明义,本库的“assembly and disassembly of tar archives”能力是由github.com/vbatts/tar-split/tar/storage支撑的。因此理解 asm 必须把 asm 与 storage 两个包放在一起看:asm 负责“流”的处理,storage 负责“数据落地”的抽象。
asm 的两条流水线:解组与重组
从 doc.go 与 assemble.go、disassemble.go 的源码结构看,asm 包对外只暴露几条核心入口,但内部把“tar 流”拆成了两类信息:
- SegmentType(原始字节段):tar 头、块间填充、归档末尾的 1024 个零字节等所有“非载荷”的原始字节,被原样截取下来作为元数据条目;
- FileType(文件条目):每个文件的名称、大小,以及一个 8 字节的 CRC64 校验和(存于
Entry.Payload字段),文件真实内容则交给storage.FilePutter另行保存。
- 解组(disassemble):
NewInputTarStream/NewInputTarStreamWithDone把一个 tar 流的io.Reader包装成另一个内容相同的io.Reader,中途把 SegmentType/ FileType 元数据写进storage.Packer,把文件载荷写进storage.FilePutter; - 重组(assemble):
NewOutputTarStream/WriteOutputTarStream接收一个storage.FileGetter(按名称取回载荷)和一个storage.Unpacker(按序读出元数据),两者结合即可重新生成一份“precise”(字节级一致)的 tar 归档。
这种“元数据 + 载荷分离”的设计,正是容器镜像层处理(分层存储、签名校验、增量传输)的常见底层范式。
解组管线源码走读:TeeReader、io.Pipe 与 padding 防护
解组的核心逻辑在 disassemble.go 中。
共享流:TeeReader 加 io.Pipe 的“中间人”结构
newInputTarStreamCommon(disassemble.go#L160-L192)搭建了全部管线:
pr, pw := io.Pipe() if fp == nil { fp = storage.NewDiscardFilePutter() } outputRdr := io.TeeReader(r, pw) go runInputTarStreamGoroutine(outputRdr, pw, p, fp, done)- 调用方从返回的
PipeReader读取到的,就是原始 tar 流的完整内容,与直接读原流无异; - 后台 goroutine 通过
TeeReader同步“旁路”消费同一份字节流,用于解析 tar 结构并向storage.Packer写入元数据; - 源码注释明确解释选
TeeReader的原因:它不缓冲,只按需读取调用方实际消费的字节;同时 tar 归档末尾的 padding 由解析方负责读完,即使用户的archive/tar并不关心这部分; - 若调用方不需要保留文件载荷,可以传
nil的FilePutter,内部会自动退化为NewDiscardFilePutter()——载荷被丢弃,但 CRC 校验和仍然照常计算。
逐条解析:SegmentType 与 FileType 的落盘时机
runInputTarStream(disassemble.go#L49-L141)是真正的解析循环,几个关键细节值得注意:
- 它使用 tar-split 自带的 fork 版
archive/tar,并开启tr.RawAccounting = true,目的是拿到每个 tar 头占用的原始字节tr.RawBytes()。这些原始头(含可能的扩展头,如 PAX/GNU 扩展)被原封不动地p.AddEntry为SegmentType条目——这是重组时能逐字节还原 tar 头的前提,而不是重新序列化一个语义等价但字节不同的头; - 对每个
hdr.Size > 0的条目,调用fp.Put(hdr.Name, tr)把载荷写入FilePutter并取回 CRC 校验和csum,连同hdr.Size一起存入FileType条目(disassemble.go#L84-L103)。即使Size == 0的文件条目也会被添加,保证元数据序列与归档条目一一对应; - EOF 时仍会把归档末尾“常见存在的 1024 个空字节”收集为一个
SegmentType条目(disassemble.go#L59-L68); - 结尾还有专门的 padding 排空循环(disassemble.go#L115-L138):按
paddingChunkSize = 1024 * 1024分块读取并落盘。源码注释解释了动机——“tar 归档末尾可能还有超出预期 1024 零字节之外的额外填充,且可能是恶意构造的 tar 试图让我们把数 GB 数据读进内存”,因此必须分块处理。
Done 通道协议:可中止、可等待的 goroutine
NewInputTarStreamWithDone(disassemble.go#L233-L237)返回(io.ReadCloser, <-chan error, error),而旧的NewInputTarStream已被标记 Deprecated(disassemble.go#L208-L214),原因是旧 API 在调用方提前中止时会遗留 goroutine,且无法得知何时可以安全释放输入 reader。新 API 的协议由runInputTarStreamGoroutine(disassemble.go#L12-L38)集中保证:
pW永远恰好关闭一次(CloseWithError(nil)等价于Close());done通道恰好收到一个值:成功为nil,失败为非 nil 错误;- 调用方提前关闭返回的 reader 会触发 pipe 写失败,使后台解析循环立即报错退出,而不是无限阻塞在 pipe 写入上;
- panic 会被先转化为非 nil 错误再重新抛出,保证协议本身始终被执行。
重组管线源码走读:按元数据顺序还原 + CRC64 校验
重组逻辑在 assemble.go 中,入口有两个:NewOutputTarStream(assemble.go#L21-L36)返回一个io.ReadCloser,内部用io.Pipe加一个 goroutine 驱动真正的写入;WriteOutputTarStream(assemble.go#L39-L96)则是同步版本。两者都要求storage.FileGetter与storage.Unpacker同时非 nil,否则直接返回 nil/无操作。
WriteOutputTarStream的主循环非常直接:
for { entry, err := up.Next() ... switch entry.Type { case storage.SegmentType: if _, err := w.Write(entry.Payload); err != nil { ... } case storage.FileType: if entry.Size == 0 { continue } fh, err := fg.Get(entry.GetName()) ... multiWriter = io.MultiWriter(w, crcHash) copyWithBuffer(multiWriter, fh, copyBuffer) if !bytes.Equal(crcHash.Sum(crcSum[:0]), entry.Payload) { return fmt.Errorf("file integrity checksum failed for %q", entry.GetName()) } } }要点有三:
- SegmentType 条目原样写出——解组时截下的 tar 头与填充字节被逐字节还原,不经过任何重编码;
- FileType 条目按名称从
FileGetter取回载荷,通过io.MultiWriter同时写入输出流和 CRC64 哈希(crc64.New(storage.CRCTable)),写完后与元数据中记录的entry.Payload(即解组时算出的 8 字节校验和)比对,不一致则返回file integrity checksum failed错误(assemble.go#L86-L92)。这形成了一道端到端完整性防线:载荷在任意存储介质中被篡改或错存,都会在这里暴露; - 拷贝使用从
sync.Pool取的 32 KiB 复用缓冲(assemble.go#L98-L102),copyWithBuffer直接改编自标准库io.Copy的实现(assemble.go#L104-L132)。
此外 iterate.go 提供了IterateHeaders:它不经过任何文件系统,直接从Unpacker的 SegmentType 条目里重新解码出每个 tar 头(tar.Header)并逐个交给回调函数,同时正确处理了“文件尾部 padding 会并入下一个 SegmentType 条目”的边界情况(iterate.go#L13-L56)。这意味着仅凭元数据即可在不取回任何文件载荷的情况下枚举归档内容——这对“只看清单、不解压”的场景很有价值。
README 的核心论点:路径覆盖(clobbering)与 CAS 的必要性
回到 README.md 本身,它虽然篇幅不长,但提出了一个非常本质的设计约束,这是本文最重要的技术点。
问题:tar 允许多条同路径记录,且“最后一条生效”
README 的 Concerns 一节指出:为了“完全安全”的组装/解组装,需要一个**内容寻址存储(CAS)**目录,其键映射到storage.FileType对应storage.Entity的校验和。原因在于:
tar archivescanallow multiple records for the same path, but the last one effectively wins. Even if the prior records had a different payload.
也就是说,tar 格式并不禁止归档中出现多个同路径条目,解压语义是后写覆盖先写(clobbering),且前后两条记录的内容可以完全不同。如果重组时把“相对路径”作为文件载荷的唯一键(例如clobbered/path/to/file直接覆盖写),那么当归档对同一路径存在多条记录时,所有从该相对路径读回的载荷都将是最后一条——先前的版本永久丢失,于是无法重组出一份与原归档精确一致的 tar。
这与源码观察一致:解组时载荷的存取完全按hdr.Name组织(fp.Put(hdr.Name, tr)/fg.Get(entry.GetName())),元数据序列里同名 FileType 条目会出现多次且各自携带不同的 CRC。若后端存储按路径覆盖写,第二次Put就会覆盖第一次的载荷;而 CAS 模式下,每条载荷按其内容校验和独立存放,元数据中的校验和即可反查回“那一条”载荷,重复路径也互不干扰。
README 给出的两个方案及取舍
Thoughts 一节提出了两条路线:
方案一:旁路(look-aside)目录保留被覆盖的载荷
遇到 clobbering 记录时,把先前已存在的文件载荷另存到 CAS,命名约定为:
clobbered/path/to/file.[0-N]这样新记录可以直接提取(最后一条生效的解压语义保持正确),而被覆盖的旧载荷仍被保留下来,可用于“重组一份精确的 tar 归档”。
方案二:干脆不支持含 clobbering 路径的 tar 流
README 认为追加/覆盖式记录“并不是非常常见,大多数实现默认也不会产生”,因此也可以直接拒绝这类流。README 还给出了安全性论证:即便恶意或异常的归档确实发生了覆盖,重组出来的归档也将无法通过签名/校验和验证,本来就不该被信任,所以不支持它不构成安全漏洞。README 的结论是把追加文件支持留作FUTURE FEATURE。
对使用者的实际含义
结合解组/重组源码可以推断出工程影响:任何按“路径”作为唯一键存储 tar 内文件的后端(例如直接把归档解包进目录再重新打包),在遇到同路径多记录的 tar 时会悄悄丢失早期版本,产出的归档在字节层面与原归档不一致。容器镜像层这类“要求字节精确、可校验、可签名”的场景,正是该约束最典型的受害面——理解了这一点,也就理解了为什么 tar-split 要把 CRC 写进元数据、并要求重组侧做逐文件校验。
适用前提与证据边界
- 本文所有行为描述均基于 buildah 仓库内 vendored 的 tar-splitv0.12.3副本(见 go.mod 与 vendor/modules.txt),与上游最新版可能存在差异;
- 从 buildah 的 Go 源码中未检索到对
vbatts/tar-split的直接 import(仅 go.mod 以 indirect 形式声明),因此该库在 buildah 构建产物中是由其他容器生态依赖间接使用的; - README 中提到的
storage.Entity、CAS 目录布局等属于设计文档层面的描述,当前 vendored 副本中tar/storage提供了 entry.go、packer.go、getter.go 等接口实现,CAS 具体落盘形态取决于使用方如何实现FilePutter/FileGetter。
小结
tar-split 的tar/asm包以“原始字节段 + 文件元数据(名称/大小/CRC64)+ 独立载荷存储”三段式结构,实现了 tar 归档的无损流式解组与重组:解组侧用TeeReader/io.Pipe在不改变调用方读流行为的前提下旁路记录结构,并用分块读取防御恶意 padding;重组侧严格按元数据顺序还原、逐文件做 CRC64 校验。而其 README 点出的“tar 同路径多记录”覆盖问题,则给出了明确的架构约束——要么用内容寻址(CAS)加旁路保留被覆盖的载荷,要么在功能上直接推迟对 clobbering 流的支持。对于深入容器镜像分层与字节精确性处理的开发者,这套“元数据/载荷分离 + 校验和反查”的机制是绕不开的基础设施级参考。
- 云原生
【免费下载链接】buildah
A tool that facilitates building OCI images.
相关推荐
skopeo 依赖剖析:tar-split/tar/asm 的流式 tar 归档组装与分解机制
skopeo 依赖剖析:tar split/tar/asm 的流式 tar 归档组装与分解机制 本篇技术指南围绕 skopeo 仓库所依赖的 github.co
云原生CLI镜像仓库vcluster 依赖解析:tar-split/asm 的流式 tar 拆解(Disassembly)与精确重组(Assembly)原理与实践
vcluster 依赖解析:tar split/asm 的流式 tar 拆解(Disassembly)与精确重组(Assembly)原理与实践 本文围绕 vcl
云原生集群管理虚拟化多集群Podman 镜像层中的 tar 流式拆解与重组:深入解读 tar-split/asm 的元数据分离设计与重复路径处理
Podman 镜像层中的 tar 流式拆解与重组:深入解读 tar split/asm 的元数据分离设计与重复路径处理 导读 在 Podman 的镜像与存储体系
容器运行时云原生CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考