近来在跟朋友们一起基于 buildkit 实现一个新的开源项目。它是一个面向算法工程师或者数据科学家的开发环境管理工具。能够帮助他们在不了解 Docker 的情况下借助 Python 语言构建高效且缓存友好的基于容器的开发环境。

在这个过程里,我们也了解了一下 buildkit,docker buildx 相关的设计与实现。docker buildx 是下一代 docker build 的实现。在设计上它完全兼容 Dockerfile 的语法。但同时它也支持了非常多的扩展语法,能够大大简化镜像构建的过程,以更快的速度构建体积更小的镜像。在这一文章中,我们会对 docker buildx 进行一些简单的功能介绍,进而分析它的设计与实现。在下一篇文章中,我们会分享 buildx 背后的组件 buildkit 的使用方式,以及基于 buildkit 进行开发的过程中遇到的一些非常有趣的问题(提前剧透,涉及 multi docker daemons on the same /var/lib/docker )。

先说一个冷知识,Dockerfile 的语法,是有不同的版本的。我们常写的 Dockerfile 是 1.0 版本,而目前其实 Dockerfile 的最新版本是 1.4.x。那么如何使用新的 Dockerfile 语法,以及这些语法有什么新特性呢?

首先来讲如何让 Docker Host 知道,我们要使用最新的 Dockerfile 语法,而不是默认的 1.0 版本。这是通过首行特殊的注释 # syntax=docker/dockerfile:1.4 来实现的。而 docker build 是无法识别这一个特殊的注释的。如果想要使用新语法,需要用到 docker buildx。所以在修改完 Dockerfile 之后,还需要将命令 docker build ... 修改为 docker buildx build ...

# syntax=docker/dockerfile:1.4
FROM alpine
COPY /foo /bar

那么,新版本的语法带来了哪些新特性呢?挑三个我觉得比较实用的功能介绍一下。

Dockerfile 1.4 新语法的特性

跨越多次 build 的文件缓存

在构建容器镜像时,如果前面的镜像层(image layer)发生了变动,其后的镜像层的缓存也会随之失效。这是 Docker 在构建镜像时的局限。经常跟镜像打交道的朋友,一定会遇到过这样的问题。

当你在构建镜像时通过 pip install 等命令来安装依赖时,经常在修改之前的命令后,后面命令的缓存也会随之失效。而通常依赖安装相对而言比较耗时,尤其是涉及到 tensorflow 等特别大而且间接依赖特别多的库时。所以缓存的失效会使得镜像构建的速度大大变慢。

以下面的命令为例,如果前面的 <my-command> 被修改,那么后面的 RUN pip install ... 也会随之失效,需要重新下载。这个问题在国内更加麻烦。因为镜像中的 pip mirror 通常是国外的镜像源,导致请求会路由到海外的镜像源,导致下载速度极慢。

FROM python:3.8
# When you update the command, `pip install ...` also fails to get the cache.
RUN <my-command>
RUN pip install ...

那么比较好的体验是什么样的呢?可以参考操作系统下的体验。如果你是在 Ubuntu 下执行 pip install ... 命令时,pip 会在 $HOME/.cache/pip 目录中维护之前下载过的库的缓存。那么当再次执行时,就会简单地检查 checksum 后直接通过文件系统中的 cache 得到库,而不需要再次通过 HTTP/HTTPS 来下载一次。

# The package will be cached after the first download.
$ pip install ormb
Looking in indexes: https://mirror.sjtu.edu.cn/pypi/web/simple
Collecting ormb
  Using cached https://mirror.sjtu.edu.cn/pypi-packages/d3/20/f7940ea7b8ad2e6ffdaa9daedbb3bf207fe2748720d049d11bf242a95924/ormb-0.1.0-py3-none-any.whl (8.5 MB)
Installing collected packages: ormb
Successfully installed ormb-0.1.0

那么镜像里能不能得到相同的体验呢?如果是 Dockerfile 1.0 版本,是不行的,但是在 1.4 版本中,是可行的。具体的写法如下。

# syntax = docker/dockerfile:1.4
FROM python:3.8
RUN --mount=type=cache,target=/root/.cache/pip pip install ...

RUN 后跟着一个参数 --mount,声明一个 cache 类型的 mount,它会建立一个在构建时会生效的挂载点,它会在构建之间被共享使用。所以就算是镜像层的缓存失效了,之前的构建中下载过的库还是仍旧会生效。

 => [internal] load build definition from Dockerfile                     0.0s
 => => transferring dockerfile: 31B                                      0.0s
 => [internal] load .dockerignore                                        0.0s
 => => transferring context: 2B                                          0.0s
 => resolve image config for docker.io/docker/dockerfile:1.4             1.1s
 => CACHED docker-image://docker.io/docker/dockerfile:1.4@sha256:443aab  0.0s
 => [internal] load build definition from Dockerfile                     0.0s
 => [internal] load .dockerignore                                        0.0s
 => [internal] load metadata for docker.io/library/python:3.8            0.0s
 => CACHED [stage-0 1/2] FROM docker.io/library/python:3.8               0.0s
 => pip install tensorflow                                               0.1s
 => => # Collecting tensorflow
+ => => #   Using cached tensorflow

通过跨越多个构建的 cache,Dockerfile 1.4 能够大大加快镜像构建的速度。

多体系架构支持

