Please enable Javascript to view the contents

排查构建镜像时 IO 慢问题

 ·  ☕ 6 分钟

1. 遇到的问题

项目介绍:

  • 文件大小 5.6 GB
  • 文件数量 529352

Dockerfile

1
2
3
FROM golang:1.13

COPY ./ /go/src/code

构建命令及输入如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
time DOCKER_BUILDKIT=1 docker build --no-cache -t test:v3 -f Dockerfile .  --progress=plain

#1 [internal] load build definition from Dockerfile
#1 sha256:2a154d4ad813d1ef3355d055345ad0e7c5e14923755cea703d980ecc1c576ce7
#1 transferring dockerfile: 37B done
#1 DONE 0.1s

#2 [internal] load .dockerignore
#2 sha256:9598c0ddacf682f2cac2be6caedf6786888ec68f009c197523f8b1c2b5257b34
#2 transferring context: 2B done
#2 DONE 0.2s

#3 [internal] load metadata for golang:1.13
#3 sha256:0c7952f0b4e5d57d371191fa036da65d51f4c4195e1f4e1b080eb561c3930497
#3 DONE 0.0s

#4 [1/2] FROM golang:1.13
#4 sha256:692ef5b58e708635d7cbe3bf133ba934336d80cde9e2fdf24f6d1af56d5469ed
#4 CACHED

#5 [internal] load build context
#5 sha256:f87f36fa1dc9c0557ebc53645f7ffe404ed3cfa3332535260e5a4a1d7285be3c
#5 transferring context: 18.73MB 4.8s
#5 transferring context: 38.21MB 9.8s done
#5 DONE 10.5s

#6 [2/2] COPY ./ /go/src/code
#6 sha256:2c63806741b84767def3d7cebea3872b91d7ef00bd3d524f48976077cce3849a
#6 DONE 26.8s

#7 exporting to image
#7 sha256:e8c613e07b0b7ff33893b694f7759a10d42e180f2b4dc349fb57dc6b71dcab00
#7 exporting layers
#7 exporting layers 67.5s done
#7 writing image sha256:03b278543ab0f920f5af0540d93c5e5340f5e1f0de2d389ec21a2dc82af96754 done
#7 naming to docker.io/library/test:v3 done
#7 DONE 67.6s

real    1m45.411s
user    0m18.374s
sys     0m7.344s

其中比较花时间的是:

  • 10s,load build context
  • 26s,执行 COPY 操作
  • 67s,导出镜像,镜像大小 5.79GB

以下也是按照这个思路进行逐一排查,测试验证,寻找构建时的 IO 瓶颈。

2. 自制 go client 直接提交给 Dockerd 构建效果不佳

工程 https://github.com/shaowenchen/demo/tree/master/buidl-cli 实现的功能就是将本地的 Dockerfile 及上下文提交给 Dockerd 进行构建,从而测试 Docker CLI 是否有提交文件上的瓶颈。

2.1 编译生成二进制文件

1
GOOS=linux GOARCH=amd64 go build  -o build main.go

2.2 自制二进制提交构建任务

1
2
3
4
5
time ./build ./ test:v3

real    5m12.758s
user    0m2.182s
sys     0m14.169s

使用 Go 写的 cli 工具,将构建上下文提交给 Dockerd 进行构建,时长急剧增加;与此同时,构建机的负载飙升。

也可能还有其他优化点,需要慢慢调试。而 Docker CLI 其实也有相关的参数可以用于减少 IO 占用时间。

3. 构建参数 compress、stream 参数优化效果不佳

compress 会将上下文压缩为 gzip 格式进行传输,而 stream 会以流的形式传输上下文。

3.1 使用 compress 优化

1
2
3
4
5
time DOCKER_BUILDKIT=1 docker build --no-cache -t test:v3 -f Dockerfile . --compress

real    1m46.117s
user    0m18.551s
sys     0m7.803s

3.2 使用 stream 优化

