Jason Pan

给 Docker 镜像瘦身

潘忠显 / 2022-11-24


我们在使用 CI/CD 的时候,如果用于构建或运行的镜像较大,会直接影响拉取速度,进而影响构建与部署的时间。本文会简单介绍一下 Docker 的镜像大小是什么决定的,然后针对以下几种常见的镜像使用场景,介绍如何减少镜像的空间:

一、镜像、容器和层

Docker uses storage drivers to store image layers, and to store data in the writable layer of a container.

Docker 使用存储驱动程序(storage drivers)来存储镜像的层容器的可写层。存储驱动程序针对空间效率进行了优化,但写入速度低于本机文件系统性能(取决于存储驱动程序)。

类似于数据库存储、本地日志服务的写入密集型应用程序应当使用 Docker Volumn,不然会受存储驱动性能开销的影响,尤其在只读层中存在预先存在的数据的情况下。(关于卷更广为人知的是:用于在容器生命周期之外保留的数据、容器之间共享的数据)

1.1 只读层、可写层、写时复制

Dockerfile 中的 FROMRUN 的行都会生成层,无论是生成文件或删除文件,都会产生新的层。这些层是只读的,随着 Dockerfile 的增加,后边的层依赖前面的层,堆叠在一起。

镜像的大小,就是每层累积起来的大小,而镜像的每一层都是只读层。最后的 CMD 表示在容器里运行的指令,是不会在镜像中产生层的。

容器和镜像之间的主要区别在于顶层可写层。当创建一个新容器时,会在镜像的层基础上,增加一个可写层(通常被称为“容器层”)。对正在运行的容器所做的所有更改,例如写入新文件、修改现有文件和删除文件,都将写入这个薄的可写容器层(thin R/W layer)。当容器被删除时,可写层也被删除。

因为每个容器都有自己的可写容器层,所以多个容器可以共享对同一底层镜像的访问,但有自己的数据状态。

共享同一镜像的容器

写时复制 (CoW) 是一种高效的共享/复制文件策略。如果一个文件或目录存在于镜像中的较低层,而另一更高层(包括可写层)需要对其进行读取访问时:文件被从低层复制到高层并进行修改。这最大限度地减少了 I/O 和每个后续层的大小。

1.2 实验展示

接下来,以一个实际的 Dockerfile 进行构建和运行,来形象地展示镜像、容器、层之间的关系。

这个 Dockerfile 中使用基础镜像 ubuntu:18.04,然后创建了一个 100M 的文件放在 /tmp/ 目录下,最后将该文件删除。

FROM ubuntu:18.04
RUN head -c 100M </dev/urandom >/tmp/100M.log
RUN rm /tmp/100M.log

为了更清晰地展示,先将之前的容器、镜像、构建缓存都清理掉:

docker stop $(docker ps -a -q)
docker rm $(docker ps -a -q)
docker image prune -a -f
docker builder prune

将上边的 Dockerfile 存成文件,并在相同目录下构建镜像:docker build -t layer-test .

image-20221123203123559

为了比较,这里把 Ubuntu 的镜像也拉一次:docker pull ubuntu:18.04 可以看到提示中说,该层已经存在。

image-20221123203957256

通过 docker image ls 指令,可以查看两个镜像的大小,layer-test 比基础镜像多了 100MB 左右的空间:

image-20221123204420275

通过 docker inspect layer-testdocker inspect ubuntu:18.04 指令可以查看两个镜像的详细信息,其中就包含层信息,如上一节介绍的一样,layer-test 有三层,ubuntu:18.04 有1层:

image-20221123204153626

二、自写 Dockerfile 的优化

根据前面的介绍,每次 RUN 都会产生一个层,而这些层是累积的。因此,如果我们自己写 Dockerfile 的时候,就要每一个 RUN 都要清理自己产生的后边不需要的中间文件。这些中间文件包括:

针对上边列举的,再给出一些实际的清理例子:

清理 Go 缓存:

go clean --cache && go clean --modcache

清理 yum 安装缓存:

RUN rpm --rebuilddb && yum makecache fast && yum install -y curl-devel expat-devel gettext-devel openssl-devel perl-devel zlib-devel unzip zip which glibc-headers gcc-c++ glibc-headers gcc-c++ kernel-headers libstdc++-static && yum -y clean all

清理构建中间结果:

RUN cd /tmp/ && mkdir gcc-tmp && cd gcc-tmp && wget https://ftp.gnu.org/gnu/gcc/gcc-9.5.0/gcc-9.5.0.tar.gz --no-check-certificate && tar zxf gcc-9.5.0.tar.gz && cd /tmp/gcc-tmp/gcc-9.5.0 && ./contrib/download_prerequisites && cd .. && mkdir objdir && cd objdir && ../gcc-9.5.0/configure --enable-languages=c,c++ && make -j 8 && make install && rm -rf /tmp/gcc-tmp/

三、现存镜像的优化

我们先构建一个【比较大的+没有Dockerfile+有无用数据在层里累积】的镜像,然后再拿这个镜像,做优化的练习。

3.1 从 container 构建镜像

之前 dev-cloud 提供的容器服务,并不是像现在一样提供的容器集群,而是提供拉起的单个 Docker 容器。使用卷的方式,存储需要持久化的数据。同时,还提供将当前容器的状态,打成镜像并上传的功能。

我们在单机上也能通过拉起基础镜像,进行操作,然后再将结果打成新的镜像,跟上边的流程原理应该是一样的。接下来介绍下如何操作:

docker-create-image-from-container

上图中有几点需要注意:

再次通过 docker inspect mirrors.tencent.com/jasonzxpan/layer-test 查看层信息:

截屏2022-11-24 20.20.28

3.2 裁剪现有的镜像

上边的操作得到了一个两层的镜像,你可以直接通过 docker pull 拉取,也可以直接通过 docker run 运行:

docker run -i -t mirrors.tencent.com/jasonzxpan/layer-test

如前边解释的【进入容器直接删掉文件,然后对该容器做镜像】只能增加一层,而不会减小镜像大小:

image-20221124202254588

裁剪现有镜像需要使用 docker export + docker import 将容器的状态,导出成一层然后导入,这样删除起作用了。实际是通过丢弃层累积的信息来实现的。具体的:

image-20221124204133855

参考资料