Dockerfile 中 Run mv 比 cp 慢

2023年 4月 13日 51.5k 0

不同于 CentOS、Ubuntu,我们感受到 mv 比 cp 快;在使用 Dockerfile 构建镜像时,使用 Run cp 会比 Run mv 更快。本篇将给出相关的一些测试、验证的数据结果。

1. 测试准备

  • 机器环境

Ubuntu 20.04.1 LTS
32C
125Gi
由于是生产机器,上面会有些负载,因此测试会有偏差。我会多次测试,等结果稳定时取样。

  • 文件结构
1
2
3
ls 

main  lib  i100  cc
  • 文件大小
1
2
3
4
5
6
7
du -h --max-depth=1

2.7M    ./i100
47M     ./main
808K    ./cc
424M    ./lib
474M    .
  • 文件及目录总数
1
2
3
ls -lR| wc -l

42978

2. 使用 Run mv 命令构建

Dockerfile 内容

1
2
3
4
5
6
7
FROM golang:1.13

COPY ./ /go/src/code
RUN mkdir /a && mv /go/src/code/cc/* /a/ \
    && mkdir /b && mv /go/src/code/lib/* /b/ \
    && mkdir /c && mv /go/src/code/i100/resource/* /c/ \
    && mkdir /d && mv /go/src/code/main/* /d/

构建镜像

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
DOCKER_BUILDKIT=1 docker build --no-cache -t test:v1 -f ./Dockerfile1 .

[+] Building 78.0s (8/8) FINISHED
 => [internal] load build definition from Dockerfile1                                                                       0.0s
 => => transferring dockerfile: 334B                                                                                        0.0s
 => [internal] load .dockerignore                                                                                           0.0s
 => => transferring context: 2B                                                                                             0.0s
 => [internal] load metadata for golang:1.13                                                                                0.0s
 => CACHED [1/3] FROM golang:1.13                                                                                           0.0s
 => [internal] load build context                                                                                           2.0s
 => => transferring context: 3.05MB                                                                                         1.9s
 => [2/3] COPY ./ /go/src/code                                                                                              4.7s
 => [3/3] RUN mkdir /a && mv /go/src/code/cc/* /a/     && mkdir /b && mv /go/src/code/lib/* /b/     && mkdir /c && mv /go/src/code/i100/resource/* /c/     && mkdir /d && mv /go/src/code/main/* /d/                      57.6s
 => exporting to image                                                                                                     13.6s
 => => exporting layers                                                                                                    13.6s
 => => writing image sha256:973b97d407a6403132d279f2c8ac713268ada69fe067e355700efa650ff65d8b                                0.0s
 => => naming to docker.io/library/test:v1                                                                                  0.0s

Run mv 使用了 57.6s。

3. 使用 Run cp 命令构建

Dockerfile 内容

1
2
3
4
5
6
7
FROM golang:1.13

COPY ./ /go/src/code
RUN mkdir /a && cp -R /go/src/code/cc/* /a/ \
    && mkdir /b && cp -R /go/src/code/lib/* /b/ \
    && mkdir /c && cp -R /go/src/code/i100/resource/* /c/ \
    && mkdir /d && cp -R /go/src/code/main/* /d/

构建镜像

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
DOCKER_BUILDKIT=1 docker build --no-cache -t test:v1 -f ./Dockerfile2 .

[+] Building 26.2s (8/8) FINISHED                                                                                                
 => [internal] load build definition from Dockerfile2                                                                       0.0s
 => => transferring dockerfile: 282B                                                                                        0.0s
 => [internal] load .dockerignore                                                                                           0.0s
 => => transferring context: 2B                                                                                             0.0s
 => [internal] load metadata for golang:1.13                                                      0.0s
 => [internal] load build context                                                                                           2.0s
 => => transferring context: 3.05MB                                                                                         2.0s
 => CACHED [1/3] FROM golang:1.13                                                                 0.0s
 => [2/3] COPY ./ /go/src/code                                                                                              5.4s
 => [3/3] RUN cp -R /go/src/code/cc /     && cp -R /go/src/code/lib /     && cp -R /go/src/code/i100/resource /     && cp -R /go/src/code/main /                                                                   5.1s
 => exporting to image                                                                                                     13.5s
 => => exporting layers                                                                                                    13.4s
 => => writing image sha256:bd3c53ac40006a79ec009b6112fdcfec85e0adef6d0fcf6aa65d3ee02b2e202a                                0.0s
 => => naming to docker.io/library/test:v1                                                                                  0.0s

Run cp 使用了 5.1s。

4. 使用 strace 追踪 mv 和 cp 命令

上面没有给出大量测试之后的统计值,但多次执行能稳定复现,在相同工程下 RUN cp 命令比 RUN mv 命令镜像构建效率高很多。

  • Ubuntu 上,如果源文件和目标文件在同一个文件系统上

mv 命令会先尝试调用 rename 快速移动,在失败之后,才会采用 cp 模式的复制。下面是使用 strace 跟踪到的系统部分系统调用。

1
2
3
4
5
6
7
8
strace cp -R main main1

read(3, "# -*- coding: utf-8 -*-\n\n\"\"\"\[email protected]"..., 131072) = 8425
write(4, "# -*- coding: utf-8 -*-\n\n\"\"\"\[email protected]"..., 8425) = 8425
mkdir("main1/scripts/update_oauth", 0755) = 0
lstat("main1/scripts/update_oauth", {st_mode=S_IFDIR|0755, st_size=4096, ...}) = 0
openat(AT_FDCWD, "main/scripts/update_oauth", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3
fstat(3, {st_mode=S_IFDIR|0755, st_size=4096, ...}) = 0
1
2
3
4
5
strace mv main1 main2

ioctl(0, TCGETS, {B9600 opost isig icanon echo ...}) = 0
renameat2(AT_FDCWD, "main1", AT_FDCWD, "main2", RENAME_NOREPLACE) = 0
lseek(0, 0, SEEK_CUR)                   = -1 ESPIPE (Illegal seek)

可以看到 mv 直接调用了 renameat2,跳过了复制文件的操作。

  • Ubuntu 上,如果源文件和目标文件不在同一个文件系统上

此时,mv 命令不仅需要复制文件,还需要 unlink 删除文件,比 cp 多一个步骤。如果文件很多,那么 unlink 将非常耗时。
/data 挂载了另外一个硬盘

1
2
3
4
5
strace cp -R main /data/main1

lstat("main/upgrade/1.0.1/README.MD", {st_mode=S_IFREG|0644, st_size=1599, ...}) = 0
openat(AT_FDCWD, "main/upgrade/1.0.1/README.MD", O_RDONLY|O_NOFOLLOW) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=1599, ...}) = 0
1
2
3
4
5
6
7
strace mv main /data/main2

renameat2(AT_FDCWD, "main/upgrade/1.0.7/README.MD", AT_FDCWD, "/data/main2/upgrade/1.0.7/README.MD", RENAME_NOREPLACE) = -1 EXDEV (Invalid cross-device link)

lstat("main/upgrade/1.0.7/README.MD", {st_mode=S_IFREG|0644, st_size=1280, ...}) = 0
newfstatat(AT_FDCWD, "/data/main2/upgrade/1.0.7/README.MD", 0x7fff09ac3810, AT_SYMLINK_NOFOLLOW) = -1 ENOENT (No such file or directory)
unlink("/data/main2/upgrade/1.0.7/README.MD") = -1 ENOENT (No such file or directory)

mv 尝试 rename,但是失败,只能进入 cp 模式。

  • 在 Dockerfile 中的 Run mv 命令

由于上面的基础镜像没有 strace 命令,这里在 test:v1 镜像的基础上,安装 strace 重新提交,具体步骤略过。
Dockerfile 内容

1
2
3
4
5
6
FROM test:v1

RUN strace mv /a /a1 \
    && strace mv /b /b1 \
    && strace mv /c /c1 \
    && strace mv /d /d1 

构建镜像

1
2
3
4
5
6
DOCKER_BUILDKIT=1 docker build --no-cache -t test:v2 -f ./Dockerfile . --progress=plain

#5 0.435 renameat2(AT_FDCWD, "/a", AT_FDCWD, "/a1", RENAME_NOREPLACE) = -1 EXDEV (Invalid cross-device link)
#5 0.436 newfstatat(AT_FDCWD, "/a/CHANGES.txt", {st_mode=S_IFREG|0644, st_size=435, ...}, AT_SYMLINK_NOFOLLOW) = 0
#5 0.436 newfstatat(AT_FDCWD, "/a1/CHANGES.txt", 0x7ffeb191e6d0, AT_SYMLINK_NOFOLLOW) = -1 ENOENT (No such file or directory)
#5 0.436 unlink("/a1/CHANGES.txt")               = -1 ENOENT (No such file or directory)

Invalid cross-device link 说明,Dockerfile 中的 Run mv 调用 rename 并不能成功。也就是说 Dockerfile 中的 Run mv = Run cp + Run unlink

5. 总结

由于在生产的 CICD 系统中,有些流水线构建时执行 Run mv 很慢,找不到原因。本篇主要是分析这一问题,并给出解,可以通过 Run cp 替代 Run mv。对于文件数比较大的构建项目,会有显著加速效果。如果文件数较少,可以忽略这一优化。具体结论如下:

  • 在同一个文件系统下,mv 比 cp 快
  • 在不同文件系统下,cp 比 mv 快
  • 在 Dockerfile 中,Run cp 比 mv 快,上面的例子从 57s 降到了 5s

那么问题来了,Docker Daemon 是怎么处理 Dockerfile 的?其他工具,例如 Kaniko 等会不会有类似问题?为什么 Dockerfile 中的 unlink 比主机上的 unlink 操作更费时?
在这个案例下,第二种优化是,使用 Copy、Add 命令替代 Run mv,避免在 Dockerfile 中执行 Run 进行文件的操作;第三种优化是,一个项目很难达到 4w 文件数,可以通过 .dockerignore 忽略用不上的文件传入 context,例如 .git、node_modules、vendor、.m2 等,以减少 unlink 时间。

6. 参考

  • https://unix.stackexchange.com/questions/277412/cp-vs-mv-which-operation-is-more-efficient

相关文章

KubeSphere 部署向量数据库 Milvus 实战指南
探索 Kubernetes 持久化存储之 Longhorn 初窥门径
征服 Docker 镜像访问限制!KubeSphere v3.4.1 成功部署全攻略
那些年在 Terraform 上吃到的糖和踩过的坑
无需 Kubernetes 测试 Kubernetes 网络实现
Kubernetes v1.31 中的移除和主要变更

发布评论