1
2
3
4
5
time DOCKER_BUILDKIT=1 docker build --no-cache -t test:v3 -f Dockerfile . --stream

real    1m51.825s
user    0m19.399s
sys     0m7.657s

这两个参数对缩短构建时间,并没有什么效果。但需要注意的是测试项目的文件大而且数量多,如果测试用例发生变化,可能产生不同的效果。接着,我们一起看看文件数量、文件大小对 Dockerd 构建镜像的影响。

4. 文件数量对 COPY 影响远不及文件大小

4.1 准备测试文件

1
2
3
4
du -h --max-depth=1

119M    ./data
119M    .

在 data 目录下放置了一个 119MB 的文件,通过复制该文件不断增加 build context 的大小。

4.2 测试 Dockerfile

1
2
3
FROM golang:1.13

COPY ./ /go/src/code

4.3 构建命令

1
DOCKER_BUILDKIT=1 docker build --no-cache -t test:v3 -f Dockerfile .

4.4 测试文件大小对 COPY 影响明显

文件大小 构建时长 文件个数
119M 0.3s 1个
237M 0.4s 2个
355M 0.5s 3个
473M 0.6s 4个
1.3G 3.7s 11个
2.6G 9.0s 22个

文件大小对 COPY 影响明显,接近线性增长。

4.5 测试文件数量对 COPY 影响甚微

文件大小 构建时长 文件个数
2.9G 13.8s 264724个
5.6G 37.1s 529341个

文件数量对 COPY 影响不大。这是由于在 Docker CLI 将 build context 发送给 Dockerd 时,会对 context 进行 tar 打包,并不是一个一个文件传输。

4.6 构建并发数的瓶颈在磁盘 ​IO

5.6G 529341个

并发量 构建时长
1 37.1s
2 46s
3 81s

通过 iotop 可以实时观测到磁盘写速度,最快能达到 200MB/s,与文件系统 4K 随机写速度最接近。

1
2
Rand_Write_Testing: (groupid=0, jobs=1): err= 0: pid=30436
  write: IOPS=37.9k, BW=148MiB/s (155MB/s)(3072MiB/20752msec); 0 zone resets

由于公用一个 Dockerd,并发时 Dockerd 吞吐会有瓶颈,系统磁盘 IO 也会成为瓶颈。

5. 不清理 Buildkit 缓存对新的构建影响甚微

如果提示找不到 docker build,则需要开启EXPERIMENTAL 或者没有 buildx,需要下载 docker-buildx/usr/libexec/docker/cli-plugins/ 目录。

  • 查看 build 缓存
1
docker system df  -v
  • 清理全部 build 缓存
1
DOCKER_BUILDKIT=1 docker builder prune -f 

仅当开启 BuildKit 时,才会产生 Build cache。生产环境的缓存大小达到 1.408TB,但比较清理前后,对于新项目的构建并没有发现明显构建速度变化;对于老项目,如果没有变动,命中缓存后速度很快。可能的原因是缓存虽大但条目不多,查询是否有缓存的时间开销很小。

但定期定理缓存,有利于预防磁盘被占满的风险。

  • 定时清理远期的构建缓存

清理掉 72h 之前的缓存

1
DOCKER_CLI_EXPERIMENTAL=enabled docker buildx prune --filter "until=72h" -f

6. 构建不会限制 CPU 但 IO 速度很慢

6.1 测试 CPU 限制

Dockerfile 文件

1
2
3
4
FROM ubuntu
RUN apt-get update -y
RUN apt-get install -y stress
RUN stress -c 40
1
DOCKER_BUILDKIT=1 docker build --no-cache -t test:v3 -f Dockerfile .

构建机有 40C,构建时机器 CPU 负载能达到 95%,说明构建时,Dockerd 默认不会对 CPU 消耗进行限制。在生产环境下,出现过 npm run build 占用 十几个 GB 内存的场景,因此我判断 Dockerd 默认也不会对内存消耗进行限制。

6.2 在 Dockerfile 中测试 IO

Dockerfile 文件

