运维 缩减 Docker 镜像体积历程总结

lanzhiheng · 2019年02月06日 · 最后由 lanzhiheng 回复于 2019年02月11日 · 9351 次阅读

Docker

容器化的过程中总是免不了要构建镜像,一个体积更小的镜像除了能够节省机器的磁盘空间之外,还能够提升传输效率。这篇文章主要是想讲述一下自己在优化镜像体积时所采取的措施,当然并不是所有方案都对减少镜像体积有明显效果,具体项目还要具体分析。这篇文章我以 Rails 项目的镜像构建作为例子。

为什么构建出来的镜像这么大?

在优化镜像大小之前首先要知道为何我们所构建的镜像会这么大?下面是我项目中用于构建镜像的Dockerfile文件

FROM ruby:2.5.3

RUN apt-get update -y && apt-get install -y \
        build-essential \
        imagemagick \
        default-libmysqlclient-dev
RUN apt-get install -y \
        nodejs \
        yarn
RUN rm -rf /var/lib/apt/lists/*

WORKDIR /beansmile-web
COPY . /beansmile-web

RUN bundle install

镜像文件我定义得比较随意,它所构建出的镜像信息如下

web1                 latest               1a8a32d5253a        9 hours ago         1.26GB

构建的镜像的过程跟平常基于一个操作系统打造供项目运行基础环境的过程差不多。只是日常的操作系统通常都不只一个项目在运行,因此系统里所包含的东西是比较全面的。而镜像只期望提供给特定的项目使用,因此所依赖的东西比较有针对性,不必要的东西尽量不要加进去。

针对上面的 Dockerfile 文件我觉得有以下几个优化方向

  1. 基础的 ruby:2.5.3 是基于 buildpack-deps 来构建的其中包含大量额外的软件包,或许用更轻量级的镜像来作为基础镜像能够进一步缩减空间。
  2. 文件中有太多的 RUN 命令,每一条命令都会叠加层数,可能会造成体积的变大。
  3. 把整个项目目录都拷贝到镜像中,并不是个好主意,随着项目的增大,Rails 项目中的 public 目录下可能会存在着图片之类的静态资源,把这些东西打包到镜像中意义不大。
  4. 是否能够采用multi-stage的构建方式,把一部分不那么紧要的资源丢弃,让最终镜像体积更小?

下面一条条来分析。

具体分析

方向 1:基于更小的操作系统

前面的例子最终构建出来的镜像体积十分庞大,主要归咎于相关的基础镜像本身就很大。

REPOSITORY          TAG                  IMAGE ID            CREATED             SIZE
ruby                2.5.3                60c3a1518797        3 weeks ago         871MB
web1                latest               1a8a32d5253a        9 hours ago         1.26GB

可见我们的基础 Ruby 镜像本身就 800 多 M 了,构建镜像的过程还需要安装依赖,导致了最终的web镜像体积会达到 1.26G。这个体积可不利于网络传输,官方所提供的Ruby 基础镜像有许多个版本,除了 Ruby 本身的版本不同之外,还有许多基于不同操作系统所构建的基础镜像可以选择,而这些不同的操作系统所构建出来的 Ruby 基础镜像的体积相差甚大

REPOSITORY          TAG                  IMAGE ID            CREATED             SIZE
ruby                2.5.3-slim-stretch   20132a4ab93d        2 weeks ago         129MB
ruby                2.5.3                60c3a1518797        3 weeks ago         871MB
ruby                2.5.3-alpine         b3361f13ff1f        3 weeks ago         43.6MB

基于alpine操作系统的 Ruby 镜像是最迷你的,只有 43.6MB。slim-stretch也是个不错的选择。或许采用更轻量级的镜像将会是一个优化的契机。

经验小贴士: 从我自己的构建经验来看,采用slim-stretch或许会是更加亲民的选择,它是 Debian 系,包管理器跟ubuntu是一样的都是用apt-get,用惯ubuntu的人肯定会觉得比较亲切。alpine所用的包管理器是apk(是不是想到安卓的安装包?),一些常用包的命名有点不太一样需要自己慢慢去解决。*

不过无论用哪种方案都避免不了时间的投入,网上也没那么多现成的解决方案,迷你镜像的话你不得不自己安装一些构建过程中所依赖的软件。

方向 2: 缩减镜像的层数

Docker 官网对镜像的说法是,它是由一层层的只读层组成的,层次越少镜像的性能表现越出众。这也是官方建议我们采用特定基础镜像去构建自己的项目镜像,而不是基于一个赤裸裸的操作系统镜像 (如 Ubuntu 镜像) 的原因。

上述的例子中我们用了三个RUN命令,这会无意中多构建了两个层,其实我们可以把它合并成一条RUN命令

RUN apt-get update -y && apt-get install -y \
        build-essential \
        imagemagick \
        default-libmysqlclient-dev \
        nodejs \
        yarn \
        && rm -rf /var/lib/apt/lists/*

基于这个改动重新创建一个镜像web2

REPOSITORY          TAG                  IMAGE ID            CREATED             SIZE
web2                latest               221a316a6903        14 minutes ago      1.25GB
web1                latest               1a8a32d5253a        9 hours ago         1.26GB

可见这种改动对于缩减镜像体积效果并不明显

官方的说法是这样的

In older versions of Docker, it was important that you minimized the number of layers in your images to ensure they were performant.

我们可以得出结论,或许缩减层数主要是为了让镜像操作起来更高效吧,减少层数这个优化方向对于缩减镜像体积并没有多大的帮助,不过我们这样做还是有好处的。

方向 3: 忽略一些文件

从上面的配置可以看出,为了方便镜像的构建我直接把整个项目都移动到镜像中去 (COPY命令)。然而对于构建的镜像而言,并不是所有的文件我们都应该关心,最为值得关心的应该只有源码部分。所以我预想着在构建的镜像中可以把以下的目录剔除掉

  • public/: 用于存放一些静态文件的目录,如果其中包含大量像图片这样的资源的话会对镜像的体积有较大的影响。
  • tmp/: 用于存放一些缓存资源,项目进程文件等等,这些文件对于镜像而言用处不大。
  • log/: 用于存放日志相关的信息。

PS: 当然每个人对实际项目的考量会有所不同,这几个目录只是根据我个人的项目情况所做的决定,并不具有通用性。

要忽略这些文件,我们采用一个名为.dockerignore的文件,把它放在当前的目录下即可,它的写法跟.gitignore文件很相似,内容大概如下

/public/**
/tmp/**
/log/**

然后重新构建镜像

web3                latest               fb13cc1301b2        About a minute ago   1.2GB
web2                latest               221a316a6903        23 hours ago         1.25GB
web1                latest               1a8a32d5253a        33 hours ago         1.26GB

这种方式的影响也不怎么大,这是因为目前我本地这些目录下所包含的“垃圾”资源所占的比重较小。

方向 4: multi-stage 方案

这个是官方推荐的方案,在 Docker17.05 之后可以使用

In Docker 17.05 and higher, you can do multi-stage builds and only copy the artifacts you need into the final image. This allows you to include tools and debug information in your intermediate build stages without increasing the size of the final image.

好像看起来有点复杂,不过它的原理大概就是先使用一个体积较大,依赖较为齐全的镜像来构建所需要的资源,然后把这些资源复制到一个轻量的基础镜像中,并继续我们的镜像构建工作,这样就可以把原先庞大的基础镜像给抛弃了。这种做法能避免我们最终的镜像中包含了一堆无用的依赖,在某种程度上能够减少最终镜像的体积。

这看起来是个很不错的策略,我也在项目中进行了尝试。我们决定把 bundle 依赖包的安装以及,静态文件的编译都放到一个功能完备的基础镜像中去完成,然后把所需要的资源拷贝到一个轻量级的基础镜像中 (类似 alpine 这种轻量级系统的相关镜像) 再继续完成构建步骤。

不过我构建过程中遇到如下问题

  • 用 bundle 安装依赖的过程中不仅仅涉及到 ruby 代码的引入,mysql2nokogiri这些第三方库除了会引入 Ruby 代码之外还会在安装的时候进行编译,并生成一些共享库,如果把依赖资源从一个镜像拷贝到另一个镜像的话除了要拷贝 bundle 相关目录下的 ruby 代码之外,还不得不拷贝这些第三方库所依赖的共享库,这比想象中要麻烦。
  • 我们期望在一个镜像里面完成静态文件的构建,那么我们便可以在最终镜像中免去了安装 nodejs, yarn 这些用于编译静态资源相关的依赖了。不过后来还是觉得这种方案不太适用。一方面,安装了 nodejs 与没有安装 nodejs 的镜像差别也就是 30M 左右,另一方面,要运行bin/rails c需要依赖 JS 运行时,这无论对于开发还是生产都是一个比较重要的操作,因此在最终镜像中舍弃 JS 运行时并不是个好主意。

最终构建

前面提到了 4 个优化的方向,但似乎最终只有

  • 采用更轻量级的操作系统的相关基础镜像来进行构建。
  • multi-stage。

对最终的镜像体积影响较大。考虑到multi-stage的解决方案所带来的好处可能还不如麻烦来得多,因此最终还是舍弃了这个方案,与其这样绕来绕去还不如直接采用最精简的ruby:2.5.3-alpine作为基础镜像来打造自己的项目镜像。选择一个精简的操作系统最大的问题就是在构建项目镜像过程中的所有基础依赖都得自己一个个去解决,要投入不少的时间和精力,以下是我经过反复测试所得到的 Dockerfile 文件(仅供参考,毕竟你的项目所依赖的东西可能有所不同)

FROM ruby:2.5.3-alpine

RUN apk --update --upgrade add \
        # bundle 安装相关的依赖
        git \
        curl \
        # mysql2 依赖
        mysql-dev \
        # 基础设施,比如gcc相关的东西
        build-base \
        # nokogiri 相关依赖
        libxslt-dev \
        libxml2-dev \
        # 图片处理相关依赖
        imagemagick \
        # tz相关,如果没有bundle的时候会报错
        tzdata \
        nodejs \
        yarn \
        && rm -rf /var/cache/apk/*

WORKDIR /beansmile-web
COPY . /beansmile-web/
RUN bundle install

构建出来的镜像如下

web4                latest               71b75128d0d9        14 hours ago         586MB

与之前的镜像相比体积大幅度减少了。这是一个我们可以接受的大小了,考虑到时间成本就不进一步压缩了。

总结

这篇文章主要简单总结了个人在缩减 Rails 项目镜像方面的探究。为了缩减镜像体积提出了 4 个主要的优化方向,用迷你的操作系统构建镜像的方式来减少镜像的体积的方式十分有效。不过不同类型,基于不同语言的项目可能会有不同的侧重点,不能一概而论,可能有的项目中multi-stage会帮你省下更多的时间。

赞啦,以后会写点 Docker Swarm 的 CI/CD 实践文章吗?

一个小项目用 ubuntu 的镜像打包出来也是 500+MB,alpine 把依赖安装全估计体积差不多。


看了下,ubuntu 18.04 基础镜像是 86.2MB。

参考我之前的文章运维 GitLab, Docker, Ruby on Rails CI/CD 实践,采用编译环境和生产环境分离,可以极大的减少镜像大小。我的生产镜像,基于 Ubuntu 18.04,包括 ruby 2.5,passenger,nginx,nodejs,imagemagick,mysql 驱动,postgresql 驱动,才 431MB。而编译环境镜像大概要接近 600MB,主要是编译工具占了很大的空间。用 dive 工具看了一下,估计可以进一步缩减到 400MB 一下。

freefishz 回复

感谢,我稍后会去看看,最近公司也正准备做这个事情。目前我还停留在镜像打包的地步。

1c7 回复

谢谢,应该不会用 Docker Swarm 了,公司的运维都把 K8S 环境搭建好了,估计到时候会直接上 K8S。

Rei 回复

感谢指正。那个最大的基础镜像不是基于 ubuntu 的,是我先入为主了,已经更正。ubuntu 的基础镜像确实只有几十 M,基础镜像是 buildpack-deps

This tag is based off of buildpack-deps. buildpack-deps is designed for the average user of Docker who has many images on their system. It, by design, has a large number of extremely common Debian packages. This reduces the number of packages that images that derive from it need to install, thus reducing the overall size of all images on your system.

Dokcer 镜像减肥的重点应该是『简』而不是『小』,单纯的追求容量的减小并不会带来什么提升或收益

反倒是花些时间理清镜像中,哪些是编译依赖,哪些是运行依赖,哪些是必须的,再适度优化,利用好镜像层的缓存,我觉得会更有意义

IChou 回复

花时间清理这些倒也不是不行,不过区分了编译依赖还有运行依赖,并清理了之后其本质也是减少了容量而已吧?我当时考虑是清理这些似乎对减少容量没有太大的影响所以就把优先级放低了。

lanzhiheng 回复

本质上是帮你更清楚的认识应用所需的运行环境,维护一个没有“无用”组件的环境

由于镜像传输是分层 + 压缩的,对于持续构建的项目,镜像大小真没太大区别,比如你的镜像,只有 COPY 之后的两层会在 pull 时更新,前面的都是有缓存的(部署新机器例外)

我之前的做法是预打包一个足够简单又足够通用的基础镜像,公司所有 Rails 项目都基于它来打包,以保证快速扩容时可以在集群的任何一台机器上快速部署

我始终觉得镜像正确的进化方式是“是不是足够简单”,以避免环境变得复杂而难以维护,体积小只是顺手而为之

说得很有道理

我之前的做法是预打包一个足够简单又足够通用的基础镜像,公司所有 Rails 项目都基于它来打包,以保证快速扩容时可以在集群的任何一台机器上快速部署

我是否能把这理解成,COPY 前面的部分可以打包成一个基础镜像?然后其他的 Rails 项目都用同一个基础镜像?

需要 登录 后方可回复, 如果你还没有账号请 注册新账号