鲸品堂|如何快速打通镜像发布流程?

2022-08-09 8

近期Q-eye发布了一篇“镜像瘦身”的分享,引起大家的关注,很多人来咨询“镜像瘦身”的方案。这篇分享从实例出发,证明了镜像优化的重要性和有效性,起到了很好的抛砖引玉作用,但是其内容较短,原理性说明较少,希望通过本文来对“镜像瘦身”过程进行梳理,以实战案例进一步阐明该方案。


容器化发布通过将应用以及应用所依赖的环境(比如JRE、动态库,环境变量,系统目录等)一起打包为镜像,解决了发布的一致性问题,即能够Build Once,Run Everywhere,但是这种方案也带来一个副作用,就是镜像太大,容器镜像提供了分层机制,每次只需要传输变更的层,一定程度上降低了传输量。


为了解释清楚容器镜像的压缩和传输方法,首先简单介绍一下容器分层:





容器分层文件系统




当我们在主机上创建一个新的容器时,会为这个容器单独创建一个文件系统,这个分层的文件系统是将镜像中的文件层作为只读层,并新建一个可读可写的文件层叠加到镜像的只读层上面,容器内的所有操作都发生在读写层。这样做可以实现:



镜像文件都是只读的,可以使用一个镜像创建N个容器,每个容器之间都相互隔离。


多个容器共享一个只读层,能够利用文件系统的缓存机制,加速数据的读取。



当我们制作一个新的镜像时,过程也是类似的,会在基础镜像文件层的基础上新叠加一个读写层,并在读写层上放入新内容,然后将新的读写层变成一个新的只读层,形成一个新的镜像。所以新手容易碰到的一个问题是:我在Dockerfile里面使用rm删除了基础镜像中的一些文件,但是为什么最终镜像没有减小?理解了这个读写层的机制就会知道,任何操作都发生在读写层,因此删除文件时并不能真的去删除以前的文件,只是在读写层上做一个特殊标记,让文件系统看不到这个文件而已,因此镜像不会缩小。


因此分层机制有如下的弊端:



每次做修改都会新建一个层,这样层级就非常多。


一旦写入文件并打成了镜像,后续基于这个镜像制作的镜像,就无法删除这个文件所对应的空间。






镜像分层传输机制




一个容器镜像是由描述文件和一系列数据文件组成,每个数据文件对应一个文件层。当我们拉取或者推送镜像时,首先会获取描述文件,然后根据描述文件判断哪些层本地已经有了(或者远端已经有了),然后就只传输不存在的层即可,大幅减少传输量。但是,如果两个系统之间无法直连,就无法判断哪些镜像层对端已经有了,因此这个机制就会失效。


了解了这些原理后,就可以进一步讨论如何优化镜像的大小了。





Dockerfile上的优化




为了降低容器镜像的大小,在编写Dockerfile时注意参考如下经验:



各团队尽量使用统一的基础镜像。建立并维护公司统一的基础镜像列表。


减少Dockerfile的行数,使用“&”连接多个命令,因为每一行命令都会生成一个层。


将增加文件和清理文件的动作放到一行里面,比如yum install和yum clean all,如果分为两行,第二条清理动作就无法真正删除文件。


只复制需要的文件,如果整个目录复制,一定要仔细检查目录下是否有隐藏文件、临时文件等不需要的内容。


容器镜像自身有压缩机制,因此把文件压缩成压缩文件然后打入容器,容器启动时解压的方法并不会有什么效果。


避免向生产镜像打入一些不必要的工具,比如有的团队打入了sshd,不应该使用这种方案,增加安全风险。


尽量精简安装的内容,比如只安装工具的运行时,无须带上帮助文档、源码、样例等等。






镜像分层上的优化




一个典型的java应用的镜像的大小是在500M左右,其中300M左右是基础镜像(包含OS/JRE等),还有200M是应用相关的文件。虽然看上去并不多,但是在微服务架构下,应用的数量比较多,假设我们每个版本发布30个镜像,则总传输量会有300M + 200M* 30,大概要6G左右,还是会比较大。


可以通过将应用进一步分层来减少每次发布量,有两种划分分层的方法:





相似的几个产品共享一个基础镜像,将公共的包放到基础镜像层

假设A/B/C三个产品都使用了相同的技术架构,比如都使用了20个相同的jar包,这些jar包一共100M。如果这三个产品创建一个共享的中间层基础镜像,然后基于这个基础镜像再打各自的镜像,则每次应用层的发布数据量会由原来的 200M * 3 变成 100M + 100M * 3,这样就可以减少发布量。



根据冷热进行分离,将不变的部分做到基础镜像

假设A应用一共200M,但是三方jar包就有180M,自己的应用只有20M,则可以创建一个中间层基础镜像,这样每次发版本时,如果三方jar包没有变动,则中间层不需要重新传递,只传递20M的自有应用,也可以降低发布的量。



我通常把这种为了减少发布量的分层叫做offload层。效果非常直接,但是offload层也会带来很多管理问题:



对于第一类,多个产品共享一个offload层,需要这几个产品保持密切的沟通,假设需要更新offload层某个组件的版本,则几个产品需要同时协同一起变更和发布。如果存在不一致则可能会引入问题。


第二个也会存在offload层更新的问题。比如:目前Java应用流行使用maven来管理依赖,如何根据maven的输出及时更新offload层?如果更新不及时,也会造成不一致。



Offload层的更新问题如果依赖管理流程或者人为检查是不可靠的,我们建议将offload层当成cache一样使用,实现方案如下:



按照业务特点抽取出公共文件和冷文件做成offload层


在制作镜像时,利用multi-stage builds机制,先将最新的全量的内容复制到Stage中,然后在Stage进行一次比对,如果offload层是最新的就用,如果不是最新的,则使用最新的替代。


以Java为例,Dockerfile写法如下:









Stage 复制所有的lib包到/app/lib/目录下RUN一个脚本,检查下/app/lib/下的文件和offload层中/app/lib_shared/下的文件,如果两个文件一致,则删除文件并建立一个软链接来替代实际文件
Build 从Stage中复制/app/lib/目录做成最终的真正的层


这样即使出现依赖包发生了更新,而offload层未能及时更新的情况,只会造成镜像offload失败,镜像比较大一些而已,不会造成故障。


注意: 目前发现tomcat如果要支持软链接,需要打开allowLinking开关,否则会失败。





镜像传输的优化




前文提到过,镜像分层传输必须是源仓库和目标仓库的网络能够互通的情况,但是实际场景往往比较复杂,比如很多项目都有严格的网络管控,不允许服务器直接访问外网;很多国际项目到国内的网络连接比较差,速度慢并且经常丢包。所以我们需要分情况来讨论。





在线的压缩和传输方案




假设能够找到一台机器,这台机器既能够访问源仓库也能够访问目标仓库,可以在这台机器行安装一个Docker,然后直接使用docker pull/push的方式,拉取和上传的过程都是增量传输;但是这种方式需要安装Docker,并且会占用文件系统空间(镜像会暂存在本地)。推荐使用我们开源的 image-transmit (https://github.com/wct-devops/image-transmit)工具这个工具一端连接源仓库,一端连接目标仓库,直接将增量数据层转发过去,中间数据不落盘,效率是最高的。同时这个工具是一个绿色版的界面化工具,使用简单(也支持命令行),压缩后只有几M大小,资源消耗很少,可以在一些安全跳板机上稳定运行。





离线的压缩和传输方案




很多场景下我们无法实现在线的传输,需要先将镜像保存成一个文件,然后利用各种手段发给现场,比如通过百度云盘中转、存到U盘然后快递过去等。在离线传输模式下重点是考虑如何能够把镜像包压缩到最小,以及压缩和解压缩的时间。下面介绍几种模式:



docker save|gzip方式,这种是最基本的方法,找一台安装有Docker的机器,将镜像拉取到本地,然后使用save命令保存并压缩。这种方式非常耗时,同时压缩包也是最大的。


使用上文提到的image-transmit工具进行离线打包,默认使用tar算法。这个工具可以直接从仓库上下载镜像的数据文件,合并成压缩包,比上一种方式减少了保存到本地然后导出以及压缩和解压缩的过程,速度可以提升20倍,同时如果一次压缩多个镜像,相同的镜像层只会保留一份,这能够降低压缩包的大小,以我们自己的镜像版本为例,使用工具得到的压缩包是docker save方法的1/3到1/5大小。


同时image-transmit还提供了squashfs算法,这种压缩算法在上一种方式的基础上更进一步,会把每一个镜像层都解压,对每一个文件进行固实压缩,举个例子,A产品和B产品都用到了demo.jar,但是这个jar并不在基础镜像层,上一种方式是无法识别这种重复的,但是squashfs压缩方式可以识别并将其压缩为一份,这样能够进一步降低压缩包的大小,以我们自己的镜像包为例,这种压缩方式可以在上一种方式的基础上再降低30%~50%。但是这种压缩算法由于需要将所有的镜像层都解压然后进行压缩,导致其压缩时间非常长,上一种方式5分钟可以压缩完的包,这种方式要一个小时左右。



压缩算法的选择可以根据实际情况来选择,甚至把两种方式都试一下,综合选择一个合适的。离线方式因为无法直连仓库,所以上文中的方法是把所有的镜像层都保存到离线包中了,那么如何在离线模式下也能实现增量方式发布呢?


image-transmit实现了这样一种增量发布方法:在制作压缩包时,可以根据上次的压缩包的信息自动跳过已经发送过的镜像层,只发增量变更的部分,这样就可以进一步降低压缩包的大小。不过这种方式也有弊端,比如必须严格按照顺序下载版本,如果遗漏某个版本可能会造成现场仓库缺失一些数据层,造成失败。如果版本发布比较多,可以采用类似如下的方案来规避这种风险:



每个月发布一个全量的版本包


每日/周的版本基于月初的全量包来制作增量包


这种准实时的增量同步加上定期的全量同步,即可以降低同步量又可以避免缺失一些层造成传输失败。





总结和后续规划




镜像传输还有一些优化方向亟待研究,比如:



业界有很多镜像,其容器内只有应用的二进制程序,因此其镜像大小与传统应用发布没有什么区别。但是这种镜像在做问题分析时,需要依赖外部的工具,一定程序上提高了故障分析的门槛,可以通过sidecar方式来提供排障工具。


镜像过大不仅仅影响发布,在版本升级、容器切换等容器重新创建的场景下,消耗大量的网络带宽和磁盘IO,镜像仓库容易成为瓶颈,需要考虑类似Dragonfly的P2P分发方案。


目前我们主要使用Centos作为基础镜像,我们正在考虑更换更为轻量的,专门为容器设计的基础镜像。



“镜像瘦身”其实是个系统性的工程,需要多个团队相互配合,从技术平台到业务应用到交付相互配合一起落地。



官方微信公众号

浩鲸云计算科技股份有限公司 版权所有 2003-2022

苏ICP备10224443号-6