优化Linux容器大小和构建更小镜像的五种方法。Docker这几年的爆发式发展,让大家逐渐了解了容器和容器镜像的概念。尽管Linux容器技术已经存在了很长时间,但由于Docker的用户友好命令行界面以及它使用Dockerfile格式轻松构建映像的方式,该技术最近蓬勃发展。尽管Docker大大降低了容器技术的入门难度,但在构建既强大又紧凑的容器镜像的过程中,需要学习的技能也很多。第一步:清理无用文件这一步与清理普通服务器上的文件没有太大区别,需要更仔细地清理。小的容器镜像在传输方面有很大的优势,同时在磁盘上存储多份不需要的数据也是一种资源浪费。因此,与具有大量专用内存的服务器相比,这些技术更适用于容器。清理容器镜像中的缓存文件可以有效减小镜像的大小。下面对比是使用dnf安装Nginx构建的镜像,是清理和不清理yum缓存文件的结果:#DockerfilewithcacheFROMfedora:28LABELmaintainerChrisCollinsRUNdnfinstall-ynginx-----#Dockerfilew/ocacheFROMfedora:28LABEL维护者ChrisCollinsRUNdnfinstall-ynginx\&&dnfcleanall\&&rm-rf/var/cache/yum-----[chris@krang]$dockerbuild-tcache-fDockerfile。[chris@krang]$dockerimages--format"{{.Repository}}:{{.Size}}"|head-n1cache:464MB[chris@krang]$dockerbuild-tno-cache-fDockerfile-wo-cache.[chris@krang]$dockerimages--format"{{.Repository}}:{{.尺寸}}"|head-n1no-cache:271MB从上面的结果来看,清理缓存文件的效果还是比较显着的。与清除了元数据和缓存文件的容器镜像相比,未清除的镜像大小是前者的近两倍。此外,包管理器缓存文件、Rubygem临时文件、nodejs缓存文件,甚至下载的源代码tarball***都被清理干净。Layer:一个潜在的隐患不幸的是(往下看,你会发现这是不幸中的幸事),按照容器中layer的概念,不能简单的写一个RUNrm-rf/var/cache到Dockerfile/yum就大功告成了。因为Dockerfile的每条命令都是以分层的形式存储,逐层叠加的。所以,如果你这样写:RUNdnfinstall-ynginxRUNdnfcleanallRUNrm-rf/var/cache/yum你的容器镜像会包含三层,而RUNdnfinstall-ynginx层仍然会保留那些缓存的文件然后在其他两层中删除。但缓存实际上仍然存在。当您将一个文件系统挂载到另一个文件系统时,文件仍然存在,但您无法看到或访问它们。在上一节的示例中,您将看到正确的方法是将几个命令链接在一起以在生成缓存文件的同一Dockerfile指令中清理缓存文件:RUNdnfinstall-ynginx\&&dnfcleanall\&&rm-rf/var/cache/yum这样将几个命令连成一个命令,最后的镜像只占用一层。这样只会浪费一点缓存带来的好处,构建容器镜像的时间也会多一点,但是清理出来的缓存文件不会留在最终的镜像中。作为一种妥协,你只需要将一些相关命令(比如yuminstall和yumcleanall、下载文件、解压文件、删除tarballs等)连接成一个命令,这样可以在最终的容器镜像中节省大量体积,你还可以利用Docker的缓存来加速开发。图层还有一个更微妙的特性。每层记录文件的变化。这里的变化不仅仅是现有文件的积累,而是包括文件属性在内的所有变化。因此,即使对文件进行chmod操作也会在新层中创建文件的副本。下面是dockerimages命令的输出。其中,容器镜像layer_test_1是在CentOS基础镜像上添加1GB文件构建的镜像,容器镜像layer_test_2是使用FROMlayer_test_1语句创建的,只是执??行了一条chmodu+x命令,没有任何变化。layer_test_2lateste11b5e58e2fc7秒前2.35GBlayer_test_1latest6eca792a4ebe2分钟前1.27GB如您所见,layer_test_2图像比layer_test_1图像大1GB以上。尽管layer_test_1只是layer_test_2的上一层,但在第二层中隐藏了一个额外的1GB文件。如果在构建容器镜像的过程中在单个层中移动、更改或删除文件,也会出现类似的结果。私有和公共镜像一个个人经验:我们部门严重依赖RubyonRails,所以我们开始使用容器。从一开始我们就建立了一个所有团队都可以使用的官方Ruby基础镜像,并且为了简单起见(并以“这就是我们在服务器上搞砸的事情”的思想为指导),我们使用rbenv导入Ruby***所有四个版本的Ruby都安装到这个镜像中,目的是允许开发人员将使用不同版本的Ruby的应用程序迁移到仅使用这个单一镜像的容器中。我们也认为这是一个非常大的镜像,兼容性很好,因为这个镜像可以同时满足各个团队的需求。其实吃力不讨好。如果维护版本略有不同的单独镜像,则可以轻松实现镜像维护的自动化。同时,选择特定版本的特定镜像也有助于在引入颠覆性变更时,在应用程序接近生命周期结束之前提前采取预防措施,避免不可控的后果。巨大的公众形象也会浪费资源。当我们根据Ruby版本拆分这个巨大的图像时,我们最终会得到多个图像共享一个基础图像。如果它们都放在同一台服务器上,会占用一些额外的空间,但比安装多个版本的巨型镜像要小得多。这个例子并不是说构建一个灵活的镜像没有用,只是为了这个例子,从一个通用镜像创建一个专用镜像最终会节省存储资源和维护成本,同时受益于通用基础镜像,每个团队也可以根据自己的需要进行定制化配置。从头开始:将您需要的内容添加到空白映像一样小。曾经写过一篇关于Buildah的文章,在这里再次推荐一下这个工具。因为它足够灵活,可以操作空白映像并使用主机上的工具安装映像中未包含的打包应用程序。Buildah取代了dockerbuild命令。您可以使用Buildah在主机上挂载容器的文件系统并与之交互。我们用Buildah来实现上面的Nginx例子(暂时忽略缓存处理):#!/usr/bin/envbashset-oerrexit#创建容器container=$(buildahfromscratch)#挂载容器文件系统mountpoint=$(buildahmount$container)#安装一个基本的文件系统和最小的包集,然后nginxdnfinstall--installroot$mountpoint--releasever28glibc-minimal-langpacknginx--setoptinstall_weak_deps=false-y#将容器保存到一个imagebuildahcommit--formatdocker$containernginx#cleanupbuildahunmount$container#将镜像推送到Dockerdaemon的storagebuildahpushnginx:latestdocker-daemon:nginx:latest你会发现这里不再使用Dockerfile,而是普通的Bash脚本,并且是从骨架(或空白)图像构建的。上面的Bash脚本将容器的根文件系统挂载到宿主机上,然后使用宿主机的命令安装应用程序,这样包管理器就不需要放在容器镜像中了。这样所有不相关的内容(基础镜像之外的部分,比如dnf)将不再包含在镜像中。本例中构建的镜像大小仅为304MB,比使用Dockerfile构建的镜像小了100多MB。[chris@krang]$docker图片|grepnginxdocker.io/nginxbuildah2505d35974574分钟前304MB注意:此图像是使用上述构建脚本构建的,图像名称中的docker.io前缀仅在推送到注册表时添加。对于一个300MB的容器基础镜像,能够缩小100MB是一个显着的节省。使用包管理器安装Nginx会带来很多依赖。如果能使用宿主机直接从源代码编译应用,然后构建到容器镜像中,节省的空间会更多,因为这时候你可以精细地选择必要的依赖,非必要的依赖是没有内置到图像中。TomSweeney有一篇文章《用 Buildah 构建更小的容器》,如果你想在这方面做深度优化,不妨参考一下。通过Buildah可以构建一个不包含完整操作系统和代码编译工具的容器镜像,极大地减小了容器镜像的体积。对于某些类型的图像,我们可以更进一步,创建一个只包含应用程序本身的图像。使用静态链接的二进制文件构建镜像按照这种思路,我们甚至可以更进一步,抛弃容器内部的管理和构建工具。比如,如果我们足够专业,不需要在容器中调试,那我们可以不用Bash吗?可以不用GNU核心包吗?没有Linux基本文件系统可以吗?如果你使用的编译语言支持静态链接库,应用程序需要的所有库和函数都被编译成二进制文件,那么程序需要的函数和库就可以复制并存储在二进制文件本身中。这种做法在Golang社区很常见。下面我们用一个Go语言编写的应用程序来演示一下:下面的Dockerfile基于golang:1.8镜像构建了一个小的HelloWorld应用镜像:FROMgolang:1.8ENVGOOS=linuxENVappdir=/go/src/gohelloworldCOPY.//go/src/goHelloWorldWORKDIR/go/src/goHelloWorldRUNgogetRUNgobuild-o/goHelloWorld-aCMD["/goHelloWorld"]构建的镜像包含二进制文件和源代码以及基础镜像层,共716MB。但是应用程序运行唯一需要的是编译后的二进制文件,其余的在映像中都是多余的。如果在编译时通过指定参数CGO_ENABLED=0禁用cgo,那么在编译二进制文件时可以忽略某些函数的C语言库:GOOS=linuxCGO_ENABLED=0gobuild-agoHelloWorld.go编译后的二进制文件可以加入空白(或框架)图像:FROMscratchCOPYgoHelloWorld/CMD["/goHelloWorld"]让我们看一下两个构建的图像比较:[chris@krang]$dockerimagesREPOSITORYTAGIMAGEIDCREATEDSIZEgoHelloscratcha5881650d6e913秒前1.55MBgoHellobuilder980290a100db14秒前716MB在图像大小方面有很大差异。从golang:1.8图像构建的带有goHelloWorld二进制文件(带有builder标签)的图像比从仅包含二进制文件的空白图像构建的图像大460倍!后者整个图像大小只有1.55MB,也就是说713MB的数据是不需要的。上面说了,这种缩小图片大小的方式在Golang社区非常流行,因此也不乏关于它的文章。KelseyHightower有一篇文章专门用于处理这些库依赖项。压缩图像层除了前面几节提到的将多个命令链接为一个命令的技术外,还可以对图像进行压缩。图像压缩的本质是将其导出,去除图像构建过程中的所有中间层,然后将图像的当前状态保存为单个图像层。这可以进一步将镜子缩小到更小的体积。在Docker1.13之前,镜像层压缩的过程可能比较繁琐,需要docker-squash等工具导出容器的内容,重新导入成单层镜像。但是Docker在Docker1.13中引入了--squash参数,可以在构建过程中实现同样的功能:FROMfedora:28LABELmaintainerChrisCollinsRUNdnfinstall-ynginxRUNdnfcleanallRUNrm-rf/var/cache/yum[chris@krang]$dockerbuild-tsquash-fDockerfile-squash--squash.[chris@krang]$dockerimages--format"{{.Repository}}:{{.Size}}”|head-n1squash:271MB这种方式使用Dockerfile构建的镜像大小为271MB,与上述连接多条命令的方案构建的镜像大小相同,所以这个方案也是有效的,但是还有一个潜在的问题,但是是另一种问题。“什么?另一个问题?”好吧,和以前的问题有点相同,以另一种方式提出问题。过度:过度压缩,太小,太专用容器图像可以共享图像层。基础镜像可能有几Mb大小,但只需要拉取/存储一次,每个镜像都可以复用。所有共享基础镜像的实际镜像大小是基础镜像层加上每个特定变化层的方差,所以如果有数千个基于同一个基础镜像的容器镜像,它们的大小之和可能只大于一个基础镜像image镜像并没有大多少。这就是过度使用压缩或专用图像层的缺点。通过将不同的镜像压缩成一个单一的镜像层,容器镜像之间没有可以共享的镜像层,每个容器镜像将占用一个单独的卷。如果只需要维护几个容器镜像就可以运行多个容器,这个问题可以忽略;但是如果你要维护大量的容器镜像,从长远来看,会消耗大量的存储空间。回顾上面的Nginx压缩示例,我们可以看到这不是一个大问题。在此图像中,有Fedora操作系统和Nginx应用程序,它们未缓存且已被压缩。但是我们一般不会使用原来的Nginx,而是修改配置文件,引入其他代码或应用来配合Nginx,而这样做,Dockerfile就变得比较复杂。如果使用普通的镜像构建方式,构建出来的容器镜像会有Fedora操作系统的镜像层,一个安装了Nginx的镜像层(带缓存或者不带缓存),以及多个其他Nginx自定义配置的镜像层,如果还有其他的容器镜像需要使用Fedora或者Nginx,可以复用这个容器镜像的前两层。[App1Layer(5MB)][App2Layer(6MB)][NginxLayer(21MB)]------------------^[FedoraLayer(249MB)]如果使用压缩镜像层构建方式,Fedora操作系统和Nginx等配置内容会被压缩到同一层。如果有其他容器镜像需要使用Fedora,则必须重新引入Fedora基础镜像。每个容器映像将额外增加249MB的大小。[Fedora+Nginx+App1(275MB)][Fedora+Nginx+App2(276MB)]当您构建许多往往功能分散的小型容器镜像时,就会出现此问题。与生活中的一切一样,关键是要适度。根据镜像层的实现原理,如果一个容器镜像变得更小、更专业,其他容器镜像就更难共享基础镜像层,从而带来不好的结果。对于在基础镜像上只做少量改动构建的多个容器镜像,可以考虑共享基础镜像层。上面说了,一个镜像层本身是有一定体积的,但是只要它存在于镜像仓库中,就可以被其他容器镜像复用。在这种情况下,数千张图像可能比单个图像占用更少的空间。[特定应用][特定应用2][定制]------------^[基础层]容器镜像变得越小、越专业,与其他容器镜像集成的难度就越大共享基础图像层最终会不必要地占用越来越多的存储空间。[具体应用1][具体应用2][具体应用3]总结有很多方法可以减少处理容器镜像所需的存储空间和带宽,其中最直接的就是减少容器镜像本身的大小。在使用容器的过程中,时刻注意容器镜像是否过大。根据不同的情况,使用上面提到的清除缓存、压缩到一层、在空白图像中添加二进制文件等不同的方法。卷减小到有效大小。