在构建 Docker 容器时,您应该始终争取更小的image。较小的映像可以更快地传输和部署。但是,当每个语句都创建一个新层时,您如何控制RUN,以减小image镜像的大小 ?

你可能已经注意到,大多数Dockerfile 在编写的时候会有一些技巧,比如:

1FROM ubuntu
2RUN apt-get update && apt-get install vim

为什么使用&& 而不是像下面一样使用两个 RUN语句?

1FROM ubuntu
2RUN apt-get update
3RUN apt-get install vim

从 docker 1.10 开始COPY, ,ADDRUN语句会为您的图像添加一个新层。前面的示例创建了两层而不是一层。

docker image各层就像 git 提交。

Docker image 层的先前版本和当前版本之间的差异 和 git commits 一样。事实上,当您从 registry 请求image 镜像时,您只会下载您尚未拥有的层,这种方式共享镜像的效率要高得多。

您拥有的layer越多,最终镜像就越大。Git 存储库在这方面是相似的。存储库的大小随着层数的增加而增加,因为 Git 必须存储提交之间的所有更改。将多个RUN语句组合在一行中是一种很好的做法。

1. 通过多阶段 Docker 构建将多层压缩为一层

当 Git 存储库变大时,您可以选择将历史压缩为单个提交并忘记过去的提交(git rebase)。您也可以通过多阶段构建在 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

1FROM node:8
2EXPOSE 3000
3WORKDIR /app
4COPY package.json index.js ./
5RUN npm install
6CMD ["npm", "start"]

您可以使用以下方法构建镜像:

1$ docker build -t node-vanilla .

您可以测试它是否可以正常工作:

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

您应该能够访问 http://localhost:3000 并收到_“Hello World!”的问候语。

中有一个COPY和一个RUN语句,所以你应该期望看到只比基本镜像多两层。

 1$ docker history node-vanilla
 2IMAGE          CREATED BY                                      SIZE
 3075d229d3f48   /bin/sh -c #(nop)  CMD ["npm" "start"]          0B
 4bc8c3cc813ae   /bin/sh -c npm install                          2.91MB
 5bac31afb6f42   /bin/sh -c #(nop) COPY multi:3071ddd474429e1…   364B
 6500a9fbef90e   /bin/sh -c #(nop) WORKDIR /app                  0B
 778b28027dfbf   /bin/sh -c #(nop)  EXPOSE 3000                  0B
 8b87c2ad8344d   /bin/sh -c #(nop)  CMD ["node"]                 0B
 9<missing>      /bin/sh -c set -ex   && for key in     6A010…   4.17MB
10<missing>      /bin/sh -c #(nop)  ENV YARN_VERSION=1.3.2       0B
11<missing>      /bin/sh -c ARCH= && dpkgArch="$(dpkg --print…   56.9MB
12<missing>      /bin/sh -c #(nop)  ENV NODE_VERSION=8.9.4       0B
13<missing>      /bin/sh -c set -ex   && for key in     94AE3…   129kB
14<missing>      /bin/sh -c groupadd --gid 1000 node   && use…   335kB
15<missing>      /bin/sh -c set -ex;  apt-get update;  apt-ge…   324MB
16<missing>      /bin/sh -c apt-get update && apt-get install…   123MB
17<missing>      /bin/sh -c set -ex;  if ! command -v gpg > /…   0B
18<missing>      /bin/sh -c apt-get update && apt-get install…   44.6MB
19<missing>      /bin/sh -c #(nop)  CMD ["bash"]                 0B
20<missing>      /bin/sh -c #(nop) ADD file:1dd78a123212328bd…   123MB

然而生成的像有五个新层:每个层对应Dockerfile ,让我们尝试多阶段 Docker 构建 。您将使用Dockerfile上面相同的内容,但使用两次:

1FROM node:8 as build
2WORKDIR /app
3COPY package.json index.js ./
4RUN npm install
5
6FROM node:8
7COPY --from=build /app /
8EXPOSE 3000
9CMD ["index.js"]

