33

Docker镜像瘦身的三个窍门

 5 years ago
source link: http://dockone.io/article/8174?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

uA3UbeU.png!web

在构建Docker容器时,我们应尽可能减小镜像的大小。使用共享层的镜像尺寸越小,其传输和部署速度越快。

不过在每个 RUN 语句都会创建一个新层的情况下,如果我们需要获取镜像完成前的中间产物,又如何控制其大小呢?

你可能已经注意到市面上多数的Dockerfile都会使用类似这样的招数:

FROM ubuntu

RUN apt-get update && apt-get install vim

为什么要使用&&,而不像这样运行两个 RUN 语句?

FROM ubuntu

RUN apt-get update

RUN apt-get install vim

从Docker 1.10起, COPYADDRUN 语句会在镜像中添加新层。上述示例将创建两个层,而不是一个。

fMJvMz3.gif

层跟Git提交类似。

Docker层存储了镜像上一版本和当前版本之间的差异。与Git提交类似,层有利于与其他仓库或镜像进行共享。

实际上,当我们从Registry请求镜像时,我们只会下载那些不存在的层。这种方式让镜像共享更高效。

但是,层是有代价的。

层会占用空间,层越多,最终的镜像就越大。Git仓库在这方面是相似的。因为Git需要保存提交之间的所有变更,仓库的大小会随着层数的增加而增加。

在过去,做法就是像第一个例子那样将几个 RUN 语句合并在一行中。

然后就没有然后了。

1. 使用Docker多阶段构建将层合并为一

当Git仓库变得越来越大时,我们可以放弃所有过往信息,将历史提交合并成一个。

使用Docker的多阶段构建,我们也能实现类似的功能。

接下来的例子中,我们将构建一个Node.js容器。

首先是 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"]

构建镜像:

$ docker build -t node-vanilla .

然后我们可以这么验证它:

$ docker run -p 3000:3000 -ti --rm --init node-vanilla

访问 http://localhost:3000 应该就能看到“Hello World!”欢迎语。

这个 Dockerfile 文件中有一个 COPY 和一个 RUN 语句。在我们的预期中,在基础镜像上应该至少有两层:

$ docker history node-vanilla

IMAGE          CREATED BY                                      SIZE

075d229d3f48   /bin/sh -c #(nop)  CMD ["npm" "start"]          0B

bc8c3cc813ae   /bin/sh -c npm install                          2.91MB

bac31afb6f42   /bin/sh -c #(nop) COPY multi:3071ddd474429e1…   364B

500a9fbef90e   /bin/sh -c #(nop) WORKDIR /app                  0B

78b28027dfbf   /bin/sh -c #(nop)  EXPOSE 3000                  0B

b87c2ad8344d   /bin/sh -c #(nop)  CMD ["node"]                 0B

<missing>      /bin/sh -c set -ex   && for key in     6A010…   4.17MB

<missing>      /bin/sh -c #(nop)  ENV YARN_VERSION=1.3.2       0B

<missing>      /bin/sh -c ARCH= && dpkgArch="$(dpkg --print…   56.9MB

<missing>      /bin/sh -c #(nop)  ENV NODE_VERSION=8.9.4       0B

<missing>      /bin/sh -c set -ex   && for key in     94AE3…   129kB

<missing>      /bin/sh -c groupadd --gid 1000 node   && use…   335kB

<missing>      /bin/sh -c set -ex;  apt-get update;  apt-ge…   324MB

<missing>      /bin/sh -c apt-get update && apt-get install…   123MB

<missing>      /bin/sh -c set -ex;  if ! command -v gpg > /…   0B

<missing>      /bin/sh -c apt-get update && apt-get install…   44.6MB

<missing>      /bin/sh -c #(nop)  CMD ["bash"]                 0B

<missing>      /bin/sh -c #(nop) ADD file:1dd78a123212328bd…   123MB

实际上,最终的镜像上添加了五个新层: Dockerfile 里的每条语句一层。

我们来试试Docker的多阶段构建。

我们使用的 Dockerfile 与之前相同,不过分成了两部分:

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 ["index.js"]

Dockerfile 的第一部分创建了三个层。接着这些层被合并复制到第二个阶段中。然后又在这个镜像之上添加了两层,最终变成三个层。

RfInAzU.gif

现在来验证一下。首先,构建该容器:

$ docker build -t node-multi-stage .

查看其构建历史:

{{

$ docker history node-multi-stage

IMAGE CREATED BY SIZE

331b81a245b1 /bin/sh -c #(nop) CMD ["index.js"] 0B

bdfc932314af /bin/sh -c #(nop) EXPOSE 3000 0B

f8992f6c62a6 /bin/sh -c #(nop) COPY dir:e2b57dff89be62f77… 1.62MB

b87c2ad8344d /bin/sh -c #(nop) CMD ["node"] 0B

<missing> /bin/sh -c set -ex && for key in 6A010… 4.17MB

<missing> /bin/sh -c #(nop) ENV YARN_VERSION=1.3.2 0B

<missing> /bin/sh -c ARCH= && dpkgArch="$(dpkg --print… 56.9MB

<missing> /bin/sh -c #(nop) ENV NODE_VERSION=8.9.4 0B

<missing> /bin/sh -c set -ex && for key in 94AE3… 129kB

<missing> /bin/sh -c groupadd --gid 1000 node && use… 335kB

<missing> /bin/sh -c set -ex; apt-get update; apt-ge… 324MB

<missing> /bin/sh -c apt-get update && apt-get install… 123MB

<missing> /bin/sh -c set -ex; if ! command -v gpg > /… 0B

<missing> /bin/sh -c apt-get update && apt-get install… 44.6MB

<missing> /bin/sh -c #(nop) CMD ["bash"] 0B

<missing> /bin/sh -c #(nop) ADD file:1dd78a123212328bd… 123MB

}}

