Docker镜像:第一部分-减小镜像大小

原文地址:Docker Images : Part I – Reducing Image Size

介绍

在开始使用容器时,我们很容易对生成的镜像大小感到震惊。在不牺牲开发人员和操作人员的便利性的前提下,我们将回顾多种减少镜像大小的技术。在第一部分中,我们将讨论多阶段构建,因为任何人想要减小镜像大小,都应该从这里开始。我们还将说明静态链接和动态链接之间的区别,以及我们为什么要关注这些。这也是介绍Alpine的机会。

在第二部分中,我们将看到与各种流行语言相关的一些特殊性。我们将讨论Go,以及Java,Node,Python,Ruby和Rust。我们还将讨论有关Alpine的更多信息,以及如何全面利用Alpine。

在第三部分中,我们将介绍一些与大多数语言和框架相关的模式(和反模式!),例如使用通用基本镜像,剥离二进制文件并减小大小。我们将总结一些更奇特的或高级的方法,例如Bazel,Distroless,DockerSlim或UPX。我们将看到其中的一些在某些情况下会适得其反,但在某些特定情况下可能会有用。

请注意,示例代码以及此处提到的所有Dockerfile,都可以在公共GitHub存储库中方便地获得,所有镜像都带有用来构建的Compose文件,并可以轻松比较它们的大小。

我们要解决的问题

我敢打赌,每个构建了第一个Docker镜像并编译了一些代码的人都对该镜像的大小(不是很好)感到惊讶。

看一下用C编写的这个“ hello world”程序:

/* hello.c */
int main () {
  puts("Hello, world!");
  return 0;
}

我们可以使用以下Dockerfile构建它:

FROM gcc
COPY hello.c .
RUN gcc -o hello hello.c
CMD ["./hello"]

…但是生成的镜像将超过1 GB,因为它将包含整个gcc镜像!

如果使用Ubuntu镜像,安装C编译器并构建程序,则会得到300 MB的镜像;看起来更好了,但对于小于20 kB的二进制文件而言,仍然太多了:

$ ls -l hello
-rwxr-xr-x   1 root root 16384 Nov 18 14:36 hello

与等效的Go程序的情况相同:

package main

import "fmt"

func main () {
  fmt.Println("Hello, world!")
}

使用该golang镜像构建此代码,即使hello程序只有2 MB ,生成的镜像仍为800 MB:

$ ls -l hello
-rwxr-xr-x 1 root root 2008801 Jan 15 16:41 hello

一定有更好的方法!

让我们看看如何大幅度减小这些镜像的大小。在某些情况下,我们可以实现99.8%的尺寸减小(但是,这么大的减幅并不总是一个好事)。

提示:为了轻松比较镜像的大小,我们将使用相同的镜像名称,但使用不同的标签。举例来说,我们的镜像会叫做hello:gcc,hello:ubuntu,hello:thisweirdtrick等,我们就可以运行docker images hello,它会列出所有标签为hello的镜像,并标记它们的大小,在我们自己的Docker引擎上不会被其他的镜像干扰。

多阶段构建

这是减小镜像尺寸的第一步(也是最有效的一步)。不过,我们需要小心,因为如果处理不正确,可能会导致镜像难以继续操作(甚至可能完全损坏)。

多阶段构建来自一个简单的想法:“我不需要在最终的镜像中包括C或Go编译器以及整个构建工具链。我只想传输二进制文件!”

我们通过在Dockerfile中添加另一行FROM来获得多阶段构建。看下面的例子:

FROM gcc AS mybuildstage
COPY hello.c .
RUN gcc -o hello hello.c
FROM ubuntu
COPY --from=mybuildstage hello .
CMD ["./hello"]

我们使用gcc镜像来构建我们的hello.c程序。然后,我们使用该ubuntu镜像开始一个新阶段(我们称为“运行阶段”)。我们从上一阶段复制hello二进制文件。最终镜像为64 MB,而不是1.1 GB,因此大小减少了约95%:

