对于 Docker 和 Kubernetes 来说,在自身发展的壮大过程中,都会经历一个因为功能不断增加导致的软件结构庞杂的问题。对于 Kubernetes 来说,出于架构上的考量,kubectl 等项目的代码都会逐渐从主项目中移除。对于 Docker 来说,事情更为复杂,它既要考虑开源,又要考虑自己的商业化,所以有了 moby 以及 *kit 等一系列项目。
下图清晰地展示出了 Docker 对于相关项目的一个架构规划:

总体来说,Docker 希望将容器技术与容器产品分离开,核心技术是开源的,可扩展的,这样允许有其他人来基于同样的技术来构建类似于 Docker CE/Docker EE 这样的产品。
BuildKit 自 2017 年年底便已经实现,但似乎关注度不是很高(在国内),在 CI/CD 系统中的应用也不是很广泛。本文是关于 BuildKit 的一个相关介绍。

为什么需要 BuildKit#

除了上面说的商业以及架构上的考量,本身在构建这一块,Dockerfile 以及相应的机制也面临着一些问题

  1. 并发程度不够好

    很多步骤都是可以并行执行的,比如两部构建中的两次 pull 镜像,以及各种 run 命令

  2. 对 cache 的支持不好

    像 golang 这样的语言,每次构建都要从头开始,没法复用缓存

  3. 对 secret 的支持不好

    难以支持在 dockerfile 中通过 username-password/ssl key 等方式获取一些加密信息

  4. 可扩展性不够好

这些问题并不是说在现有的代码基础之上实现不了,而是综合来看,工作量比较大,而且到了一种必须审视现有架构的时机了。有了 moby 与 containerd 之后更是如此,这并不是一个孤立的问题,而是每个 docker 子功能(command)都会面临的机遇与挑战。

BuildKit 长什么样子#

通常来讲,我们将 Dockerfile 当做一个项目里的一个用于部署的配置文件。但从某种角度来说,Dockerfile 就是一种编程语言,他有自己特定的语法,有编译器(docker),有最终的产出(image)。这样来看,docker build 做的就是编译器的工作。关于编译器的结构与性能,我们已经有了在各个语言方面的实现以及优化经验,完全可以应用于 dockerfile。

从最初的 buildkit 的 proposal来看,也正是采用这样的视角。在架构上像 LLVM 一样,采用模块化的结构,每一个部分都是可以单独扩展的,只需要保正彼此之间的接口兼容性。

BuiltKit 主要分为以下几个部分

  • frontends: build 的描述定义,比如 Dockerfile,面向用户
  • solver/low level builder: 负责语法解析,生成中间结构,cache 管理
  • exporter: 生成物导出,比如现在的 image

有了抽象的 frontends 和 exporter,BuildKit 已经不再单单是针对 dockerfile 的一个优化,而是一个通用的构建系统。像 LLVM 一样,它可以支持多语言: Dockerfile,BuildPack,以及各种可能的自定义语言。在输出端,镜像也不再是唯一的输出格式,还可以是文件,目录,plugin,其他的镜像格式等等。

而最核心的,自然是 solver 部分。类似编译器的最核心部分:将源代码专程成目标结果

LLB#

LLB 指 low level builder,也即 solver。上层语言转成一些中间指令之后,形成一个DAG,它负责找到一种最有效的方式来执行指令并且管理 cache。
它的主要目标有:

1.能够复用相同的步骤
2.结果相同的 instruction 能够复用结果
3.尽量并行化

不管 Dockerfile 里支持多少指令,frontend 支持多少种语言,对于 LLB 来说,核心的操作都是类似的:在一个 snapshot 中做一些操作,记录结果。其伪代码定义大概如下所示:

type Input struct {
Base Op
Index int
}

type Op struct {
Deps []Input
Outs []snapshot.Snapshot
}

type ExecOp struct {
Op
Meta ExecMeta
Mounts []Mount // mapping for inputs to paths
}

type CopyOp struct {
Op
Sources []string
Dest string
}

type SourceOp struct {
Op
Identifier string
}

type ExecMeta struct {
Args []string
Env []string
User string
Wd string
Tty bool
// DisableNetworking bool
}

ExecMeta部分定义了构建过程中所需要的尽量少的环境信息。SourceOp定义了构建开始时的初始数据,在 dockerfile 中就是我们常见的 FROM 语句,当然 BuildKit 并不限于此,
而是也可以使用其他的 git 仓库,本地文件/目录等数据。

有用的新功能#

启用了 BuildKit 之后,除了性能上的改善,我们现在就可以在 Dockerfile 中体验到一些非常有用的新功能。比如 mount 操作,其语法格式如下所示:

RUN --mount=type=<bind|cache|secret|tempfs|ssh>

这几种类型就解决了开始所说的一些问题

  • cache: 各语言 package managers 所使用的 cache
  • secret: 用于一些需要登录才能获取的资源,在最终的生成物中也不会保留这些敏感信息
  • ssh: 直接使用本地的 ssh-agent 去做登陆认证。

Links#

  1. Moby Project: Introducing BuildKit
  2. Buildkit Proposal
  3. Dockerfile frontend experimental syntaxes
The useless web