第一部分创建Dockerfile三个层。然后将这些层合并并复制到第二个也是最后一个阶段。在图像之上再添加两层,总共 3 层。

来验证下

1$ docker build -t node-multi-stage .

现在检查历史:

 1$ docker history node-multi-stage
 2IMAGE          CREATED BY                                      SIZE
 3331b81a245b1   /bin/sh -c #(nop)  CMD ["index.js"]             0B
 4bdfc932314af   /bin/sh -c #(nop)  EXPOSE 3000                  0B
 5f8992f6c62a6   /bin/sh -c #(nop) COPY dir:e2b57dff89be62f77…   1.62MB
 6b87c2ad8344d   /bin/sh -c #(nop)  CMD ["node"]                 0B
 7<missing>      /bin/sh -c set -ex   && for key in     6A010…   4.17MB
 8<missing>      /bin/sh -c #(nop)  ENV YARN_VERSION=1.3.2       0B
 9<missing>      /bin/sh -c ARCH= && dpkgArch="$(dpkg --print…   56.9MB
10<missing>      /bin/sh -c #(nop)  ENV NODE_VERSION=8.9.4       0B
11<missing>      /bin/sh -c set -ex   && for key in     94AE3…   129kB
12<missing>      /bin/sh -c groupadd --gid 1000 node   && use…   335kB
13<missing>      /bin/sh -c set -ex;  apt-get update;  apt-ge…   324MB
14<missing>      /bin/sh -c apt-get update && apt-get install…   123MB
15<missing>      /bin/sh -c set -ex;  if ! command -v gpg > /…   0B
16<missing>      /bin/sh -c apt-get update && apt-get install…   44.6MB
17<missing>      /bin/sh -c #(nop)  CMD ["bash"]                 0B
18<missing>      /bin/sh -c #(nop) ADD file:1dd78a123212328bd…   123MB

文件大小有没有改变?

1$ docker images | grep node-
2node-multi-stage   331b81a245b1   678MB
3node-vanilla       075d229d3f48   679MB

是的,多阶段的image小1MB。但是整体还是很大!有什么办法可以让它更小吗?

2.用distroless从容器中移除所有不必要的cruft

当前映像包含 Node.js 以及yarnnpmbash许多其他二进制文件。它也是基于 Ubuntu 的。所以你有一个完全成熟的操作系统及其所有的小二进制文件和实用程序。运行容器时不需要任何这些。您唯一需要的依赖项是 Node.js。

Docker 容器应该包装单个进程并包含运行它的最低限度。您不需要操作系统。事实上,您可以删除除 Node.js 以外的所有内容。

但是怎么办?

幸运的是,谷歌也有同样的想法,并提出了GoogleCloudPlatform/distroless

正如其页面上描述的:

“Distroless”镜像仅包含您的应用程序及其运行时依赖项。它们不包含包管理器、shell 以及您希望在标准 Linux 发行版中找到的任何其他程序。

这正是您所需要的!您可以像这样调整Dockerfile以利用新的基本图像:

1FROM node:8 as build
2WORKDIR /app
3COPY package.json index.js ./
4RUN npm install
5FROM gcr.io/distroless/nodejs
6COPY --from=build /app /
7EXPOSE 3000
8CMD ["index.js"]

您可以像往常一样编译镜像:

1$ docker build -t node-distroless .

应用程序应正常运行。要验证情况是否仍然如此,您可以像这样运行容器:

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

并访问 http://localhost:3000 页面 。没有额外不需要的二进制文件的镜像是否更小?

1$ docker images | grep node-distroless
2node-distroless   7b4db3b7f1e5   76.7MB

天哪,比您之前的图片少 600MB!但是当涉及到 distroless 时,您应该注意一些事情。当您的容器正在运行并且您希望检查它时,您可以附加到正在运行的容器:

1$ docker exec -ti <insert_docker_id> bash