$ docker images minimage
REPOSITORY          TAG                    ...         SIZE
minimage            hello-c.gcc            ...         1.14GB
minimage            hello-c.gcc.ubuntu     ...         64.2MB

还不错吧?我们可以做得更好。但是首先来了解一些技巧和警告。

在声明构建阶段时,不必使用AS关键字。从上一个阶段复制文件时,你只需指明该构建阶段的编号(从零开始)。

换句话说,以下两行是等效的:

COPY --from=mybuildstage hello .
COPY --from=0 hello .

就个人而言,我认为在构建阶段中,对于较短的Dockerfile(例如,少于10行)使用数字是很好的,但是当Dockerfile变长(并且可能更复杂,具有多个构建阶段),最好用明确的命名。这将有助于你的团队成员进行维护(以及未来几个月后你可能也会回来检查)。

警告:使用经典镜像
我强烈建议在“运行”阶段使用经典镜像。“经典”是指CentOS,Debian,Fedora,Ubuntu等一些熟悉的镜像。你可能听说过Alpine,并很想使用它。千万不要!至少现在还不是时候。稍后我们将讨论Alpine,并解释为什么我们需要谨慎使用Alpine。

警告:COPY –from 使用绝对路径
从上一阶段复制文件时,路径被解释为相对于上一阶段的根目录的相对路径。

一旦我们使用带有WORKDIR的构建器镜像(例如golang镜像),问题就会出现。

如果我们尝试构建此Dockerfile:

FROM golang
COPY hello.go .
RUN go build hello.go
FROM ubuntu
COPY --from=0 hello .
CMD ["./hello"]

我们会得到与以下错误类似的错误:

COPY failed: stat /var/lib/docker/overlay2/1be...868/merged/hello: no such file or directory

这是因为COPY命令尝试复制/hello,但是由于WORKDIR在 golang是/go,所以程序路径实际上是/go/hello。

如果我们在构建中使用正式(或非常稳定)的镜像,则可以指定完整的绝对路径。

但是,如果将来我们的构建或运行镜像可能会更改,我建议在构建映像中指定一个WORKDIR。这将确保文件在期望的位置,即使未来用于构建的基础镜像发生更改。

遵循此原则,用于构建Go程序的Dockerfile如下所示:

FROM golang
WORKDIR /src
COPY hello.go .
RUN go build hello.go
FROM ubuntu
COPY --from=0 /src/hello .
CMD ["./hello"]

如果想知道Golang多阶段构建的效率,它可以从800 MB的镜像下降到66 MB的镜像:

$ docker images minimage
REPOSITORY     TAG                              ...    SIZE
minimage       hello-go.golang                  ...    805MB
minimage       hello-go.golang.ubuntu-workdir   ...    66.2MB

使用 FROM scratch

回到我们的“ Hello World”程序。C版本为16 kB,Go版本为2 MB。我们可以得到这么大的镜像吗?

我们可以仅使用二进制文件而不用其他文件来构建镜像吗?

可以! 我们要做的就是使用多阶段构建,然后选择scratch作为我们的运行镜像。scratch是虚拟镜像,不能拉取或运行它,因为它完全是空的。这就是为什么Dockerfile以FROM scratch开头的原因,这意味着我们是从头开始构建的,没有使用任何预先存在的成分。

这为我们提供了以下Dockerfile:

FROM golang
COPY hello.go .
RUN go build hello.go
FROM scratch
COPY --from=0 /go/hello .
CMD ["./hello"]

如果我们构建该镜像,则其大小恰好是二进制文件的大小(2 MB),并且可以正常工作!

但是,在scratch用作基础时,需要牢记一些注意事项。

没有shell
该scratch镜像没有命令解析器外壳。这意味着我们不能将字符串语法与CMD(或RUN)一起使用。考虑以下Dockerfile:

...
FROM scratch
COPY --from=0 /go/hello .
CMD ./hello

如果尝试docker run生成结果镜像,则会收到以下错误消息:

docker: Error response from daemon: OCI runtime create failed: container_linux.go:345: starting container process caused "exec: \"/bin/sh\": stat /bin/sh: no such file or directory": unknown.

它的显示方式不是很清楚,但是核心信息在这里:镜像中缺少/bin/sh。

发生这种情况是因为当我们将字符串语法与CMD或RUN 一起使用时,参数将传递给/bin/sh。这意味着我们CMD ./hello上面会执行/bin/sh -c "./hello",因为在scratch镜像中不存在/bin/sh,进而导致失败。

解决方法很简单:在Dockerfile中使用JSON语法。CMD ./hello改为CMD ["./hello"]。当Docker检测到JSON语法时,它将直接运行参数,而无需使用shell。

没有调试工具

根据scratch定义,该镜像为空;因此它没有任何帮助我们查询容器问题的方法。无shell(正如我们在上一段说的)也没什么lspsping,等等。这意味着我们无法向容器输入内容(使用docker execkubectl exec进行查看)。

(请注意,严格来说,有一些方法可以对我们的容器进行故障追踪。我们可以docker cp用来从容器中取出文件;我们可以docker run --net container:用来与网络堆栈进行交互;像nsenter这样的低级工具可能非常强大。 Kubernetes最近的版本具有临时容器的概念,但它仍然处于alpha状态。请记住,所有这些技术肯定会使我们的工作变得更加复杂,尤其是当我们有很多事情要做的时候!)

这里的一个解决办法是使用类似busyboxalpine的镜像代替scratch。当然,它们更大(分别为1.2 MB和5.5 MB),但是在庞大的程序中,如果将其与原始图像的数百兆字节或千兆字节进行比较,付出的代价其实很小。

没有libc
这是一个更加棘手的问题。我们在Go中使用简单的“ hello world”可以很好地工作,但是,如果我们尝试在scratch镜像中放置C程序,或者是在更复杂的Go程序(例如,使用网络类库的任何程序),则会收到以下错误消息:

standard_init_linux.go:211: exec user process caused "no such file or directory"

某些文件似乎丢失了。但这并不能告诉我们确切缺少哪个文件。

丢失的文件是运行我们的程序所必需的动态库。

什么是动态库,为什么我们需要它?

程序编译后,将与所使用的库链接。(很简单,我们的“ hello world”程序也在使用库,就是puts函数。)很久以前(90年代之前),我们主要使用静态链接,这意味着所使用的所有库将包含在二进制文件中。当从软盘或磁带执行软件时,或者根本没有标准库时,这是完美的选择。但是,在像Linux这样的分时系统上,我们运行许多并发程序,这些程序存储在硬盘上。这些程序几乎总是使用标准的C库。

在这种情况下,使用动态链接会变得更加有利。使用动态链接,最终的二进制文件不包含它使用的所有库的代码。相反,它包含这些库的引用,如“这个程序需要的功能cossintan来自libtrigonometry.so。执行程序时,系统会查找libtrigonometry.so并将其与程序一起加载,以便程序可以调用这些函数。

动态链接具有多个优点。

  1. 由于不再需要复制通用库,因此可以节省磁盘空间。
  2. 由于这些库可以从磁盘加载一次,然后在多个程序之间共享,因此可以节省内存。
  3. 这使维护更加容易,因为在更新库时,我们不需要使用该库重新编译所有程序。