1
2
3
4
FROM ubuntu
RUN apt-get update -y
RUN apt-get install -y fio
RUN fio -direct=1 -iodepth=128 -rw=randwrite -ioengine=libaio -bs=4k -size=3G -numjobs=1 -runtime=1000 -group_reporting -filename=/tmp/test.file --allow_mounted_write=1 -name=Rand_Write_Testing
1
2
3
4
DOCKER_BUILDKIT=1 docker build --no-cache -t test:v3 -f Dockerfile . 

Rand_Write_Testing: (groupid=0, jobs=1): err= 0
   write: IOPS=17.4k, BW=67.9MiB/s (71.2MB/s)(3072MiB/45241msec); 0 zone resets

6.3 在容器中测试 IO

1
docker run -it shaowenchen/demo-fio bash
1
2
Rand_Write_Testing: (groupid=0, jobs=1): err= 0
  write: IOPS=17.4k, BW=68.1MiB/s (71.4MB/s)(3072MiB/45091msec); 0 zone resets

6.4 在容器的存储卷中测试 IO

1
docker run -v /tmp:/tmp -it shaowenchen/demo-fio bash
1
2
Rand_Write_Testing: (groupid=0, jobs=1): err= 0
  write: IOPS=39.0k, BW=152MiB/s (160MB/s)(3072MiB/20162msec); 0 zone resets

6.5 在主机上试 IO

1
2
Rand_Write_Testing: (groupid=0, jobs=1): err= 0
  write: IOPS=38.6k, BW=151MiB/s (158MB/s)(3072MiB/20366msec); 0 zone resets

Dockerd 在构建 Dockerfile 时,遇到 Run 命令会启动一个容器运行,然后提交镜像。从测试结果,可以看到 Dockerfile 中的 IO 速度远达不到主机的,与容器中的 IO 速度一致;主机存储卷的 IO 速度与主机的 IO 速度一致。

7. 直接使用 buildkitd 构建效果不佳

虽然可以通过 DOCKER_BUILDKIT=1 开启 Buildkit 构建,但如果直接使用 buildkitd 效果不错,用于替换 Dockerd 构建也是一个不错的选择。

7.1 安装 buildkit

1
2
3
wget https://github.com/moby/buildkit/releases/download/v0.11.2/buildkit-v0.11.2.linux-amd64.tar.gz
tar xvf buildkit-v0.11.2.linux-amd64.tar.gz
mv bin/* /usr/local/bin/

7.2 部署 buildkitd

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
cat > /usr/lib/systemd/system/buildkitd.service <<EOF
[Unit]
Description=/usr/local/bin/buildkitd
ConditionPathExists=/usr/local/bin/buildkitd
After=containerd.service

[Service]
Type=simple
ExecStart=/usr/local/bin/buildkitd
User=root
Restart=on-failure
RestartSec=1500ms

[Install]
WantedBy=multi-user.target
EOF
1
2
3
4
systemctl daemon-reload
systemctl restart buildkitd
systemctl enable buildkitd
systemctl status buildkitd

查看到 buildkitd 正常运行即可。

7.3 测试 buildctl 提交构建

1
2
3
buildctl build --frontend=dockerfile.v0 --local context=. --local dockerfile=. --no-cache --output type=docker,name=test:v4 | docker load

[+] Building 240.8s (7/7) FINISHED

使用 buildctl 提交给 buildkitd 进行构建,需要的时间更多,达到 4min,较之前增加一倍。

8. 当前存储驱动下读写镜像有瓶颈

8.1 查看 Dockerd 处理逻辑

在代码 https://github.com/moby/moby/blob/8d193d81af9cbbe800475d4bb8c529d67a6d8f14/builder/dockerfile/dispatchers.go 可以找到处理 Dockerfile 的逻辑。

1,Add 和 Copy 都是调用 performCopy 函数
2,performCopy 中调用 NewRWLayer() 新建层,调用 exportImage 写入数据

因此,怀疑的是 Dockerd 写镜像层速度慢。

8.2 测试镜像层写入速度

准备一个镜像,大小 16GB,一共 18 层。

  • 导入镜像
1
2
3
time docker load < /tmp/16GB.tar

real    2m43.288s
  • 保存镜像
1
2
3
time docker save 0d08de176b9f > /tmp/16GB.tar

real    2m48.497s

docker loaddocker save 速度差不多,对镜像层的处理速度大约为 100 MB/s。这个速度比磁盘 4K 随机写速度少了近 30%。在我看来,如果是个人使用勉强接受;如果用于对外提供构建服务的平台产品,这块磁盘显然是不合适的。

8.3 存储驱动怎么选

下面是从 https://docs.docker.com/storage/storagedriver/select-storage-driver/ 整理得出的一个比较表格:

存储驱动 文件系统要求 高频写入性能 稳定性 其他
overlay2 xfs、ext4 当前首选
fuse-overlayfs 无限制 - - 适用 rootless 场景
btrfs btrfs - -
zfs zfs - -
vfs 无限制 - - 不建议生产
aufs xfs、ext4 - Docker 18.06 及之前版本首选,不维护
devicemapper direct-lvm 不维护
overlay xfs、ext4 差,但好于 overlay2 - 不维护

排除不维护和非生产适用的,可选项其实没几个。正好有一台机器,前段时间初始化时,将磁盘格式化成 Btrfs 文件格式,可以用于测试。zfs 存储驱动推荐用于高密度 PaaS 系统。

8.4 测试 Btrfs 存储驱动

  • 在主机上
1
2
Rand_Write_Testing: (groupid=0, jobs=1): err= 0
  write: IOPS=40.0k, BW=160MiB/s (168MB/s)(3072MiB/19191msec); 0 zone resets
  • 容器下的测试命令

运行容器

1
docker run -it shaowenchen/demo-fio bash

执行测试

1
fio -direct=1 -iodepth=128 -rw=randwrite -ioengine=libaio -bs=4k -size=3G -numjobs=1 -runtime=1000 -group_reporting -filename=/data/test.file --allow_mounted_write=1 -name=Rand_Write_Testing
  • 测试 overlay2 存储驱动
1
2
3
4
5
docker info

Server Version: 20.10.12
Storage Driver: overlay2
  Backing Filesystem: btrfs
1
2
Rand_Write_Testing: (groupid=0, jobs=1): err= 0: pid=78: Thu Feb  2 02:41:48 2023
  write: IOPS=21.5k, BW=84.1MiB/s (88.2MB/s)(3072MiB/36512msec); 0 zone resets
  • 测试 btrfs 存储驱动
1
2
3
4
5
docker info

Server Version: 20.10.12
Storage Driver: btrfs
  Build Version: Btrfs v5.4.1 
1
2
Rand_Write_Testing: (groupid=0, jobs=1): err= 0
  write: IOPS=39.8k, BW=156MiB/s (163MB/s)(3072MiB/19750msec); 0 zone resets

可以明显看到 btrfs 存储驱动在速度上优于 overlay2。

9. 总结

本篇主要是记录在生产环境下碰到的 Dockerfile 构建 IO 慢问题排查过程。

通过设计各种测试案例排查问题,对各个要素进行一一验证,需要极大耐心,也特别容易走错方向,得出错误结论。

本篇主要观点如下:

  • compress、stream 参数对构建速度不一定有效
  • 减少构建上下文大小,有利于缓解构建 IO 压力
  • Buildkit 的缓存可以不用频繁清理
  • 构建 Dockerfile 执行命令时,CPU、Mem 不会受到限制,但 IO 速度慢
  • 使用 buildkitd 构建速度不如 Dockerd 开启 DOCKER_BUILDKIT
  • 使用 Btrfs 存储有利于获得更好的 IO 速度

但最简单的还是使用 4K 随机读写快的磁盘,在拿到新的环境用于生产之前,务必先进行测试,仅当满足需求时,再执行后续计划。

10. 参考


微信公众号
作者
微信公众号