BuildKit MergeOp 与 DiffOp 深度指南:基于 LLB 的高效层链操作与缓存优化
2026/9/15 17:41:35 网站建设 项目流程

BuildKit MergeOp 与 DiffOp 深度指南:基于 LLB 的高效层链操作与缓存优化

【免费下载链接】buildkitconcurrent, cache-efficient, and Dockerfile-agnostic builder toolkit项目地址: https://gitcode.com/GitHub_Trending/bu/buildkit

导读

MergeOp 与 DiffOp 是 BuildKit 中两个相辅相成的 LLB(Low-Level Builder)操作:前者负责把多个 LLB 结果"rebase"堆叠为一个状态,后者负责从状态链中"剥离"出某个状态相对其基座的差异。二者共同构成了对容器层链的细粒度操纵能力,是实现多阶段构建缓存最大化复用、免拉取远程镜像拼接、依赖式打包等高效构建场景的基石。读完本文,你将掌握 MergeOp/DiffOp 的完整语义、Go LLB 客户端用法、镜像导出行为、底层懒加载与快照优化原理,以及几个可直接落地的实战案例。

本文以仓库中的 merge-diff.md 为核心骨架展开,并结合 Go LLB 客户端源码、solver 端实现、快照层实现 与 仓库示例 进行纵深佐证。阅读前建议对 LLB 与 ExecOp、FileOp 有一定了解。

MergeOp:把多个状态 rebase 到彼此之上

极简接口

MergeOp 的 Go 客户端接口非常简单:

func Merge(inputs []llb.State) llb.State

直觉上的理解是:它把传入的多个状态的内容合并成一个状态(因此得名 Merge),后传入的状态中的文件优先于先传入的状态中的文件。

更精确地说,MergeOp 返回的状态是各个输入状态按照传入顺序依次"rebase"到彼此之上得到的。"Rebasing"一个状态B到另一个状态A之上,会产生一个这样的状态:

  • 拥有B的全部内容;
  • 拥有A的全部内容,但当某个路径同时存在于BA中时:
    • 若两侧都是目录,则目录内容被合并,来自B的目录元数据(如权限)优先;
    • 若其中一侧不是目录,则B中的内容优先。这意味着如果B中的文件覆盖了A中的目录,A中该路径下整棵子树中的所有文件/目录也会被一并移除。

MergeOp 满足结合律,即用简写记法:Merge(A, B, C) == Merge(Merge(A, B), C) == Merge(A, Merge(B, C))。BuildKit 知道这一点,并会在内部优化:凡是等价的 LLB merge 都复用相同的缓存条目。删除(deletion)等更微妙的行为会在后文"高级细节"一节讨论。

MergeOp 产生的状态与任何其他 LLB 状态无异:可以作为 exec 的基座,可以被挂载到 exec 的任意路径,可以被接入其他 merge/diff,可以被导出,等等。

客户端实现细节

从客户端源码看,Merge函数(client/llb/merge.go)在构造真正的MergeOp之前会做两个优化:

  • 过滤掉Scratch输入(即Output() == nil的状态),因为空状态参与合并没有任何效果;
  • 若过滤后只剩 0 个输入,直接返回Scratch();只剩 1 个非空输入,则直接返回该输入本身。

同时它会通过addCap(&c, pb.CapMergeOp)声明能力位,pb.CapMergeOp定义于 solver/pb/caps.go。MergeOpValidate(client/llb/merge.go)要求至少 2 个输入,而Marshal阶段会把每个输入编码为pb.MergeOp中的MergeInput,并显式设置pop.Platform = nil,注释说明"merge op is not platform specific"——这也解释了为什么 MergeOp 与具体平台架构无关。

一个最简单的示例