(如果我们想更透彻一点,内存节省不是动态库的结果,而是共享库的结果。也就是说,两者通常可以并存。你知道吗,在Linux上,动态库文件通常具有扩展名.so,即代表共享库(share object)。在Windows上是.DLL,它代表动态链接库(Dynamic-link library

回过头看我们的程序:默认情况下,C程序是动态链接的。对于某些包,Go程序也是如此。我们的特定程序使用标准的C库,该库在最新的Linux系统上将在libc.so.6文件中。因此,要运行我们的程序,需要将该文件放到在容器镜像中。如果使用scratch,则显然没有该文件。如果我们使用busybox或alpine情况是相同的,因为busybox它不包含标准库,alpine正在使用另一个不兼容的库。稍后我们将详细介绍。

我们该如何解决?至少有3种方案。

构建静态二进制文件
我们可以告诉我们的工具链制作一个静态二进制文件。有多种方法可以实现这一目标(取决于我们起初构建程序的方式),但是如果使用gcc,我们需要添加-static到命令行中:

gcc -o hello hello.c -static

现在生成的二进制文件是760 kB(在我的系统上),而不是16 kB。当然,我们将库嵌入到了二进制文件中,因此它要大得多。但是该二进制文件现在可以在scratch镜像中正确运行。

如果使用Alpine构建静态二进制文件,则可以得到更小的图像。结果小于100 kB!

将库添加到我们的镜像

我们可以使用ldd工具找出程序需要哪些库:

$ ldd hello
    linux-vdso.so.1 (0x00007ffdf8acb000)
    libc.so.6 => /usr/lib/libc.so.6 (0x00007ff897ef6000)
    /lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007ff8980f7000)

我们可以看到程序所需的库,以及系统找到它们的实际路径。

在上面的示例中,唯一的“真实”库是libc.so.6linux-vdso.so.1与一种称为VDSO(虚拟动态共享对象)的机制有关,该机制可以加速某些系统调用。让我们假装它不在那里。至于ld-linux-x86-64.so.2,它实际上是动态链接器本身。(从技术上讲,我们的hello二进制文件包含的信息表述:“嘿,这是一个动态程序,并且知道如何将其所有部分放在一起,这就是ld-linux-x86-64.so.2”。)

如果我们愿意,可以将上面ldd列出的所有文件手动添加到镜像中。这将是相当繁琐且难以维护的,尤其是对于程序有很多依赖的情况。对于我们小的hello world程序,我们可以这么做。但是对于更复杂的程序,例如使用DNS的程序,我们会遇到另一个问题。GNU C库(在大多数Linux系统上使用)通过相当复杂的称为名称服务开关(简称为NSS )的机制实现DNS(以及其他一些功能)。该机制需要一个配置文件/etc/nsswitch.conf和其他库。但是这些库没有在ldd中展现,因为它们稍后会在程序运行时加载。如果我们希望DNS解析正常工作,我们仍然需要包括它们!(这些库通常位于/lib64/libnss_*。)

我个人不建议这样做,因为它很神秘,难以维护,将来很可能会被改变。

使用 busybox:glibc
有专门为解决所有这些问题而设计的镜像:busybox:glibc。busybox是一个小镜像(5 MB),提供了许多用于故障排除和操作的有用工具,并提供了GNU C库(或glibc)。该镜像恰好包含我们前面提到的所有这些讨厌的文件。如果要在小的镜像中运行动态二进制文件,可以使用此方法。

但是请记住,如果我们的程序使用其他库,则也需要复制这些库。

总结和(部分)结论

让我们看看在C. Spoiler alert中如何为“ hello world”程序做些事情:此列表包括了通过使用Alpine获得的结果,本系后续文章会介绍这种方式。

  • 原始镜像内置gcc:1.14 GB
  • gcc和ubuntu多级构建:64.2 MB
  • 使用alpine,静态glibc二进制:6.5 MB
  • 使用alpine,动态二进制:5.6 MB
  • 使用scratch,静态二进制:940 kB
  • 使用scratch,静态musl二进制:94 kB

大小减少了12000倍,磁盘空间减少了99.99%。

不错。

就个人而言,我不会使用scratch镜像(因为对它们进行故障定位可能会很麻烦),但是如果你要这样做,那么它们就在这里!

在下一部分中,我们将介绍Go语言特定的一些方面,包括cgo和标签。我们还将介绍其他流行语言,并且我们将讨论更多有关Alpine的信息,它非常棒。


发表评论

电子邮件地址不会被公开。 必填项已用*标注