附加到正在运行的容器并运行bash就像建立 SSH 会话一样。但由于 distroless 是原始操作系统的精简版,因此没有额外的二进制文件。容器里没有壳!如果没有 shell,你如何连接到正在运行的容器?

结果是你不能像之前的容器一样直接连接bash shell,这是个坏消息,因为您只能在容器中执行二进制文件。您可以运行的唯一二进制文件是 Node.js。

1$ docker exec -ti <insert_docker_id> node

这是个好消息,因为利用您的应用程序并获得对容器的访问权限的攻击者将无法像访问 shell 那样造成那么大的破坏。换句话说,更少的二进制文件意味着更小的大小和更高的安全性。但是以更痛苦的调试为代价。

_请注意,也许您不应该在生产环境中附加和调试容器。您应该依赖适当的日志记录和监控。

但是,如果您关心调试和更小的image怎么办?

3. 使用 Alpine 的更小的基础图像

您可以使用基于 Alpine 的映像替换 distroless 基础映像。Apline Linux是:

*基于_musl libc*和_busybox的面向安全的轻量级 Linux 发行版

换句话说,一个更小、更安全的 Linux 发行版。您应该调整Dockerfile并使用node:8-alpine

1FROM node:8 as build
2WORKDIR /app
3COPY package.json index.js ./
4RUN npm install
5FROM node:8-alpine
6COPY --from=build /app /
7EXPOSE 3000
8CMD ["npm", "start"]

您可以使用以下方法构建镜像:

1$ docker build -t node-alpine .

您可以通过以下方式检查大小:

1$ docker images | grep node-alpine
2node-alpine   aa1f85f8e724   69.7MB

69.7MB!

甚至比 distroless 图像还要小!与 distroless 不同,您可以附加到正在运行的容器吗?

我们先启动容器:

1$ docker run -p 3000:3000 -ti --rm --init node-alpine
2Example app listening on port 3000!

您可以附加到正在运行的容器:

1$ docker exec -ti 9d8e97e307d7 bash
2OCI runtime exec failed: exec failed: container_linux.go:296: starting container process caused "exec: \"bash\": executable file not found in $PATH": unknown

不可用?apline使用的是精简过的sh shell,而不是直接bash shell:

1$ docker exec -ti 9d8e97e307d7 sh / #

您仍然可以附加到正在运行的容器,并且您拥有的整体较小的映像。这听起来很不错,但有一个问题。基于 Alpine 的图像基于 muslc — C 的替代标准库。然而,大多数Linux发行版,如Ubuntu,Debian和CentOS都是基于glibc的。这两个库应该实现内核的相同接口。

但是,他们有不同的目标:

  • glibc 是最常见和最快的
  • muslc 使用更少的空间,并且在编写时考虑到了安全性

编译应用程序时,它大部分是针对特定的 libc 编译的。如果你想将它们与另一个libc一起使用,你必须重新编译它们。

换句话说,使用 Alpine 映像构建容器可能会导致意外行为,因为标准 C 库不同。您可能会注意到差异,尤其是在处理预编译的二进制文件(如 Node.js C++ 扩展)时。例如,PhantomJS预构建包在Alpine上不起作用。

您应该选择哪种基础映像?

如果您在生产环境中运行并且担心安全性,也许 distroless images 更合适。添加到 Docker 映像的每个二进制文件都会给整个应用程序增加一定程度的风险。可以通过在容器中仅安装一个二进制文件来降低总体风险。攻击者也许能够利用在 Distroless 上运行的应用中的漏洞,他们将无法在容器中生成 shell,因为没有 shell!

_注意 OWASP 建议将攻击面区域降至最低

如果您不惜一切代价担心镜像大小, 那么您应该切换到基于 Alpine 的镜像。

这些通常非常小,但以兼容性为代价。Alpine使用了一个略有不同的标准C库 – muslc。您可能会不时遇到一些兼容性问题。 More examples of that here and here.

原版基础映像非常适合测试和开发 。它很大,但提供的体验与安装Ubuntu的工作站相同。此外,您还可以访问操作系统中可用的所有二进制文件。