// a 包含 /dir/a a := llb.Scratch(). File(llb.Mkdir("/dir", 0755)). File(llb.Mkfile("/dir/a", 0644, []byte("a"))) // b 包含 /dir/b 和 /otherdir b := llb.Scratch(). File(llb.Mkdir("/dir", 0755)). File(llb.Mkfile("/dir/b", 0644, []byte("b"))). File(llb.Mkdir("/otherdir", 0755)) // c 包含 /dir/a 和 /dir/c c := llb.Scratch(). File(llb.Mkdir("/dir", 0700)). File(llb.Mkfile("/dir/a", 0644, []byte("overwritten"))). File(llb.Mkfile("/dir/c", 0644, []byte("c"))) // merged 将包含 /dir/a、/dir/b、/dir/c 和 /otherdir。 // /dir/a 的内容是 "overwritten",因为 c 在 a 之后合并。 // 同理,/dir 的权限被设置为 0700。 merged := llb.Merge([]llb.State{a, b, c}) // merged 可以作为新状态的基座 mergedPlusMore := merged.File(llb.Mkdir("/yetanotherdir", 0755)) // 也可以作为其他 merge 的输入 mergedPlusMore = llb.Merge([]llb.State{merged, llb.Scratch().File(llb.Mkdir("/yetanotherdir", 0755))})

注意示例中/dir权限的取舍:c/dir设置了0700且合并顺序靠后,因此目录元数据以c为准;而/dir/a同时存在于ac,同样由后者的内容覆盖。

MergeOp 的容器镜像导出行为

当 MergeOp 的结果被导出为容器镜像时,镜像由各输入自身的层按 MergeOp 的顺序拼接而成。这意味着:

  • 如果 BuildKit 已经缓存了其中任何一层,则不需要重新导出(即重新打包为压缩 tarball);
  • 如果镜像被推送到 registry 且 registry 已拥有其中某些层,BuildKit 可以直接跳过推送这些层;
  • MergeOp 拼接起来的层之间互不依赖,因此某个输入层的缓存失效不会级联到其他输入的层上。

在 solver 端,mergeOp.Exec(solver/llbsolver/ops/merge.go)把各输入 ref 收集起来后,调用m.worker.CacheManager().Merge(ctx, refs, ...)完成实际合并;其CacheMap使用类型标识"buildkit.merge.v0"与序列化后的pb.MergeOp参与缓存摘要计算,这保证了合并结果的缓存可与输入层相互独立地命中。

DiffOp:从 lower 到 upper 的差异状态

极简接口

func Diff(lower llb.State, upper llb.State) llb.State

直觉上,它返回的状态内容是lowerupper之差,可以看作 MergeOp 的某种逆操作:MergeOp 是"加"状态,而 DiffOp 是"减"掉lower

更具体地说,DiffOp 返回的状态包含upper中那些要么不存在于lower、要么相对lower发生了变化的内容。另一种理解方式:从A出发应用Diff(A, B),就会到达B。或者更简洁地:Merge(A, Diff(A, B)) == B

关于"变化"的判定:文件与目录在lowerupper之间只要内容不相等,或元数据(如权限、mtime)发生变化,即视为发生变化;atimectime的不等被视为变化。

DiffOp 产生的状态同样可以用于 exec 基座、任意路径挂载、接入 merge/其他 diff、导出等场景。

客户端实现细节

客户端Diff函数(client/llb/diff.go)同样做了特判优化:

  • lower 与 upper 均为Scratch时,返回Scratch()(空减空为空);
  • lower 为Scratch时,直接返回upper(空状态 diff 出 upper 就是 upper 本身)。

solver/llbsolver/ops/diff.go中的diffOp.Exec则进一步覆盖了边界情况(solver/llbsolver/ops/diff.go):lower 与 upper 都为 nil 时返回空 ref;lower 为 nil 时返回 upper 的克隆;当lowerRef.ID() == upperRef.ID()时返回空 ref——即一个状态与自身相减的结果为空。真正的差异计算通过CacheManager().Diff完成,缓存类型标识为"buildkit.diff.v0"

一个最简单的示例

base := llb.Image("alpine") basePlusBuilt := base.Run(llb.Shlex("touch /foo")).Root() // diffed 只包含文件 /foo,alpine 镜像中的任何内容都不存在 diffed := llb.Diff(base, basePlusBuilt)

DiffOp 的容器镜像导出行为

DiffOp 导出为镜像时会尽量复用层。考虑下面这个场景:

lower := llb.Image("alpine") middle := lower.Run(llb.Shlex("touch /foo")).Root() upper := middle.Run(llb.Shlex("touch /bar")).Root() diff := llb.Diff(lower, upper)

这里存在一条从lowerupper的"已知链"(known chain),因为lower出现在upper的历史中。这意味着该 DiffOp 导出为容器镜像时,可以直接由middle的容器层拼接upper的容器层得到。

另一种理解方式:当lowerupper历史中的状态时,两者之差等价于它们之间各状态的 merge。即:

llb.Diff(lower, upper) == llb.Merge([]llb.State{ llb.Diff(lower, middle), llb.Diff(middle, upper), })

这个行为可以推广到lowerupper之间相隔任意数量的状态。而当 BuildKit 无法在lowerupper之间确定这样的链时,DiffOp 仍能一致地工作,但导出时始终生成一个单一的、不从输入复用的层。

实战案例一:用 MergeOp 消除"复制链"的级联缓存失效

问题所在

构建容器镜像时一个常见模式是:独立组装镜像的各个组件,再通过一串 Copy FileOp 把它们组合进最终镜像。对应到 Dockerfile frontend,就是多阶段构建加一串COPY --from=...

这种模式的问题在于:复制链中任何一个输入发生变化,不仅会使其自身缓存失效,还会让其后所有被复制的层连带失效。具体看下面的 Go LLB:

// 阶段 a a := llb.Image("alpine").Run("build a").Root() // 阶段 b b := llb.Image("alpine").Run("build b").Root() // 阶段 c c := llb.Image("alpine").Run("build c").Root() // 最终组合阶段 combined := llb.Image("alpine"). File(llb.Copy(a, "/bin/a", "/usr/local/bin/a")). File(llb.Copy(b, "/bin/b", "/usr/local/bin/b")). File(llb.Copy(c, "/bin/c", "/usr/local/bin/c"))

这基本等价于下面的 Dockerfile:

FROM alpine as a RUN build a FROM alpine as b RUN build b FROM alpine as c RUN build c FROM alpine as combined COPY --from=a /bin/a /usr/local/bin/a COPY --from=b /bin/b /usr/local/bin/b COPY --from=c /bin/c /usr/local/bin/c

假设你先完整构建并导出combined到 registry,然后对a做了一次修改再构建。a的构建不再命中缓存,那么把/bin/a复制进/usr/local/bin/a的 FileOp 也必须重跑。问题在于:combined中的每次复制是链式串在一起的,a的复制失效会级联到其后代——即bc的复制,尽管bca完全独立。

其后果不仅是bc的复制需要重跑,还意味着需要导出并推送新的层到 registry。对许多用例而言,这是构建耗时与存储/传输数据量的重要开销来源。

MergeOp 解法

a := llb.Scratch().File(llb.Copy(llb.Image("alpine").Run("build a").Root(), "/bin/a", "/usr/local/bin/a")) b := llb.Scratch().File(llb.Copy(llb.Image("alpine").Run("build b").Root(), "/bin/b", "/usr/local/bin/b")) c := llb.Scratch().File(llb.Copy(llb.Image("alpine").Run("build c").Root(), "/bin/c", "/usr/local/bin/c")) combined := llb.Merge([]llb.State{ llb.Image("busybox"), a, b, c, })

(注:较新版本的 Dockerfile 为COPY提供了--link标志,其底层正是这个模式。)

相比之前做了两处改动:

  1. abc改为把各自想要的内容复制进Scratch(一个新的空状态);
  2. combined定义为对最终镜像所需各状态的 MergeOp。

首次构建时,BuildKit 会先创建abc,各自只包含/usr/local/bin/a/usr/local/bin/b/usr/local/bin/c的单层;随后 MergeOp 把每个状态 rebase 到busybox基座之上。如前所述,MergeOp 的镜像导出就是各输入层拼接,因此最终镜像与之前基本一致。

MergeOp 的优势在a被修改时体现出来:之前会导致bc复制失效,而现在这些 merge 输入完全不受影响,无需为它们创建任何新缓存条目或新容器层。最终,当a变化时 BuildKit 只做两件事:重新构建a,然后推送/usr/local/bin/a的新层(外加一份新的镜像 manifest)。/usr/local/bin/b/usr/local/bin/c既不需要重新导出,也不需要重新推送。

