减小Docker镜像的简单技巧
by 夏泽民
https://mp.weixin.qq.com/s/UCm27by8Ro7NzFflsPISCQ
https://github.com/GoogleCloudPlatform
当涉及到建造Docker containers问题的时候,你应该尽力获得较小的镜像。文件层既共享又小的镜像能够更快的进行传输和部署。
但是,当你为每个“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上,COPY, ADD and RUN语句为镜像添加了一个新镜像层。上一个示例创建了两个层,而不是一个。
镜像层类似于git commits
Docker镜像层存储镜像的前一版本和当前版本之间的不同之处。和git commits一样,如果您将它们与其他存储库或镜像像共享,这将会很方便。
事实上,当您从注册表请求一个镜像像时,您只下载您还没有拥有的镜像层。这样可以更有效地共享镜像。
但镜像分层并不是没有代价的。
镜像层占用空间
镜像层越多,最终镜像像越大。Git存储库在这方面和它是相似的。存储库的大小随着镜像层数量的增加而增加,因为Git必须存储所提交(版本)之间的所有更改。
在过去,将多个“run”语句组合在一行是一种很好的做法,就像在第一个示例中一样。但现在不再是了。
使用多阶段的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
/bin/sh -c set -ex && for key in 6A010…
2019/8/12 3 simple tricks for smaller Docker images - Skills Matter - Medium
https://medium.com/skills-matter/3-simple-tricks-for-smaller-docker-images-cf2760645621 5/14
4.17MB
/bin/sh -c #(nop) ENV YARN_VERSION=1.3.2 0B
/bin/sh -c ARCH= && dpkgArch="$(dpkg --print…
56.9MB
/bin/sh -c #(nop) ENV NODE_VERSION=8.9.4 0B
/bin/sh -c set -ex && for key in 94AE3… 129kB
/bin/sh -c groupadd --gid 1000 node && use… 335kB
/bin/sh -c set -ex; apt-get update; apt-ge… 324MB
/bin/sh -c apt-get update && apt-get install… 123MB
/bin/sh -c set -ex; if ! command -v gpg > /… 0B
/bin/sh -c apt-get update && apt-get install…
44.6MB
/bin/sh -c #(nop) CMD ["bash"] 0B
/bin/sh -c #(nop) ADD file:1dd78a123212328bd… 123MB
太棒了!文件大小有变化吗?
$ docker images | grep node-
node-multi-stage 331b81a245b1 678MB node-vanilla 075d229d3f48 679MB
是的,最后一个镜像稍微小一些。不错,即使这是一个已经缩小过的应用程序,您也会减小它的总体大小。
但镜像像仍然很大!你能做些什么使它更小吗?
在distroless的协助下
删掉容器中所有不必要的东西
当前的镜像提供node.js以及 yarn、npm、bash 和许多其他二进制文件。它也基于Ubuntu。所以你有一个完全成熟的操作系统,它有所有的小二进制文件和实用程序。
运行容器时不需要任何这些。唯一您需要的依赖项是node.js。
Docker 容器应该包装一个进程,并包含运行它的最小值。你不需要操作系统。
实际上,除了node.js,您可以删除所有内容。
但是如何实现呢
幸运的是,谷歌也有同样的想法,并提出了谷歌云平台/发行版(https://github.com/googlecloudplatform/distrioles)。
正如对存储库的描述所指出的那样:
“distroles”映像只包含应用程序及其运行时的依赖项。它们不包含包管理器,也不包含您希望在标准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,比之前的镜像少600M!这是一个好消息,但当涉及到发行版时,你应该注意一些事情。
当您的容器正在运行,并且您希望检查它时,您可以使用以下方式连接到正在运行的容器:
$docker exec-tibash
连接到一个正在运行的容器上并且运行“bash”就好像建立一个ssh会话。
但是,由于发行版是原始操作系统的精简版,因此没有额外的二进制文件。容器里没有shell!如果没有shell,如何连接到正在运行的容器?
你不能连接到容器,这是一个既好又坏的消息。
这是一个坏消息,因为您只能在容器中执行二进制文件。唯一可以运行的二进制文件是node.js:
$docker exec-tinode
这是一个好消息,因为攻击者如果利用您的应用程序并获得对容器的访问权限,不会像访问shell那样造成太大的破坏。换句话说,更少的二进制文件意味着更小的大小和更高的安全性。但代价是更令人痛苦的调试。
请注意,也许您不应该在生产环境中附加和调试容器。您应该更依赖于正确的日志记录和监视。
但是如果您关心调试和较小的大小呢?
基于Alpine的较小基础镜像
您可以用基于Alpine的镜像替换发行版的基础镜像。Alpine Linux(https://alpineinux.org/)是基于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.7兆,比发行版镜像还要小!
但它是否与发行版不同,可以连接到到正在运行的容器上呢?是时候弄清楚这件事了。让我们先启动容器:
$ 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
运气很不好。但也许这个容器有一个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还是vanilla 的镜像?
如果您在生产环境中运行,并且担心安全性,那么发行版镜像可能更合适。添加到Docker映像的每个二进制文件都会给整个应用程序增加一定的风险。您可以通过在容器中只安装一个二进制文件来降低总体风险。
例如,如果攻击者能够利用在发行版上运行的应用程序中的漏洞进行攻击,他们将无法在容器中生成shell,因为没有shell!
如果您关心的是镜像大小并且不计代价,那么您应该切换到基于 Alpine 的镜像。它通常非常小,却以牺牲兼容性为代价。
Alpine 使用了稍微不同的标准C库——Muslc。您可能会不时遇到一些兼容性问题。更多例子请看https://github.com/grpc/grpc/issues/8528 和 https://github.com/grpc/grpc/issues/6126 。
Vanilla基础镜像非常适合测试和开发,它很大,但提供的体验与安装Ubuntu的工作站相同。此外,您还可以访问操作系统中可用的所有二进制文件。重述镜像大小:
node:8 681MB
node:8 with multi-stage build 678MB
gcr.io/distroless/nodejs 76.7MB
node:8-alpine 69.7MB