优化 docker 镜像的几种方法

Devops 和 k8s 的火热,越来越多的企业将 docker 运用到自动化运维中,不管是为了保证开发、测试、生产环境的环境一致性,还是和 CI/CD 工具的集成度,比如 jenkins 对 docker 或 k8s 的自动构建部署等,亦或利用 docker 进行自动化测试等

那么,在现在这种随随便便一天动辄几十次的快速构建迭代中,镜像作为一个贯穿整个自动化过程中的一个关键,怎么保证自动化构建部署的效率?就是镜像尽可能的小

要保证镜像尽可能小,可以从五个方面

  • 基础镜像小
  • 层级尽量少
  • 去除不必要
  • 复用镜像层
  • 分阶段构建

基础镜像小

基础镜像小,主要是保证镜像层底层或者说 From 的镜像本身小

每个企业或个人使用容器,都是应对不同的业务场景,没有完全一致的业务场景,所以你最好不要直接用别人的第三方镜像,除非你了解该镜像的所有层级内容,而且从安全角度考虑,也尽量使用官方镜像,它没有太多第三方的,你不需要的东西,你可以在此基础上增加你的业务部分内容

选择 Alpine 镜像代替 Ubuntu、CentOS、Debian 等镜像

虽然 Alpine 没有其他系统完备的库、依赖,但是基本的应用它都是支持的,都可以通过 apk 去安装(apk 包管理) ,而且官方现在也推荐用 Alpine,很多开源项目都有基于 Alpine 的官方镜像

但如果你的项目涉及到编译,比如 python 等涉及编译的项目,要注意,Alpine 用的是 musl,因为它原本是用作嵌入式系统的,所以并没有 glibc 那么完整的 C 标准库

另外如果你要在 Alpine 中跑一些脚本的话,那你要注意一些 shell 和 linux 下的还是有所区别的,Alpine 是基于 busybox 的,同样也是设计于嵌入式的,所以很多 shell 命令做了裁剪,并不具备 Ubuntu、CentOS、Debian 等系统中那么完整的功能

除了使用小镜像之外,可以使用空镜像 scratch,自己手动添加 rootfs 来构建镜像,这个不建议新手,因为你可能不知道你需要些啥,折腾半天反而比官方镜像还要大

层级尽量少

前面文章有介绍 docker 的联合文件系统”Docker挂了,数据如何找回“,Dockerfile 构建镜像流程大致如下:

  • docker 从基础镜像运行一个容器
  • 执行一条指令对容器进行修改
  • 执行类似 docker commit 的操作提交这次修改
  • docker 再基于刚提交的镜像运行一个新容器
  • 执行 Dockerfile 中的下一条指令,依次循环,直到命令执行完成

所以每执行一条 Dockerfile 中的指令,就会提交一次修改,这次修改会保存成一个只读层挂载到联合文件系统,看过上面的文章应该知道,上面层的文件,如果和下面层有冲突或不同,会覆盖隐藏底层的文件,所以每增加一层,镜像大小就会增加,虽然在 docker1.10 后只有 RUN、COPY、ADD 指令会创建层,其他指令会创建临时的中间镜像,不会直接增加构建的镜像大小

所以在编写 Dockerfile 时,我们可以根据实际情况去合并一些指令,比如我们在编译安装 nginx 时,解压、编译、安装以及删除源文件的指令可以放在一起,以减少最终的镜像层

去除不必要

前面提到的用空镜像,或者裁剪过的小镜像来做基础镜像,其实就是一种去除不必要的依赖、库的一种形式

除了以上的这种形式,还有必要去除的,就是 Dockerfile 构建过程中或手动构建后 commit 的过程中所产生的临时文件

比如源码包、编译过程中产生的日志文件、添加的包管理仓库、包管理缓存,以及构建过程中安装的一些当时又用过后没用的软件或工具

如果可以,甚至建议不在容器中进行编译,如果二进制 binary 文件可以执行的话,在本地编译后,将 binary 文件 copy 到容器内

除了上面的,还有一些不常更新的文件,比如 web 静态资源文件 css、js 以及图片、视频等资源,建议存储 OSS 或共享存储系统 nfs、mfs 等,这些文件不应该打包到镜像里面,而应该通过 OSS 调用或通过共享存储挂载

对于不需要 build 进镜像的资源,可以使用.dockerignore 文件进行指定要忽略的文件或目录

当然,如果你想基于别人的镜像来做优化的话,可以通过 docker history 命令来查看镜像的层级关系,做相应的优化,更好的工具推荐 dive

当然也可以用自动化的镜像瘦身工具 docker-slim,它支持静态分析和动态分析,静态分析主要是通过分析镜像历史信息,获取生成镜像的 dockerfile 文件及相关的配置信息,而动态分析主要是通过 ptrace、pevent、fanotify 解析出镜像中必要的文件和文件依赖,将对应文件组织成新镜像来减小镜像体积

