在构建容器镜像时,我们总是希望得到尺寸更小的镜像。比如尽可能的减少镜像中的层数,因为创建新的层是有代价的,每个层都会产生一些数据上的开销。常见的手段是通过 && 把多个 RUN 指令合并为一个:



# 两个 RUN 指令会创建两个镜像层
FROM ubuntu
RUN apt-get update
RUN apt-get install vim

# 通过 && 把两个 RUN 指令合并为一个,
# 这样只会创建一个镜像层,
# 从而减小最终镜像的尺寸
FROM ubuntu
RUN apt-get update && apt-get install vim



而现在,我们有了更多的选择。我们可以使用 multi-stage 技术(关于 multi-stage 技术,请参考笔者的博文《Dockerfile 中的 multi-stage》)来减小容器镜像的尺寸,并且还可以使用 multi-stage 技术结合不同的父镜像来极大的减小最终的镜像的尺寸。有没有很心动呢?接下来就让我们通过 demo 演示笔者砍掉镜像虚膘的三板斧!

通过 multi-stage 减小镜像尺寸

让我们创建一个 host 在容器中的 nodejs 程序,并用它来进行本文的 demo 演示。先创建 index.js 文件,其内容如下:



const express = require('express')
const app = express()

app.get('/', (req, res) => res.send('Hello World!'))

app.listen(3000, () => {
  console.log(`Example app listening on port 3000!`)
})



然后创建 package.json 文件,内容如下:

{
  "name": "hello-world",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "express": "^4.16.2"
  },
  "scripts": {
    "start": "node index.js"
  }
}


最后创建的 Dockerfile 文件内容如下:



FROM node:8

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

CMD ["npm", "start"]



我们使用父镜像 node:8 来构建并运行上面的 nodejs 应用:



$ docker build -t node-demo .
$ docker run -p 3000:3000 -ti --rm --init node-demo



查看容器镜像的名字 容器,镜像_运维

这样应用程序就运行起来了,然后可以通过 http://localhost:3000/ 访问它。

现在让我们把注意力转移到运行容器的镜像 node-demo 身上,先看看它的构建历史:


$ docker history node-demo


查看容器镜像的名字 容器,镜像_运维_02

我们的构建过程只是在父进行的基础上增了 3M 多一点的数据。
下面在 Dockerfile 中使用 multi-stage(关于 multi-stage 技术,请参考笔者的博文《Dockerfile 中的 multi-stage》):



FROM node:8 as build

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

FROM node:8

COPY --from=build /app /
EXPOSE 3000
CMD ["npm", "start"]



把上面的内容保存到 Dockerfile.multi 文件中,然后构建镜像 node-demo-multi:


$ docker build --no-cache -t node-demo-multi . -f Dockerfile.multi


构建成功后看看 node-demo-multi 的历史:

$ docker history node-demo-multi


查看容器镜像的名字 容器,镜像_操作系统_03

这次在父镜像的基础上增加的数据更少,总共只有 1.63M,应该是 multi-stage 在中间过程中进行了层合并的结果。然后再对比一下两个镜像的大小:



$ docker images|grep node-demo



查看容器镜像的名字 容器,镜像_操作系统_04

在我们的场景下使用了 multi-stage 产生的镜像只比没有使用的情况小了 1M,但是对于镜像层数比较多的场景效果会更加明显。关于 multi-stage 的更多内容,请参考笔者的博文《Dockerfile 中的 multi-stage》。

移除镜像中的非必要内容

以我们使用的父镜像 node:8 为例:

查看容器镜像的名字 容器,镜像_操作系统_05

其 676M 的体积中包含了众多运行 nodejs 应用并不需要的程序,比如 npm,bash 和其它众多的文件。如果我们能够把这些不需要的文件全部移除,镜像就会缩小很多。但是,具体该怎么做呢?答案是:使用合适的父镜像!
Google 在 github 上创建了 distroless 项目专门来做这件事!Distroless 项目提供的容器镜像只包含运行应用程序的最新集合,所以能够把镜像压缩到很小。但是需要为不同的应用程序使用不同的镜像,下面是当前已经提供的镜像:

查看容器镜像的名字 容器,镜像_docker_06

让我们使用 distroless 提供的 nodejs 镜像来重新构建 demo 应用的镜像:



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"]



把上面的内容保存到文件 Dockerfile.less 中,并运行下面的命令:



$ docker build --no-cache -t node-demo-less . -f Dockerfile.less
$ docker run -p 3000:3000 -ti --rm --init node-demo-less



查看容器镜像的名字 容器,镜像_docker_07

应用程序可以正常的运行,然后让我们看看刚才构建的容器镜像 node-demo-less:

查看容器镜像的名字 容器,镜像_运维_08

只有 76.7M!这确实太不可思议了,在吃惊之于让我们回归理性,看看 distroless 究竟是如何把镜像做的这么小?我想先用 docker exec 命令进入容器内部看看情况,结果是我无法用下面的命令进入到容器内部:



$  docker exec -it <container id> bash



结论是为了减小镜像的大小,镜像中没有 bash 这样的工具。那么镜像中有什么?答案是只有 nodejs。你唯一能通过 docker exec 运行的命令就是:



$ docker exec -it <container id> node



这同样让人大吃一惊,不能进入容器的话可怎么调查故障?但现实就是这么残酷,当你享受极小的镜像时,你也为方便性付出了代价。其实故障调查完全可以通过完善的日志系统来解决。镜像中只有一个程序带来的另一个好处是安全性的提升!

使用 Alpine 作为 base 镜像

除了 Google 的 distroless 项目,其实我们还可以有其他的选择,它就是 Alpine。使用 Alpine 作为父镜像同样也会为你带来意想不到的惊喜(如果你留意了《Dockerfile 中的 multi-stage》一文中的镜像大小)!

Alpine Linux 是一个基于 musl libc 和 busybox 的,以安全性为目标的轻量级 Linux 发行版。换句话说就是:它的 size 更小,安全性更高!
接下来让我们用 Alpine 版的父镜像构建 demo 程序镜像:



FROM node:8 as build

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

FROM node:8-alpine

COPY --from=build /app /
EXPOSE 3000
CMD ["npm", "start"]



把上面的内容保存到文件 Dockerfile.alpine 中,并运行下面的命令:



$ docker build --no-cache -t node-demo-alpine . -f Dockerfile.alpine
$ docker run -p 3000:3000 -ti --rm --init node-demo-alpine



查看容器镜像的名字 容器,镜像_docker_09

应用程序可以正常的运行,然后让我们看看刚才构建的容器镜像 node-demo-alpine:

查看容器镜像的名字 容器,镜像_运维_10

只有 69.7M,比使用 distroless 项目创建的镜像还要小!能用 docker exec 命令进入容器吗?让我们运行一个命名的容器试试:



$ docker run -p 3000:3000 -d --name democon --init node-demo-alpine
$ docker exec -it democon sh



查看容器镜像的名字 容器,镜像_运维_11

这次我们成功的进入了容器,虽然不支持 bash,但能有个 shell 哥们就感觉幸福无边了!

Alpine Linux 看起来很完美,但是请把接下来的文章读完!
Alpine Linux 中的 C 库不是我们常用的 glibc,而是 muslc。也就是说,使用基于 Alpine 的镜像有可能产生 C 库不同导致的问题。举个例子,PhantomJS 就不能在 Alpine 中正常工作。

我们究竟该如何选择父镜像

  • 如果是在生产环境中使用,并且有安全性的考虑,建议使用 distroless 镜像。
  • 如果要保持尽可能小的镜像,建议使用 alpine 进行。
  • 开发、测试环境中建议使用官方镜像通过 multi-stage 构建,方便问题调查。

下表展示了使用不同方式构建的镜像大小:

查看容器镜像的名字 容器,镜像_json_12

总结

控制镜像的大小是一件永远在路上的事情,使用 multi-stage 技术并选择合适的父镜像可以把我们从繁琐的操作中解放出来,这就是生产力啊!需要注意的是,你需要小心的选择父镜像,选择体积又小又适合你的应用程序的镜像可是个技术活儿呢!

参考:
3 simple tricks for smaller Docker images