提到Docker镜像构建,大多数人的认知停留在编写Dockerfile这一层。但如果你的构建流程比较复杂,比如需要多阶段交叉编译、依赖缓存精细控制、或者想在CI中并行构建多个目标,光靠Dockerfile就显得力不从心了。这时候就轮到LLB出场了。LLB全称Low-Level Build Language,直译过来就是低级构建语言,它是BuildKit引擎真正的执行单元,Dockerfile只是一层语法糖,最终都要编译成LLB才能被引擎消费。

理解LLB并不只是为了满足好奇心。掌握它之后,你可以直接用Go语言编写构建逻辑,实现Dockerfile做不到的动态构建、条件分支、并行编排等能力,同时也能真正搞明白BuildKit为什么快、缓存为什么准。这篇文章就从原理到实战,把LLB掰开揉碎讲清楚。
LLB的本质:内容寻址的有向无环图
LLB的核心数据结构是一张有向无环图,图中的每个节点代表一个操作,比如拉取镜像、执行命令、复制文件、导出结果等。节点之间通过边连接,表示数据的流动方向。整个构建过程就是从源头节点开始,沿着图的拓扑顺序逐步求值,最终得到产物。因为是无环的,BuildKit可以精确识别哪些节点之间没有依赖关系,从而把它们丢到不同的worker上并发执行,这是构建提速的第一个关键点。
第二个关键点是内容寻址。LLB中的每个操作都会根据其输入内容和操作参数计算出一个确定的digest,类似Git的commit hash。如果两次构建中某个节点的输入完全一致,它的digest就相同,BuildKit会直接复用缓存结果,跳过实际执行。这比传统builder基于Dockerfile文本行的缓存机制精确得多——后者哪怕指令没变,只要COPY的文件时间戳变了就可能整个失效,而内容寻址只关心字节内容本身。
来看一个直观的对比。传统构建器缓存失效的粒度是指令级别,而LLB的缓存粒度是操作级别,甚至可以精确到单个文件的传递。比如RUN apt-get update这条指令后面跟COPY . .,如果源码变了,传统构建器会从COPY那行开始全部重跑,而LLB图中apt-get节点因为输入没变会被直接命中,只有受影响的子图会重新求值。
从Dockerfile到LLB:前端编译器的工作机制
BuildKit采用了前端架构,Dockerfile解析器就是官方提供的dockerfile前端,本质上是一个把Dockerfile翻译成LLB的编译器。前端和构建引擎之间通过gRPC的Solve请求通信,前端负责把高层语法降级为LLB的Op定义列表。这个设计带来的好处是可插拔:社区出现了支持Mockerfile、Buildpacks、甚至直接用Helm Chart作为构建输入的各种前端,只要最终产出合法的LLB,引擎都能执行。
LLB的Op类型虽然不多,但组合能力很强。常用的包括Source(从镜像仓库、本地目录、HTTP地址或Git仓库获取输入)、Exec(在文件系统快照上运行命令)、File(执行mkdir、copy、rm等文件操作,不需要启动容器)、以及Export系列(导出为镜像、本地目录或推送到registry)。Dockerfile里的一条RUN指令,编译后通常就是一个Source节点加一个Exec节点的组合。
可以通过下面的命令把一个Dockerfile编译成LLB中间表示,直接观察编译产物:
docker buildx build --print . 2>/dev/null | head -50 # 或者使用buildctl查看更底层的LLB定义 buildctl build --frontend dockerfile.v0 --local context=. --local dockerfile=. --print
打印出来的JSON会展示完整的DAG结构:每个节点的Op类型、输入引用、执行参数以及结果digest。第一次看可能会觉得信息量很大,但仔细读一遍就能发现Dockerfile的每条指令都变成了图中清晰的节点,多阶段构建则表现为多个子图汇合到最终的Export节点。
用Go直接编写LLB:绕过Dockerfile的实战
BuildKit提供了client包,允许开发者用Go代码直接构造LLB定义。当你需要根据运行时条件动态决定构建步骤,或者把构建逻辑纳入类型安全的代码管理时,这比手写Dockerfile灵活得多。下面是一个完整的例子,从拉取alpine基础镜像开始,执行命令,最后导出为本地目录:
package main
import (
"context"
"os"
"github.com/moby/buildkit/client"
"github.com/moby/buildkit/client/llb"
"github.com/pkg/errors"
)
func main() {
ctx := context.Background()
// 定义构建图:拉取基础镜像并执行命令
def, err := llb.Image("docker.io/library/alpine:latest").
Run(llb.Sharg("apk add --no-cache git curl")).
Run(llb.Sharg("git clone https://ipipp.com/repo/demo.git /src")).
Root().Marshal(ctx)
if err != nil {
panic(errors.Wrap(err, "marshal llb definition"))
}
// 连接buildkitd守护进程并执行求解
c, err := client.New(ctx, "unix:///run/buildkit/buildkitd.sock")
if err != nil {
panic(err)
}
defer c.Close()
_, err = c.Solve(ctx, def, client.SolveOpt{
Exports: []client.ExportEntry{
{
Type: client.ExporterLocal,
OutputDir: "/tmp/output",
},
},
}, nil)
if err != nil {
panic(err)
}
os.Exit(0)
}这段代码展示了LLB编程的基本范式:先用llb包提供的链式API构造图,调用Marshal把图序列化成定义,再通过client.Solve提交给buildkitd执行。注意连续两次Run调用会共享同一个根文件系统快照,引擎会自动处理状态传递。如果想要并行执行两条独立的命令链,只需要从同一个llb.Image节点分叉出两条链,最后用llb.Merge或File的Copy操作汇合,引擎会自动并发调度。
在实际项目中,这种编程方式的价值体现在几个场景。一是多目标构建:需要为amd64和arm64分别构建时,可以在代码里循环生成两个LLB子图并行提交。二是动态依赖:构建前先探测网络或代码仓库的状态,据此决定拉取哪个基础镜像。三是构建逻辑复用:把公司内部的标准构建流程封装成Go库,多个项目直接调用,避免Dockerfile在各仓库间复制粘贴后逐渐腐化。
LLB的缓存与分发机制详解
内容寻址带来的另一个能力是缓存可以被导出和共享。buildctl支持把本地缓存推送到registry,格式化成专门的cache镜像,其他机器构建时可以直接拉取。在CI环境中,这意味着第一次构建可能要十几分钟,后续哪怕换了执行节点,只要缓存digest能命中,构建时间能压缩到秒级。这一点在多分支、大规模团队的场景下收益尤其明显。
缓存导出的命令大概是这样:
buildctl build \ --frontend dockerfile.v0 \ --local context=. \ --local dockerfile=. \ --export-cache type=registry,ref=docker.io/ipipp/demo:buildcache \ --import-cache type=registry,ref=docker.io/ipipp/demo:buildcache \ --output type=image,name=docker.io/ipipp/demo,push=true
需要提醒的是,缓存共享并不意味着结果不可信。有人担心拉取别人的缓存会不会拿到被污染的镜像,实际上LLB的digest机制保证了这一点:缓存命中要求输入digest完全一致,引擎在复用前会校验完整性,digest对不上就会重新执行。这也是内容寻址设计在安全层面的价值——结果可验证,而不依赖对构建过程的信任。
除此之外,LLB还支持结果原地复用。通过ExportTypeImage配合oci mediatype,构建产物可以直接以OCI镜像格式落盘,配合containerd的镜像存储实现无Docker daemon的构建部署链路。这套能力在Kubernetes环境的CI系统中几乎是标配方案。
结语:什么时候值得深入LLB
平心而论,绝大多数日常构建任务用Dockerfile加上BuildKit的默认前端就足够了,不需要触碰LLB。但当你遇到下面这些信号时,深入LLB就开始变得值得:构建流程里有大量动态逻辑需要条件判断;多个项目反复维护相似的Dockerfile急需抽象复用;CI构建时间成为瓶颈,需要精细控制缓存与并行度;或者你想为团队打造一个定制化的构建前端。
学习路径上,建议先从buildctl的--print命令观察Dockerfile编译出的LLB结构,建立直观认识,再用moby/buildkit仓库example目录下的Go示例动手实践。官方的client包文档和源码中的integration测试是很好的参考材料。理解了LLB,你就不再是把BuildKit当黑盒使用,而是能站在构建系统设计者的视角,主动掌控整个镜像生产流水线。
BuildKitLLBDocker镜像构建修改时间:2026-09-06 11:49:58