摘要:云栖TechDay41期,阿里云高级研发工程师御坂带来Docker镜像优化与最佳实践。从Docker镜像存储的原理开始,针对镜像的存储、网络传输,介绍如何在构建中对这些关键点进行优化。并介绍Docker最新的多阶段构建的功能,以解决构建依赖的中间产物问题。
以下是精彩内容整理:
镜像概念
镜像是什么?从一个比较具体的角度去看,镜像就是一个多层存储的文件,相较于普通的ISO系统镜像来说,分层存储会带来两个优点,一个是分层存储的镜像比较容易扩展,比如我们可以基于一个Ubuntu镜像去构建我们的Nginx镜像,这样我们只需要在Ubuntu镜像的基础上面做一些Nginx的安装配置工作,一个Nginx镜像工作就算制作完成了,我们不需要从头开始去制作各种镜像。另一点我们可以优化镜像存储空间,假如我们有两个镜像,Tag1.0镜像和 Tag2.0镜像,我们如果以传统方式去传这两个镜像,每个镜像大概130多兆,但如果我们以分层的方式去存储两个镜像,我们通过下面两个紫色的才能共享,可以节约大量的空间,两个镜像加起来只需要140多兆的空间就可以存下来。这样一是节省了存储空间,二是可以减少网络上的开销,比如我们已经把下面镜像下载了,我们要去下载上面镜像的时候,我们只需要去下10M的部分。
如果从抽象的角度去看,Docker镜像其实是Docker提供的一种标准化的交付手段,传统应用在交付的时候其实是交付一个可执行文件,这个可执行文件不包括它的运行环境,我们可能会因为32位系统或64位系统,或者开发测试使用1.0软件,结果交付时候发现用户的环境是2.0等各种各样的问题,导致我们要去花时间去排查,如果我们以Docker镜像的标准化形式去交付,我们就会避免掉这些问题。
镜像基本操作与存储方式
我们的一个镜像会有一个坐标,一个镜像坐标基本上会由四个部分组成,前面会有一个镜像服务域名,每一个服务提供商都会有不同的域名,当我们确定服务提供商给我们的域名之后,我们一般会要到服务提供商那里去申请自己的命名空间,仓库名称一般是标识镜像的用途,比如说Ubuntu镜像、CentOS镜像,标签一般是用于去区分镜像版本,比如我们对Ubuntu镜像可能会打一些16.04的包,在我们确定了一个镜像服务域名以及在云服务商申请命名空间之后,我们就可以对镜像做一些操作了。
首先我们需要去登陆,我们会用第一条命令去登陆,然后,当我们在本地准备好一个镜像想要上传的时候,我们先要对这个镜像进行打标,把它的坐标变成我们现在需要上传镜像的坐标,然后再去做一些推送拉取的动作,最后针对Docker还提供两个额外命令去做镜像交付,如果我们是特殊的环境,没有办法网络连通的时候,我们可以将这个镜像打包成一个普通文件进行传输。比如我们和公安合作,他们没有办法通过我们的Registry下载镜像,我们可能要把它打成一个普通文件,然后以U盘的方式去交付。
镜像存储细节
Docker镜像是存在联合文件系统的,每一个镜像其实是分层存储的,比如在第一层我们添加了三个新文件,然后在这一层基础上我们又增加了一层,添加了一个文件,第三层可能会需要做一些修改,我们把File3做了一个修改移到上面来,然后删掉了File4,这里就会引到联合文件系统里面的写时复制机制,当我们要去修改一个文件的时候,镜像依赖底层都是只读的,我们不能去直接修改,比如我们想去修改File3,我们不能直接去修改这个文件,我们需要在修改的时候把文件复制到当前这一层,比如说L3层,然后再去修改它。
一个镜像做好之后,当我们想要知道镜像里面有哪一些内容的时候,我们其实会有一个视图概念,我们从联合文件系统的角度去看镜像的时候,其实我们不会看到L1、L2、L3,我们会最后看到结果,File1、File2、File3,File4就看不到了,然后在我们了解原理之后,我们就可以去理解容器运行起来是一个什么样的情况。容器运行起来和上面形成是类似的,图中下半部分,同样也是L1、L2、L3的三层镜像,当容器运行起来的时候,Docker daemon会动态生成一层可写层作为容器的运行层,然后当容器里面需要去修改一些文件,比如File2,也是copy on write机制把文件复制上来,然后做一些修改,新增文件的时候也是一样,然后容器在运行的时候也会有一个视图,当我们把容器停掉的时候,视图一层就没有了,它会被销毁,但是容器层读写层还会保留,所以我们把容器停掉再启动的时候,我们依旧会看到我们之前在容器里面的一些操作。
常见的存储驱动主要有AUFS、OverlayFS,还有Device Mapper,前两种驱动都是基于文件,它的原理就是需要修改一个文件的时候把整个文件复制上去做修改, Device Mapper更偏底层一点,它是基于块设备的,它的好处在于当我想要修改一个文件的时候,我不会将整个文件拷上去,我会将文件修改的一些存储块拷上去做一些修改,当我有一些大文件想要修改的时候,Device Mapper会比AUFS、OverlayFS好很多。所以AUFS和OverlayFS就比较适合传统的WEB应用,它的文件操作不会很多,但是它可能对我们的应用启动速度会有一些要求,比如我可能经常要发布,我希望能够启动比较快,但是对于文件修改的一些效率我不是很关心,那可以使用基于文件的驱动,当我们是一些计算密集型的应用时候,我们就可以选择Device Mapper,虽然启动比较慢,但是它的运行效率相对表现要好一些。
镜像自动化构建
我们构建一个镜像的时候,Docker其实提供了一个标准化的构建指令集,当我们去用这些构建指令去写类似于脚本,这种脚本我们称之为DockerFile,Docker可以自动解析DockerFile,并将其构建成一个镜像,所以你就可以简单的认为这是一个标准化的脚本。DockerFile在做一些什么?首先第一行FROM指令表示要以哪一个镜像作为基础镜像进行构建,我们用了openJDK的官方镜像,以JAVA环境作为基础,我们在镜像上面准备跑一个JAVA应用,然后接下来两条LABLE是对镜像进行打标,标下镜像版本和构建日期,然后接下来的六个RUN是做了一个maven安装,maven是JAVA的一个生命周期管理工具,接下来将一些源代码从外面的环境添加到镜像里面,然后两条RUN命令做了打包工作,最后写了一个启动命令。
总的来说DockerFile写的还可以,至少思路是很清晰的,一步一步从基础镜像选择到编译环境,再把源代码加进去,然后再到最后的构建,启动命令写好,可读性、可维护性都可以,但是还是可以进行优化的。
我们可以减少镜像的层数, Docker对于Docker镜像的层数是有一定要求的,除掉最上面在容器运行时候的读写层以外,我们一个镜像最多只能有127层,如果超过可能会出现问题,所以第二行命令LABLE就可以把它合成一层,减少了层数,下面六个RUN命令做了maven的安装工作,我们也可以把它做成一层,把这些命令串起来,后面的构建我们也可以把它合成一层,这样我们一下就把镜像层数从14层减少到7层,减掉了一半。
我们在做镜像优化的时候,我们希望能够尽量减少镜像的层数,但是和它相对应的是我们DockerFile的可读性,我们需要在这两者之间做折中,我们在保证可读性不受很大影响的情况下去尽量减少它,其实六条RUN命令在做一件事,就是做maven环境打结,做编译环境的准备工作。
接下来我们继续对镜像进行优化,我们可以做一些什么工作呢?在安装maven构建工具的时候我们多加了一行,我们把安装包和展开目录删掉了,我们清理了构建的中间产物,我们要去注意每一个构建指令执行的时候,尽量把垃圾清理掉,我们通过apt-get去装一些软件的时候,我们也可以去做这样的清理工作,就是把这些软件包装完之后就可以把它删掉了,这样可以尽量减少空间,通过增加一行命令,我们可以把镜像的大小从137M削减到119M。
通过apt-get去装软件或者命令基本上是所有编写DockerFile的人都去写的,所以官方已经在debian、Ubuntu的仓库镜像里面默认加了Hack,它会去帮助你在install自动去把源代码删掉。
我们可以利用构建的缓存,Docker构建默认会开启缓存,缓存生效有三个关键点,镜像父层没有发生变化,构建指令不变,添加文件校验和一致。只要一个构建指令满足这三个条件,这一层镜像构建就不会再执行,它会直接利用之前构建的结果,根据构建缓存特性我们可以加一行RUN,这里是以JAVA应用为例,一般一个JAVA应用的pom文件都是描述JAVA的一些依赖,而在我们平常的开发过程中这些依赖包发生变化的频率比较低,那么我们就可以把POM加进来,把POM文件依赖全部都准备好,然后再去下源代码,再去做构建工作,只要我们没有把缓存关掉,我们每次构建的时候就不需要重新下安装包,这样可以节省大量时间,也可以节省一些网络流量。
现在阿里云的容器镜像服务其实已经提供了构建功能,我们在统计用户失败案例的时候就会发现,网络原因导致的失败占90%,比如如果用户通过node开发NPM在安装一些软件包的时候经常卡在中间。所以我们建议加一个软件源,我们把阿里云maven地址加到里面去,我们把配置项加到阿里云的软件地址,加阿里云的maven源作为软件包的下载目标,时间直接少了40%,这样对一个镜像构建的成功率也是有帮助的。
多阶段构建
DockerFile最终需要做到的产物其实是JAVA应用,我们对于构建、编译、打包或者安装这些事情都不关心,我们要的其实是最后的产物。所以,我们可以采取分步的方式去做镜像构建,首先我们将之前遇到的所有问题全部都做成基础镜像,上面FROM镜像其实已经改了新的,镜像里面已经把软件源的地址改成了Maven,缓存都已经做好了。我们会去利用缓存,然后添加源代码,我们把前面构建的事情做成了镜像,让镜像去完成构建,然后我们才会去完成把JAVA包拷进去,启动工作,但是两个DockerFile其实是两个镜像,所以我们需要一段脚本去辅助它,第一行的shell脚本是做第一个构建指令,我们指定以Bulid的DockerFile去启动构建,然后生成一个APP Bulid镜像,接下来两行脚本是把镜像生成出来,把里面的构建产物拷出来,然后我们再去做构建,最后把我们需要的JAVA应用给构建出来,这样我们的DockerFile相比之前就更加清晰了,而且分步很简单。
Docker在17.05之后官方支持了多阶段构建,我们把下面的脚本去掉了,我们不需要一段辅助脚本,我们只需要在后面申明基础镜像的地方标记,我们第一阶段的构建产物名字叫什么,我们就可以在第二个构建阶段里面用第一个构建阶段的产物。比如我们第一阶段把JAVA应用构建好,把Maven包里面的target下面的JAVA架包拷到新的镜像里面,然后在所有优化做完之后效果如图,我们在第一次构建的时候,优化前102秒,在Docker构建优化后只花55秒就完成了,主要优化在网络上面。当我们修改了JAVA文件重新进行构建,第二次构建花了86秒,因为Maven安装那一块被缓存了,我们利用了构建缓存,所以少掉20多秒,优化后只花了8秒,因为所有的源代码前面的一些软件包下载全部被缓存了,我们直接拉新的镜像,然后依赖没有变,直接进行构建,所以8秒基本上是完整构建时间。
我们再来看一下存储空间上面的优化,第一次构建我们在优化前把镜像打出来有137M,但是在我们整个优化之后,只有81M了,这里的基础镜像由JDK改成JRE,为什么?因为之前我们把所有流程都放在一个镜像里面时,我们是需要去做构建的,构建时需要去RUN Maven,这种情况下没有JDK环境是RUN不起来的,但是如果我们分阶段,把构建交给Maven镜像来做,把真正运行交给新的镜像来做,就没必要用JDK了,我们直接用JRE,优化之后镜像少了将近50%。当我们修改源代码重新进行构建的时候,由于镜像成共享的原因,第二次构建在优化前其实多加了两层到三层,一共有9M,但是优化后的第二次构建只增加1.93KB,这样我们针对DockerFile的优化就已经做完了。
镜像优化有哪些重要的点呢?具体如下:
1. 减少镜像的层数,尽量把一些功能上面统一的命令合到一起来做;
2. 注意清理镜像构建的中间产物,比如一些安装包在装完之后就把它删掉;
3. 注意优化网络请求,我们去用一些镜像源,去用一些网络比较好的开源站点,这样可以节约时间、减少失败率;
4. 尽量去用构建缓存,我们尽量把一些不变的东西或者变的比较少的东西放在前面,因为不变的东西都是可以被缓存的;
5. 多阶段进行镜像构建,将我们镜像制作的目的做一个明确,把我们的构建和真正的一些产物做分离,构建就用构建的镜像去做,最终产物就打最终产物的镜像。
容器镜像服务
最后介绍一下阿里云容器镜像服务。这个服务已经公测一年了,现在我们的服务公测是全部免费的,现在在全球的12个Region都已经部署了我们的服务,每个Region其实都有内网服务和VPC网络服务,如果ECS也在同样的Region,那么它的服务是非常快的。然后团队管理和组织帐号功能也已经上线了,镜像购建和镜像消息通知其实都是一些DevOps能力,针对一些镜像优化我们提供了一些镜像层信息浏览功能,我们后续也会提供分析,推出镜像安全扫描、镜像同步。
网友评论