上面一张图片虽然看着有点夸张,却是很多团队的真实写照。同样是构建Docker镜像,为什么有人可以在2分钟之内完成,有人要花30分钟以上。构建耗时长,耽误的不仅仅是一次构建的时间,更是能改变一个人的研发习惯。
当构建耗时仅2分钟的时候,研发同学可以等待构建完成后立刻开展下面的流程,专注于做好一件事情。当构建耗时超过10分钟的时候,研发同学基本会放弃等待,转而去做另一件事情,等到再想起来查看构建结果时,黄花菜都凉了。
本文将基于Docker构建的场景,分析构建的过程到底在做什么,构建的合理时间开销应该是多少。另外,将针对比较典型的误区,分析误区产生的原因以及工程中的最佳实践。
希望这篇文档不仅可以帮助你提升构建的效率,而且可以提高构建成功率和工程的鲁棒性。
构建过程在做什么
image.png上图展示了Docker构建的全部过程。橙色的部分是构建过程的核心,Docker镜像本身的存储结构是分层结构模型,构建的过程就如同砌墙,一层层的堆积砖头,直到把墙砌好。
image.png
接下来,我们将针对每个步骤,解答下面几个问题:
- 该步骤做了什么事
- 如何提升该步骤的性能
- 该步骤还有哪些注意点
下载基础镜像
基础镜像的选择可以看出一名工程师的素养。选择轻量干净的镜像,不仅能提升镜像下载的效率。而且对镜像的上传和分享也会高效很多。Docker推崇单进程的思想,容器本身应该是一个application而不是一台机器。我们没必要把一大堆监控运维工具全部打包在容器里,制作一个臃肿无比的基础镜像,而是应该让容器足够的轻便。至于监控和运维,应该交给paas层来解决。
上传上下文
这一步是容易被忽略的一步。以一个最简单的build命令来说:
docker build .
最后一个参数'.'就是上下文(context)。上下文是一个目录,命令中的'.'表示的是当前目录以及所有子目录。拉取base镜像后,docker client会将context传给docker daemon。Dockerfile
中所有的RUN
, COPY
, ADD
指令依赖的文件都必须在context
包含的目录中。
我们应该精简构建的上下文,而不是上传整个代码仓库,否则可能导致大量无效的数据传输消耗。
一个真实的案例,某团队代码仓库中的.git目录过大(4G以上),docker构建时上传上下文造成巨大的时间开销。使用了.dockerignore文件将.git目录过滤掉后,构建速度提升了100%
起容器执行命令并提交
基础镜像下载完成后,将按照Dockerfile中指令的书写顺序, 逐层构建Docker镜像。Docker镜像的层数和Dockerfile的行数相关,构建每一层的步骤如下所示:
- 基于上一层的镜像启动一个容器(
docker run
) - 在容器内执行用户的指令
- 将当前的容器文件系统提交为镜像供下一层使用(
docker commit
)
docker run
和docker commit
存在一定的时间开销,而这部分开销对用户来说是并不关心的。易得,整个阶段的总耗时公式为:
构建镜像耗时 = 镜像层数 * 单层的平均时间
因此我们优化的思路也比较简单:
(1) 减少镜像层数
(2) 减少单层构建的平均时间。
减小镜像层数
我们可以通过合并命令的方式减小镜像, 举例说明:
某同学的Dockerfile写成下面的形式。
FROM centos:7
RUN command1
RUN command2
RUN command3
这样一共会构建3层, 稍加改动, 就可以只构建一层。
FROM centos:7
RUN command1 && command2 && command3
减小单层构建的时间
造成单层构建时间过长,往往是业务问题而非技术问题。 我们首先应该问自己一个问题,镜像是用来做什么的, 镜像里面应该存放什么东西。
按照Docker的设计理念,容器是单进程的。 镜像里应该存放应用的启动程序(war包,binary,python脚本等)以及应用的环境依赖。
很多同学为了方便调查问题,在容器里装了一大堆常用工具,这实际上是把容器当做虚拟机在用,并没有真正GET到Docker带来的技术红利。
了解了镜像里应该存放的东西后,我们还应该思考一个问题:这些依赖需要经常更新吗
有不少同学喜欢在构建镜像的时候安装最新版本的依赖,在研发场景来说也许是件好事(可以不断测试最新版本依赖的兼容性,享受高版本依赖带来的优化)。但是对于正式发布的版本控制来说,可就不是一件好事了。
设想一下:如果你发布了
release1.0
版本的镜像。半年后,你需要修复源码中的一个bug,然后重新构建。如果你在Dockerfile里直接安装了最新版本的依赖。你就很难恢复release1.0
镜像中的依赖版本。
在正式发布的时候,我们应该使用固定的依赖版本。以便于若干时日之后能够"rebuild"。既然依赖版本不变,每次构建的时候都去安装一遍,是不是很浪费时间呢。这个问题Docker的设计者不可能没有想到。有两种解决思路:
- 利用Docker构建的缓存机制,将安装依赖的部分写在Dockerfile的前面。
- 抽象出一层业务的base镜像,这个镜像只负责安装依赖和必备的工具。业务base镜像的更新频率较低。而应用镜像的更新频率较高。
这两种思路都可以有效降低构建的用时,具体如何选择,就看业务场景了。
上传镜像
上传镜像的耗时除了和网络等硬件条件相关,最主要的就是和镜像的体积相关了。docker images
或者 docker image ls
命令可以列出机器上的镜像,并显示其大小。
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
my_image 1.0 2b57089fbf01 11 days ago 152 MB
docker.io/ubuntu 15.04 d1b55fd07600 3 years ago 131 MB
SIZE 这一列说明了镜像的总体积。通常应用的镜像体积以不超过1G为宜。
进一步还可以通过docker history
或docker image history
命令查看单层的镜像体积:
$ docker image history 2b57089fbf01
IMAGE CREATED CREATED BY SIZE COMMENT
2b57089fbf01 11 days ago /bin/sh -c rm -f /test 0 B
3c237abb3ad8 11 days ago /bin/sh -c #(nop) COPY file:d2e2fb53c7e398... 21 MB
d1b55fd07600 3 years ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0 B
<missing> 3 years ago /bin/sh -c sed -i 's/^#\s*\(deb.*universe\... 1.88 kB
<missing> 3 years ago /bin/sh -c echo '#!/bin/sh' > /usr/sbin/po... 701 B
<missing> 3 years ago /bin/sh -c #(nop) ADD file:3f4708cf445dc1b... 131 MB
如上图,docker image history
命令清晰的展示了每一层执行的命令以及该层镜像的体积。通过这些数据,我们可以做到优化的时候有的放矢。
常见误区
前面我们以构建的流程顺序为基准,谈到了每个步骤的时间开销和优化方案。接下来我们换个视角,聊聊开发同学在构建镜像时最容易犯的错误和误区。
1.把容器当成机器
容器并不等同于虚拟机,容器的进程实际上还是跑在宿主机上,只不过在容器内通过namespace隔离技术让你误以为在一个隔离的环境中。
容器并不适合频繁的读写大量数据,因此如果进程对读写有较高的要求,应该通过挂载数据卷(volume
)的方式,直接使用宿主机的文件系统。
有很多同学把容器当成虚拟机,在里面安装了过重的依赖,甚至还有把容器当做ftp服务器使用,这完全是一种为了隔离而牺牲大量性能的做法。
2.过频安装依赖
很多同学将安装依赖的步骤放在构建镜像中。这当然是一种可行的做法。但是过于频繁就有问题了。有些同学设置了CI自动构建,每天团队CI代码的次数达到几十次,每一次都执行一堆yum install
, pip install
等命令,想想都觉得浪费。真的需要更新那么频繁吗?正确的做法可以参考前文提到的两种思路。
3.使用静态机器
如果你的构建放在动态机器(生命周期很短的机器)上, 将无法享受到docker cache带来的加速效果。每次都要全量拉取base镜像并完整执行Dockerfile的所有步骤,哪怕你只改了一行业务代码,这无疑是非常不划算的一桩买卖。
4.不能正确使用缓存
docker提供了构建缓存的能力,但是由于不了解缓存的机制,很多同学并不能正确使用缓存。docker缓存的机制可以归纳为下面的公式:
使用缓存 = 父镜像存在 && 本层镜像的命令或数据相同
这个公式意味着,构建过程中从某一层开始如果没有使用缓存,它后面的所有层都无法使用缓存了。
因此,为了提高缓存命中率,我们一定要把不变的内容写到前面,这样可以尽可能的利用缓存。
5.使用国外的源
由于网络原因,使用国外的源会非常的慢。这里国外的源不仅包括基础镜像,还包括安装依赖时的各种源,例如yum源,pip包的源。当你发现构建速度特别慢又找不到原因的时候,检查一下是否依赖了糟糕的网络吧。
6.不使用.dockerignore文件
如果上传的上下文并不大,这种情况问题倒不大。但是如果构建时上传了上百兆甚至上G的上下文,就需要检查一下是否应该使用.dockerignore文件过滤掉一些无关的内容了。
网友评论