容器镜像构建技术存在的弊端与未来
研发效能领域洞察系列
在蚂蚁金服,自主研发的中间件、数据库、研发平台等金融科技引领着企业数字化的技术趋势。其中,以蚂蚁研发效能为代表,催生了很多赋能行业的方法论和工程实践,特别整合推出 研发效能领域洞察 系列文章。今天的内容将 从 dockerfile 入手,介绍目前广泛使用的基于 docker build 的镜像构建方式及其弊端,同时为你解码 moby 公司开源的下一代镜像构建工具 buildkit 的应用和蚂蚁镜像构建上的思考与探索。
作者简介
岁丰
蚂蚁金服 高级开发工程师
耿路,花名岁丰,蚂蚁金服研发效能部高级开发工程师, 主要从事 DevOps 平台构建系统的研发工作, 在容器镜像化构建方面具备丰富的实战经验。
01
前言
容器正迅速成为企业应用打包和部署的基本单位,而容器镜像作为一种特殊的文件系统,本身不包含任何动态数据,其内容在构建之后也不会被改变,可以真正的实现 build once & run everywhere,因此 镜像的构建在持续集成和持续交付中扮演着越来越重的角色 。
蚂蚁大多数线上的应用都是以容器的方式进行部署,伴随着云原生技术的发展和推进,将会有更多的应用以容器的方式进行构建和部署,这对镜像构建在效率、安全性、资源弹性扩展和隔离型等方面提出了更高的要求。但是目前蚂蚁的镜像构建仍然基于“docker build”的方式进行镜像的构建,而由于其存在着编译效率低、云原生集成性差等问题,已经渐渐不能满足需求。这篇文章先从 dockerfile 入手,介绍目前广泛使用的基于 docker build 的镜像构建方式及其弊端,同时为你解码 moby 公司开源的下一代镜像构建工具 buildkit 是如何应用到蚂蚁镜像构建中的。
02
Dockerfile、`docker build` 的问题和挑战
Dockerfile 及 docker build
Dockerfile 是一个包含镜像组合命令的文本文件, 在用 docker build 做镜像构建时,Dockerfile 会被顺序执行,每行命令的执行结果会被缓存成 copy-on-write 文件系统的一个层。
在写 dockerfile 做镜像构建时应该特别注意 dockerfile 的书写规范,因为镜像的层一旦执行结束都不会再被修改,不规范的 dockerfile 不但会使镜像的尺寸太大,而且会影响到后续镜像构建缓存的复用。 关于 dockerfile 的书写规范可以参考 Best practices for writing Dockerfiles ¹ 。
另外,在一些构建场景下,容器镜像的构建是通过先构建出应用程序产出物,然后将产出物打包进容器,从而实现应用的容器化。如果这些操作命令是在同一个Dockerfile的定义中完成构建,会产生很多不必要的层,使得容器的尺寸很庞大,无论时在分发还是部署都会带来更多的负担。为此,docker 从 17.05 版本开始,镜像的构建支持 multi-stage 类型的 Dockerfile 构建。
对于 multi-stage 类型的构建,Dockerfile 中使用多个 FROM 语句。每个 FROM 指令可以使用不同的基础镜像,并且每个指令都是基于各自的基础镜像进行构建,而且可以选择性地将产出物从一个阶段复制到另一个阶段,从而保证最后的镜像只包含需要的内容。 一个 multi-stage 类型的 Dockerfile 定义如下:
#app
FROM golang:1.7.3 as builder
WORKDIR /go/src/github.com/genglu/href-counter/
COPY app.go .
RUN go build -o app .
#image
FROM alpine:latest
WORKDIR /root/
COPY --from=builder /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]
执行 docker build
,这样构建出的是一个比较小的镜像,同时不会创建任何不必要的中间层(或者镜像)。
2.docker build 的弊端
1. 对于 multi-stage 的 Dockerfile 构建,无法实现并行编译
2. docker build 缓存利用效率低,改变 Dockerfile 前面的一层,后面所有的层都需要重新构建而无法使用缓存, 这要求用户不得认真控制写好自己的 Dockerfile 以确保镜像缓存复用
3. 没有提供对 Dockerfile 中所需私钥信息的保护方法 (如git、OSS账号等),如果在镜像构建过程中需要特定权限,需要将私钥信息放进镜像中构建,这部分信息即使在Dockerfile 删除,仍然会保存在镜像中,docker build 没有提供运行过程中的私钥信息管理方式
4. 另外,在云原生的技术浪潮下,为了保证构建效率,和构建资源的弹性扩展,将镜像的构建执行在 k8s集群是必然趋势, 这就要求在容器中去进行容器镜像的构建。
由于 docker build 需要依赖 docker daemon,常见的在容器中执行 docker build 的方案有两种:
-
一种是基于 docker-in-docker 的镜像,每次构建的容器启动自己的 dockerd,那意味着每次构建需要重新拉取基础镜像,进行全部层的构建,另外因为 dind 容器是在宿主机器的 ufs 之上再做一层 ufs,这样对磁盘的 IO 也是一个负担;
-
还有一种常见方案是将宿主机的 docker daemon 挂载到容器中,在容器中实际上运行的是宿主机的 dockerd 进行构建,这在公有云下,用户可以直接访问宿主机的所有资源,这显然也不是一个好的解决方案。
03
下一代镜像构建工具 Buildkit
简介
buildkit ² 是从 docker build 分离出来的单独项目,它允许不同用户在相同底层技术的基础上进行自定义构建。
buildkit 设计的一个重要特征是 前后端分离 。 前端 是为用户设计的,用于定义和描述自己的构建,然后把这种自定义的构建转换成一种通用的低级别描述语言(LLB), 后端 则是使用一种通用的解决方案执行这些低级别描述语言进行构建。
这样设计的原因是 buildkit 不仅仅是一个任务的执行器,同时也可以支持 构建执行过程是可移植和可追溯到不变源的 。 这样,buildkit 能够更准确引用先前的产出物并进行缓存匹配。另外,Buildkit 的设计是将其用作长期运行的服务,针对复杂项目的并行执行和多项目的同时执行都进行了优化。基于 Dockerfile 的容器镜像构建,是 buildkit 支持的典型前端之一,目前 buildkit 已经集成到 Docker 18.06 之后的版本之中。
特点
1. LLB
LLB(low-level builder)是 buildkit 的核心,它是一种中间的二进制格式,定义了一个内容可寻址的依赖图(DAG),包含了构建的执行、依赖关系和缓存。与当前的 Dockerfile 构建器相比,完全重写了缓存模型,能够实现更准确的依赖分析和缓存匹配;
2. 可扩展的前端格式
buildkit 使用前后端分离的架构设计,除了Dockerfile 也支持其他类型的前端格式;
3. 并行构建执行
对于 multistage 类型 Dockerfile, buildkit 可以实现不同 stage 之间的并行执行;
4. 构建缓存导入/导出
buildkit 提供了将构建缓存导入/导出到本地或者远程registry的功能;
5. 多种输出格式
buildkit 支持导出成 tar 包或者 oci 格式的镜像格式;
6. 引入Dockerfile 新语法 RUN –mount 支持构建时挂载
buildkit 通过 Dockerfile 中 RUN –mount 可以挂载上下文容器,或者本地 context 目录到执行构建的容器中,这对于构建加速,和构建中依赖鉴权信息而不打进镜像有很好的支持;
7. 支持运行的容器不需要特殊权限
使用
BuildKit 由两个组件组成, 一个是构建守护进程 buildkitd , 另一个是 buildctl 的 cli 工具, buildkit 的设计是作为长期运行的服务给客户端提供构建服务。
首先,启动一个 dockerd 服务,绑定在宿主机的 1234 端口:
docker run -d --privileged -p 1234:1234 tonistiigi/buildkit --addr tcp://0.0.0.0:1234
客户端先将构建服务的地址和端口号定义在环境变量 BUILDKIT_HOST 中
export BUILDKIT_HOST=tcp://0.0.0.0:1234
在本地定义一个 Dockerfile
FROM alpine:latest AS buildc
RUN apk add --no-cache build-base
RUN echo -e "#include \nint main(int ac, char *av[]){printf(\"hello c\\\\n\");return 0;}" | tee /hello.c
COPY . /foo
RUN gcc -o /a.out /hello.c
#the COPY above SHOULD NOT invalidate the cache for the buildgo stage.
FROM alpine:latest AS buildgo
RUN apk add --no-cache build-base
RUN apk add --no-cache go
RUN echo -e "package main\nfunc main(){println(\"hello go\")}" | tee /hello.go
RUN go build -o /a.out /hello.go
FROM alpine:latest
COPY --from=buildc /a.out /hello1
COPY --from=buildgo /a.out /hello2
导出容器镜像,同时 push 到远程的镜像仓库中,客户端会读取`$HOME/.docker/config.json` 中的授权信息:
$ buildctl --addr tcp://0.0.0.0:1234 build --frontend dockerfile.v0 --frontend-opt filename=Dockerfile_a --local context=. --local dockerfile=. --exporter=image --exporter-opt name=buildkit:master --exporter-opt push=true
[+] Building 61.8s (16/16) FINISHED
=> [internal] load build definition from Dockerfile_a 0.0s
=> => transferring dockerfile: 718B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for alpine:latest 0.3s
=> CACHED [internal] helper image for file operations 0.0s
=> => resolve docker.io/docker/dockerfile-copy:v0.1.9@sha256:e8f159d3f00786604b93c675ee2783f8dc194bb565e61ca5788f6a6e9d304061 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 4.92MB 0.1s
=> CACHED [buildgo 1/5] FROM alpine:latest@sha256:1354db23ff5478120c980eca1611a51c9f2b88b61f24283ee8200bf9a54f2e5c 0.0s
=> => resolve alpine:latest@sha256:1354db23ff5478120c980eca1611a51c9f2b88b61f24283ee8200bf9a54f2e5c 0.0s
=> [buildgo 2/5] RUN apk add --no-cache build-base 24.9s
=> [buildgo 3/5] RUN apk add --no-cache go 29.7s
=> [buildc 3/5] RUN echo -e "#include \nint main(int ac, char *av[]){printf("hello c\\n");return 0;}" | tee /hello.c 1.9s
=> [buildc 4/5] COPY . /foo 1.1s
=> [buildc 5/5] RUN gcc -o /a.out /hello.c 1.1s
=> [stage-2 2/3] COPY --from=buildc /a.out /hello1 0.1s
=> [buildgo 4/5] RUN echo -e "package main\nfunc main(){println("hello go")}" | tee /hello.go 1.6s
=> [buildgo 5/5] RUN go build -o /a.out /hello.go 1.8s
=> [stage-2 3/3] COPY --from=buildgo /a.out /hello2 0.2s
=> exporting to image 3.2s
=> => exporting layers 0.1s
=> => exporting manifest sha256:3fd237ef12fc943e5278cb03a45ee1de43dbb8821dd835771e9f7a4eb1ec6cd1 0.0s
=> => exporting config sha256:cc1d33cda06e20efc9555df6af1fcad4aeebf16e224b596866d3e560d4e07fc2 0.0s
=> => pushing layers 2.2s
=> => pushing manifest for buildkit:master
从日志可以看出,buildkit 构建 buildgo 和 buildc 两个阶段并行执行,和 docker build 相比速度有较大提升:
$ time docker build -t test:1 -f Dockerfile_a .
Sending build context to Docker daemon 4.932 MB
Step 1 : FROM alpine:latest AS buildc
---> baa5d63471ea
Step 2 : RUN apk add --no-cache build-base
。。。。。省略若干日志
Step 13 : COPY --from=buildgo /a.out /hello2
---> 6bf097f9c4a5
Removing intermediate container b0251de66450
Successfully built 6bf097f9c4a5
real 1m28.570s
user 0m0.022s
sys 0m0.012s
另外,buildkit 可以导出成本地 docker 格式的 image:
$ buildctl --addr tcp://0.0.0.0:1234 build --frontend dockerfile.v0
--frontend-opt filename=Dockerfile_a
--local context=. --local dockerfile=.
--exporter=docker
--exporter-opt name=buildkit:master | docker load
[+] Building 2.6s (16/16) FINISHED
... 省略日志
=> => exporting manifest sha256:3fd237ef12fc943e5278cb03a45ee1de43dbb8821dd835771e9f7a4eb1ec6cd1
=> => exporting config sha256:cc1d33cda06e20efc9555df6af1fcad4aeebf16e224b596866d3e560d4e07fc2 0.0s
=> => sending tarball 0.1s
8d8b68a44267: Loading layer [==================================================>] 14.34 kB
8af98fd79061: Loading layer [==================================================>] 1.105 MB
The image buildkit:master already exists, renaming the old one with ID sha256:c8dd1e8b52623ab0f700acd7bbf00eaf6a15f94cc6f298109beeb37ce37968f3 to empty string
Loaded image: buildkit:master
另外,buildkit 还支持将构建缓存导入/导出到本地/远程镜像仓库,具体的使用 参看 ³ 。
和其他开源工具的比较
我们从对一个真实案例中的 Dockerfile 的构建着手对 kaniko、makisu、docker build 和 buildkit 进行对比。
FROM maven:3.5.2-alpine as builder
WORKDIR /app
COPY ./ /app/
# 编译打包 (jar包生成路径:/app/target)
RUN mvn package -Dmaven.test.skip=true -Dmaven.repo.local=/app/.m2
FROM centos:centos7.0.6
ENV APP=ant-cloud-quality-recoveryTest-service-1.0-SNAPSHOT-executable
COPY ccbin/start.sh /home/admin/ccbin/
RUN mkdir -p /home/admin/release/source/
COPY --from=builder /app/target/boot/ant-cloud-quality-recoveryTest-service-1.0-SNAPSHOT-executable.jar /home/admin/release/run/
ENV LANG en_US.utf8
ENTRYPOINT ["/bin/bash", "/home/admin/ccbin/start.sh"]
进行两次构建,一次是在无缓存下,第二次不做任何修改的情况下构建,docker build,buildkit, kaniko 和 makisu 比较的结果如下图:
kaniko ⁴ 是谷歌开源的基于 Dockerfile 的镜像构建工具,用户可以以非特权模式在容器或者 Kubernetes 集群中构建镜像并上传到镜像仓库。另外,kaniko 还支持镜像层的远程 registry 缓存,但是对于基础镜像的缓存依赖 warm 镜像将其缓存到本地,另外 kaniko 尚不支持第三方私有 registry 的上传;上图中由于kaniko无法使用本地的缓存,所以两次构建的速度相当,都较慢;
makisu ⁵ 是 urber 开源的镜像构建工具,可以在非特权模式在容器和Kubernetes集群运行,Makisu在镜像构建时支持缓存中间层到本地或者远程 registry,同时 makisu 用 key-value 数据库映射 Dockerfiles 中命令行到 Docker 镜像仓库中的 digests,每次构建时makisu 通过查询 key-value 数据库来确认依赖的层,并从 registry 中下载,同时 makisu也支持使用本地缓存;另外,Makisu在支持多阶段构建,在Dockerfile中引入#!COMMIT,实现构建层的缓存优化,但是目前 makisu 还不支持 oauth 授权的镜像仓库;
相比, builkdit 发布的 0.4 版本支持buildkid运行在非特权模式的容器中,由于 buildkitd 作为一个长期运行的服务,构建使用的缓存在服务端本地以 snapshot 的方式保存,缓存的使用不依赖于网络和外部的 registry,同时 buildkit 支持私有 registry 的 push, 但是目前 buildkit 的缓存只限单点,尚不支持不同节点之间缓存的传输和共享。
04
云原生下蚂蚁镜像构建的挑战及思考
云原生下的蚂蚁容器镜像构建的挑战来自两个方面:
1.镜像构建效率 ,目前的镜像构建主要基于物理机,为了达到镜像的复用,采用限制构建落到固定机器的方式,大多数的镜像构建具有较高的缓存利用率,但是迁移到 Kubernetes 集群中以 kube job 的形式落地,本地无缓存,如何解决缓存问题,提高构建效率是目前镜像工具面临的重要挑战;
2. 优化镜像大小 ,小的镜像不仅节省空间,而且可以节省转换、压缩和启动的时间。虽然多阶段构建很好的解决了镜像大小的问题,但是也带了更复杂 Dockerfile 的缺点,另外,Dockerfile的更改对于历史遗留系统的改造也会是一个挑战。
附录
1. [Best practices for writing Dockerfiles]:
https://docs.docker.com/develop/develop-images/dockerfile_best-practices/
2、3. [buildkit]、[参看]:
https://github.com/moby/buildkit
4. [kaniko]:
https://github.com/GoogleContainerTools/kaniko
5. [ makisu ]:
https://github.com/uber/makisu#configuring-distributed-cache
如果还想要了解更多,这里有更多研发效能内容推荐:
长按识别二维码关注我们
P.S.
蚂蚁金服研发效能团队招募进行中, 解决方案架构师、技术运营、数据研发专家、技术专家、技术支持专家、 产品专家、测试平台高级开发工程师、代码分析技术专家 等众多岗位持续开放,让我们共同开赴DevMind/ DevOps /DevServices三大战场,助力内部及外部伙伴研发效能的持续提升:rocket::rocket::rocket:
如果你对任何岗位感兴趣,请留下联系方式,或者发简历到: AntLinkE@antfin.com
▼ 点击“阅读原文”获取更多职位详情