这个行为的一个重要前提是 MergeOp 是懒执行的:其磁盘上的文件系统表示只有在确实需要时才会在本地创建。因此,即使a的改动使整个 MergeOp 失效,只要它只是被导出为容器镜像,就不需要在磁盘上重建合并后的状态。懒加载的更多细节见"性能考量"一节。

仓库中 examples/buildkit3 与 examples/buildkit4 提供了可运行的可对照示例:buildkit3 通过llb.Scratch().With(copyAll(...), ...)链式复制组装最终产物(examples/buildkit3/buildkit.go),而 buildkit4 改用llb.Merge(inputs),每个组件先经prefixed()复制到独立 Scratch 状态再参与合并(examples/buildkit4/buildkit.go)。

实战案例二:仅远程操作即可拼接镜像

如果某些层已经推送到远程 registry,MergeOp 允许你以任意方式组合这些层来创建新镜像,而无需先拉取任何层。例如:

foo := llb.Image("fooApp:v0.1") bar := llb.Image("barApp:v0.3") qaz := llb.Image("qazApp:v1.2") merged := llb.Merge([]llb.State{foo, bar, qaz})

如果把merged导出到已经拥有fooAppbarAppqazApp各层的同一 registry,那么导出过程中 BuildKit 唯一要做的事就是创建镜像 manifest(只是一些元数据)并推送它。没有任何层需要推送(它们已经在 registry 上),甚至不需要把层拉到 BuildKit 本地。

需要注意,如果改成这样:

merged := llb.Merge([]llb.State{foo, bar, qaz}).Run(llb.Shlex("extra command")).Root()

那么fooAppbarAppqazApp就必须被拉取,尽管它们通常会被比"直接把层逐个解包堆叠"更高效地合并到一起。详见"性能考量"一节。

此外,如果把 BuildKit 缓存导出到 registry,同样的思路可以扩展到任意 LLB 类型而不只是llb.Image。沿用上一个案例:

a := llb.Scratch().File(llb.Copy(llb.Image("alpine").Run("build a").Root(), "/bin/a", "/usr/bin/a")) b := llb.Scratch().File(llb.Copy(llb.Image("alpine").Run("build b").Root(), "/bin/b", "/usr/bin/b")) c := llb.Scratch().File(llb.Copy(llb.Image("alpine").Run("build c").Root(), "/bin/c", "/usr/bin/c")) combined := llb.Merge([]llb.State{ llb.Image("alpine"), a, b, c, })

如果某个构建包含远程缓存导出,那么任何导入了该缓存的 BuildKit worker 都可以对这些层做不同的 merge 而不必拉取任何东西。比如另一个 worker 导入远程缓存后构建:

combined2 := llb.Merge([]llb.State{ c, a, })

combined2的导出同样无需拉取任何层,因为它只是ca的 merge,而这两者的层早已因远程缓存而存在于 registry。这之所以成立,是因为远程缓存导入实际上只是一次元数据下载:层只在确实需要时才会被拉到本地,而这个 MergeOp 并不需要它们。

实战案例三:用 MergeOp + DiffOp 建模"依赖式构建"

Merge 与 Diff 的潜在用例很多,其中一个主要方向是帮助基于 LLB 的上层工具建模"依赖式构建"(dependency-based builds),例如各类包管理器与构建系统。

这类系统中建模"包"(或等价概念)构建的常见模式是:

  1. 把包的构建期依赖合并进一个文件系统,这些依赖本身也是已构建好的包;
  2. 执行一些能够访问合并后依赖的命令进行构建,产出的构建产物要与依赖隔离开来,这些被隔离的产物成为新包的内容;
  3. 新包可以继续作为其他包的依赖,或直接提供给最终用户,同时需要确保使用包时其运行期依赖也在场。

把上述模型映射到 LLB,一种写法如下:

