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的信息,它非常棒。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343