另外还可以通过 docker-squash 来压缩镜像层级,但是要考虑实际情况,并不是压缩一定是好的

复用镜像层

接上面为什么压缩不一定是好,压缩的原理是将镜像导出,然后删除所有中间层,将镜像的当前状态保存为单一层,达到压缩层级的效果

当你使用单一镜像或者少量镜像的时候可能没有太大问题,但是这样完全破坏了镜像的层级缓存功能

还是之前的文章中提的关于 docker 的存储的,docker 镜像的每个层级会存一个 hash 计算后的目录,那么 Dockerfile 构建过程中怎么利用缓存?

在镜像的构建过程中,Docker 根据 Dockerfile 指定的顺序执行每个指令。在执行每条指令之前,Docker 都会在缓存中查找是否已经存在可重用的镜像,如果有就使用现存的镜像,不再重复创建

而如果压缩为单一的层之后,缓存就失效了,不会命中缓存的层级,所以每次构建或者 pull 的时候,都是整个镜像构建或 pull

缓存命中除了和分层有关系,还和指令执行编排顺序有关系,首先看下缓存匹配遵循的基本规则:

  • 从一个基础镜像开始(FROM 指令指定),下一条指令将和该基础镜像的所有子镜像进行匹配,检查这些子镜像被创建时使用的指令是否和被检查的指令完全一样。如果不是,则缓存失效
  • 在大多数情况下,只需要简单地对比 Dockerfile 中的指令和子镜像。然而,有些指令需要更多的检查和解释
  • 对于 ADD 和 COPY 指令,镜像中对应文件的内容也会被检查,每个文件都会计算出一个校验值。这些文件的修改时间和最后访问时间不会被纳入校验的范围。在缓存的查找过程中,会将这些校验和和已存在镜像中的文件校验值进行对比。如果文件有任何改变,比如内容和元数据,则缓存失效
  • 除了 ADD 和 COPY 指令,缓存匹配过程不会查看临时容器中的文件来决定缓存是否匹配。例如,当执行完 RUN apt-get -y update 指令后,容器中一些文件被更新,但 Docker 不会检查这些文件。这种情况下,只有指令字符串本身被用来匹配缓存
  • 一旦缓存失效,所有后续的 Dockerfile 指令都将产生新的镜像,缓存不会被使用

所以为什么和指令执行顺序和编排有关系,或者说我们在合并命令减少层级的时候不能一味的追求合并,而是需要合理的合并一些指令

举个例子,比如我们用同一个基础镜像,分别编译 nginx 和 php,那么 nginx 也需要 pcre 库依赖,php 也需要,那我们是不是可以提取共同的依赖用一条 RUN 指令去执行,而不是每次构建都执行

再或者最简单的,添加镜像仓库,安装基本的编译工具,比如 gcc、autoconf、make、zlib 等这些不常改动,但是常用的指令放在前面去执行,这样后面构建用到的所有镜像都不会再重新安装

这样合理的利用层级缓存,不管是在 jenkins 中自动构建镜像,还是 push 到远程仓库、亦或是在部署 pull 的时候,都能够利用缓存,从而节省传输带宽和时间

分阶段构建

最后一个更重要的是分阶段构建,或者多阶段构建,其实它也是一种减少分层或者去除不必要的一种方式,单独列出来,是觉得这个方式应该是推荐的一种方式,在 docker17.05 中开始支持

具体的多阶段构建,就是通过将构建过程分析,分成多个阶段来执行,后面的或者最终的构建可以使用前面构建的结果,而不需要所有的构建都包含到最终的镜像中

拿 nginx 构建来举个例子

上图是一个 nginx 的 Dockerfile,构建之后查看大小

然后通过多阶段构建改造 Dockerfile

再构建后查看镜像大小

只比基础镜像多了 1MB,之前的所有构建阶段 as build 在打包镜像的时候全部被抛弃,只最后 FROM 生成最终镜像

基于多阶段构建,google 推出了 distroless,更加轻量级,它只包含应用程序机器运行时依赖项,不包含程序管理器、shell 以及标准 liunx 发行版中可以找到的任何其他程序

这应该就是正是我们所需要的,而我们又不会剪裁,distroless 帮我们做了这个工作

还是基于刚才的 nginx,我们不以 rhel7 为基本镜像了,我们以 distroless 为基本镜像构建

构建后查看大小

通过以上几种方式,可以有效精简 docker 镜像,从而提高自动化运维过程中的 CI/CD 效率,缩短交付时间

本文作者: InfoQ

余下全文(1/3)
分享这篇文章:

请关注我们:

共有 1 条讨论

  1. admin  这篇文章, 并对这篇文章的反应是俺的神呀赞一个

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注