在之前的构建过程中,如果需要支持 amd64arm64 等不同的体系架构时,需要执行多次 docker build 命令,并且为不同的 image 打上不同的后缀。而 buildx 支持基于相同的 Dockerfile 进行不同体系架构下的构建。

# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:alpine AS build
ARG TARGETPLATFORM
ARG BUILDPLATFORM
RUN echo "I am running on $BUILDPLATFORM, building for $TARGETPLATFORM" > /log
FROM alpine
COPY --from=build /log /log

并且这些支持不同体系架构的镜像,会被打上相同的 tag,而当你在执行 docker pull 时,适合于你当前执行环境的镜像会被 pull 下来。做到了使用者(基本)不需要感知体系架构的良好体验。

$ docker buildx build --platform linux/amd64,linux/arm64 .

多行脚本

除此之外,1.4 版本的 RUN 命令也支持了多行脚本的执行。在之前的 Dockerfile 中,如果执行的命令特别长,需要用 && 连接起来并且用 \ 来进行分行。而现在 RUN 可以通过 <<eot <SHELL_NAME> 来构建多行脚本,而且只会产生一层镜像。

# syntax = docker/dockerfile:1.4
FROM debian
-RUN apt-get && \
-    apt-get install -y vim
+RUN <<eot bash
+  apt-get update
+  apt-get install -y vim
eot

设计与实现

如果你只关心 Dockerfile 的新特性的话,可以参考 Dockerfile frontend 1.4 syntaxes 进一步了解。对于本文的阅读可以停止啦。如果你仍想了解一下,这一切是如何发生的,那么可以继续了解它的设计与实现原理。

架构

在新的 buildx 设计中,这个过程一共引入了两个组件,buildx CLI 和 buildkitd。buildx CLI 负责向 buildkitd 发起构建请求,并且在 Terminal 里输出日志等。buildkitd 负责真正的处理请求。在 buildkitd 里的过程就非常类似于传统编程语言的编译过程。

buildkitd 中首先通过 frontend 将 Dockerfile 转换成一个中间表示(Intermediate Representation),这个中间表示在 buildkit 中被称作 LLB

LLB

LLB 这个抽象将 buildkitd 划分成了两个阶段,前端与后端(在 buildkitd 中被称作 solver)。因此,如果你实现了自己的前端,那么你可以实现自己的构建语言。事实上社区里也有一些这样的工作了,比如 nerdctl 支持通过 nix 而非 dockerfile 来构建镜像。

# syntax = ghcr.io/akihirosuda/buildkit-nix:v0.0.2@sha256:ad13161464806242fd69dbf520bd70a15211b557d37f61178a4bf8e1fd39f1f2
{
  inputs.flake-utils.url = "github:numtide/flake-utils";
  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
        # See https://ryantm.github.io/nixpkgs/languages-frameworks/go/
        app = pkgs.buildGoModule {
          name = "golang-httpserver";
          src = ./.;
          vendorSha256 = "FdDIvZrvGFHk7aqjLtJsiqsIHM6lob9iNPLd7ITau7o=";
          runVend = true;
        };
      in rec {
        defaultPackage = pkgs.dockerTools.buildImage {
          name = "golang-httpserver";
          tag = "nix";
          contents = [ pkgs.bash pkgs.coreutils app ];
          config = {
            Cmd = [ "golang-httpserver" ];
            ExposedPorts = { "80/tcp" = { }; };
          };
        };
      });
}

可以看到,它也是通过定义了 #syntax= 来告诉 buildkitd 应该选择什么样的前端来处理这个构建语言。而后端 solver 则是前端语言无关的,它负责将 LLB 编译成具体的构建命令,然后执行。

buildx 作为 CLI,它不止可以使用一个 buildkitd 进行构建。相反,它可以创建多个 buildkitd 容器。但是,这也引出了 buildkit 和 buildx 的一个局限性。buildkitd 运行在容器中时,是无法跟 docker daemon 共享 image 和 layer 的。docker 默认会在 /var/lib/docker/image/ 下存储 image 和 layer。当我们通过 docker build 构建一个镜像时,对应的镜像层和 manifest layer 都会存储在 layerdb 和 imagedb 目录中。而 buildkit 容器无法访问这些目录,所以也就无法借助其进行构建。

/var/lib/docker/image/overlay2
├── distribution
├── imagedb
├── layerdb
└── repositories.json

这导致的后果是,尽管在 docker 下已经 pull 过的镜像,如果通过 docker buildx build 命令进行构建时,仍然需要重新 pull 一次。还有就是通过 buildx 构建好的镜像,是无法在 docker 下直接被使用的,需要通过一次 docker load 才可以导入到 docker 中。

为了尽可能避免这个问题,docker daemon 中默认集成了一个内置的 buildkitd。这个 docker daemon 中内置的 buildkitd 与 docker daemon 可以做到共享镜像和镜像层,同时也不需要额外的 docker load 来加载镜像。但是它也有自己的问题。在 docker 21.10.x 版本中内置的 buildkitd 版本非常老,非常多的新特性是不支持的。

那么,buildkit 的架构是什么样的呢?这介绍起来估计要挺长的篇幅,下一篇文章继续吧。另外如果对我们的开源项目,面向算法工程师使用的开发环境管理工具感兴趣的话,可以把 GitHub ID 邮件发给我 cegao#tensorchord.ai( # 替换成 @ ),目前我们可以提供 private preview :-)

License

  • This article is licensed under CC BY-NC-SA 3.0.
  • Please contact me for commercial use.

评论