// "包"就是 LLB 状态。构建期依赖用 MergeOp 合并成一个文件系统。 runtimeDeps := llb.Merge([]llb.State{depC, depD}) buildDeps := llb.Merge([]llb.State{src, depA, depB, runtimeDeps}) // 新包的构建是叠在 MergeOp 之上的 ExecOp // (一次用于构建,一次用于安装)。安装 ExecOp 将构建产物写入 // 专门的 Mount,通过 /output 与依赖隔离。 builtPackage := buildDeps.Run( llb.Dir("/src"), llb.Shlex("make"), ).Root().Run( llb.Dir("/src"), llb.Shlex("make install"), llb.AddEnv("DESTDIR", "/output"), llb.AddMount("/output", llb.Scratch()), ).GetMount("/output") // 如果包需要在其他构建中运行或交付给最终用户, // 通过 MergeOp 把运行时依赖并入状态即可。 llb.Merge([]llb.State{runtimeDeps, builtPackage})

上面的示例有所简化(比如忽略了在 merge 之前需要对依赖 DAG 做拓扑排序),但要点在于它只需要 MergeOp 与 ExecOp,完全不需要 DiffOp。对许多用例来说这是完全可行的。

不过,某些用例会在"把构建产物与依赖隔离"这一环节遇到麻烦。上面的示例使用DESTDIR这一约定:该环境变量指定make install应把产物放到哪个目录。大多数构建系统要么支持DESTDIR,要么有某种等价的产物隔离机制。但总有时机该约定不可用或不可取,此时 DiffOp 可以作为通用的、与工具无关的方式,把状态从其原始依赖基座中分离出来。相对上一个示例的改动很小:

// 与之前相同的 `make` 命令 buildBase := buildDeps.Run( llb.Dir("/src"), llb.Shlex("make"), ).Root() // 现在 `make install` 不再使用 DESTDIR,而是直接安装到构建的 rootfs。 // 包的内容改为通过 diff 安装前后的 rootfs 来隔离。 builtPackage := llb.Diff(buildBase, buildBase.Run( llb.Dir("/src"), llb.Shlex("make install"), ).Root())

这种基于 DiffOp 的做法能达到与前一版相同的最终结果,但不再依赖make install支持DESTDIR

DiffOp 更通用、写起来也可能更简单,但这不意味着它每个场景都严格更优。当两种方案都可行时,需要注意以下几点:

  1. 使用DESTDIR的版本在多数用例下性能可能好:对 BuildKit 来说,合并一个"scratch 之上的单层"状态(即DESTDIR版本的builtPackage)比合并"两个非空状态之间的 diff"状态(即 DiffOp 版本)更快。性能差异是否重要需要按具体场景评估。
  2. DiffOp 有一些在"高级细节"一节讨论的微妙行为,虽然对多数用例无关紧要,但偶尔会使其与DESTDIR方案产生差异。

性能考量

懒加载(Laziness)

MergeOp 与 DiffOp 都是懒实现的:它们磁盘上的文件系统表示只在绝对必要时才会创建。

最常见的"解懒"(在磁盘上创建结果)场景是作为 Exec 或 File op 的输入。例如:

rootfs := llb.Merge([]llb.State{A, B}) extraLayer := rootfs.Run(llb.Shlex("some command")).Root()

如果extraLayer尚未被缓存,运行它就需要rootfs存在于磁盘,因此rootfs必须被解懒。若extraLayer被定义为 FileOp,或rootfs由 DiffOp 定义,逻辑相同。

更有意思的是那些 merge/diff 结果不需要解懒的场景:

  • 导出为容器镜像时。如前所述,镜像导出会尽量复用 merge/diff 输入的各层,因此最终合并/差分结果并不需要,只有输入需要。
  • merge/diff 作为另一个 merge/diff 的输入时。例如:
diff1 := llb.Diff(A, B) diff2 := llb.Diff(C, D) merge := llb.Merge([]llb.State{diff1, diff2})

这里diff1diff2虽然是merge的输入,但因为merge自身也是懒的,它们无需解懒;如果ABCD是懒的 LLB 状态,它们同样无需解懒。懒加载在这一意义上具有传递性。

依赖快照器的优化(Snapshotter-dependent Optimizations)

