如何构建容器镜像(详解容器镜像-你需要了解的关于容器镜像的一切)

第一部分 减小镜像大小介绍

在开始使用容器时,很容易会被我们生成的镜像的大小震惊。在不牺牲开发人员和操作人员的便利性的前提下,我们将回顾许多减小镜像尺寸的技术。在第一部分中,我们将讨论多阶段构建,因为这是任何人想要减小镜像大小的地方都应该从那里开始。我们还将说明静态链接和动态链接之间的区别,以及我们为什么要关心它。这将是介绍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发动机的bazillions被拖累。

多阶段构建

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

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

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

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变长(并且可能更复杂,具有多个构建阶段),就可以命名明确的阶段。这将有助于您的团队成员的维护(以及将来您将在几个月后复习该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,但是由于WORKDIRingolang是/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镜像没有Shell。这意味着我们不能将字符串语法与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(正如我们在上一段说的)也没什么ls,ps,ping,等等等等。这意味着我们将无法登入容器(使用docker exec或kubectl exec进行查看)。

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

这里的一个解决办法是使用镜像像busybox或alpine代替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年代之前),我们主要使用静态链接,这意味着a所使用的所有库程序将包含在二进制文件中。当从软盘或盒式磁带执行软件时,或者根本没有标准库时,这是完美的选择。但是,在Linux等分时系统上,我们运行许多并发程序,这些并发程序存储在硬盘上。这些程序几乎总是使用标准的C库。

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

动态链接具有多个优点。

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

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

回到我们的故事:默认情况下,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.6。linux-vdso.so.1与一种称为VDSO(虚拟动态共享对象)的机制有关,该机制可以加速某些系统调用。让我们假装它不在那里。至于ld-linux-x86-64.so.2,它实际上是动态链接器本身。 (从技术上讲,我们的hello二进制文件包含以下信息:“嘿,这是一个动态程序,并且知道如何将其所有部分放在一起的东西是ld-linux-x86-64.so.2”。)

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

我个人不建议这样做,因为它非常神秘,难以维护,并且将来很可能会中断。

使用 busybox:glibc

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

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

总结和(部分)结论

让我们看看我们如何在C. Spoiler警报中为“ hello world”程序做些事情此列表包括通过使用Alpine所获得的结果,这将在本系列的下一部分中进行描述。

  • 原始镜像内置gcc:1.14 GB
  • 多级构建与gcc和ubuntu:64.2 MB
  • 静态glibc二进制文件alpine:6.5 MB
  • 动态二进制文件alpine:5.6 MB
  • 静态二进制输入scratch:940 kB
  • 静态musl二进制文件在scratch:94 kB

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

不错。

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

在下一部分中,我们将介绍Go语言特定的一些方面,包括cgo和标签。我们还将介绍其他流行语言,并且我们将讨论更多有关Alpine的信息,因为如果您问我的话,这真是太棒了。


第二部分 特定于不同语言的详细信息介绍

在第一部分中,我们介绍了多阶段构建,静态和动态链接,并简要提到了Alpine。在第二部分中,我们将深入研究Go特有的一些细节。然后,我们将更多地讨论Alpine,因为它值得。最后,我们将看到其他语言(例如Java,Node,Python,Ruby和Rust)如何发挥作用。

那么,Go呢?

您可能已经听说Go做了很聪明的事情:构建二进制文件时,它包括该二进制文件中的所有必需依赖项,以方便其部署。

您可能会想,“等等,那是静态二进制文件!” 你会是对的。差不多了。(如果您想知道什么是静态二进制文件,可以检查本系列的第一部分。)

一些Go软件包依赖于系统库。举例来说,DNS解析,因为它可以以各种方式进行配置(认为/etc/hosts,/etc/resolv.conf和其他一些文件)。一旦我们的代码导入了这些软件包之一,Go就需要生成一个将调用系统库的二进制文件。为此,它启用了一种称为cgo的机制(通常来说,允许Go调用C代码),并生成一个动态可执行文件,引用了需要调用的系统库。

这意味着使用例如net包的Go程序将生成动态二进制文件,并且约束条件与C程序相同。该Go程序将要求我们复制所需的库,或使用类似的镜像busybox:glibc。

但是,我们可以完全禁用cgo。在这种情况下,Go不会使用系统库,而是会使用它们自己内置的这些库的重新实现。例如,它将使用其自己的解析器,而不是使用系统的DNS解析器。生成的二进制文件将是静态的。要禁用cgo,我们要做的就是设置环境变量CGO_ENABLED = 0。

例如:

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

由于cgo已禁用,因此Go不会与任何系统库链接。由于它不与任何系统库链接,因此可以生成静态二进制文件。由于它生成静态二进制文件,因此该二进制文件可以在scratch镜像中工作。

标签和netgo

也可以根据每个包选择要使用的实现。这是通过使用Go“标签”完成的。标记是Go构建过程的指示,指示应构建或忽略哪些文件。通过启用标签“ netgo”,我们告诉Go使用本机net软件包,而不是依赖于系统库的软件包:

go build -tags netgo whatsmyip.go

如果没有其他使用系统库的软件包,则结果将是静态二进制文件。但是,如果我们使用另一个导致启用cgo的程序包,我们将回到平方。

(这就是为什么CGO_ENABLED=0环境变量是确保我们获得静态可执行文件的简便方法的原因。)

标签还用于选择要在不同体系结构或不同操作系统上构建的代码。如果我们在Linux和Windows或Intel和ARM CPU上有一些需要不同的代码,我们也使用标签来指示编译器“仅在Linux上构建时才使用”。

Alpine

我们在第一部分中简要提到了Alpine,然后说:“我们稍后再讨论。” 现在是时候了!

Alpine是Linux发行版,直到几年前,大多数人都将其称为“exotic”。它的设计小巧,安全,并使用自己的包管理器apk。

与CentOS或Ubuntu不同,它没有像Red Hat或Canonical这样的大型公司提供的维护人员支持。它的软件包少于这些发行版。(使用开箱即用的默认存储库,Alpine拥有大约10,000个软件包; Debian,Fedora和Ubuntu各自拥有超过50,000个软件包。)

在容器兴起之前,Alpine并不是很流行,也许是因为很少有人真正在乎Linux系统的安装大小。毕竟,与我们处理的文档和数据(如最终用户的图片和电影;或服务器上的数据库)的大小相比,程序,库和其他系统文件的大小通常可以忽略不计。

