纯干货!如何优雅的精简Docker镜像?

2,138 阅读11分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

近年来,随着容器技术的持续火热,越来越多的企业将Docker运用到自动化运维中,不管是为了保证开发、测试、生产环境的环境一致性,还是和CI/CD工具的集成,比如,Jenkins对Docker的自动构建部署。

随着敏捷开发越来越流行,在现在这种随随便便一天动辄几十次的快速构建迭代中,镜像作为一个贯穿整个自动化过程中的一个关键,怎么保证自动化构建部署的效率呢?

因此,精简镜像显得非常有重要。

精简Docker镜像尺寸的好处

  • 减少构建时间
  • 减少磁盘使用量
  • 减少下载时间
  • 提高安全性,减少攻击面积

越小的镜像表示无用的程序越少,可以大大的减少被攻击的目标,从而提高了安全性。

  • 提高部署速度

虽然存储资源较为廉价,但是网络 IO 是有限的,在带宽有限的情况下,部署一个 1G 的镜像和 10M 的镜像带来的时间差距可能就是分钟级和秒级的差距。特别是在出现故障,服务被调度到其他节点时,这个时间尤为宝贵。

优化Docker镜像的方法

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

  • 优化基础镜像
  • 串联Dockerfile指令,保证层级尽量少
  • 去除不必要的内容
  • 复用镜像层
  • 分阶段构建

优化基础镜像

优化基础镜像的方法就是选用合适的更小的基础镜像,常用的 Linux 系统镜像一般有 Ubuntu、CentOS、Alpine,其中Alpine更推荐使用。

注意:

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

Alpine镜像

Alpine一个基于musl libcbusybox、面向安全的轻量级Linux发行版。它本身的Docker镜像只有4~5M大小。各开发语言和框架都有基于alpine制作的基础镜像,在开发自己应用的镜像时,选择这些镜像作为基础镜像,可以大大减小镜像的体积。 

例如,Java、Python、Node.js语言对应的基础镜像如下:

  • Java(Spring Boot): - openjdk:8-jdk-alpine,openjdk:8-jre-alpine等
  • Java(Tomcat) - tomcat:8.5-alpine等
  • Nodejs - node:9-alpine, node:8-alpine等
  • Python - python:3-alpine, python:2-alpine等

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

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

除了Alpine这样的轻量级镜像之外,还推荐的一些镜像,如scratch、busybox、distroless等。

scratch镜像

scratch是一个空镜像,只能用于构建其他镜像,比如你要运行一个包含所有依赖的二进制文件,如Golang程序,可以直接使用scratch作为基础镜像。

样例:

FROM scratch
ARG ARCH
ADD bin/pause-${ARCH} /pause
ENTRYPOINT ["/pause"]

busybox镜像

如果你希望镜像里可以包含一些常用的Linux工具,busybox镜像是个不错选择,它集成了一百多个最常用Linux命令和工具的软件工具箱,镜像本身只有1.16M,非常便于构建小镜像。

distroless镜像

distroless镜像,它仅包含您的应用程序及其运行时依赖项。它们不包含您希望在标准 Linux 发行版中找到的包管理器、shell或任何其他程序。

由于Distroless是原始操作系统的精简版本,不包含额外的程序。容器里并没有Shell!如果黑客入侵了我们的应用程序并获取了容器的访问权限,他也无法造成太大的损害。也就是说,程序越少则尺寸越小也越安全。不过,代价是调试更麻烦。

需要注意的是,我们不应该在生产环境中,将Shell附加到容器中进行调试,而应依靠正确的日志和监控。

样例:

FROM node:8 as build

WORKDIR /app
COPY package.json index.js ./
RUN npm install

FROM gcr.io/distroless/nodejs

COPY --from=build /app /
EXPOSE 3000
CMD ["index.js"]

Distroless 镜像和 Alpine 镜像应该如何选择?

如果是在生产环境中运行,并且注重安全性, Distroless镜像可能会更合适。

Docker镜像中每增加一个二进制程序,就会给整个应用程序带来一定的风险。在容器中只安装一个二进制程序即可降低整体风险。

举个例子,如果黑客在运行于Distroless的应用中发现了一个漏洞,他也无法在容器中创建Shell,因为根本就没有。

如果更在意要是大小,则可以换成Alpine基础镜像。

这两个都很小,代价是兼容性。Alpine用了一个稍稍有点不一样的C标准库——muslc,时不时会碰到点兼容性的问题。

说明:

原生基础镜像非常适合用于测试和开发。它的尺寸比较大,不过用起来就像你主机上安装的Ubuntu一样。并且,你能访问该操作系统里有的所有二进制程序。

串联Dockerfile指令

补充说明:

Docker镜像由很多镜像层(Layers)组成(最多127层),镜像层依赖于一系列的底层技术,比如文件系统(filesystems)、写时复制(copy-on-write)、联合挂载(union mounts)等技术。总的来说,Dockerfile中的每条指令都会创建一个镜像层,继而会增加整体镜像的尺寸。我们可以通过命令 docker history image_id 来查看每一层的大小。

在定义Dockerfile时,如果太多的使用RUN指令,经常会导致镜像有特别多的层,镜像很臃肿,而且甚至会碰到超出最大层数(127层)限制的问题,遵循 Dockerfile 最佳实践,我们应该把多个命令串联合并为一个 RUN(通过运算符&&来实现),从而有效的减少Docker镜像的层级,因此,每一个 RUN 都要精心设计,确保安装构建之后,还要进行清理,这样才可以降低镜像体积,以及最大化的利用构建缓存。

样例:

FROM ubuntu:focal

ENV REDIS_VERSION=6.0.5
ENV REDIS_URL=http://download.redis.io/releases/redis-$REDIS_VERSION.tar.gz

# update source and install tools
# 将archive.ubuntu.com和security.ubuntu.com更换为国内源mirrors.aliyun.com
RUN sed -i "s/archive.ubuntu.com/mirrors.aliyun.com/g; s/security.ubuntu.com/mirrors.aliyun.com/g" /etc/apt/sources.list && \
    apt update && \
    apt install -y curl make gcc && \
# download source code and install redis
    curl -L $REDIS_URL | tar xzv && \
    cd redis-$REDIS_VERSION && \
    make && \
    make install && \
# clean up
    apt remove -y --auto-remove curl make gcc && \
    apt clean && \
    rm -rf /var/lib/apt/lists/* 

CMD ["redis-server"]

linux中大部分包管理软件都需要更新源,该操作会带来一些缓存文件,这里记录了常用的清理方法。

针对centos镜像安装包如下:

# 换国内源并更新
RUN curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo && \
  yum makecache && \
  yum install -y a b c  && \
  yum clean all

基于debian的镜像安装包如下:

 # 换国内源,并更新     
RUN sed -i “s/deb.debian.org/mirrors.aliyun.com/g” /etc/apt/sources.list && \
  apt update  && \
  # --no-install-recommends 很有用     
  apt install -y --no-install-recommends a b c && \
  rm -rf /var/lib/apt/lists/*

基于alpine镜像安装包如下:

# 换国内源,并更新     
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories && \ 
  # --no-cache 表示不缓存     
  apk add --no-cache a b c && \
  rm -rf /var/cache/apk/*

使用多阶段构建

Dockerfile中每条指令都会为镜像增加一个镜像层,并且你需要在移动到下一个镜像层之前清理不需要的组件。多阶段构建方法是官方打包镜像的最佳实践,它是将精简层数做到极致的方法。通俗点讲它是将打包镜像分成两个阶段,一个阶段用于开发,打包,该阶段包含构建应用程序所需的所有内容;一个用于生产运行,该阶段只包含你的应用程序以及运行它所需的内容,这被称为“建造者模式”。 使用多阶段构建肯定会降低镜像大小,但是瘦身的粒度和编程语言有关系,对编译型语言效果比较好,因为它去掉了编译环境中多余的依赖,直接使用编译后的二进制文件或jar包。而对于解释型语言效果就不那么明显了。

使用多阶段构建,你可以在Dockerfile中使用多个FROM语句,每条FROM指令可以使用不同的基础镜像,这样您可以选择性地将服务组件从一个阶段COPY到另一个阶段,在最终镜像中只保留需要的内容。

样例:

# Compile
FROM golang:1.9.0 AS build
WORKDIR /go/src/v9.git...com/.../k8s-monitor
COPY . .
WORKDIR /go/src/v9.git...com/.../k8s-monitor
RUN make build
RUN mv k8s-monitor /root

# Package 
# Use scratch image
FROM scratch
WORKDIR /root/
COPY --from=build /root .
EXPOSE 8080
CMD ["/root/k8s-monitor"] 

使用多阶段构建主要有三点不同:

  1. 第一行多了AS build, 为后面的COPY做准备。
  2. 第一阶段中没有了清理操作,因为第一阶段构建的镜像只有编译的目标文件(二进制文件或jar包)有用,其它的都无用 。
  3. 第二阶段直接从第一阶段拷贝目标文件。

通过,这样构建镜像,你会发现生成的镜像只有上面COPY 指令指定的内容,镜像大小只有2M。

去除不必要的内容

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

除了以上的这种形式,还有必要去除的,就是Dockerfile构建过程中所产生的临时文件。比如,源码包、编译过程中产生的日志文件、添加的包管理仓库、包管理缓存,以及构建过程中安装的一些当时有用,过后没用的软件或工具。

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

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

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

补充说明

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

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

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

复用镜像层

刚刚提到压缩不一定是好,为什么呢?

压缩的原理是将镜像导出,然后删除所有中间层,将镜像的当前状态保存为单一层,达到压缩层级的效果。

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

我们知道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的时候,都能够利用缓存,从而节省传输带宽和时间。

参考文章