vcluster 依赖解析:go-containerregistry tarball 包——Docker Load 兼容镜像 Tarball 的读写机制
vcluster 依赖解析go-containerregistry tarball 包——Docker Load 兼容镜像 Tarball 的读写机制【免费下载链接】vclustervCluster creates tenant clusters: fully isolated environments delivered as managed Kubernetes, or as the foundation for Slurm, Ray, Run:ai and inference clusters. Each gets its own API server, CRDs and RBAC, and runs on an existing cluster or standalone on bare metal. CNCF Certified Kubernetes.项目地址: https://gitcode.com/gh_mirrors/vc/vcluster在 vcluster 仓库中go.mod 以v0.20.7版本引入了github.com/google/go-containerregistry其中的pkg/v1/tarball包提供了一套在磁盘 tarball 与 OCI/Docker 镜像对象v1.Image之间相互转换的能力它既能读取docker save产生的旧格式 tarball也能写出可直接被docker load消费的新格式 tarball。本文以该包的官方文档为主体结合本仓库 vendor 目录下的 完整源码实现深入讲解 tarball 格式的文件结构、读写 API、manifest.json的语义以及 vcluster 在实际代码中对 go-containerregistry 的消费方式。包定位与docker savelegacy 格式的区别根据 tarball/README.md 的说明该包产生的 tarball 可以被docker load直接消费。需要特别注意的是这是一种与 legacy 格式不同的格式legacy tarball由docker save传统上产生结构基于旧的repositories文件与镜像 ID 的扁平化组织方式本包产出的 tarball遵循 docker 现代保存格式Docker Archive v2以顶层manifest.json为入口内部按 SHA 命名 config 与 layer 文件兼容性虽然产出的是新格式但本包仍然能够读取legacy 格式即docker save产生的旧 tarball这保证了在迁移期可以双向对接存量镜像文件。从源码看这一能力由 image.go 实现其核心类型Opener是一个打开 tar 文件的惰性函数// Opener is a thunk for opening a tar file. type Opener func() (io.ReadCloser, error) func pathOpener(path string) Opener { return func() (io.ReadCloser, error) { return os.Open(path) } }Opener的设计使得读取过程可以延迟到真正需要时再打开文件便于上层按需流式处理大体积层数据。快速上手从 tarball 读取镜像并改写标签README 给出了一个完整的可运行示例读取包含 ubuntu 的 tarball再以新的 tag 写回另一个 tarball。这里完整复现并补充注释package main import ( os github.com/google/go-containerregistry/pkg/name github.com/google/go-containerregistry/pkg/v1/tarball ) func main() { // Read a tarball from os.Args[1] that contains ubuntu. tag, err : name.NewTag(ubuntu) if err ! nil { panic(err) } img, err : tarball.ImageFromPath(os.Args[1], tag) if err ! nil { panic(err) } // Write that tarball to os.Args[2] with a different tag. newTag, err : name.NewTag(ubuntu:newest) if err ! nil { panic(err) } f, err : os.Create(os.Args[2]) if err ! nil { panic(err) } defer f.Close() if err : tarball.Write(newTag, img, f); err ! nil { panic(err) } }对应的底层调用链在源码中清晰可循tarball.ImageFromPath(path, tag)内部调用Image(pathOpener(path), tag)见 image.go#L69-L71tarball.Write(newTag, img, f)是MultiRefWrite(map[name.Reference]v1.Image{ref: img}, w, opts...)的单镜像包装见 write.go#L68-L70而MultiRefWrite最终把每个镜像的 config blob 各层压缩数据 顶层 manifest.json依次写入 tar 流。读取时的压缩探测与两种镜像视图Image()在加载 tarball 后会先取出第一个 layer 做压缩探测areLayersCompressed调用comp.PeekCompression然后分支构造两种镜像视图见 image.go#L90-L117compressedImagelayer 是压缩过的实现partial.CompressedImageCore通过LayerByDigest按压缩 digest 取层uncompressedImagelayer 是未压缩的实现partial.UncompressedImageCore通过LayerByDiffID按 DiffID 取层。两者都通过partial.CompressedToImage/partial.UncompressedToImage提升为标准v1.Image因此对调用方透明。深入 tarball 内部结构以ubuntu:latest为例README 用crane演示如何产出一个可剖析的 tarball$ crane pull ubuntu ubuntu.tar mkdir ubuntu tar xf ubuntu.tar -C ubuntu rm ubuntu.tar $ tree ubuntu/ ubuntu/ ├── 423ae2b273f4c17ceee9e8482fa8d071d90c7d052ae208e1fe4963fceb3d6954.tar.gz ├── b6b53be908de2c0c78070fff0a9f04835211b3156c4e73785747af365e71a0d7.tar.gz ├── de83a2304fa1f7c4a13708a0d15b9704f5945c2be5cbb2b3ed9b2ccb718d0b3d.tar.gz ├── f9a83bce3af0648efaa60b9bb28225b09136d2d35d0bed25ac764297076dec1b.tar.gz ├── manifest.json └── sha256:72300a873c2ca11c70d0c8642177ce76ff69ae04d61a5813ef58d40ff66e3e7c 0 directories, 6 files文件的命名规则manifest.json整个 tarball 的入口与索引sha256:7230...config 文件文件名即 config blob 的 digest 字符串*.tar.gz各 layer 的压缩数据文件名是 layer digest 去掉sha256:前缀后的 hex 值再加.tar.gz后缀。文件名这种去冒号、加扩展名的规整逻辑由 write.go#L178-L187 实现源码注释解释了原因tar assumes anything with a colon is a remote tape drive —— 冒号会让 GNU tar 误认为远程磁带设备因此必须去掉sha256:前缀而gunzip又依赖文件扩展名判断压缩格式因此追加.tar.gz。manifest.json 的数据模型manifest.json是tarball.Descriptor的数组类型Manifest见 image.go#L123-L134。每个 Descriptor 描述 tarball 中的一张镜像// Descriptor stores the manifest data for a single image inside a docker save tarball. type Descriptor struct { Config string RepoTags []string Layers []string // Tracks foreign layer info. Key is DiffID. LayerSources map[v1.Hash]v1.Descriptor json:,omitempty }对 ubuntu 的例子其内容如下[ { Config: sha256:72300a873c2ca11c70d0c8642177ce76ff69ae04d61a5813ef58d40ff66e3e7c, RepoTags: [ ubuntu ], Layers: [ 423ae2b273f4c17ceee9e8482fa8d071d90c7d052ae208e1fe4963fceb3d6954.tar.gz, de83a2304fa1f7c4a13708a0d15b9704f5945c2be5cbb2b3ed9b2ccb718d0b3d.tar.gz, f9a83bce3af0648efaa60b9bb28225b09136d2d35d0bed25ac764297076dec1b.tar.gz, b6b53be908de2c0c78070fff0a9f04835211b3156c4e73785747af365e71a0d7.tar.gz ] } ]字段语义字段含义Config指向镜像 config 文件的文件名即sha256:开头的 digestRepoTags该镜像在 tarball 中被赋予的仓库标签列表即怎么被拉取的Layers各层文件名的有序列表从 base 到顶层LayerSources可选字段记录 foreign layer非可分发型层的来源信息键为 DiffID读取时Manifest.findDescriptor见 image.go#L136-L157负责按 tag 匹配若未传 tag则要求 tarball 中恰好只有一张镜像若传了 tag则遍历RepoTags并通过解析后的规范名称repoTag.Name() tag.Name()进行比较——因为同一个 tag 可以有多种写法如是否带默认 registry/命名空间前缀。manifest.json 与 registry manifest 的差异tarball 中的manifest.json与 registry 返回的镜像 manifest 信息相似但并非同一结构。registry 中的 manifestDocker Manifest v2形如{ schemaVersion: 2, mediaType: application/vnd.docker.distribution.manifest.v2json, config: { mediaType: application/vnd.docker.container.image.v1json, size: 3408, digest: sha256:72300a873c2ca11c70d0c8642177ce76ff69ae04d61a5813ef58d40ff66e3e7c }, layers: [ { mediaType: application/vnd.docker.image.rootfs.diff.tar.gzip, size: 26692096, digest: sha256:423ae2b273f4c17ceee9e8482fa8d071d90c7d052ae208e1fe4963fceb3d6954 }, ... ] }两者的关键差异在于registry manifest 中 layer 的 key 是压缩后的 digestdigest而 tarball 的Layers仅是文件名列表config/layer 的 digest 信息需要通过计算内容本身才能得到。这就带来了一个值得注意的工程结论README 原话tarball 格式在镜像往返roundtrip时难以保持 digest 稳定因此如果你关心 provenance来源可追溯这不是一个好格式。写入端如何重建 manifest.json在写 tarball 时write.go#L222-L287 的calculateManifest会为每张镜像生成 Descriptor遍历镜像的 layers取其Digest().Hex生成hex.tar.gz文件名对每个 layer 调用partial.BlobDescriptor检查 mediaType若!desc.MediaType.IsDistributable()即 foreign / 不可分发层则通过partial.BlobToDiffID计算 DiffID并写入layerSources[diffid] *desc从而保留完整的来源描述最终按RepoTags排序 manifest 数组保证输出稳定、便于人工阅读。此外write.go#L296-L337 还会预先计算整个 tarball 的期望大小CalculateSize/calculateTarballSize每层按 512 字节块向上取整并加 512 字节 tar header最后补 1024 字节的 tar 结尾块。这一能力配合WithProgress选项write.go#L398-L403可以向上层汇报写入进度v1.Update携带Total/Complete字段。多平台镜像与 foreign layerhello-world:nanoserver案例README 的第二个例子揭示了 tarball 格式处理多平台镜像与不可分发层的细节。由于hello-world是多平台镜像文档作者在 amd64/linux 上需要按 digest 拉取 windows 镜像$ crane pull hello-world:nanoserversha256:63c287625c2b0b72900e562de73c0e381472a83b1b39217aef3856cd398eca0b nanoserver.tar $ mkdir nanoserver tar xf nanoserver.tar -C nanoserver rm nanoserver.tar $ tree nanoserver/ nanoserver/ ├── 10d1439be4eb8819987ec2e9c140d44d74d6b42a823d57fe1953bd99948e1bc0.tar.gz ├── a35da61c356213336e646756218539950461ff2bf096badf307a23add6e70053.tar.gz ├── be21f08f670160cbae227e3053205b91d6bfa3de750b90c7e00bd2c511ccb63a.tar.gz ├── manifest.json └── sha256:bc5d255ea81f83c8c38a982a6d29a6f2198427d258aea5f166e49856896b2da6 0 directories, 5 files其manifest.json有两个显著特征[ { Config: sha256:bc5d255ea81f83c8c38a982a6d29a6f2198427d258aea5f166e49856896b2da6, RepoTags: [ index.docker.io/library/hello-world:i-was-a-digest ], Layers: [ a35da61c356213336e646756218539950461ff2bf096badf307a23add6e70053.tar.gz, be21f08f670160cbae227e3053205b91d6bfa3de750b90c7e00bd2c511ccb63a.tar.gz, 10d1439be4eb8819987ec2e9c140d44d74d6b42a823d57fe1953bd99948e1bc0.tar.gz ], LayerSources: { sha256:26fd2d9d4c64a4f965bbc77939a454a31b607470f430b5d69fc21ded301fa55e: { mediaType: application/vnd.docker.image.rootfs.foreign.diff.tar.gzip, size: 101145811, digest: sha256:a35da61c356213336e646756218539950461ff2bf096badf307a23add6e70053, urls: [ https://mcr.microsoft.com/v2/windows/nanoserver/blobs/sha256:a35da61c356213336e646756218539950461ff2bf096badf307a23add6e70053 ] } } } ]特征一i-was-a-digest哨兵 tag由于该镜像不是按 tag 拉取而是按 digest 拉取tarball 格式又要求RepoTags中必须有 tag因此工具用一个哨兵字符串i-was-a-digest充当 tag 来取悦 docker。这一点在读取端同样有对应处理findDescriptor按规范名比较 tag如果镜像原本就是按 digest 拉取的那么通过这一哨兵 tag 也可以重新定位到对应 Descriptor。特征二LayerSources与 foreign layerLayerSources以DiffID 为键而非压缩 digest保存了重建 foreign layer 所需的全部信息。所谓 foreign或称 non-distributable层是指出于法律/分发约束如微软不希望他人代为分发 Windows 基础镜像层内容不随镜像本体传播而是通过urls指向官方下载地址。对应的 mediaType 为application/vnd.docker.image.rootfs.foreign.diff.tar.gzip。将 tarball 的LayerSources与 config 文件中的rootfs.diff_ids对照即可验证键的对应关系$ jq .[0].LayerSources nanoserver/manifest.json { sha256:26fd2d9d4c64a4f965bbc77939a454a31b607470f430b5d69fc21ded301fa55e: { mediaType: application/vnd.docker.image.rootfs.foreign.diff.tar.gzip, size: 101145811, digest: sha256:a35da61c356213336e646756218539950461ff2bf096badf307a23add6e70053, urls: [ https://mcr.microsoft.com/v2/windows/nanoserver/blobs/sha256:a35da61c356213336e646756218539950461ff2bf096badf307a23add6e70053 ] } } $ jq nanoserver/sha256:bc5d255ea81f83c8c38a982a6d29a6f2198427d258aea5f166e49856896b2da6 | jq .rootfs { type: layers, diff_ids: [ sha256:26fd2d9d4c64a4f965bbc77939a454a31b607470f430b5d69fc21ded301fa55e, sha256:601cf7d78c62e4b4d32a7bbf96a17606a9cea5bd9d22ffa6f34aa431d056b0e8, sha256:a1e1a3bf6529adcce4d91dce2cad86c2604a66b507ccbc4d2239f3da0ec5aab9 ] }sha256:26fd2d9d...正是 config 中第一个 layer 的 DiffID而该层对应的压缩 digestsha256:a35da61c...又出现在 registry manifest 的 layers[0] 中。因此只要 tarball 保留了LayerSources在把镜像推回 registry 时就能完整重建 foreign layer 的指针mediaType digest urls不丢失不可分发层的来源信息。源码层面这一机制由 image.go#L291-L334读取与 write.go#L258-L269写入双向支撑。值得注意的是读取端存在一个兼容性细节若LayerSources中的 mediaType 是DockerUncompressedLayer/OCIUncompressedLayer对应 docker 25 的某种行为则回退为普通层处理避免破坏partial包对 uncompressed layer 的假设。单层 API把任意 tarball 当作 v1.Layer除整镜像读写外tarball包还提供了把单个层文件包装成v1.Layer的 API实现在 layer.gofunc LayerFromFile(path string, opts ...LayerOption) (v1.Layer, error) func LayerFromOpener(opener Opener, opts ...LayerOption) (v1.Layer, error)LayerFromOpener会先探测输入的压缩类型然后构造压缩/解压两条读取路径若输入本身未压缩则默认以 gzipBestSpeed级别压缩。可用的函数式选项包括选项作用WithCompression(comp)覆盖默认压缩算法支持GZip/ZStdNone与未知类型会被降级为 gzip 并告警见 layer.go#L101-L116WithCompressionLevel(level)覆盖压缩级别见 layer.go#L120-L124WithMediaType(mt)覆盖 layer 的 mediaTypeZStd 压缩应配合OCILayerZStd见 layer.go#L127-L131WithCompressedCaching(l)记忆化压缩结果避免重复 gzip 的开销见 layer.go#L136-L158WithCompressedCaching尤其适合LayerFromOpener配合remote.Write的场景该场景下压缩字节可能被多次消费先算 SHA256再上传缓存可以避免对同一个层反复 gzip。vcluster 中的实际关联依赖版本与 OCI 使用本仓库对 go-containerregistry 的引入位于 go.modv0.20.7vendor 目录下保留了完整的 tarball 包源码。需要说明的是vcluster 的核心控制面代码并不直接消费v1/tarball包而是更倾向于使用同库的OCI Image Layoutpkg/v1/layout做镜像内文件提取。这一点可以从源码得到印证pkg/cli/oci/extract.go 基于layout.Path(archive).ImageIndex()打开镜像逐层解压后处理 whiteout.wh.文件与.wh..wh..opq不透明目录实现Extract整目录提取与ExtractFile单文件提取pkg/cli/create_docker.go#L860-L866 与 pkg/cli/create_docker.go#L931-L937 在docker 运行时模式下通过oci.PullImage拉取镜像、再用oci.ExtractFile/oci.Extract从镜像中取出vcluster二进制与/kubernetes目录。尽管 vcluster 自身以 OCI layout 为主要提取路径v1/tarball包所承载的与docker load/docker save生态互操作能力仍是 go-containerregistry 库不可或缺的一部分——凡是需要与 docker CLI 的 tarball 交换格式打交道的场景都落在本包的责任范围内。总结与适用边界综合文档与源码pkg/v1/tarball包的核心结论可以归纳为产出与读取能写出docker load直接消费的 Docker Archive v2 格式也能兼容读取docker save的 legacy 格式格式本质顶层manifest.jsonManifest[]Descriptor 按 digest 命名的 config 文件 hex.tar.gz命名的层文件文件名规整去冒号、加扩展名是为了兼容 GNU tar 与 gzip 的既有行为provenance 局限manifest.json与 registry manifest 结构不同、不含压缩 digest镜像经过 tarball 往返后 digest 难以保持因此不适合作为注重来源追溯的持久化格式多平台与 foreign layer按 digest 拉取的镜像会得到i-was-a-digest哨兵 tagLayerSources以 DiffID 为键完整保留了 foreign layer 的 mediaType/size/digest/urls保证推送回 registry 时指针可重建工程化细节写入端支持进度上报WithProgress、大小预计算CalculateSize、多镜像合并MultiRefWrite以及层级去重seenLayerDigests见 write.go#L151-L206。如果你需要在 vcluster 或任何 Go 项目中实现导出 docker 兼容镜像、导入docker save产物、或在无 registry 环境下搬运镜像的能力tarball包就是最直接的落点而如果只是需要从镜像里提取文件可参考 vcluster 的做法使用同库的 OCI layout 路径。【免费下载链接】vclustervCluster creates tenant clusters: fully isolated environments delivered as managed Kubernetes, or as the foundation for Slurm, Ray, Run:ai and inference clusters. Each gets its own API server, CRDs and RBAC, and runs on an existing cluster or standalone on bare metal. CNCF Certified Kubernetes.项目地址: https://gitcode.com/gh_mirrors/vc/vcluster创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考