几年前,当 Docker 突然兴起时,它将容器和容器镜像带给了大众。尽管 Linux 容器在那之前就已存在,但 Docker 通过用户友好的命令行界面和易于理解的 Dockerfile 格式镜像构建方式,使入门变得容易。但是,虽然入门可能很容易,但在构建可用,甚至功能强大,但尺寸仍然很小的容器镜像方面,仍然存在一些细微之处和技巧。
第一步:清理自己
其中一些示例涉及与传统服务器相同的清理类型,但更严格地执行。较小的镜像大小对于快速移动镜像至关重要,并且在磁盘上存储多个不必要的数据副本是一种资源浪费。因此,这些技术应比在具有大量专用存储的服务器上更频繁地使用。
这种清理的一个例子是从镜像中删除缓存文件以回收空间。考虑一下通过 dnf
安装了 Nginx 的基础镜像与清理了元数据和 yum 缓存的镜像之间的大小差异
# Dockerfile with cache
FROM fedora:28
LABEL maintainer Chris Collins <collins.christopher@gmail.com>
RUN dnf install -y nginx
-----
# Dockerfile w/o cache
FROM fedora:28
LABEL maintainer Chris Collins <collins.christopher@gmail.com>
RUN dnf install -y nginx \
&& dnf clean all \
&& rm -rf /var/cache/yum
-----
[chris@krang] $ docker build -t cache -f Dockerfile .
[chris@krang] $ docker images --format "{{.Repository}}: {{.Size}}"
| head -n 1
cache: 464 MB
[chris@krang] $ docker build -t no-cache -f Dockerfile-wo-cache .
[chris@krang] $ docker images --format "{{.Repository}}: {{.Size}}" | head -n 1
no-cache: 271 MB
这是一个显着的大小差异。带有 dnf
缓存的版本几乎是没有元数据和缓存的镜像大小的两倍。软件包管理器缓存、Ruby gem 临时文件、nodejs
缓存,甚至下载的源代码 tarball 都是清理的完美候选者。
层 - 潜在的陷阱
不幸的是(或幸运的是,正如您稍后将看到的),基于层与容器一起工作的方式,您不能简单地在 Dockerfile 中添加 RUN rm -rf /var/cache/yum
行并将其称为一天。Dockerfile 的每个指令都存储在一个层中,层之间的更改应用在顶部。因此,即使您这样做
RUN dnf install -y nginx
RUN dnf clean all
RUN rm -rf /var/cache/yum
...您仍然会得到三个层,其中一个层包含所有缓存,而两个中间层“从镜像中删除”缓存。但是缓存实际上仍然存在,就像您将文件系统挂载在另一个文件系统的顶部时一样,文件在那里 - 您只是看不到或访问它们。
您会注意到上一节中的示例将缓存清理链接到生成缓存的同一 Dockerfile 指令中
RUN dnf install -y nginx \
&& dnf clean all \
&& rm -rf /var/cache/yum
这是一个单独的指令,最终成为镜像中的一个层。您会丢失一些 Docker (咳咳) 缓存,这会使镜像的重建时间稍长,但缓存的数据不会最终出现在您的最终镜像中。作为一个不错的折衷方案,只需链接相关命令(例如,yum install
和 yum clean all
,或下载、解压缩和删除源代码 tarball 等)就可以节省最终镜像大小,同时仍然允许您利用 Docker 缓存来加快开发速度。
但是,这个层“陷阱”比最初看起来更微妙。由于镜像层记录了每个层的更改,一个接一个地叠加,因此不仅仅是文件的存在会累积,而且对文件的任何更改也会累积。例如,即使更改文件的模式也会在新层中创建该文件的副本。
例如,下面的 docker images
的输出显示了有关两个镜像的信息。第一个 layer_test_1
是通过向基础 CentOS 镜像添加单个 1GB 文件创建的。第二个镜像 layer_test_2
是从 layer_test_1
创建的,除了使用 chmod u+x
更改 1GB 文件的模式外,什么也没做。
layer_test_2 latest e11b5e58e2fc 7 seconds ago 2.35 GB
layer_test_1 latest 6eca792a4ebe 2 minutes ago 1.27 GB
如您所见,新镜像比第一个镜像大 1GB 以上。尽管事实上 layer_test_1
只是 layer_test_2
的前两个层,但在第二个镜像内部仍然隐藏着一个额外的 1GB 文件。当您在镜像构建过程中删除、移动或更改任何文件时,情况都是如此。
专用镜像 vs. 灵活镜像
轶事:当我的办公室大量投资于 Ruby on Rails 应用程序时,我们开始接受使用容器。我们做的第一件事是为我们所有团队创建一个官方 Ruby 基础镜像供使用。为了简单起见(并遭受“这就是我们在服务器上执行的方式”的困扰),我们使用 rbenv 将最新的四个 Ruby 版本安装到镜像中,使我们的开发人员可以使用单个镜像将其所有应用程序迁移到容器中。这导致了一个非常大但灵活(我们认为)的镜像,涵盖了我们正在合作的各个团队的所有基础。
事实证明,这是浪费工作。维护特定镜像的单独的、略微修改的版本所需的工作很容易自动化,并且选择具有特定版本的特定镜像实际上有助于在引入破坏性更改(在下游造成严重破坏)之前识别接近生命周期的应用程序。这也浪费了资源:当我们开始拆分不同版本的 Ruby 时,我们最终得到了多个共享单个基础的镜像,如果它们在服务器上共存,则占用非常小的额外空间,但与安装了多个版本的大型镜像相比,它们在传输时要小得多。
这并不是说构建灵活的镜像没有帮助,但在这种情况下,从通用基础构建专用镜像最终节省了存储空间和维护时间,并且每个团队都可以根据需要修改其设置,同时保持通用基础镜像的优势。
从无杂物开始:向空白镜像添加您需要的内容
尽管 Dockerfile 友好且易于使用,但仍有一些工具可以提供灵活性,以创建非常小的 Docker 兼容容器镜像,而无需完整的操作系统 - 即使是像标准 Docker 基础镜像那样小的镜像。
我之前写过关于 Buildah 的文章,我将再次提及它,因为它足够灵活,可以使用主机中的工具从头开始创建镜像,以安装打包的软件和操作镜像。然后,这些工具永远不需要包含在镜像本身中。
Buildah 替换了 docker build
命令。使用它,您可以将容器镜像的文件系统挂载到您的主机上,并使用主机中的工具与之交互。
让我们使用上面的 Nginx 示例尝试 Buildah(暂时忽略缓存)
#!/usr/bin/env bash
set -o errexit
# Create a container
container=$(buildah from scratch)
# Mount the container filesystem
mountpoint=$(buildah mount $container)
# Install a basic filesystem and minimal set of packages, and nginx
dnf install --installroot $mountpoint --releasever 28 glibc-minimal-langpack nginx --setopt install_weak_deps=false -y
# Save the container to an image
buildah commit --format docker $container nginx
# Cleanup
buildah unmount $container
# Push the image to the Docker daemon’s storage
buildah push nginx:latest docker-daemon:nginx:latest
您会注意到我们不再使用 Dockerfile 来构建镜像,而是使用简单的 Bash 脚本,并且我们是从 scratch(或空白)镜像构建它。Bash 脚本将容器的根文件系统挂载到主机上的挂载点,然后使用主机的命令来安装软件包。这样,软件包管理器甚至不必存在于容器内部。
没有额外的杂物 - 基础镜像中的所有额外内容,例如 dnf
- 镜像只有 304 MB,比上面使用 Dockerfile 构建的 Nginx 镜像小 100 MB 以上。
[chris@krang] $ docker images |grep nginx
docker.io/nginx buildah 2505d3597457 4 minutes ago 304 MB
注意:镜像名称附加了 docker.io
,这是因为镜像被推送到 Docker 守护程序的命名空间的方式,但它仍然是使用上面的构建脚本在本地构建的镜像。
当您考虑到基础镜像本身已经大约 300 MB 时,这 100 MB 已经是巨大的节省。使用软件包管理器安装 Nginx 也会引入大量依赖项。对于使用主机中的工具从源代码编译的东西,节省的费用可能会更大,因为您可以选择确切的依赖项,而不会拉入任何您不需要的额外文件。
如果您想尝试这条路线,Tom Sweeney 写了一篇更深入的文章,使用 Buildah 创建小型容器,您应该查看一下。
使用 Buildah 构建没有完整操作系统和包含构建工具的镜像可以实现比您原本能够创建的镜像小得多的镜像。对于某些类型的镜像,我们可以更进一步,创建仅包含应用程序本身的镜像。
创建仅包含静态链接二进制文件的镜像
遵循相同的理念,引导我们在镜像内部抛弃管理和构建工具,我们可以更进一步。如果我们足够专业化,并且放弃在生产容器内部进行故障排除的想法,我们需要 Bash 吗?我们需要 GNU core utilities 吗?我们真的需要基本的 Linux 文件系统吗?您可以使用任何编译语言来执行此操作,该语言允许您使用 静态链接库创建二进制文件 - 其中程序所需的所有库和函数都复制到并存储在二进制文件本身中。
这在 Golang 社区中是一种相对流行的方法,因此我们将使用 Go 应用程序进行演示。
下面的 Dockerfile 采用了一个小的 Go Hello-World 应用程序,并在 FROM golang:1.8
的镜像中对其进行编译
FROM golang:1.8
ENV GOOS=linux
ENV appdir=/go/src/gohelloworld
COPY ./ /go/src/goHelloWorld
WORKDIR /go/src/goHelloWorld
RUN go get
RUN go build -o /goHelloWorld -a
CMD ["/goHelloWorld"]
生成的镜像包含二进制文件、源代码和基础镜像层,大小为 716 MB。但是,我们的应用程序实际需要的唯一东西是编译后的二进制文件。其他所有东西都是未使用的杂物,会随我们的镜像一起运送。
如果我们使用 CGO_ENABLED=0
在编译时禁用 cgo
,我们可以创建一个不包装其某些函数的 C 库的二进制文件
GOOS=linux CGO_ENABLED=0 go build -a goHelloWorld.go
生成的二进制文件可以添加到空镜像或“scratch”镜像中
FROM scratch
COPY goHelloWorld /
CMD ["/goHelloWorld"]
让我们比较一下两者之间的镜像大小差异
[ chris@krang ] $ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
goHello scratch a5881650d6e9 13 seconds ago 1.55 MB
goHello builder 980290a100db 14 seconds ago 716 MB
这是一个巨大的差异。从 golang:1.8
构建的镜像,其中包含 goHelloWorld
二进制文件(上面标记为“builder”)比仅包含二进制文件的 scratch 镜像大 460 倍。带有二进制文件的 scratch 镜像的全部大小仅为 1.55 MB。这意味着如果我们使用 builder 镜像,我们将运送大约 713 MB 的不必要数据。
如上所述,这种创建小型镜像的方法在 Golang 社区中经常使用,并且有很多关于该主题的博客文章。Kelsey Hightower 写了一篇关于该主题的文章,其中更详细地介绍了,包括处理 C 库以外的其他依赖项。
如果对您有效,请考虑压缩
有一种替代方法可以将所有命令链接到层中以尝试节省空间:压缩您的镜像。当您压缩镜像时,您实际上是在导出它,删除所有中间层,并保存一个包含镜像当前状态的单层。这具有将该镜像缩小到更小尺寸的优势。
压缩层过去需要一些创造性的解决方法来展平镜像 - 导出容器的内容并将其作为单层镜像重新导入,或使用像 docker-squash
这样的工具。从 1.13 版本开始,Docker 引入了一个方便的标志 --squash
,以在构建过程中完成相同的操作
FROM fedora:28
LABEL maintainer Chris Collins <collins.christopher@gmail.com>
RUN dnf install -y nginx
RUN dnf clean all
RUN rm -rf /var/cache/yum
[chris@krang] $ docker build -t squash -f Dockerfile-squash --squash .
[chris@krang] $ docker images --format "{{.Repository}}: {{.Size}}" | head -n 1
squash: 271 MB
将 docker squash
与此多层 Dockerfile 一起使用,我们最终得到了另一个 271MB 的镜像,就像我们在链接指令示例中所做的那样。这对于这种情况非常有效,但存在潜在的陷阱。
“什么?又一个陷阱?”
嗯,有点 - 这与之前的问题相同,以另一种方式引起问题。
走得太远:压缩过度、太小、太专业化
镜像可以共享层。基础的大小可能是 x 兆字节,但它只需要拉取/存储一次,并且每个镜像都可以使用它。共享层的所有镜像的有效大小是基础层加上每个特定更改在其之上的差异。通过这种方式,数千个镜像可能只占用比单个镜像多一点的空间。
这是压缩或过度专业化的缺点。当您将镜像压缩为单层时,您将失去与其他镜像共享层的任何机会。每个镜像最终都与其单层的总大小一样大。如果您仅使用少量镜像并从中运行许多容器,这可能对您有效,但如果您有许多不同的镜像,从长远来看,这可能会浪费您的空间。
回顾 Nginx 压缩示例,我们可以看到对于这种情况来说没什么大不了的。我们最终得到了 Fedora,安装了 Nginx,没有缓存,并且压缩它很好。但是,Nginx 本身并不是非常有用。您通常需要自定义才能做任何有趣的事情 - 例如,配置文件、其他软件包,甚至是一些应用程序代码。这些都将最终成为 Dockerfile 中的更多指令。
使用传统的镜像构建,您将拥有一个带有 Fedora 的单层基础镜像,一个安装了 Nginx 的第二层(带有或不带有缓存),然后每个自定义都将是另一层。带有 Fedora 和 Nginx 的其他镜像可以共享这些层。
需要一个镜像
[ App 1 Layer ( 5 MB) ] [ App 2 Layer (6 MB) ]
[ Nginx Layer ( 21 MB) ] ------------------^
[ Fedora Layer (249 MB) ]
但是,如果您压缩镜像,那么即使 Fedora 基础层也会被压缩。任何基于 Fedora 的压缩镜像都必须运送其自己的 Fedora 内容,每个镜像增加 249 MB!
[ Fedora + Nginx + App 1 (275 MB)] [ Fedora + Nginx + App 2 (276 MB) ]
如果您构建许多高度专业化、超小的镜像,这也将成为一个问题。
与生活中的一切一样,适度是关键。同样,由于层的运作方式,您会发现,随着您的容器镜像变得更小、更专业,并且不再能够与其他相关镜像共享基础层,回报会递减。
具有小自定义的镜像可以共享基础层。如上所述,基础的大小可能是 x 兆字节,但它只需要拉取/存储一次,并且每个镜像都可以使用它。所有镜像的有效大小是基础层加上每个特定更改在其之上的差异。通过这种方式,数千个镜像可能只占用比单个镜像多一点的空间。
[ specific app ] [ specific app 2 ]
[ customizations ]--------------^
[ base layer ]
如果您在镜像缩减方面走得太远,并且您有太多的变体或专业化,您最终可能会得到许多镜像,这些镜像都没有共享基础层,并且都占用自己的磁盘空间。
[ specific app 1 ] [ specific app 2 ] [ specific app 3 ]
结论
有很多不同的方法可以减少您在使用容器镜像时花费的存储空间和带宽,但最有效的方法是减小镜像本身的大小。无论您只是清理缓存(避免将它们遗留在中间层中)、将所有层压缩为一个,还是在空镜像中仅添加静态二进制文件,都值得花一些时间查看容器镜像中可能存在的膨胀,并将其缩小到高效的大小。
3 条评论