Merge 与 Diff 的实现中有一些针对大规模构建(涉及大量 merge/diff)的优化,关心这类扩展性问题的用户值得了解。这些优化本质上是实现细节,不会影响 merge/diff 结果的实际内容。

当一个 merge/diff 结果需要被解懒时,适用于所有快照器后端的"通用"兜底实现是按需从输入中把文件复制进一个新的文件系统。这在规模上来后会在磁盘空间与 CPU 时间上变得昂贵。

但对于两个默认快照器(overlay 与 native),存在一个优化:避免复制文件,而是把输入中的文件硬链接进合并/差分后的文件系统。这至少与复制一样快,对包含大文件尺寸的输入往往显著更快。

仓库实现可以佐证:snapshot/merge.gohardlinkMergeSnapshotters明确列出nativeoverlayfs两种快照器(snapshot/merge.go),并据此开启tryCrossSnapshotLink;而overlayBasedSnapshottersoverlayfsstargz)还会启用"跳过基座层"的优化(snapshot/merge.go),前提是内核支持userxattr(rootless 下需要 5.11+ 内核)。实际的硬链接应用逻辑位于 snapshot/diffapply_linux.go,其中diffApply在条件允许时优先尝试从输入硬链接,仅在跨设备、超过 inode 链接上限等场景回退为复制(snapshot/diffapply_linux.go)。此外,mergeSnapshotter.Merge对通过 hardlink 创建的快照会用标签buildkit.mergeUsageSize/buildkit.mergeUsageInodes记录真实用量,因为内建 usage 计算无法统计跨不可变快照的硬链接(snapshot/merge.go)。

高级细节

这些细节大概率不会影响大多数用例,但如果你在使用 Merge/Diff 时遇到出乎意料的行为,或想更深层地理解它们,值得一读。

Merge/Diff 的"层状"行为

LLB 结果有一条重要原则:当它们被导出为容器镜像时,镜像被外部运行时(非 BuildKit)拉取并解包后,必须看到与构建期间一致的文件系统。

这看似显而易见,但对 Merge 与 Diff 意义重大——它们是专门设计为尽量复用输入容器层的操作,以最大化缓存复用与效率。本文其余部分讨论的许多看似"意外"的行为,正是源于"保证 Merge+Diff 结果在导出为容器层前后看起来一致"这一需求。

删除(Deletions)

当出现以下两种情况之一时,删除会被视为与文件/目录同级的"实体"(entity),并在该状态作为 merge 输入时产生影响:

  1. LLB 状态删除了其父链中的文件;
  2. 使用 DiffOp 时upper缺少lower中存在的路径。

例如:

// 创建一个只包含 /foo 的状态 foo := llb.Scratch().File(llb.Mkfile("/foo", 0644, nil)) // 创建一个删除了 /foo、什么都不剩的状态 rmFoo := foo.File(llb.Rm("/foo")) // 在前述"空"状态之上创建一个包含 /bar 的状态 bar := rmFoo.File(llb.Mkfile("/bar", 0644, nil)) merged := llb.Merge([]llb.State{foo, bar})

你可能以为merged会包含/foo/bar,但实际上它只包含/bar。因为状态bar的链中也包含对/foo的一次删除,删除是其定义的一部分。

一种理解方式是:合并foobar时,实际合并的是各自链中每一段 diff,即:

llb.Merge([]llb.State{foo, bar}) == llb.Merge([]llb.State{ // foo 的链(只有 1 层) llb.Diff(llb.Scratch(), foo), // 创建 /foo // bar 的链(3 层) llb.Diff(llb.Scratch(), foo), // 创建 /foo llb.Diff(foo, rmFoo), // 删除 /foo llb.Diff(rmFoo, bar), // 创建 /bar })

可以看到Diff(foo, rmFoo)被包含其中,而它的全部"内容"就是对/foo的一次删除。因此构造merged时会应用这次删除,最终merged中不存在/foo

另外注意:如果把 merge 顺序反过来写成Merge([]State{bar, foo})/foo会与/bar一起存在于merged中,因为foo的内容优先于bar,创建/foo因而"覆盖"了之前的删除。

最后一个细节:尽管删除与文件/目录同为实体,它们在被挂载时并不会显现。例如,在构建中挂载llb.Diff(foo, rmFoo),你只会看到一个空目录。删除只在使用其作为 MergeOp 输入时才有影响。

规避方案

对于遇到此行为但不希望如此的用例,最佳选择是设法在构建定义中避免包含有问题的删除。这往往高度依赖具体场景,以上面的例子而言,一种方案是:

justBar := llb.Diff(rmFoo, bar) merged := llb.Merge([]llb.State{foo, justBar})

现在merged同时包含/foo/bar,因为justBar已把父状态rmFoo"diff 掉",只保留创建/bar的最后一层。其他用例可能需要不同手段,例如调整构建命令以避免对文件/目录产生不必要的删除。

对于无论如何都无法避免删除的用例,兜底方案是用 Copy op 把 merge 输入"压平"(squash),丢弃所有删除。沿用上例:

squashedBar := llb.Scratch().File(llb.Copy(bar, "/", "/")) merged := llb.Merge([]llb.State{foo, squashedBar})

这样merged同样包含/foo/bar,因为squashedBar是只由bar中存在的文件与目录构成的单层,不含任何删除。

注意这种复制方案目前存在性能代价:它确实会在磁盘上产生一次复制(没有硬链接优化)、复制结果不是懒的,并且就 BuildKit 缓存与远端 registry 而言squashedBar是与输入不同的独立层——是否重要取决于具体用例。

Diff 的边界情况

某些场景下合并 diffs 时的正确行为存在歧义。如前所述,为了保持一致性,Merge+Diff 会遵循与容器镜像导入/导出实现相同的行为来解决这些歧义。

一个例子:

dir := llb.Scratch().File(llb.Mkdir("/dir", 0755)) dirFoo := dir.File(llb.Mkfile("/dir/foo", 0755, nil)) // rmFoo 包含对 /dir/foo 的一次删除 rmFoo := llb.Diff(dirFoo, dirFoo.File(llb.Rm("/dir/foo"))) // otherdir 只包含 /otherdir otherdir := llb.Scratch().File(llb.Mkdir("/otherdir", 0755)) // merged 包含 /otherdir 和 /dir(但没有 /dir/foo) merged := llb.Merge([]llb.State{otherdir, rmFoo})

这个例子里,起点只有/otherdir,然后应用rmFoo——对/dir/foo的删除。但/dir/foo并不存在,因此合理的直觉可能是"该删除没有效果"。然而镜像导入/导出代码会真的创建/dir,即便它只是为了承载一次不适用的删除。因此 Merge+Diff 也表现出相同的行为。

总结

MergeOp 与 DiffOp 是 BuildKit 层链操纵的一对核心原语:MergeOp 以结合律将多个状态 rebase 叠加并做到缓存隔离,DiffOp 以Merge(A, Diff(A, B)) == B的语义实现状态间的减法。二者均为懒实现,镜像导出时最大化复用输入层,在 overlay/native 快照器上还有硬链接级的底层优化。无论是消除 Dockerfile 多阶段 COPY 链的级联缓存失效、免拉取地远程拼接镜像,还是为包管理器类工具建模依赖式构建,MergeOp/DiffOp 都能显著降低构建时间与数据搬运开销。

进一步阅读:

  • client/llb/merge.go:MergeOp 的 Go 客户端实现
  • client/llb/diff.go:DiffOp 的 Go 客户端实现
  • solver/llbsolver/ops/merge.go 与 solver/llbsolver/ops/diff.go:solver 端 op 执行与缓存键
  • snapshot/merge.go 与 snapshot/diffapply_linux.go:快照层的合并/差分与硬链接优化
  • examples/buildkit3/buildkit.go 与 examples/buildkit4/buildkit.go:Copy 链 vs MergeOp 的可运行对照示例

【免费下载链接】buildkitconcurrent, cache-efficient, and Dockerfile-agnostic builder toolkit项目地址: https://gitcode.com/GitHub_Trending/bu/buildkit

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

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

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

立即咨询