符合预期!文件大小有变化么?

$ docker images | grep node-

node-multi-stage   331b81a245b1   678MB

node-vanilla       075d229d3f48   679MB

是的,新的镜像要小一点点。

看起来还不错!尽管应用本身已经做了精简,我们还是减少了其整体大小。

不过,镜像依然很大!

要让它变得更小一点,我们还能做点什么?

2. 使用Distroless移除容器中的所有累赘

目前的镜像不仅含有Node.js,还含有 yarnnpmbash 以及大量其他二进制文件。同时,它是基于Ubuntu的。因此拥有一个完整的操作系统以及所有的二进制文件和实用程序。

这些在运行容器时都不是必需的。我们唯一的依赖项是Node.js。

Docker容器应封装在单一进程中,且只包含运行所需的最精简内容。我们不需要一个操作系统。

实际上,除了Node.js,其他都可以移除。

那么要怎么做呢?

幸运的是,Google也有同样的想法,他们带来了 GoogleCloudPlatform/distroless

有如其仓库说明所述:

“Distroless”镜像只包含应用程序及其运行时依赖。不包含包管理器、Shell以及其他标准Linux发行版中能找到的其他程序。

这正是我们所需要的!

我们可以调整Dockerfile文件来使用这个新的基础镜像:

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

然后像平常那样编译镜像:

$ docker build -t node-distroless .

应用程序应能正常运行。要验证这一点,可以像这样运行容器:

$ docker run -p 3000:3000 -ti --rm --init node-distroless

访问 http://localhost:3000 页面即可。

这个未包含额外程序的镜像会多小呢?

$ docker images | grep node-distroless

node-distroless   7b4db3b7f1e5   76.7MB

仅仅76.76MB!

比前一个镜像少了600MB!

真是个好消息!不过在使用Distroless时有些事项需要注意。

容器运行时,如果想对其进行检查,可以这么做:

$ docker exec -ti <替换成_docker_id> bash

上述命令将附加到容器中并运行 bash ,这与发起一个SSH会话相近。

不过由于Distroless是原始操作系统的精简版本,不包含额外的程序。容器里并没有Shell!

如果没有Shell,要如何附加到运行的容器中呢?

好消息和坏消息是,做不到。

坏消息是我们只能运行容器中的二进制程序。这里能运行的只有Node.js:

$ docker exec -ti <替换成_docker_id> node

好消息是因为没有Shell,如果黑客入侵了我们的应用程序并获取了容器的访问权限,他也无法造成太大的损害。也就是说,程序越少则尺寸越小也越安全。不过,代价是调试更麻烦。

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

如果我们既希望能调试,又关心尺寸大小,又该怎么办?

3. 使用Alpine作为更小的基础镜像

我们可以使用Alpine取代Distroless来作为基础镜像。

Alpine Linux 是:

一个基于 musl libcbusybox 、面向安全的轻量级Linux发行版。

换言之,它是一个尺寸更小、更安全的Linux发行版。

是否言过其实,我们来检查一下这个镜像是否更小。

修改之前的 Dockerfile 并使用 node:8-alpine

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

构建该镜像:

$ docker build -t node-alpine .

现在看一下它的大小:

$ docker images | grep node-alpine

node-alpine   aa1f85f8e724   69.7MB

69.7MB!

甚至比Distroless镜像还要小!

我们来看看能不能附加运行中的容器。

首先,启动容器:

$ docker run -p 3000:3000 -ti --rm --init node-alpine

Example app listening on port 3000!

现在附加到容器中:

$ docker exec -ti 9d8e97e307d7 bash

OCI runtime exec failed: exec failed: container_linux.go:296: starting container process caused "exec: \"bash\": executable file not found in $PATH": unknown

运气不佳。但或许容器有 sh 这个Shell?

$ docker exec -ti 9d8e97e307d7 sh

/ #

很好!我们既可以附加到运行的容器中,得到的镜像尺寸也很小。

听起来很棒,不过有一个小问题。

Alpine基础镜像是基于muslc的,这是一个C的替代标准库。

但是,多数Linux发行版,比如Ubuntu、Debian及CentOS都是基于glibc的。这两个库照理应该实现了相同的接口。

不过,它们的目标不同:

  • glibc 最常用,速度更快
  • muslc 占用空间更少,以安全为核心

在编译应用程序时,多数情况下是使用某个libc来编译的。如果想在其他libc中使用,只能重新编译。

也就是说,使用Alpine镜像来构建容器可能会造成不可预期的问题,因为使用的是不同的C标准库。

特别是在处理预编译的二进制文件时,比如Node.js的C++扩展,这个差异更明显。

举个例子,PhantomJS预置包就无法在Alpine中工作。

怎么选择基础镜像?

Alpine、Distroless或是原生镜像到底用哪个?

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

Docker镜像中每增加一个二进制程序,就会给整个应用程序带来一定的风险。

在容器中只安装一个二进制程序即可降低整体风险。

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

注意: 最小化攻击面是OWASP的推荐做法

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

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

原生基础镜像非常适合用于测试和开发。

它的尺寸比较大,不过用起来就像你主机上安装的Ubuntu一样。并且,你能访问该操作系统里有的所有二进制程序。

下面,回顾一下各个镜像大小:

node:8 681MB

node:8 结合多阶段构建 678MB

gcr.io/distroless/nodejs 76.7MB

node:8-alpine 69.7MB

原文链接: 3 simple tricks for smaller Docker images (翻译: 梁晓勇


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK