最近做了一个好玩的工具叫xbin.io[1]。其中一项任务是为不同的工具构建Docker镜像,使它们都运行在Docker中(实际上是其他兼容Docker镜像的沙箱系统,不直接使用Docker)。支持的工具越来越多。为了节省资源,Build的Docker镜像尽量小,文件少。其实启动速度会稍微快一些,也比较安全。本文介绍制作DockerImage的一些技巧。Docker镜像是如何工作的,在之前的博文Docker原理(容器)[2]中有介绍。简单来说,利用Linux的overlayfs[3],overlay文件系统可以将两个文件系统合并在一起,下层文件系统是只读的,上层文件系统是可写的。如果你读,找到上层就读上层,否则,找到下层读给你听。然后write的话会写到上层。这样一来,其实对于最终用户来说,可以认为merge之后只有一个文件系统,在使用上和普通的文件系统没有区别。有了这个功能,Docker在运行时,从最底层的文件系统开始,合并两层,得到一个新的fs,然后合并上层,再合并上层,最后得到最终的目录,然后使用chroot[4]更改进程的根目录并启动容器。了解原理后,你会发现这种设计非常适合Docker:如果两个镜像都是基于Ubuntu的,那么两个镜像可以共享Ubuntu的基础镜像,只需要存储一份;如果拉取新镜像,如果某层已经存在,则该层之前的内容不需要拉取;后面的形象塑造技巧其实就是基于这两点。另外稍微提一下,Dockerimage其实就是一个tar包[5]。一般来说,我们使用dockerbuilt命令来构建Dockerfile,但是也可以使用其他工具来构建。只要构建的镜像符合Docker规范[6],就可以运行。例如,之前的博文构建一个最小的RedisDocker镜像[7]就是使用Nix构建的。技巧一:删除缓存一般的包管理器,比如apt、pip等,在下载包的时候都会下载缓存。下次安装同一个包,就不用网络下载了,直接使用缓存即可。但是在DockerImage中,我们不需要这些缓存。所以我们通常使用这个命令来下载Dockerfile里面的东西:RUNdnfinstall-y--setopt=tsflags=nodocs\httpdvim&&\systemctlenablehttpd&&\dnfcleanall包安装完成后,删除缓存。一个常见的错误是有人会这样写:FROMfedoraRUNdnfinstall-ymariadbRUNdnfinstall-ywordpressRUNdnfcleanallDockerfile文件中的每一个RUN都会创建一个新层,如上所述,这实际上创建了3层layer,前2层带上缓存,第三层删除缓存。就像git一样,你在一个新的commit中删除了之前的文件,但是这个文件还在git历史中,最终的docker镜像其实并没有减少。但是Docker有一个新特性,dockerbuild--squash。squash功能会在Docker构建完成后将所有层压缩为一层,也就是说最终构建的Docker镜像只有一层。所以也可以像上面那样把clean命令写在多个RUN中。我不是很喜欢这种方式,因为前面说了,多镜像共享基础镜像,加速拉取的特性其实并没有用到。包管理器删除缓存的一些常用方法:yumyumcleanalldnfdnfcleanallrvmrvmcleanupallgemgemcleanupcpanrm-rf~/.cpan/{build,sources}/*piprm-rf~/.cache/pip/*apt-getapt-getclean中另外,上面的命令其实也有一个缺点。因为我们在同一个RUN中写了多行,所以不太容易看出这个dnf到底安装了什么。此外,第一行与最后一行不同。修改的话diff会看到两行,很不友好,容易出错。可以写成这种形式,比较清楚。RUNtrue\&&dnfinstall-y--setopt=tsflags=nodocs\httpdvim\&&systemctlenablehttpd\&&dnfcleanall\&&true技巧二:把不经常改动的内容放在前面通过前面介绍的原理,可以知道,对于一个Docker镜像有ABCD四层,如果修改了B,那么BCD也会改变。根据这个原则,我们在构建的时候可以把系统依赖向前写,因为像apt、dnf这样的东西是很少修改的。然后编写应用的库依赖,比如pipinstall,最后copy应用。例如,以下Dockerfile会在每次代码更改时重新构建大部分层,即使仅更改网页标题也是如此。FROMpython:3.7-buster#copysourceRUNmkdir-p/opt/appCOPYmyapp/opt/app/myapp/WORKDIR/opt/app#安装依赖项nginxRUNapt-getupdate&&apt-getinstallnginxRUNpipinstall-rrequirements.txtRUNchown-Rwww-data:www-data/opt/app#startserverEXPOSE8020STOPSIGNALSIGTERMCMD["/opt/app/start-server.sh"]我们可以改成先安装Nginx,然后单独复制requirements.txt,和然后Installpipdependencies,最后复制应用代码。FROMpython:3.7-buster#installdependenciesnginxRUNapt-getupdate&&apt-getinstallnginxCOPYmyapp/requirements.txt/opt/app/myapp/requirements.txtRUNpipinstall-rrequirements.txt#copysourceRUNmkdir-p/opt复制代码/appCOPYmyapp/opt/app/myapp/WORKDIR/opt/appRUNchown-Rwww-data:www-data/opt/app#startserverEXPOSE8020STOPSIGNALSIGTERMCMD["/opt/app/start-server.sh"]提示3:BuildandrunImageseparation我们在编译应用的时候需要很多build工具,比如gcc,golang等,但是runtime不需要。构建完成后,移除那些构建工具很麻烦。我们可以这样做:使用一个Docker作为构建器,安装所有的构建依赖,构建,构建完成后,重新选择一个Baseimage,然后将构建好的产品复制到新的baseimage中,这样最终的image只包含运行需要的东西。例如,这是安装golang应用程序pup的代码:FROMgolangasbuildENVCGO_ENABLED0RUNgoinstallgithub.com/ericchiang/pup@latestFROMalpine:3.15.4asrunCOPY--from=build/go/bin/pup/usr/对于local/bin/pup,我们使用golang这个1G的镜像来安装。安装完成后将binary拷贝到alpine中,最终成品只有10M左右。这种方式特别适用于一些静态编译的编程语言,比如golang、rust。技巧4:检查构建产品这是最有用的技巧。dive是一个TUI,一个命令行交互式应用程序,可让您查看每一层docker中的内容。diveubuntu:latest命令可以查看ubuntu镜像中有哪些文件。内容将显示为两侧,左侧显示各层的信息,右侧显示当前层(包括之前所有层)的文件内容,显示本层新增的文件黄色。使用tab键在左右操作之间切换。一个很好用的功能就是按ctrl+U只能显示当前层相对于上一层添加的内容,这样可以看出添加的文件是否符合预期。按ctrl+Space折叠所有目录并交互式打开它们,就像Docker中的ncdu一样。
