vlambda博客
学习文章列表

[译]Docker镜像:第二部分-不同语言的表现

此系列文章

介绍

在第一部分中,我们介绍了多阶段构建,静态和动态链接,并简要提到了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镜像中工作。

Tags 和 netgo

我们也可以根据每个包选择不同的实现。这是通过使用Go“标签(tags)”完成的。标签是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却在没有牺牲这些工具的前提下,仅仅只有5MB。

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命令报告大小,并通过运行几次以下命令来测量时间。时间标准为eu-north-1下的t3.medium

time docker run <image> <packagemanager> install tcpdump

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

屏幕截图来自electricalmap.org[1],显示此时此刻,德国40%的电力来自燃煤发电厂

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

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

让我们尝试一下。

使用Alpine作为我们的“run”阶段

让我们构建以下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程序时,我们已经看到了该错误消息。我们看到问题出在临时镜像中缺少动态库。看起来这些库也从Alpine镜像中丢失了吗?

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

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

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

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

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

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

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

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

我们决定生成一个与musl关联的二进制文件,以便它可以在Alpine基础镜像中运行。我们有两种方式实现这点。

  • 一些官方镜像提供的alpine标签应尽可能接近普通镜像,而不是改用Alpine(和musl)。
  • 某些官方镜像没有alpine标签;对于这些,我们需要自己建立一个等效的镜像,通常以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镜像。因此,我们必须从Alpine开始,然后安装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),因为gcc在Alpine上的软件包将安装编译器,而不是我们需要的所有库。相反,我们使用build-base,它等效于Debian或Ubuntu里的build-essentials,引入了编译器,类库以及诸如make之类的工具。

关键总结:使用多阶段构建时,我们可以使用alpine镜像作为运行代码的基础。如果我们的代码是使用动态库的编译程序(这是我们可能在容器中使用的几乎每种编译语言都会遇到的情况),那么我们将需要生成一个与Alpine musl C库链接的二进制文件。最简单的方法是将我们的构建的镜像基于另一个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类文件版本的[4]信息,可以阅读此博客文章。)

JDK与JRE

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

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

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

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

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

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

Java与OpenJDK

如果你在Docker中使用Java,你可能已经知道了。但你不应该使用Java官方镜像,因为它们不再接收更新。而应该使用openjdk镜像。

你也可以尝试amazoncorretto(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版本。如果你需要详细信息,这篇博客文章[5]将说明如何在各种环境(IDE,Maven等)中执行此操作。

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

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

你可以在minimage GitHub repo中[6]找到所有Dockerfile ,这是各种构建的大小。

  • 使用 java镜像的单阶段构建:643 MB
  • 使用 openjdk镜像的单阶段构建:490 MB
  • 多级构建使用 openjdkopenjdk:jre:479 MB
  • 使用 amazoncorretto镜像的单阶段构建:390 MB
  • 使用 openjdk:11openjdk:11-jre的多阶段构建:267 MB
  • 使用 openjdk:8openjdk:8-jre-alpine的多阶段构建:85 MB

那解释型语言呢?

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

Alpine与解释性语言

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

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

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

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

在某些情况下,使用Alpine会遇到所有这些问题:Python中的数据处理相关。诸如numpy或pandas之类的流行软件包,称为wheel[7],但这些wheel绑定到特定的C库。(“哦,不!”你可能会想,“又是那些库!”。)这意味着它们可以在“正常” Python镜像上正常安装,而不能在Alpine变体上安装。在Alpine上,他们将需要安装系统软件包,在某些情况下,将需要很长时间的重建。有一篇很好的文章专门针对这个问题,解释了使用Alpine如何使Python Docker的构建速度降低50倍[8]

如果你阅读该文章,你可能会想,“那,我应该为了python远离Alpine吗?” 我不确定。对于数据相关,可能是的。但是对于其他工作,如果要减小镜像大小,则值得一试。

:slim图像

如果要在默认镜像及其Alpine变体之间找一个折中方案,可以实时:slim镜像。slim镜像通常基于Debian(以及GNU C库),但是它们通过删除许多不必要的软件包而针对大小进行了优化。有时候,他们可能会满足你的需求。有时,它们缺少必要的内容(例如,编译器!),安装这些内容会使你再次接近原始大小;但是有机会尝试使用它们也是不错的。

为了让你有所了解,以下是一些流行的解释语言的 :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[9]存储库中找到相关的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[10]缩小了Javascript,并使用Sass[11]缩小了CSS 。天真的方法是在镜像中包含所有jazz,但Dockerfile会变得复杂(因为我们将在Python镜像中安装Node),最终镜像当然会很大。相反,我们可以使用多个阶段:一个阶段node用于最小化你的产出,第二个阶段python用于应用程序本身,从第一阶段引入JS和CSS的产出结果。

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

那Rust呢?

Rust[12]是一种最初由Mozilla设计的现代编程语言,并且在Web和基础架构领域中越来越受欢迎,我一直对折中情况感到非常好奇。所以我想知道就涉及到Docker镜像而言应该期待什么样的表现。

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

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

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

结论

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

在最后一部分,我们将讨论更多。我们将看到在特定基础镜像上进行标准化如何不仅可以减少镜像大小,而且可以减少I / O和内存使用量。我们将提到一些并非特定于容器的技术,但它们很有用的。

参考资料

[1]

electricalmap.org: https://www.ardanlabs.com/blog/2020/02/electricitymap.org

[2]

官方发音: https://www.musl-libc.org/faq.html

[3]

功能差异: https://wiki.musl-libc.org/functional-differences-from-glibc.html

[4]

Java类文件版本的: http://webcode.lemme.at/2017/09/27/java-class-file-major-minor-version/

[5]

博客文章: https://www.baeldung.com/java-lang-unsupportedclassversion

[6]

minimage GitHub repo: https://github.com/jpetazzo/minimage

[7]

wheel: https://pythonwheels.com/

[8]

Alpine如何使Python Docker的构建速度降低50倍: https://pythonspeed.com/articles/alpine-docker-python/

[9]

minimage: https://github.com/jpetazzo/minimage

[10]

UglifyJS: https://www.npmjs.com/package/uglify-js

[11]

Sass: https://sass-lang.com/

[12]

Rust: https://www.rust-lang.org/

[13]

Rust文档: https://doc.rust-lang.org/1.9.0/book/advanced-linking.html#static-linking


 对原文感兴趣可点击左下阅读原文

你点的每个在看,我都认真当成了喜欢