当人们意识到Alpine可以很好地分发容器时,Alpine就引起了人们的关注。我们说它很小;究竟有多小?好吧,当容器变得流行时,每个人都注意到容器的镜像很大。它们占用磁盘空间;拉取他们很慢。 (由于担心这个问题,您很有可能正在阅读本文,对吧?)最初的基本镜像使用的是“云镜像”,该云镜像在云服务器上非常流行,重量在几百到几百之间。 MB到几GB。对于云实例(通常是通过非常快速的本地网络将镜像从镜像存储系统传输到虚拟机)的情况,该大小是合适的,但是通过电缆或DSL互联网将其拉取要慢得多。因此,发行版维护人员开始研究专门用于容器的较小镜像。但是,尽管流行的发行版(例如Debian,Ubuntu,Fedora)有时会通过删除可能有用的工具(例如ifconfig或netstat)而努力将其大小控制在100 MB以下,但Alpine通过拥有5 MB的镜像来设置得分,却不牺牲这些工具。

Alpine Linux的另一个优点(以我的观点)是它的软件包管理器非常快。软件包管理器的速度通常不是主要问题,因为在正常系统上,我们只需要安装一次即可。我们不会一直都在安装它们。但是,使用容器时,我们会定期构建镜像,并且经常使用基本镜像启动一个容器,并安装一些软件包来测试某些内容,或者因为我们需要镜像中未包含的额外工具。

只是为了好玩,我决定获取一些流行的基本镜像,并检查tcpdump在其中安装了多长时间。看一下结果:

Base image Size Time to install tcpdump --------------------------------------------------------- alpine:3.11 5.6 MB 1-2s archlinux:20200106 409 MB 7-9s centos:8 237 MB 5-6s debian:10 114 MB 5-7s fedora:31 194 MB 35-60s ubuntu:18.04 64 MB 6-8s

使用docker images命令报告大小,并通过在以下t3dium实例上运行以下命令几次来测量时间:eu-north-1

time docker run <image> <packagemanager> install tcpdump

当我在欧洲时,我在斯德哥尔摩使用服务器,因为瑞典的电力比其他任何地方都清洁,并且我关心地球。不要相信有关eu-central-1“绿色”的废话,法兰克福的数据中心主要依靠煤炭运行。

如何构建容器镜像(详解容器镜像-你需要了解的关于容器镜像的一切)(1)

屏幕截图来自electricalmap,它显示了在这一刻,德国40%的电力来自燃煤电厂

如果您想了解有关Alpine Linux内部的更多信息,我建议观看Natanel Copa在Dockercon上的演讲(dockercon.docker/watch/6nK1TVGjuTpFfnZNKEjCEr)。

好吧,所以Alpine很小。我们如何在自己的应用程序中使用它?至少有两种策略值得考虑:

  • 使用alpine作为我们的“运行”阶段,
  • 使用alpine既是我们的“构建”和“运行”阶段。

让我们尝试一下。

使用Aphine作为我们的“运行”阶段

让我们构建以下Dockerfile,并运行结果镜像:

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

我们将收到以下错误消息:

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

当我们尝试在scratch镜像中运行C程序时,我们已经看到了该错误消息。我们看到问题出在临时镜像中缺少动态库。看起来这些库也从Aphine镜像中丢失了,然后呢?

不完全是。Alpine还使用动态库。毕竟,其设计目标之一是实现较小的占位空间。静态二进制文件将无济于事。

但是Alpine使用不同的标准C库。它使用musl代替GNU C库。(我个人将其发音为emm-you-ess-ell,但官方发音为“贻贝”或“肌肉”。)该库比GNU C库更小,更简单,更安全。动态链接到GNU C库的程序不能与musl一起使用,反之亦然。

您可能会想,“如果musl更小,更简单,更安全,我们为什么不都改用它呢?”

…因为GNU C库具有许多扩展,并且某些程序确实使用了这些扩展;有时甚至没有意识到他们正在使用非标准扩展名。musl文档列出了与GNU C库的功能差异。

此外,musl不是二进制兼容的。为GNU C库编译的二进制文件无法与musl一起使用(在某些非常简单的情况下除外),这意味着必须重新编译代码(有时会进行一些微调)才能与musl一起使用。

TL,DR:仅当程序是为musl(这是Alpine使用的C库)构建的时,才将Alpine用作“运行”阶段。

话虽这么说,构建一个musl程序相对容易。我们要做的就是用Alpine本身来构建它!

使用Alpine作为“构建”和“运行”阶段

我们决定生成一个与musl关联的二进制文件,以便它可以在Aphine基础镜像中运行。我们有两条主要路线可以做到这一点。

  • 一些官方镜像提供的:alpine标签应尽可能接近普通镜像,但改用Alpine(和musl)。
  • 某些官方镜像没有:alpine标签。对于这些,我们需要自己建立一个等效的镜像,通常以阿尔卑斯山为基础。

golang镜像属于第一类:有一个golang:alpine镜像提供了在Alpine上构建的Go工具链。

我们可以使用Dockerfile来构建我们的Go小程序,如下所示:

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

生成的镜像为7.5 MB。只能打印“ Hello,world!”的程序的确很多,但是:

  • 一个更复杂的程序就不会大很多,
  • 该镜像包含许多有用的工具,
  • 由于它基于Alpine,因此可以根据需要轻松快捷地在镜像中或现场添加更多工具。

现在,我们的C程序呢?当我写这些行时,没有gcc:alpine镜像。因此,我们必须从Alpin镜像开始,并安装一个C编译器。生成的Dockerfile如下所示:

FROM alpine RUN apk add build-base COPY hello.c . RUN gcc -o hello hello.c FROM alpine COPY --from=0 hello . CMD ["./hello"]

诀窍是安装build-base(而不是简单地gcc),因为Alpine gcc上的软件包将安装编译器,而不是我们需要的所有库。相反,我们使用build-base,它等效于Debian或Ubuntu build-essentials,引入了编译器,库和诸如make之类的工具。

