【AICon】探索RAG 技术在实际应用中遇到的挑战及应对策略!AICon精华内容已上线73%>>> 了解详情
写点什么

三个技巧,将 Docker 镜像体积减小 90%

  • 2018-09-05
  • 本文字数:10149 字

    阅读完需:约 33 分钟

在构建 Docker 容器时,应该尽量想办法获得体积更小的镜像,因为传输和部署体积较小的镜像速度更快。

但 RUN 语句总是会创建一个新层,而且在生成镜像之前还需要使用很多中间文件,在这种情况下,该如何获得体积更小的镜像呢?

你可能已经注意到了,大多数 Dockerfiles 都使用了一些奇怪的技巧:

复制代码
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 开始,COPY、ADD 和 RUN 语句会向镜像中添加新层。前面的示例创建了两个层而不是一个。



镜像的层就像 Git 的提交(commit)一样。

Docker 的层用于保存镜像的上一版本和当前版本之间的差异。就像 Git 的提交一样,如果你与其他存储库或镜像共享它们,就会很方便。

实际上,当你向注册表请求镜像时,只是下载你尚未拥有的层。这是一种非常高效地共享镜像的方式。

但额外的层并不是没有代价的。

层仍然会占用空间,你拥有的层越多,最终的镜像就越大。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 的第一部分创建了三个层,然后这些层被合并并复制到第二个阶段。在第二阶段,镜像顶部又添加了额外的两个层,所以总共是三个层。



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

$ 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

最后一个镜像(node-multi-stage)更小一些。

你已经将镜像的体积减小了,即使它已经是一个很小的应用程序。

但整个镜像仍然很大!

有什么办法可以让它变得更小吗?

2. 用 distroless 去除容器中所有不必要的东西

这个镜像包含了 Node.js 以及 yarn、npm、bash 和其他的二进制文件。因为它也是基于 Ubuntu 的,所以你等于拥有了一个完整的操作系统,其中包括所有的小型二进制文件和实用程序。

但在运行容器时是不需要这些东西的,你需要的只是 Node.js。

Docker 容器应该只包含一个进程以及用于运行这个进程所需的最少的文件,你不需要整个操作系统。

实际上,你可以删除 Node.js 之外的所有内容。

但要怎么做?

所幸的是,谷歌为我们提供了 distroless

以下是 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.7MB!

比之前的镜像小了 600MB!

但在使用 distroless 时有一些事项需要注意。

当容器在运行时,如果你想要检查它,可以使用以下命令 attach 到正在运行的容器上:

$ docker exec -ti <insert_docker_id> bash

attach 到正在运行的容器并运行 bash 命令就像是建立了一个 SSH 会话一样。

但 distroless 版本是原始操作系统的精简版,没有了额外的二进制文件,所以容器里没有 shell!

在没有 shell 的情况下,如何 attach 到正在运行的容器呢?

答案是,你做不到。这既是个坏消息,也是个好消息。

之所以说是坏消息,因为你只能在容器中执行二进制文件。你可以运行的唯一的二进制文件是 Node.js:

$ docker exec -ti <insert_docker_id> node

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

或许你不应在生产环境中 attach 和调试容器,而应该使用日志和监控。

但如果你确实需要调试,又想保持小体积该怎么办?

3. 小体积的 Alpine 基础镜像

你可以使用 Alpine 基础镜像替换 distroless 基础镜像。

Alpine Linux 是:

一个基于 musl libc 和 busybox 的面向安全的轻量级 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!

甚至比 distrless 镜像还小!

现在可以 attach 到正在运行的容器吗?让我们来试试。

让我们先启动容器:

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

你可以使用以下命令 attach 到运行中的容器:

$ 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

看来不行,但或许可以使用 shell?

$ docker exec -ti 9d8e97e307d7 sh / #

成功了!现在可以 attach 到正在运行的容器中了。

看起来很有希望,但还有一个问题。

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,因为那里根本就没有 shell!

请注意,OWASP 本身就建议尽量减少攻击表面。

如果你只关心更小的镜像体积,那么可以考虑基于 Alpine 的镜像。

