原文地址:Docker Images : Part III - Going Farther To Reduce Image Size
介绍
在本系列的前两部分中,我们介绍了优化Docker镜像大小的最常用方法。我们看到了多阶段构建,结合基于Alpine的镜像以及有时是静态构建的方式,通常可以为我们带来最大的空间优化。在最后一部分中,我们将进行更深一步探讨。我们将讨论标准化基础镜像,剥离二进制文件,项目优化以及其他构建系统或附加组件,例如DockerSlim或Bazel,以及NixOS发行版。
我们还将讨论一些我们早先遗漏的小细节,但这些细节很重要,例如时区文件和证书。
公共基础配置
如果我们的节点并行运行许多容器(甚至只有几个),那么有一件事也可以节省大量资源。
Docker镜像由layer组成。每一个layer都可以添加,删除或更改文件。就像代码存储库中的提交代码或从另一个类继承的类一样。当我们执行时docker build,Dockerfile的每一行都会生成一个layer。传输镜像时,仅传输目标上尚不存在的layer。
layer不仅节省了网络带宽,还节省了存储空间:如果多个镜像共享layer,则Docker只需要存储一次这些layer。并且,根据所使用的存储驱动程序,layer还可以节省磁盘I / O和内存,因为当多个容器需要从layer读取相同的文件时,系统将仅读取和缓存这些文件一次。(overlay2和aufs驱动程序就是这种情况。)
这意味着,如果我们在运行多个容器的节点中尝试优化网络和磁盘访问以及内存使用率,则可以通过确保这些容器运行的镜像具有尽可能多的公共layer来节省大量资源。
这可能直接违反我们之前给出的一些准则!例如,如果我们使用静态二进制文件构建超级优化的镜像,则这些二进制文件可能比其动态等效文件大10倍。让我们假设一个场景,运行10个容器,每个容器使用带有这些二进制文件之一的不同镜像。
方案1:scratch镜像中的静态二进制文件
每个镜像的占用:10 MB
10个镜像的占用:100 MB
方案2:使用ubuntu镜像的动态二进制文件(64 MB)
每个镜像的单个占用:65 MB
每个镜像的细目:64 MB用于ubuntu+ 1 MB用于特定的二进制文件
磁盘总使用量:74 MB(单个layer10x1 MB +共享层64 MB)
方案3:使用alpine镜像的动态二进制文件(5.5 MB)
每个镜像的单个占用:6.5 MB
每个镜像的细目:alpine 5.5 MB + 特定的二进制文件1 MB
磁盘总使用量:15.5 MB
最初,这些静态二进制文件看起来不错,但是在这种情况下,它们会适得其反。镜像将需要更多的磁盘空间,需要更长的传输时间并使用更多的RAM!
但是,为了使这些方案起作用,我们需要确保所有镜像实际上都使用完全相同的标准。如果我们某些使用centos镜像,而其他使用debian,这是行不通的。即使我们使用比如 ubuntu:16.04和ubuntu:18.04,两个不同版本的ubuntu!这意味着在更新基础镜像时,我们应该重建所有镜像,以确保所有容器中的镜像都是一致的。
这也意味着我们需要良好的管理和团队之间的良好沟通。你可能会想,“这不是个技术问题!”,那么你是对的!这不是技术问题。这意味着对于某些人来说,解决起来会困难得多,因为你无法自己无法解决的工作量很多:你将不得不让其他人参与进来!也许你坚持使用Debian,但是另一个团队坚持使用Fedora。如果你想使用通用基础,则必须说服其他团队。这也意味着你必须接受他们也可以说服你的结果。结论:在某些情况下,最有效的解决方案是需要沟通能力而非技术能力!
最后,在特定情况下,静态镜像仍然有用:当我们知道我们的镜像将被部署在异构环境中时;或它们将是在给定节点上运行的唯一对象。在这种情况下,无论如何都不会发生任何共享。
剥离和转换
还有一些并非特定于容器的其他技术,这些技术可以从我们的镜像中删除几兆字节(有时甚至是千兆字节)。
剥离二进制文件
默认情况下,大多数编译器会生成带有标记的二进制文件,这些标记对于调试定位问题很有用,但执行时并不是必选项。strip工具可以删除这些标记。这不太可能改变程序本身的运行方式,但是如果你处在每个字节都很重要的情况下,那这肯定会有所帮助。
资源处理
如果我们的容器镜像包含媒体文件,是否可以缩小这些文件,例如通过使用不同的文件格式或解码器?我们可以将它们托管在其他地方,以使我们发送的镜像更小吗?如果代码经常更改,而资源却不更改,那后者特别有用。在这种情况下,我们应尽量避免在每次发布新版本的代码时都重新生成这些资源。
压缩:不是一个好主意
如果要减小镜像的大小,为什么不压缩文件?HTML,JavaScript,CSS之类的资源使用zip或gzip应该可以很好地压缩。还有更有效的方法,例如bzip2、7z,lzma。首先,它看起来像是一种减小镜像大小的简单方法。但是,如果我们的计划是在使用之前先解压缩这些资源,那么我们最终将浪费资源!
Layer在传输之前已经被压缩,因此提取镜像不会更快。而且,如果我们需要解压缩文件,则磁盘使用率将比以前更高,因为在磁盘上,我们现在将同时拥有文件的压缩版本和未压缩版本!更糟糕的是:如果这些文件位于共享Layer上,那么共享将不会带来任何好处,因为在运行容器时我们将解压缩的这些文件将不会被共享。
那么UPX怎么样?如果你不熟悉UPX,那么它是一个出色的工具,可以减少二进制文件的大小。如果我们想减少容器的占用空间,UPX缺会适得其反。首先,磁盘和网络的使用不会减少,因为无论如何Layer都是压缩的。因此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(和其他替代)
有些构建系统甚至不使用Dockerfile。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的软件包集合; - “派生(derivation)”是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使用的层(layer),但有一个很大的区别: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
命令,展示Nix仓库中任何文件的依赖关系。例如,要查看我们刚刚安装的Redis二进制文件的依赖性,我们可以这样做 nix-store -qR $(which redis-server)
。
在我的容器中,输出如下所示:
/nix/store/6yaj6n8l925xxfbcd65gzqx3dz7idrnn-glibc-2.27
/nix/store/mzqjf58zasr7237g8x9hcs44p6nvmdv7-redis-5.0.5
这些目录是我们在任何地方运行Redis所需要的。是的,其中包括scratch。我们不需要任何额外的库。(也许只是为了方便我们对$PATH进行了调整,但这不是必要的。)
我们甚至可以使用Nix 配置文件来实现该过程。配置文件包含我们需要添加到$PATH目录中的bin文件夹(以及其他一些内容;为方便起见,我将其简化)。这意味着,如果我执行 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目录中!)
镜像的最终大小是35MB,只带有Redis,仅此而已。如果你想要一个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中的任何程序(这对应于上面Dockerfile
COPY
中的最后一行), - 询问Nix,对应的存储对象是什么(对于X,Y和Z,以及它们的依赖关系),以及相应的层,
- 生成引用所有层的Docker镜像清单。
这就是Nixery所做的。Nixery是一个“神奇”的容器注册表,它动态地生成容器镜像清单,并引用作为Nix存储对象的层。
具体来说,如果执行docker run -ti nixery.dev/redis/memcached/bash bash
,我们将在具有Redis,Memcached和Bash的容器中获得shell;并且该容器的镜像是即时生成的。(请注意,我们最好执行docker run -ti nixery.dev/shell/redis/memcached sh
,因为当镜像以shell
开头时,Nixery在外壳顶部为我们提供了一些基本的程序包;例如coreutils
。)
Nixery中还有一些额外的优化;如果你有兴趣的话,可以查看这篇博客文章或NixConf的演讲。
使用Nix的其他方法
Nix还可以直接生成容器镜像。这个博客文章中有一个很好的例子。但是请注意,博客文章中使用的技术需要kvm并且在大多数云实例的构建环境(除了嵌套虚拟化的实例除外,这种情况仍然非常罕见)中或在容器中都无法使用。显然,你将不得不放弃上面的示例并使用buildLayeredImage,但是我还没有进行进一步探索,所以我不知道需要多少工作量。
要不要使用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之间的更改不会在同一日期发生。多年来,有些地方在实施(或取消)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。