底线:使用多阶段构建时,我们可以将alpine镜像用作运行代码的基础。如果我们的代码是使用动态库以某种语言编写的编译程序(这是我们可能在容器中使用的几乎每种编译语言的情况),那么我们将需要生成一个与Alpine musl C库链接的二进制文件。最简单的方法是alpine使用Alpine将我们的构建镜像建立在另一个镜像之上。为此,许多官方镜像都提供了标记`:alpine。

对于我们的“ hello world”程序,这是最终结果,将我们到目前为止显示的所有技术进行了比较。

  • 使用golang镜像的单阶段构建:805 MB
  • 使用golang和ubuntu的多阶段构建:66.2 MB
  • 使用golang和Alpine进行多阶段构建:7.6 MB
  • 使用golang和Scratch的多阶段构建:2 MB

大小减少了400倍,即99.75%。这听起来令人印象深刻,但是如果我们尝试使用使用该net软件包的更实际的程序,让我们来看一下结果。

  • 使用golang镜像的单阶段构建:810 MB
  • 使用golang和ubuntu的多阶段构建:71.2 MB
  • 使用golang:alpine和alpine的多阶段构建:12.6 MB
  • 使用golang和busybox的多阶段构建:glibc:12.2 MB
  • 使用golang,CGO_ENABLED = 0和scratch的多阶段构建:7 MB

尺寸仍然缩小了100倍,也就是99%。太棒了!

Java呢?

Java是一种编译语言,但是它在Java虚拟机(或JVM)上运行。让我们看看这对于多阶段构建意味着什么。

静态或动态链接?

从概念上讲,Java使用动态链接,因为Java代码将调用JVM提供的Java API。因此,这些API的代码不在Java“可执行文件”(通常是JAR或WAR文件)之外。

但是,这些Java库并不完全独立于系统库。某些Java函数最终可能会调用系统库。例如,当我们打开一个文件,在某些时候JVM会调用open(),fopen()或者它们的某种变体。您可以再次阅读:JVM将调用这些函数;因此JVM本身可能与系统库动态链接。

这意味着从理论上讲,我们可以使用任何JVM来运行Java字节码。使用musl还是GNU C库都没有关系。因此,我们可以使用具有Java编译器的任何镜像来构建Java代码,然后使用具有JVM的任何镜像来运行它。

Java类文件格式

但是,实际上,Java类文件的格式(由Java编译器生成的字节码)已经随着时间而发展。从一个Java版本到下一个Java版本的大部分更改都位于Java API中。某些更改涉及语言本身,例如Java 5中的泛型添加。这些更改可能导致Java.class文件格式的更改,从而破坏了与旧版本的兼容性。

这意味着默认情况下,使用给定版本的Java编译器编译的类不适用于较早版本的JVM。但是我们可以要求编译器使用带有-target标志(最多Java 8)或带有--release标志(来自Java 9)的较旧文件格式。后者还将选择正确的类路径,以确保如果我们构建旨在在Java 11上运行的代码,则不会意外使用Java 12的库和API(这会阻止我们的代码在Java 11上运行) 。

(如果您想了解更多有关Java类文件版本的信息,可以阅读此博客文章。webcode.lemme.at/2017/09/27/java-class-file-major-minor-version/ )

JDK与JRE

如果您熟悉大多数平台上Java打包的方式,那么您可能已经了解JDK和JRE。

JRE是Java运行时环境。它包含我们运行Java应用程序所需的内容;即JVM。

JDK是Java开发工具包。它包含与JRE相同的东西,但是它还具有我们开发(和构建)Java应用程序所需要的东西。即Java编译器。

在Docker生态系统中,大多数Java镜像都提供JDK,因此它们适合构建和运行Java代码。我们还将看到一些带有:jre标签(或包含jre某处的标签)的镜像。这些是仅包含JRE而没有完整JDK的镜像。它们较小。

对于多阶段构建,这意味着什么?

我们可以在构建阶段使用常规镜像,然后在运行阶段使用较小的JRE镜像。

java vs openjdk

如果您在Docker中使用Java,您可能已经知道这一点。但您不应该使用Java官方镜像,因为它们不再接受更新。而是使用openjdk镜像。

您也可以尝试amazon corretto使用它们(Corretto是Amazon OpenJDK的分支,带有其额外的补丁程序)。

底线

好了,那我们应该用什么呢?如果您正在寻找小型Java镜像的市场,那么这里有一些不错的选择:

  • openjdk:8-jre-alpine (仅85 MB!)
  • openjdk:11-jre (267 MB)或甚至openjdk:11-jre-slim(204 MB)(如果您需要更新版本的Java)
  • openjdk:14-alpine (338 MB)如果您需要更新的版本

不幸的是,并非所有组合都可用。即openjdk:14-jre-alpine不存在(这很可悲,因为它可能比-jre和-alpine变体小),但是可能有充分的理由。(如果您知道该原因,请告诉我,我很想知道!)

请记住,您应该构建代码以匹配JRE版本。如果您需要详细信息,此博客文章介绍了如何在各种环境(IDE,Maven等)中执行此操作。

你想要一些数字吗?我有一些电话给你!我用Java构建了一个简单的“ hello world”程序:

class hello { public static void main(String [] args) { System.out.println("Hello, world!"); } }

您可以在minimage GitHub存储库中找到所有Dockerfile ,这是各种构建的大小。

  • 使用该java镜像的单阶段构建:643 MB
  • 使用openjdk镜像的单阶段构建:490 MB
  • 多级构建使用openjdk和openjdk:jre:479 MB
  • 使用amazoncorretto镜像的单阶段构建:390 MB
  • 使用openjdk:11和的多阶段构建openjdk:11-jre:267 MB
  • 使用openjdk:8和的多阶段构建openjdk:8-jre-alpine:85 MB
解释语言呢?

如果您主要使用诸如Node,Python或Ruby之类的解释语言编写代码,您可能会怀疑是否应该担心所有这些问题,以及是否有任何方法可以优化镜像大小。事实证明,这两个问题的答案都是肯定的

Alpine 语言

我们可以使用alpine基于Alpine的镜像和其他基于Alpine的镜像来以我们喜欢的脚本语言运行代码。这仅适用于仅使用标准库或依赖项为“纯”(即用相同语言编写)而无需调用C代码和外部库的代码。

现在,如果我们的代码依赖于外部库,那么事情将会变得更加复杂。我们将不得不在Alpine上安装这些库。根据情况,可能是:

  • 轻松,只要库中包含Alpine的安装说明即可。它将告诉我们要安装哪些Alpine软件包以及如何建立依赖关系。不过,这相当罕见,因为例如Alpine不如Debian或Fedora受欢迎。
  • 一般,当库没有针对Alpine的安装说明,但具有针对其他发行版的说明时,您可以轻松地找出哪些Alpine软件包与其他发行版的软件包相对应。
  • 很难,当我们的依赖项使用没有Alpine等效项的软件包时。然后,我们可能必须从源头构建,这将是一个完全不同的故事!

这是最后一种情况时,Aphine可能没有帮助,甚至可能适得其反。如果我们需要从源代码构建,则意味着安装编译器,库,标头……这将在最终镜像上占用额外的空间。 (是的,我们可以使用多阶段构建;但是在特定的上下文中,取决于语言,这可能很复杂,因为我们需要弄清楚如何为我们的依赖项生成二进制包。)从源代码进行构建也将需要更久,更长。

在一种特殊情况下,使用Alpine会遇到所有这些问题:Python中的数据科学。诸如numpy或pandas之类的流行软件包可作为预编译的Python软件包(称为wheel)获得,但这些wheel绑定到特定的C库。 (“哦,不!”您可能会想,“再也不是库!”。)这意味着它们可以在“正常” Python镜像上正常安装,而不能在Alpine变体上安装。在Alpine上,他们将需要安装系统软件包,在某些情况下,将需要很长时间的重建。有一篇很好的文章专门针对该问题,解释了使用Alpine如何使Python Docker的构建速度降低50倍(pythonspeed/articles/alpine-docker-python/)。

如果您阅读该文章,您可能会想:“那,我应该远离Alpine寻求Python吗?” 我不确定。对于数据科学,可能是的。但是对于其他工作负载,如果要减小镜像大小,则值得一试。

SLIM:超薄镜像

如果要在默认镜像及其Alpine变体之间折衷,可以检查:slim镜像。Slim的镜像通常基于Debian(以及GNU C库),但是它们通过删除许多不必要的软件包而针对大小进行了优化。有时,他们可能会满足您的需求。有时,它们将缺少必要的内容(例如编译器!),安装这些内容会使您更接近原始大小;但是有机会尝试使用它们真是太好了。

为了让您有个好主意,以下是一些流行的解释语言的default:alpine和:slim变体的大小:

Image Size --------------------------- node 939 MB node:alpine 113 MB node:slim 163 MB python 932 MB python:alpine 110 MB python:slim 193 MB ruby 842 MB ruby:alpine 54 MB ruby:slim 149 MB

在特定的Python情况下,以下是在各种Python基本镜像上安装流行的软件包matplotlib,numpy和pandas所获得的大小:

Image and technique Size -------------------------------------- python 1.26 GB python:slim 407 MB python:alpine 523 MB python:alpine multi-stage 517 MB

我们可以看到,使用Alpine根本无法帮助我们,即使是多阶段构建也无法改善这种情况。(您可以在minimage存储库中找到相关的Dockerfile;它们是名为的文件Dockerfile.pyds.*。)

不过,不要太快得出结论Alpine对Python不利!以下是使用大量依赖项的Django应用程序的大小:

Image and technique Size -------------------------------------- python 1.23 GB python:alpine 636 MB python:alpine multi-stage 391 MB

(在这种情况下,我放弃使用该:slim镜像,因为它需要安装太多额外的软件包。)

因此,您可以看到,它并不总是很清晰。有时,:alpine会产生更好的结果,有时:slim会做到。如果我们确实需要优化镜像的大小,则需要同时尝试一下并看看会发生什么。随着时间的流逝,我们将积累经验并了解哪种变体适用于哪些应用程序。

多阶段构建 解释语言

那么多阶段构建又如何呢?

当我们产生任何种类的资产时,它们将特别有用。

例如,您有一个Django应用程序(可能使用了一些python基本镜像),但您使用UglifyJS缩小了Javascript,并使用Sass缩小了CSS 。天真的方法是在镜像中包含所有Jass,但是Dockerfile会变得复杂(因为我们将在Python镜像中安装Node),最终镜像当然会很大。相反,我们可以使用多个阶段:一个阶段node用于最小化您的资产,另一个阶段python用于应用程序本身,从第一阶段引入JS和CSS资产。

这也将导致更好的构建时间,因为Python代码的更改并不总是会导致JS和CSS的重建(反之亦然)。在这种情况下,我甚至建议对JS和CSS使用两个单独的阶段,以便更改一个阶段不会触发另一个阶段的重建。

那Rust呢?

我对Rust是一种最初由Mozilla设计的现代编程语言,并且在Web和基础架构领域中越来越受欢迎。所以我想知道就涉及到Docker镜像而言应该期待什么样的行为。

事实证明,Rust生成与C库动态链接的二进制文件。因此,与内置的二进制rust镜像将与平常基本镜像一样运行debian,ubuntu,fedora,等,但不能和busybox:glibc工作。这是因为二进制文件与链接libdl,目前不包含在busybox:glibc内。

但是,有一个rust:alpine镜像,并且生成的二进制文件以alpine为基础可以很好地工作。

我想知道Rust是否可以生成静态二进制文件。该Rust文档解释了如何做到这一点。在Linux上,这是通过构建Rust编译器的特殊版本来完成的,并且需要musl。是的,与musl Alpine相同。如果要使用Rust获得最少的镜像,请按照文档中的说明进行操作,然后将生成的二进制文件放入scratch镜像中,这将非常容易。

结论

在本系列的前两部分中,我们介绍了用于优化Docker镜像大小的最常用方法,并且了解了它们如何应用于各种语言(编译或解释)。

在最后一部分,我们将讨论更多。我们将看到在特定基础镜像上进行标准化如何不仅可以减少镜像大小,而且可以减少I / O和内存使用量。我们将提到一些并非特定于容器的技术,但是它们总是有用的。为了完整起见,我们将唤起更多的异国情调的建筑


第三部分 进一步缩小镜像尺寸介绍

在本系列的前两部分中,我们介绍了用于优化Docker镜像大小的最常用方法。我们看到了多阶段构建,结合基于Alpine的镜像以及有时是静态构建的方式通常可以为我们带来最显着的节省。在最后一部分中,我们将了解如何走得更远。我们将讨论标准化基础镜像,剥离二进制文件,资产优化以及其他构建系统或附加组件(如DockerSlim或Bazel)以及NixOS发行版。

我们还将讨论我们之前遗漏的小细节,但这些细节仍然很重要,例如时区文件和证书。

共同基础

如果我们的节点并行运行许多容器(甚至只有几个),那么一件事也可以节省大量资金。

Docker镜像由层组成。每一层都可以添加,删除或更改文件。就像代码存储库中的提交或从另一个类继承的类一样。当我们执行时docker build,Dockerfile的每一行都会生成一层。传输镜像时,仅传输目标上尚不存在的图层。

层节省了网络带宽,还节省了存储空间:如果多个镜像共享层,则Docker只需要存储一次这些层。并且,根据所使用的存储驱动程序,层还可以节省磁盘I / O和内存,因为当多个容器需要从层读取相同的文件时,系统将仅读取和缓存这些文件一次。(对于overlay2和aufs驱动程序就是这种情况。)

这意味着,如果我们在运行多个容器的节点中尝试优化网络和磁盘访问以及内存使用率,则可以通过确保这些容器运行具有尽可能多的公共层的镜像来节省大量资源。

这可能直接违反我们之前给出的一些准则!例如,如果我们使用静态二进制文件构建超级优化的镜像,则这些二进制文件可能比其动态等效文件大10倍。让我们看一下运行10个容器时的一些假设情况,每个容器使用带有这些二进制文件之一的不同镜像。

方案1:scratch镜像中的静态二进制文件

  • 每张镜像的大小:10 MB
  • 10张镜像的小:100 MB

方案2:带有ubuntu镜像的动态二进制文件(64 MB)

  • 每个镜像的单个权重:65 MB
  • 每个镜像的细目:64 MB用于ubuntu 1 MB用于特定的二进制文件
  • 磁盘总使用量:74 MB(单个层10x1 MB 共享层64 MB)

方案3:带有alpine镜像的动态二进制文件(5.5 MB)

  • 每个镜像的单个权重:6.5 MB
  • 每个镜像的细目:5.5 MB的alpine 1 MB的特定二进制文件
  • 磁盘总使用量:15.5 MB

首先,这些静态二进制文件看起来像是一个好主意,但是在这种情况下,它们会适得其反。镜像将需要更多的磁盘空间,需要更长的传输时间,并使用更多的RAM!

但是,为了使这些方案起作用,我们需要确保所有镜像实际上都使用完全相同的基准。如果我们使用某些镜像,而使用的centos其他镜像debian,则将其破坏。即使我们使用egubuntu:16.04和ubuntu:18.04。即使我们使用的是两个不同版本的ubuntu:18.04!这意味着在更新基本镜像时,我们应该重建所有镜像,以确保在所有容器中它都是一致的。

这也意味着我们需要良好的管理和团队之间的良好沟通。您可能会想,“这不是技术问题!”,那么您将是对的!这不是技术问题。这意味着对于某些人而言,解决起来会困难得多,因为您自己无法解决的工作量很多:您将不得不让其他人参与进来!也许您绝对想要使用Debian,但是另一个团队绝对想要使用Fedora。如果您想使用共同基础,则必须说服其他团队。这意味着您必须接受他们也可以说服您。底线:在某些情况下,最有效的解决方案是需要社交技能而非技术技能的解决方案!

最后,在一种特定情况下,静态镜像仍然有用:当我们知道我们的镜像将被部署在异构环境中时;或何时它们将是在给定节点上运行的唯一对象。在这种情况下,无论如何都不会发生任何共享。

剥离和转换

还有一些不是专门针对容器的额外技术,可以使我们的镜像节省几兆字节(有时甚至几千字节)。

剥离二进制文件

默认情况下,大多数编译器会生成带有符号的二进制文件,这些二进制文件对于调试或疑难解答很有用,但对于执行而言并非绝对必要。该工具strip将删除这些符号。这不太可能改变游戏规则,但是如果您处在每个字节都很重要的情况下,那肯定会有所帮助。

处理资产

如果我们的容器镜像包含媒体文件,是否可以缩小这些文件,例如通过使用不同的文件格式或编解码器?我们可以将它们托管在其他地方,以使我们发送的镜像更小吗?如果代码经常更改,而资产却不更改,则后者特别有用。在这种情况下,我们应该尽量避免在每次发布新版本的代码时都交付资产。

压缩:一个坏主意

如果要减小镜像的大小,为什么不压缩文件?HTML,JavaScript,CSS之类的资产应使用zip或gzip很好地压缩。还有更有效的方法,例如bzip2、7z,lzma。首先,它看起来像是一种减小镜像大小的简单方法。如果我们计划以压缩形式提供这些资产,那为什么不呢。但是,如果我们的计划是在使用之前先解压缩这些资产,那么我们最终将浪费资源!

图层在传输之前已被压缩,因此拉取镜像不会更快。而且,如果我们需要解压缩文件,则磁盘使用率将比以前更高,因为在磁盘上,我们现在将同时具有文件的压缩版本和未压缩版本!更糟糕的是:如果这些文件在共享层上,共享将不会带来任何好处,因为在运行容器时我们将解压缩的这些文件将不会被共享。

什么UPX?如果您不熟悉UPX,则它是一种出色的工具,可以减少二进制文件的大小。它是通过压缩二进制文件并添加一个小存根来解压缩并透明地运行它的。如果我们想减少集装箱的占地面积,UPX也会适得其反。首先,磁盘和网络的使用不会减少一点,因为无论如何层都是压缩的。因此UPX不会在这里给我们任何东西。

当运行普通的二进制文件时,它会映射到内存中,以便仅在需要时才加载(或“分页”)所需的位。运行使用UPX压缩的二进制文件时,必须在内存中解压缩整个二进制文件。这会导致更高的内存使用率和更长的启动时间,尤其是对于像Go这样的运行时,它倾向于生成更大的二进制文件。

(我曾经尝试在hyperkube二进制文件上使用UPX,试图构建优化的节点镜像以在KVM中运行本地Kubernetes集群。这种情况并不顺利,因为虽然它减少了我的VM的磁盘使用量,但它们的内存使用量却上升了,很多!)

…以及一些奇特的技巧

还有其他工具可以帮助我们获得较小的镜像尺寸。这不会是一个详尽的清单...

DockerSlim

DockerSlim提供了一种几乎不可思议的技术来减小镜像的大小。我不知道它到底是如何工作的(除了自述文件中的设计说明),因此我将进行有根据的猜测。我想DockerSlim运行我们的容器,并检查容器中运行的程序访问了哪些文件。然后,它将删除其他文件。基于这一猜测,在使用DockerSlim之前,我会非常小心,因为许多运行时和框架正在动态或延迟地(即首次需要它们)加载文件。

为了验证该假设,我尝试使用一个简单的Django应用程序进行DockerSlim。DockerSlim将其从200 MB减少到30 MB,这真是太好了!但是,尽管该应用程序的首页运行正常,但许多链接却被破坏了。我想这是因为DockerSlim尚未检测到它们的模板,并且它们也没有包含在最终镜像中。错误报告本身也被破坏,可能是因为用于显示和发送异常的模块也被跳过了。任何可以动态地import运行某些模块的Python代码都将在其中运行。

不过,请不要误会我的意思:在许多情况下,DockerSlim仍然可以为我们创造奇迹!与往常一样,当有这样一个非常强大的工具时,了解它的内部结构将非常有帮助,因为它可以使我们对它的工作方式有一个很好的了解。

Distroless

Distroless镜像是使用外部工具构建的最少镜像的集合,而无需使用经典的Linux分发程序包管理器。它产生的镜像非常小,但是没有基本的调试工具,也没有简单的安装方法。

就个人喜好而言,我更喜欢拥有一个软件包管理器和一个熟悉的发行版,因为谁知道我可能需要什么额外的工具来解决实时容器问题?Alpine只有5.5 MB,将使我能够安装所需的几乎所有东西。我不知道是否要放手!但是,如果您有全面的方法来对容器进行故障排除,而无需从其镜像中执行工具,那么通过各种方式,您可以通过Distroless节省一些额外的费用。

此外,基于Alpine的镜像通常会比其Distroless镜像小。所以您可能想知道:我们为什么要关心Distroless?至少有两个原因。

首先,从安全性的角度来看,Distroless镜像使您获得的镜像非常少。镜像中更少的内容意味着更少的潜在漏洞。

其次,Distroless镜像是使用Bazel构建的,因此,如果您想学习或试验或使用Bazel,它们是非常不错的入门示例的集合。Bazel到底是什么?很高兴您提出要求,我们将在下一部分中介绍!

Bazel(和其他替代构建器)

有些构建系统甚至不使用Dockerfiles。Bazel就是其中之一。 Bazel的优势在于它可以表达我们的源代码和它所构建的目标之间的复杂依赖关系,有点像Makefile。这样就可以只重建需要重建的东西。无论是在我们的代码中(在进行小的本地更改时)还是在我们的基本镜像中(以便修补或升级库都不会触发所有镜像的完整重建)。它还可以以相同的效率驱动单元测试,并且仅对受代码更改影响的模块运行测试。

这在非常大的代码库上特别有效。在某个时候,我们的构建和测试系统可能需要几个小时才能运行。然后,这需要几天的时间,我们部署了并行构建服务器场和测试运行器,这又需要花费数小时,但需要大量资源,并且无法再在本地环境中运行。大约在这个阶段,Bazel之类的东西才会真正发光,因为它将能够在几分钟而不是几小时或几天内构建并测试所需的内容。

赞!那我们应该马上跳到Bazel吗?没那么快。使用Bazel需要学习完全不同的构建系统,即使拥有上面提到的所有漂亮的多阶段构建以及静态和动态库的精妙之处,使用Dockerfile也会比Dockerfile复杂得多。维护此构建系统和相关的配方将需要大量工作。虽然我本人没有与Bazel的第一手经验,但根据我周围的经历,为引入Bazel至少需要承担一名全职高级或总工程师的负担并非没有道理。

如果我们的组织有数百名开发人员;建造或测试时间是否正在成为主要障碍并阻碍我们的发展速度;那么投资Bazel可能是一个好主意。否则,如果我们是一家处于起步阶段的初创企业或小型组织,那么这可能不是最佳决定。除非我们有几位工程师非常了解Bazel并想为其他所有人设置它,否则我们将不再为您服务。

Nix

我决定增加一整个有关Nix软件包管理器的部分,因为在第1部分和第2部分发布之后,有些人对它充满了热情。

扰流板警报:是的,Nix可以帮助您获得更好的构建,但是学习曲线陡峭。也许不像Bazel那样陡峭,但是很近。您将需要学习Nix,其概念,其自定义表达语言,以及如何使用它为您喜欢的语言和框架打包代码(有关示例,请参见nixpkgs手册)。

尽管如此,我还是想谈谈Nix,这有两个原因:它的核心概念非常强大(可以帮助我们总体上更好地了解软件包装),还有一个名为Nixery的特定项目可以在部署容器时帮助我们。

什么是Nix?

我第一次听说Nix大约是10年前,当时我参加了该会议演讲。那时,它已经功能齐全且坚固。这不是崭新的时髦事物。

一点术语:

  • Nix是一个程序包管理器,您可以在任何Linux机器以及macOS上安装;
  • NixOS是基于Nix的Linux发行版
  • nixpkgs 是Nix的软件包集合;
  • “派生”是Nix构建的秘诀。

Nix是功能包管理器。 “功能性”是指每个程序包均由其输入(源代码,依赖项...)及其派生(构建配方)定义,而没有别的。如果我们使用相同的输入和相同的派生,我们将获得相同的输出。但是,如果我们更改输入(如果我们编辑源文件或更改依赖项)或构建配方中的某些内容,则输出会更改。那是有道理的,对吧?如果它使我们想起Docker构建缓存,那是完全正常的:这是完全相同的想法!

在传统系统上,当程序包依赖于另一个程序包时,该依赖关系通常表示得很松散。例如,在Debian中,python3.8依赖于,python3.8-minimal (= 3.8.2-1)而python3.8-minimal依赖于libc6 (>= 2.29)。另一方面,ruby2.5取决于libc6 (>= 2.17)。因此,我们安装的单个版本,libc6并且大多数情况下都能正常工作。

在Nix上,程序包依赖于库的确切版本,并且有一个非常巧妙的机制,因此每个程序都将使用自己的库集,而不会与其他库冲突。(如果您怀疑这样做是否可行:动态链接程序正在使用一个链接器,该链接器被设置为使用来自特定路径的库。从概念上讲,这与指定#!/usr/local/bin/my-custom-python-3.8使用特定版本的Python解释器运行Python脚本没有什么不同。)

例如,当程序使用C库时,在经典系统上,它引用/usr/lib/libc.so.6,但是对于Nix,它可能引用了/nix/store/6yaj...drnn-glibc-2.27/lib/libc.so.6。

看到那/nix/store条路了吗?那是Nix商店。存储在其中的东西是不可变的文件和目录,由哈希标识。从概念上讲,Nix存储类似于Docker使用的层,但有一个很大的区别:各层相互叠加,而Nix存储中的文件和目录是不相交的;它们永远不会相互冲突(因为每个对象都存储在不同的目录中)。

在Nix上,“安装软件包”是指在Nix商店中下载大量文件和目录,然后设置配置文件(实际上是一堆符号链接,以便我们现在可以使用刚刚安装的程序$PATH)。

使用Nix进行实验

听起来很理论吧?让我们看看Nix的作用。

我们可以使用来在容器中运行Nix docker run -ti nixos/nix。

然后,我们可以使用nix-env --query或检查安装的软件包nix-env -q。

它只会显示给我们nix和nss-cacert。很奇怪,难道我们还没有像Shell这样的外壳以及其他许多其他工具ls吗?是的,但是在该特定的容器镜像中,它们是由静态busybox可执行文件提供的。

好了,我们该如何安装?我们可以做nix-env --install redis或做niv-env -i redis。该命令的输出向我们表明,它正在获取新的“路径”并将其放置在Nix存储中。它至少会为redis本身获取一条“路径”,并且很可能为glibc获取另一条路径。碰巧的是,Nix本身(例如nix-env二进制文件和其他一些文件)也使用glibc,但它可能与redis使用的版本不同。如果运行例如,ls -ld /nix/store/*glibc*/我们将看到两个目录,分别对应于glibc的两个不同版本。在编写这些行时,我得到了以下两个版本glibc-2.27:

ef5936ea667f:/# ls -ld /nix/store/*glibc*/ dr-xr-xr-x ... /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/ dr-xr-xr-x ... /nix/store/6yaj6n8l925xxfbcd65gzqx3dz7idrnn-glibc-2.27/

您可能会想:“等等,不是同一版本吗?” 是的,没有!它是相同的版本号,但可能是用稍微不同的选项或不同的修补程序构建的。发生了某些变化,因此从Nix的角度来看,这是两个不同的对象。就像当我们构建相同的Dockerfile但在某处更改一行代码时一样,Docker构建器会跟踪这些小的差异并为我们提供两个不同的镜像。

我们可以让尼克斯向我们展示了在尼克斯存储与任何文件的相关性nix-store --query --references或nix-store -qR。例如,要查看刚刚安装的Redis二进制文件的依赖性,我们可以这样做nix-store -qR $(which redis-server)。

在我的容器中,输出如下所示:

/nix/store/6yaj6n8l925xxfbcd65gzqx3dz7idrnn-glibc-2.27 /nix/store/mzqjf58zasr7237g8x9hcs44p6nvmdv7-redis-5.0.5

现在来了。这些目录是我们在任何地方运行Redis所需要的。是的,其中包括scratch。我们不需要任何额外的库。(也许只是$PATH为了方便而对我们进行了调整,但这甚至不是绝对必要的。)

我们甚至可以使用Nix配置文件来概括该过程。配置文件包含bin我们需要添加到目录中的目录$PATH(以及其他一些内容;为方便起见,我将其简化)。这意味着,如果我这样做,nix-env --profile myprof -i redis memcached,myprof/bin将包含Redis的和Memcached的可执行文件。

更好的是,配置文件也是Nix存储中的对象。因此,我可以将其nix-store -qR与他们一起使用,以列出其依赖关系。

使用Nix创建最小的镜像

使用上一节中看到的命令,我们可以编写以下Dockerfile:

FROM nixos/nix RUN mkdir -p /output/store RUN nix-env --profile /output/profile -i redis RUN cp -va $(nix-store -qR /output/profile) /output/store FROM scratch COPY --from=0 /output/store /nix/store COPY --from=0 /output/profile/ /usr/local/

第一阶段使用Nix在新的“配置文件”中安装Redis。然后,我们要求Nix列出该配置文件的所有依赖项(即nix-store -qR命令),然后将所有这些依赖项复制到/output/store。

第二阶段将这些依赖项复制到/nix/store(即,它们在Nix中的原始位置),并复制配置文件。(主要是因为配置文件目录包含一个bin目录,并且我们希望该目录位于我们的目录中$PATH!)

结果是带Redis的35 MB镜像,仅此而已。如果您想要一个shell,只需更新Dockerfile即可-i redis bash,然后瞧瞧!

如果您打算重写所有Dockerfile来使用它,请稍等。首先,该镜像缺少关键的元数据,例如VOLUME,EXPOSE以及ENTRYPOINT相关的包装器。接下来,在下一节中,我为您提供了更好的选择。

Nixery

所有软件包管理器都以相同的方式工作:他们下载(或生成)文件并将其安装在我们的系统上。但是与Nix相比,有一个重要的区别:安装的文件在设计上是不可变的。当我们使用Nix安装软件包时,它们不会改变我们以前的版本。Docker层可以互相影响(因为一个层可以更改或删除在上一层中添加的文件),但是Nix存储对象则不能。

看一下我们之前运行的Nix容器(或使用来开始一个新容器docker run -ti nixos/nix)。特别是,请签出/nix/store。有很多这样的目录:

b7x2qjfs6k1xk4p74zzs9kyznv29zap6-bzip2-1.0.6.0.1-bin/ cinw572b38aln37glr0zb8lxwrgaffl4-bash-4.4-p23/ d9s1kq1bnwqgxwcvv4zrc36ysnxg8gv7-coreutils-8.30/

如果我们使用Nix来构建容器镜像(如上一节末尾在Dockerfile中所做的那样),那么我们需要的只是一堆目录/nix/store 一些符号链接,以方便使用。

想象一下,我们将Nix商店的每个目录上载为Docker注册表中的镜像层。

现在,当我们需要使用包X,Y和Z生成镜像时,我们可以:

  • 用符号链接束生成一个小的层,以轻松调用X,Y和Z中的任何程序(这对应于COPY上面Dockerfile中的最后一行),
  • 问Nix,什么是对应的存储对象(对于X,Y和Z,以及它们的依存关系),以及相应的图层,
  • 生成引用所有这些层的Docker镜像清单。

这正是Nixery所做的。Nixery是一个“神奇”的容器注册表,它动态地生成容器镜像清单,并引用作为Nix存储对象的图层。

具体来说,如果这样做docker run -ti nixery.dev/redis/memcached/bash bash,我们将在具有Redis,Memcached和Bash的容器中获得外壳;并且该容器的镜像是即时生成的。(请注意,我们宁愿这样做docker run -ti nixery.dev/shell/redis/memcached sh,因为当镜像以开头时shell,Nixery在外壳顶部为我们提供了一些基本软件包;coreutils例如。)

Nixery中有一些额外的优化;如果您有兴趣,可以查看此博客文章或NixConf的演讲。

利用Nix的其他方法

Nix还可以直接生成容器镜像。这篇博客文章中有一个很好的例子。但是请注意,博客文章中显示的技术需要kvm,并且在大多数利用云实例的构建环境(除了带有嵌套虚拟化的实例除外,这种情况仍然很少见)上或在容器内都无法使用。显然,您将不得不修改示例并使用buildLayeredImage,但是我没有走那么远,所以我不知道需要进行多少工作。

使用Nix还是不使用Nix?

在像这样的简短(甚至不是那么简短)的博客文章中,我无法教您如何通过书本来使用Nix来生成完美的容器镜像。但是我至少可以演示一些基本的Nix命令,并展示如何在多阶段Dockerfile中使用Nix以全新的方式生成自定义容器镜像。我希望这些示例将帮助您确定Nix是否对您的应用程序很有趣。

就个人而言,我希望在需要临时容器镜像(尤其是在Kubernetes上)时使用Nixery。让我们假设,例如,我需要的镜像curl,tar以及AWS CLI。我的传统方法已使用alpine和执行apk add curl tar py-pip,然后pip install awscli。但是使用Nixery,我可以简单地使用镜像nixery.dev/shell/curl/gnutar/awscli!

还有所有的小细节

如果我们使用非常小的镜像(例如scratch,但在某种程度上alpine甚至使用distroless,Bazel或Nix生成的镜像)​,我们可能会遇到意想不到的问题。我们通常不会考虑一些文件,但是某些程序可能希望在性能良好的UNIX系统上找到,因此也可以在容器文件系统中找到。

我们到底在谈论什么文件?好吧,这是一个简短但不详尽的清单:

  • TLS证书
  • 时区文件,
  • UID / GID映射文件。

让我们看看这些文件到底是什么,为什么以及何时需要它们,以及如何将它们添加到镜像中。

TLS证书

当我们建立到远程服务器的TLS连接时(例如,通过HTTPS向Web服务或API发出请求),该远程服务器通常会向我们显示其证书。通常,该证书已由知名证书颁发机构(或CA)签名。通常,我们要检查此证书是否有效,并且我们确实知道对其进行签名的权限。

(我之所以说“一般”,是因为在一些非常罕见的场景中,这无关紧要,或者我们以不同的方式验证事物;但是,如果您处于这些情况中的一种,则应该知道。如果您不知道,请假设您必须验证证书!安全第一!)

在此过程中,密钥(不希望使用的双关语)在于这些知名的证书颁发机构。要验证我们连接到的服务器的证书,我们需要证书颁发机构的证书。这些通常安装在下/etc/ssl。

如果使用的是scratch另一个最小镜像,或者连接到TLS服务器,则可能会收到证书验证错误。使用Go,这些看起来像x509: certificate signed by unknown authority。如果发生这种情况,我们所需要做的就是将证书添加到您的镜像中。我们可以从几乎任何常见的镜像(例如ubuntu或)中获取它们alpine。我们使用哪一个并不重要,因为它们都附带几乎相同的证书包。

下面的代码可以解决问题:

COPY --from=alpine /etc/ssl /etc/ssl

顺便说一句,这表明如果我们要从镜像中复制文件--from,即使它不是构建阶段,也可以使用它来引用该镜像!

时区

如果我们的代码操纵时间,尤其是本地时间(例如,如果我们在本地时区而不是日期或内部时间戳中显示时间),则需要时区文件。您可能会想:“等等,那是什么?如果我想管理时区,我所需要知道的就是与UTC的时差!”嗯,但这不算夏时制!夏时制(DST)棘手,因为并非所有地方都有DST。在具有DST的地方中,标准时间和夏令时之间的更改不会在同一日期发生。多年来,有些地方将实施(或取消)DST,或更改其使用期限。

因此,如果要显示本地时间,则需要描述所有这些信息的文件。在UNIX上,则是tzinfo或zoneinfo文件。传统上,它们存储在/usr/share/zoneinfo。

一些镜像(例如centos或debian)确实包含时区文件。其他人(例如alpine或ubuntu)则没有。包含文件的软件包通常命名为tzdata。

要在我们的镜像中安装时区文件,我们可以执行例如:

COPY --from=debian /usr/share/zoneinfo /usr/share/zoneinfo

或者,如果我们已经在使用alpine,我们可以简单地进行apk add tzdata。

要检查时区文件是否已正确安装,我们可以在容器中运行以下命令:

TZ=Europe/Paris date

如果显示类似的信息Fri Mar 13 21:03:17 CET 2020,则表示我们很好。如果显示UTC,则表明未找到时区文件。

UID / GID映射文件

我们的代码可能需要做的另一件事:查找用户和组ID。这是通过在/etc/passwd和中查找来完成的/etc/group。就个人而言,我唯一需要提供这些文件的场景是在容器中运行桌面应用程序(使用诸如clink或Jessica Frazelle的dockerfiles之类的工具。

如果需要将这些文件安装在最小的容器中,则可以在本地或在多阶段容器的一个阶段中生成它们,也可以从主机绑定安装它们(取决于您要实现的目标)。

这篇博客文章显示了如何将用户添加到构建容器,然后复制/etc/passwd并/etc/group运行到容器。

结论

如您所见,有许多方法可以减小镜像的大小。如果您想知道“减小镜像尺寸的绝对最佳方法是什么?”,坏消息:没有绝对最佳的方法。像往常一样,答案是“取决于”。

基于Alpine的多阶段构建将在许多情况下提供出色的结果。

但是有些库在Alpine上不可用,构建它们可能需要比我们想要的更多的工作。因此,在这种情况下,使用经典发行版进行多阶段构建会非常有用。

Distroless或Bazel之类的机制可能更好,但需要大量的前期投资。

scratch在像嵌入式系统这样的空间很小的环境中进行部署时,静态二进制文件和镜像可能会很有用。

最后,如果我们构建和维护许多镜像(数百个或更多),即使并非总是最好的,我们可能仍要坚持使用一种技术。使用相同的结构来维护数百个镜像可能要容易得多,而不是拥有很多变体以及一些针对特殊情况的奇特构建系统或Dockerfile。

如果您使用的是某种特定的技术,而我没有提到,请告诉我!我很想学习。

感谢和致谢

写这个系列文章的灵感来自特定鸣叫通过@ellenkorbes。在进行容器培训时,我总是花一些时间来解释如何减小镜像的大小,并且我经常讨论动态链接和静态链接的切线。有时,我想知道是否真的有必要提及所有这些小细节。当我看到L的推文以及对该推文的一些回应时,我想:“哇,我想如果我写下我对此事的了解,它实际上可能会帮助很多人!”。您知道的下一件事,我在一个空的Cate Mate板条箱和三篇博客文章旁边醒来!

如果您正在寻找有关在Kubernetes上运行Go代码的惊人资源(以及其他相邻主题),我强烈建议您查看L的演讲清单。YouTube上有许多此类讲座,我保证这将是您宝贵的时间。特别是,如果您喜欢我对最小化Docker镜像的追求,请留意即将发布的演讲,《最快部署时间的追求!》

非常感谢能提出改进和补充建议的人们!特别是:* DavidDelabassée提供Java建议和jlink;* Sylvain Rabot用于证书,时区以及UID和GID文件;* Gleb Peregud和Vincent Ambo分享了有关Nix的非常有用的资源。

这些帖子最初是用英语撰写的,英文版本由AJ Bowen校对,他发现了许多错别字,错误,并指出了许多改进我的散文的方法。所有其余错误仅是我的。AJ目前正在从事一个涉及古代明信片历史保存的项目,如果那是您的果酱,您应该完全订阅这里以了解更多信息。

法语版由AurélienViolet和Romain Degez翻译。如果您喜欢阅读法文版,请确保向他们致以深深的谢意,因为这代表着比看起来更多的工作!

(本文由闻数起舞翻译自Jérôme Petazzoni的文章《Docker Images》,转载请注明出处,原文链接:ardanlabs/blog/2020/02/docker-images-part1-reducing-image-size.html ardanlabs/blog/2020/02/docker-images-part2-details-specific-to-different-languages.html ardanlabs/blog/2020/04/docker-images-part3-going-farther-reduce-image-size.html )

,

免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com

    分享
    投诉
    首页