它们的体积非常小,但代价是兼容性较差。Alpine 使用了略微不同的标准 C 库——muslc。你可能会时不时地遇到一些兼容性问题。

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

它虽然体积很大,但提供了与 Ubuntu 工作站一样的体验。此外,你还可以访问操作系统的所有二进制文件。

再回顾一下各个镜像的大小:

node:8  681MB

node:8  使用多阶段构建为 678MB

gcr.io/distroless/nodejs  76.7MB

node:8-alpine  69.7MB

英文原文: https://itnext.io/3-simple-tricks-for-smaller-docker-images-f0d2bda17d1e

感谢张婵对本文的审校。

2018-09-05 18:2334922
用户头像

发布了 731 篇内容, 共 432.3 次阅读, 收获喜欢 1997 次。

关注

评论 3 条评论

发布
用户头像
妙啊!
2020-10-13 16:22
回复
用户头像
想问一下,为什么原本预期只多出两个层的却多出了 5 层?
2020-08-24 20:34
回复
用户头像
有点厉害
2019-12-12 16:18
回复
没有更多了
发现更多内容

基于业务和平台理解数字营销概念

架构 CDP DSP DMP 数据服务

Flink 实践教程-入门(6):读取 PG 数据写入 ClickHouse

腾讯云大数据

flink 流计算 Oceanus

瀑布vs敏捷质量控制

刘冉

敏捷开发 敏捷测试

点进来,与白洞一起体验一场沉浸式智慧轨道之旅

白洞计划

Ubuntu安装教程【超多图】

坚果

Ubuntu20.04 11月日更

设计消息队列存储消息数据的MySQL表格

白开水又一杯

#架构实战营

NodeJs深入浅出之旅:内存控制(上)🐍

空城机

大前端 Node 11月日更

Django中的超级用户和自己创建app原来这么简单!

老表

Python django web开发 11月日更 博客系统

敏捷中的威胁建模

刘冉

敏捷开发 威胁建模 安全测试

dart系列之:dart类的扩展

程序那些事

flutter dart 程序那些事 11月日更

服务端系统性能测试

刘冉

性能测试

[架构实战营]模块三作业:学生管理系统详细架构设计

Geek_99eefd

「架构实战营」

双11硝烟散去,零售业开始“外卷”

脑极体

谁有粉?就爬谁!他粉多,就爬他!Python 多线程采集 260000+ 粉丝数据

梦想橡皮擦

11月日更

springboot中poi操作合集

小鲍侃java

11月日更

Flink 实践教程-入门(7):消费 Kafka 数据写入 PG

腾讯云大数据

flink 流计算 Oceanus

如何使用 JavaScript 扁平化/非扁平化嵌套 JSON 对象?

devpoint

JavaScript json Object 11月日更

设计千万级学生管理系统的考试试卷存储方案

deng

架构实战营

分布式技术专题-服务架构设计-带你统一认识一下系统架构及分析和总结

洛神灬殇

架构设计 总结思考 11月日更 架构介绍

架构实战训练营模块三

人生就是梦

「架构实战营」

一分钟搞懂DDD

俞凡

架构 DDD

我们的敏捷测试实践

刘冉

软件测试 敏捷测试

模块八作业

potti

AI 预测:基于流计算 Oceanus(Flink) 实现病症的实时预测

腾讯云大数据

flink 流计算 Oceanus

北鲲云超算平台对于基因组学研究能够提供哪些帮助?

北鲲云

架构实战营模块三作业

spark99

架构实战营

数据服务系统0到1落地实现方案

架构 数据 系统架构 数据平台 数据服务

通过 Feature Level 动态控制 SAP Spartacus 的页面显示

Jerry Wang

typescript angular SAP 11月日更

软件安全测试

刘冉

软件测试 安全测试

如何使用Camtasia给视频打码

淋雨

Camtasia

性能测试之并发模型对比(JMeter,Locust和Gatling篇)

刘冉

软件测试 性能测试 自动化测试

三个技巧,将Docker镜像体积减小90%_DevOps & 平台工程_Daniele Polencic_InfoQ精选文章