五种方式:构建小巧Docker容器的学问
几年之前,Docker的爆炸式发展将容器与容器镜像概念引入了大众视野。尽管之前已经存在Linux容器,但Docker凭借着用户友好的命令行界面以及易于理解的Dockerfile格式显著降低了镜像的构建门槛。但必须承认的是,尽管上手难度已经有所下降,其中仍存在着一些细微的差别与技巧,能够帮助我们构建功能强大但却体积小巧的容器镜像。
第一关:清理内容
下面列举的部分示例采取与传统服务器类似的清理方式,只是具体要求更为严格。镜像的体积对于快速移动而言至关重要,而且在磁盘之上存储多套不必要的数据副本无疑将浪费大量资源。因此,我们有必要尽可能利用技术控制容器镜像的“身材”。
下面来看如何从镜像中删除缓存文件,从而节约存储空间。首先利用dnf以包含及不包含元数据的方式安装Nginx,查看二者之间的镜像大小区别; 而后利用yum进行缓存清理:
# Dockerfile with cache FROM fedora:28 LABEL maintainer Chris Collins <[email protected]> RUN dnf install -y nginx ----- # Dockerfile w/o cache FROM fedora:28 LABEL maintainer Chris Collins <[email protected]> RUN dnf install -y nginx \ && dnf clean all \ && rm -rf /var/cache/yum ----- [chris@krang] $ docker build -t cache -f Dockerfile . [chris@krang] $ docker images --format "{{.Repository}}: {{.Size}}" | head -n 1 cache: 464 MB [chris@krang] $ docker build -t no-cache -f Dockerfile-wo-cache . [chris@krang] $ docker images --format "{{.Repository}}: {{.Size}}" | head -n 1 no-cache: 271 MB
可以看到,二者之间的体积存在显著差异。包含dnf缓存的版本几乎是不包含元数据及缓存的镜像大小的两倍。事实上,工具包管理器缓存、Ruby gem临时文件、nodejs缓存、甚至是已下载的源代码压缩包都是清理工作的主要对象。
分层——一个潜在问题
遗憾的是(或者可以说幸运的是,具体如后文所述),由于容器以分层方式使用,因此大家无法简单将RUN rm -rf /var/cache/yum 添加到Dockerfile当中并就此作罢。Dockerfile中的每条指令都存储在一个层中,各层之间的变更最终应用于顶层。所以即使您进行如下操作:
RUN dnf install -y nginx RUN dnf clean all RUN rm -rf /var/cache/yum
……最终仍会得到三层,其中一层包含所有缓存,两个中间层则从镜像中“移除”缓存。然而,缓存仍然实际存在,正如当您将某一文件系统安装在另一文件系统之上时,文件就在这里——只是我们无法查看或者访问。
需要注意的是,上一节中的示例将缓存清理链接到了生存缓存的同一Dockerfile指令当中:
RUN dnf install -y nginx \ && dnf clean all \ && rm -rf /var/cache/yum
这是一条单独指令,最终会成为镜像中的一层。通过这种方式,您会丢弃一部分Docker缓存——这意味着镜像重构时间会稍长,但缓存数据仍将出现在最终镜像当中。作为一种良好的折衷方案,我们只需链接相关命令(例如hum install与hum clean all,或者下载、释放及移除源tarball等)即可帮助最终镜像显著瘦身,同时继续利用Docker缓存加快开发速度。
然而,这里的层将比前文中提到的更加微妙。因为镜像各层记录了每个层的具体变化——因此除了添加的文件之外,一切文件修改都将被纳入其中。例如,即使更改了文件模式,镜像中也会有新层出现以创建该文件的副本。
举例来说,以下docker images输出结果显示出与两套镜像相关的信息。第一套layer_test_1通过将单一1 GB文件添加至基础CentOS镜像的方式得出。第二套镜像layer_test_2则直接由layer_test_1创建而来,只是利用chmod u+x命令变更了该1 GB文件的模式。
layer_test_2 latest e11b5e58e2fc 7 seconds ago 2.35 GB layer_test_1 latest 6eca792a4ebe 2 minutes ago 1.27 GB
如大家所见,新的镜像较前一套镜像大出1 GB有余。尽管layer_test_1 实际上只代表着layer_test_2的前两层,但第二套镜像中仍然隐藏着另一个1 GB的文件。在镜像构建过程当中,一切与文件相关的删除、移除或更改都会造成这样的结果。
专用镜像与灵活镜像
一则轶事:当初我们大量采用Ruby on Rails应用程序时,同事们开始慢慢接受容器这种新鲜事物。我们的第一项工作就是为所有团队创建一套官方的Ruby基础镜像。为了简单起见,我们利用rebenv将四套最新的Ruby版本安装到了镜像当中,从而允许我们的开发人员能够利用单一版本将所有应用程序迁移到容器镜像当中。这实际上带来了一套非常庞大但却比较灵活(至少我们认为)的镜像,其中涵盖我们各合作团队间的一切工作基础。
但事实证明,这一切都是在浪费时间。维护特定镜像的单一修改版本能够比较轻松地实现自动化,这是因为为特定镜像选择特定版本实际上有助于在引入突破性变更之前意识到原有应用程序已经不合适接下来的需求,从而避免由此发生严重破坏。此外,过大的镜像也造成了资源浪费:当我们对不同Ruby版本进行拆分时,我们最终得到了多套共享同一基础的镜像。如果将其同时保存在服务器之上,相较于包含多个版本的巨型镜像,其占用的额外空间其实并不大,但传输速度却要快得多。
这并不是说构建灵活性镜像没有意义。只是在我们的情况下,创建专用型镜像最终节约了存储空间与维护时间,同时也确保各团队在享受好处的同时能够对共有基础镜像做出必要的修改。
从头开始:将需要的内容添加至空白镜像中
与Dockerfile的用户友好与易用性类似,还有其他一些工具能够以极为灵活的方式创建小巧的Docker兼容容器镜像且无需完整的操作系统——其小巧程度甚至堪比标准Docker基础镜像。
我在之前曾经写过关于Buildah的文章,这里我也会再次提及,因为其相当灵活且可利用主机中的工具从零开始创建镜像,同时安装打包软件并修改镜像内容。更重要的是,这些工具将永远存在于镜像之外,因此不会增加镜像本身的体积。
Buildah取代了docker build命令。有了它,您可以将容器镜像的文件系统挂载至主机上,并利用主机中的工具与其进行交互。
让我们尝试利用上面的Nginx示例看看Biuldah的效果(这里暂时不管缓存):
#!/usr/bin/env bash set -o errexit # 创建一个容器 container=$(buildah from scratch) # 挂载容器文件系统 mountpoint=$(buildah mount $container) # 安装一个基础文件系统与最低软件包集,以及nginx dnf install --installroot $mountpoint --releasever 28 glibc-minimal-langpack nginx --setopt install_weak_deps=false -y # 将容器保存为镜像 buildah commit --format docker $container nginx # 清理 buildah unmount $container # 将镜像推着至Docker守护程序进行存储 buildah push nginx:latest docker-daemon:nginx:latest
大家可能已经注意到,这里我们不再使用Dockerfile构建镜像,而是使用简单的Bash脚本。我们利用一套从零创建(或空白)镜像进行构建。该Bash脚本会将容器的root文件系统挂载至主机上的某个挂载点,而后利用主机命令安装各软件包。通过这种方式,软件包管理器甚至无需超出容器自身范围。
如果没有额外的部分——例如dnf等基础镜像中的额外内容——那么镜像本身的大小仅为304 MB,这一体积要比之前利用Dockerfile构建的Nginx镜像小上100多MB。
[chris@krang] $ docker images |grep nginx docker.io/nginx buildah 2505d3597457 4 minutes ago 304 MB
注意:镜像名称中之所以包含docker.io部分,是因为其被推送至Docker守护程序的命名空间,但其仍然是利用以上构建脚本以本地方式构建的镜像。
考虑到基础镜像本身只有300 MB左右,100 MB的节约幅度显然相当惊人。利用软件管理器安装Nginx,也会带来大量的依赖关系。如果使用由主机提供的工具进行源代码编译的处理方式,由于您可以选择确切的依赖关系而非引入任何不必要的额外文件,大家将能够进一步节约存储空间。
利用Buildah构建镜像能够有效摆脱完整操作系统以及构建工具,从而进一步压缩您的镜像体积。而对于某些特定类型的镜像,我们还可以采取同样的方法创建出仅包含应用程序本身的镜像。
仅使用静态链接的二进制文件创建镜像
遵循相同的理念,我们可以进一步将管理与构建工具从镜像中清理出去。如果我们拥有必要的专业知识,且不再需要立足容器内部进行故障排查,那么我们是否可以弃用Bash?我们还需要GNU核心程序吗?我们还需要基础的Linux文件系统吗?大家可以使用任何编译语言执行此项操作,即利用静态链接库创建二进制文件——程序运行所需要的一切库及函数都将被复制并存储在二进制文件当中。
这是一种在Golang社区中拥有一定人气的处理方式,因此我们这里使用Go应用程序进行演示。以下是Dockerfile采用一个小巧的Go Hello-World应用程序,并将其编译在一套FROM golang:1.8镜像当中:
FROM golang:1.8 ENV GOOS=linux ENV appdir=/go/src/gohelloworld COPY ./ /go/src/goHelloWorld WORKDIR /go/src/goHelloWorld RUN go get RUN go build -o /goHelloWorld -a CMD ["/goHelloWorld"]
最终得到的镜像包含二进制文件、源代码以及基础镜像层,总体积为716 MB。但是,我们的应用程序最终真正需要的只有编译后的二进制文件,其他所有内容都是多余的。
如果我们在统计时利用CGO_ENABLED=0禁用cgo,则可创建出一套不打包C库的二进制文件:
GOOS=linux CGO_ENABLED=0 go build -a goHelloWorld.go
生成的二进制文件可被添加至空的,或者“从头构建”镜像当中:
FROM scratch COPY goHelloWorld / CMD ["/goHelloWorld"]
下面,我们来比较两套镜像之间的体积差异:
[ chris@krang ] $ docker images REPOSITORY TAG IMAGE ID CREATED SIZE goHello scratch a5881650d6e9 13 seconds ago 1.55 MB goHello builder 980290a100db 14 seconds ago 716 MB
可以看到,差别非常巨大。由golang:1.8构建出的镜像中包含goHelloWorld库(标记为‘builder’),其体积达到纯二进制文件镜像的460倍。而纯二进制文件镜像的体积仅为1.55 MB。这意味着如果我们使用由builder构建的镜像,其中将有约713 MB的数据根本不必存在。
如果适合,不妨考虑压缩方法
还有一种方法可以通过将所有命令链接至层内以节约空间,就是镜像压缩(squash)。在进行镜像压缩时,您实际上是在导出镜像,删除所有中间层,并将镜像的当前状态保存为单一层。这将有效控制镜像的实际体积。
过去,我们需要利用一些创造性的解决方案才能将经过压缩的层进行还原——例如导出容器内容并将其重新导入为单层镜像,或者利用docker-squash等工具。但从1.13版本开始,Docker引入了一种便利的标记——squash,其能够在构建过程中完成同样的操作:
FROM fedora:28 LABEL maintainer Chris Collins <[email protected]> RUN dnf install -y nginx RUN dnf clean all RUN rm -rf /var/cache/yum [chris@krang] $ docker build -t squash -f Dockerfile-squash --squash . [chris@krang] $ docker images --format "{{.Repository}}: {{.Size}}" | head -n 1 squash: 271 MB
利用docker squash处理这个多层Dockerfile,我们最终得到了一个大小为271 MB的镜像,且功能与之前的链接指令镜像一样。但这,又带来了新的潜在问题。
太过极端:过度压缩、过度“瘦身”、过度专用
镜像之间可以进行层共享。其基础可能为x MB,但只需要拉取/存储一次,其他镜像就能够加以使用。进行层共享的各镜像的实际大小,为基础层加上特定变化带来的差异。通过这种方式,我们能够以极低的额外空间投入,换取数千套基于同一镜像的修改版镜像。
而这也正是镜像压缩或者专用化方法带来的弊端。在将镜像压缩为单层形式时,我们将彻底失去其与其他镜像进行层共享的机会。每套镜像最终都将与其单一层的体积保持一致。因此,如果大家只需要使用少量镜像并在其中运行大量容器,那么过度压缩还没什么问题; 但如果您面对着多种不同镜像,那么从长远角度来看,这最终反而会消耗您的存储空间。
让我们重新审视Nginx压缩示例,可以看到在这种情况下,“瘦身”过程并不会带来什么问题。我们最终安装了Fedora与Nginx,清理了缓存,并进行了有效压缩。不过,Nginx本身并没有多大作用,大家通常需要以自定义方式执行各类针对性操作——例如配置文件、其他软件包甚至是某些应用代码。而其中每一项操作都会在Dockerfile中添加更多指令。
如果以传统方式进行镜像构建,那么您将在镜像中拥有一个承载Fedora的独立基础镜像层,一个安装有Nginx的层(包含或不包含缓存),而后每项自定义又有自己的层。包含Fedora与Nginx等的其他镜像将能够共享这些层。
在这种情况下,需要的镜像为:
[ App 1 Layer ( 5 MB) ] [ App 2 Layer (6 MB) ] [ Nginx Layer ( 21 MB) ] ------------------^ [ Fedora Layer (249 MB) ]
但如果大家对该镜像进行压缩,那么Fedora基础层也会被压缩。基于Fedora的被压缩镜像需要释放相关Fedora内容,这意味着每套镜像将新增249 MB!
[ Fedora + Nginx + App 1 (275 MB)] [ Fedora + Nginx + App 2 (276 MB) ]
如果大家构建出大量高度专用且超级小巧的镜像,那么这绝对会带来大麻烦。
因为与生活中的其他事务一样,适度才是镜像体积控制的关键所在。而且考虑到镜像层的工作原理,随着容器镜像的压缩度与专用性逐渐提高,其将无法与其他相关镜像共享基础镜像层,而压缩带来的瘦身效果也将因此递减甚至消失。
经过一定程度自定义的镜像可以共享基础层。如前文所述,这一基础层可以是x MB,但只需要进行一次拉取/存储,所有镜像就都能够对其加以使用。所有镜像的有效大小为基础层加上每种特定变化造成的差异。通过这种方式,我们能够以极低的额外空间投入,换取数千套基于同一镜像的修改版镜像。
[ specific app ] [ specific app 2 ] [ customizations ]--------------^ [ base layer ]
但如果您的镜像压缩得太狠或者存在太多修改或专用调整,那么我们将不得不面对大量镜像。由于这些镜像之间没有同一套共享基础层,因此其将各自占用磁盘上的存储空间。
[ specific app 1 ] [ specific app 2 ] [ specific app 3 ]
总结