详解六种减小Docker镜像大小的方法
我从2017年做Vulhub开始,一直在和一个麻烦的问题做斗争:在编写Dockerfile的时候, 如何减小 docker build
生成的镜像大小 ?这篇文章就给大家总结一下我自己使用过的六种减小镜像大小的方法。
1. 使用Alpine Linux
Alpine Linux是一个基于BusyBox和Musl Libc的Linux发行版,其最大的优势就是小。一个纯的基础Alpine Docker镜像在压缩后仅有2.67MB。
不少Docker官方镜像都有Alpine版本,比如PHP:
比较之下就可以发现,alpine版本镜像大小是普通版本的1/5左右。
但是在Docker Hub中,大部分镜像是没有Alpine版本的,比如Mysql和PHP-Apache,如果我们需要基于这些环境开发,就不得不自己编写Alpine版本,或者找一些第三方镜像。
另外,Alpine的另一个缺点是,其使用了Musl Libc作为传统的glibc的替代,编译软件的时候可能会遇到一些不可预知的问题,这一点会导致我们耗费不少不必要的时间。
2. 只安装最少的依赖
apt-get、yum、apk等软件包管理器是我们编译镜像时必然需要用到的工具,纯净的Docker基础镜像通常会缺少wget、curl、git、gcc等工具,需要我们手工来安装。
我们以apt为例,apt-get在安装软件的时候,可以指定一个选项: --no-install-recommends
,指定这个参数后,有一些非必须的依赖将不会被一起安装。比如,我们安装wget时,如果增加这个选项,待安装的包将从6个减少为3个:
这在一定程度上缩小了镜像的大小,但这样做带来的副作用就是,可能导致目标软件缺少一些功能。
比如,此时的wget将无法验证服务器证书的真伪,导致命令出错:
所以,我们一般的做法是,使用apt时尽量增加 --no-install-recommends
,等后面出现一些错误再及时纠正。像wget这种已知的问题,可以提前预判并进行处理:
apt-get install --no-install-recommends wget ca-certificates
3. 为apt擦屁股
某些工具只有编译阶段使用,我不希望它们占用我宝贵的镜像容量,就可以在镜像编译完成后,将这些中间依赖删掉。
我们以apt为例,在使用完成后,我们需要做的事情有:
- 删除那些 不需要 的依赖:
apt-get pruge --autoremove ...
- 删除本地的软件包列表:
rm -rf /var/lib/apt/lists/*
这个过程中我们会遇到一个非常难解的问题,究竟哪些依赖是“不需要”的?
比如,在编译PHP时,我们可能会用到三个工具:wget、libxml、gcc。这三个工具,在编译PHP前都需要安装。但是在编译完成后,我们可以卸载wget和gcc,但不能卸载libxml。
原因是,libxml为PHP所依赖的一个动态链接库,如果我们将其卸载,将会出现找不到共享链接库的错误:
root@8eab53da8d5b:/# php -v php: error while loading shared libraries: libxml2.so.2: cannot open shared object file: No such file or directory
那么,有没有一个比较方便的办法,我自动只找出那些不是“共享链接库”的依赖并删除他们呢?
当然有,比较简单的办法是,我们遍历刚编译的可执行文件,使用ldd命令列出其依赖的共享链接库文件名,并在源中搜索这个文件名对应的包名:
这些包就是PHP依赖的所有动态链接库,接着我们将这些包用 apt-mark
声明为“手工安装的包”,即可阻止 apt purge
的自动卸载。
然后,我们再自动卸载其余没有用到的包即可。完整shell脚本如下:
find /usr/local -type f -executable -exec ldd '{}' ';' | awk '/=>/ { print $(NF-1) }' | sort -u | xargs -r dpkg-query --search | cut -d: -f1 | sort -u | xargs -r apt-mark manual ; apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false;
4. 尽量将中间依赖的安装与卸载操作放在一个步骤中
docker镜像是一个由“层”来堆叠起来的“千层饼”,我们可以使用 docker history <image name>
这条命令来查看任意一个镜像是由哪些层组成的,以及每一层的大小:
对于Dockerfile来说,这些层的数据都将会被保存在镜像中,即使后一层删除了前一层内保存的文件。
比如,我们有如下Dockerfile:
FROM alpine:3.12 RUN truncate -s 50M /sample.dat RUN rm -rf /sample.dat
我们可以试试看这个镜像编译出来有多大,58MB:
相比起来,正常的alpine:3.12只有5.57MB,说明即使我们已经删除了 /sample.dat
文件,在最后的镜像中也没有这个内容,但是它永远留在了镜像的history中。
所以,在删除上文说到的“中间依赖”时,我们需要将安装、使用、卸载三个部分写在一个步骤中,才能保证空间被释放。比如:
FROM debian:buster RUN apt-get update && apt-get install gcc && gcc ... && apt-get purge --autoremove gcc && rm -rf /var/lib/apt/lists/*
5. 多阶段编译
在Docker 17.05版本以后,新引入了 multi-stage builds 这一概念,这将会极大地简化我们上述的所有操作。
简单来说,multi-stage builds支持我们将Docker镜像的编译分成多个“阶段”。比如常见的软件编译的情况,我们可以将编译阶段单独提出来,软件编译完成后直接将二进制文件拷贝到一个新的基础镜像中,这样做最大的好处就是,第二个镜像不再包含任何编译阶段使用的中间依赖,干干净净明明白白。
以最常见的Java项目为例,编译Jar包的时候,我们需要使用到JDK、Maven等工具,但在实际运行阶段,我们只需要JRE环境即可。简单比较下 maven:3-openjdk-8
和 openjdk:8-jre
两个镜像的大小:
差别一倍有余。
以Vulhub中的Shiro 1.2.4环境为例,在其Dockerfile中可以看到两个 FROM
命令:
FROM maven:3-jdk-8 AS builder LABEL MAINTAINER="phithon <root@leavesongs.com>" COPY ./code/ /usr/src/ WORKDIR /usr/src RUN cd /usr/src; mvn -U clean package -Dmaven.test.skip=true FROM openjdk:8u102-jre LABEL MAINTAINER="phithon <root@leavesongs.com>" COPY --from=builder /usr/src/target/shirodemo-1.0-SNAPSHOT.jar /shirodemo-1.0-SNAPSHOT.jar EXPOSE 8080 CMD ["java", "-jar", "/shirodemo-1.0-SNAPSHOT.jar"]
第一个 FROM
用来进入 maven:3-jdk-8
环境,使用maven对源码进行编译;第二个 FROM
进入较小的 openjdk:8u102-jre
环境,使用 COPY --from=
语法,从前一个阶段的编译结果中将jar文件复制到jre的环境中。
最后,在机器上将会留下两个镜像,一个是builder,一个是最终我们需要的那个shiro 1.2.4的环境,后者可以被其他任何用户独立使用,而前者可以直接删除。
对于使用者来说,我们无需再纠结编译软件时中间依赖如何删除才能让镜像比较小的问题,反正第一阶段使用的任何依赖多不会被遗留到正式的生产环境中。
但多阶段编译对于动态链接库的依赖仍然有上述的问题,如果我们拷贝编译成果时只拷贝了可执行文件,在新环境下运行仍然会出现找不到共享链接库的错误。所以个人觉得,多段式编译仅适合于Java、golang等能够跨平台或静态编译的语言,对于C、Python这些依赖较多的项目仍然不友好。
6. 使用slim版本的镜像
细心的同学可能注意过,Docker官方的Debian镜像有个slim版本,这个版本的大小比默认的版本要小一倍多:
slim的中文意思就是“苗条的”,顾名思义, debian:stretch-slim
确实苗条的多,原因是其删除了man文档等许多不会在容器里用到的文件。
有一些上层的镜像会基于slim版本的debian进行编写,比如python。如果我们开发python的项目,可以使用 python:slim
这个基础镜像。
总结一下,六种方法,互相不会影响,我们可以同时使用。但第5个,多阶段编译将会是以后的主流方式。
下一篇:Linux bzip2 命令的使用