两个奇技淫巧,将 Docker 镜像体积减少 99%

原文连接:Docker Images : Part I - Reducing Image Sizelinux

对于刚接触容器的人来讲,他们很容易被本身构建的 Docker 镜像体积吓到,我只须要一个几 MB 的可执行文件而已,为什么镜像的体积会达到 1 GB 以上?本文将会介绍几个奇技淫巧来帮助你精简镜像,同时又不牺牲开发人员和运维人员的操做便利性。本系列文章将分为三个部分:golang

第一部分着重介绍多阶段构建(multi-stage builds),由于这是镜像精简之路相当重要的一环。在这部份内容中,我会解释静态连接和动态连接的区别,它们对镜像带来的影响,以及如何避免那些很差的影响。中间会穿插一部分对 Alpine 镜像的介绍。docker

第二部分将会针对不一样的语言来选择适当的精简策略,其中主要讨论 Go,同时也涉及到了 JavaNodePythonRubyRust。这一部分也会详细介绍 Alpine 镜像的避坑指南。什么?你不知道 Alpine 镜像有哪些坑?我来告诉你。shell

第三部分将会探讨适用于大多数语言和框架的通用精简策略,例如使用常见的基础镜像、提取可执行文件和减少每一层的体积。同时还会介绍一些更加奇特或激进的工具,例如 BazelDistrolessDockerSlimUPX,虽然这些工具在某些特定场景下能带来奇效,但大多状况下会起到副作用。ubuntu

本文介绍第一部分。bash

1. 万恶之源

我敢打赌,每个初次使用本身写好的代码构建 Docker 镜像的人都会被镜像的体积吓到,来看一个例子。微信

让咱们搬出那个屡试不爽的 hello world C 程序:网络

/* 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 语言版本的 hello world 会获得相同的结果:

package main

import "fmt"

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

使用基础镜像 golang 构建的镜像大小是 800 MB,而编译后的可执行文件只有 2 MB 大小:

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

仍是不太理想,有没有办法大幅度减小镜像的体积呢?往下看。

为了更直观地对比不一样镜像的大小,全部镜像都使用相同的镜像名,不一样的标签。例如:hello:gcchello:ubuntuhello:thisweirdtrick 等等,这样就能够直接使用命令 docker images hello 列出全部镜像名为 hello 的镜像,不会被其余镜像所干扰。

2. 多阶段构建

要想大幅度减小镜像的体积,多阶段构建是必不可少的。多阶段构建的想法很简单:“我不想在最终的镜像中包含一堆 C 或 Go 编译器和整个编译工具链,我只要一个编译好的可执行文件!”

多阶段构建能够由多个 FROM 指令识别,每个 FROM 语句表示一个新的构建阶段,阶段名称能够用 AS 参数指定,例如:

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 内容不是很复杂,构建阶段也不是不少,能够直接使用序号表示构建阶段。一旦 Dockerfile 变复杂了,构建阶段增多了,最好仍是经过关键词 AS 为每一个阶段命名,这样也便于后期维护。

使用经典的基础镜像

我强烈建议在构建的第一阶段使用经典的基础镜像,这里经典的镜像指的是 CentOSDebianFedoraUbuntu 之类的镜像。你可能还据说过 Alpine 镜像,不要用它!至少暂时不要用,后面我会告诉你有哪些坑。

COPY --from 使用绝对路径

从上一个构建阶段拷贝文件时,使用的路径是相对于上一阶段的根目录的。若是你使用 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,而 golang 镜像的 WORKDIR/go,因此可执行文件的真正路径是 /go/hello

固然你可使用绝对路径来解决这个问题,但若是后面基础镜像改变了 WORKDIR 怎么办?你还得不断地修改绝对路径,因此这个方案仍是不太优雅。最好的方法是在第一阶段指定 WORKDIR,在第二阶段使用绝对路径拷贝文件,这样即便基础镜像修改了 WORKDIR,也不会影响到镜像的构建。例如:

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

最后的效果仍是很惊人的,将镜像的体积直接从 800 MB 下降到了 66 MB

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

3. FROM scratch 的魔力

回到咱们的 hello world,C 语言版本的程序大小为 16 kB,Go 语言版本的程序大小为 2 MB,那么咱们到底能不能将镜像缩减到这么小?可否构建一个只包含我须要的程序,没有任何多余文件的镜像?

答案是确定的,你只须要将多阶段构建的第二阶段的基础镜像改成 scratch 就行了。scratch 是一个虚拟镜像,不能被 pull,也不能运行,由于它表示空、nothing!这就意味着新镜像的构建是从零开始,不存在其余的镜像层。例如:

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 语句中不能使用字符串,例如:

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

若是你使用构建好的镜像建立并运行容器,就会遇到下面的报错:

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
CMD /bin/sh -c "./hello"

解决办法其实也很简单:**使用 JSON 语法取代字符串语法。**例如,将 CMD ./hello 替换为 CMD ["./hello"],这样 Docker 就会直接运行程序,不会把它放到 shell 中运行。

缺乏调试工具

scratch 镜像不包含任何调试工具,lspsping 这些通通没有,固然了,shell 也没有(上文提过了),你没法使用 docker exec 进入容器,也没法查看网络堆栈信息等等。

若是想查看容器中的文件,可使用 docker cp;若是想查看或调试网络堆栈,可使用 docker run --net container:,或者使用 nsenter;为了更好地调试容器,Kubernetes 也引入了一个新概念叫 Ephemeral Containers,但如今仍是 Alpha 特性。

虽然有这么多杂七杂八的方法能够帮助咱们调试容器,但它们会将事情变得更加复杂,咱们追求的是简单,越简单越好。

折中一下能够选择 busyboxalpine 镜像来替代 scratch,虽然它们多了那么几 MB,但从总体来看,这只是牺牲了少许的空间来换取调试的便利性,仍是很值得的。

缺乏 libc

这是最难解决的问题。使用 scratch 做为基础镜像时,Go 语言版本的 hello world 跑得很欢快,C 语言版本就不行了,或者换个更复杂的 Go 程序也是跑不起来的(例如用到了网络相关的工具包),你会遇到相似于下面的错误:

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

从报错信息能够看出缺乏文件,但没有告诉咱们到底缺乏哪些文件,其实这些文件就是程序运行所必需的动态库(dynamic library)。

那么,什么是动态库?为何须要动态库?

所谓动态库、静态库,指的是程序编译的连接阶段,连接成可执行文件的方式。静态库指的是在连接阶段将汇编生成的目标文件.o 与引用到的库一块儿连接打包到可执行文件中,所以对应的连接方式称为静态连接(static linking)。而动态库在程序编译时并不会被链接到目标代码中,而是在程序运行是才被载入,所以对应的连接方式称为动态连接(dynamic linking)。

90 年代的程序大多使用的是静态连接,由于当时的程序大多数都运行在软盘或者盒式磁带上,并且当时根本不存在标准库。这样程序在运行时与函数库再无瓜葛,移植方便。但对于 Linux 这样的分时系统,会在在同一块硬盘上并发运行多个程序,这些程序基本上都会用到标准的 C 库,这时使用动态连接的优势就体现出来了。使用动态连接时,可执行文件不包含标准库文件,只包含到这些库文件的索引。例如,某程序依赖于库文件 libtrigonometry.so 中的 cossin 函数,该程序运行时就会根据索引找到并加载 libtrigonometry.so,而后程序就能够调用这个库文件中的函数。

使用动态连接的好处显而易见:

  1. 节省磁盘空间,不一样的程序能够共享常见的库。
  2. 节省内存,共享的库只需从磁盘中加载到内存一次,而后在不一样的程序之间共享。
  3. 更便于维护,库文件更新后,不须要从新编译使用该库的全部程序。

严格来讲,动态库与共享库(shared libraries)相结合才能达到节省内存的功效。Linux 中动态库的扩展名是 .soshared object),而 Windows 中动态库的扩展名是 .DLLDynamic-link library)。

回到最初的问题,默认状况下,C 程序使用的是动态连接,Go 程序也是。上面的 hello world 程序使用了标准库文件 libc.so.6,因此只有镜像中包含该文件,程序才能正常运行。使用 scratch 做为基础镜像确定是不行的,使用 busyboxalpine 也不行,由于 busybox 不包含标准库,而 alpine 使用的标准库是 musl libc,与你们经常使用的标准库 glibc 不兼容,后续的文章会详细解读,这里就不赘述了。

那么该如何解决标准库的问题呢?有三种方案。

一、使用静态库

咱们可让编译器使用静态库编译程序,办法有不少,若是使用 gcc 做为编译器,只需加上一个参数 -static

$ gcc -o hello hello.c -static

编译完的可执行文件大小为 760 kB,相比于以前的 16kB 是大了好多,这是由于可执行文件中包含了其运行所须要的库文件。编译完的程序就能够跑在 scratch 镜像中了。

若是使用 alpine 镜像做为基础镜像来编译,获得的可执行文件会更小(< 100kB),下篇文章会详述。

二、拷贝库文件到镜像中

为了找出程序运行须要哪些库文件,可使用 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 表示动态连接器自己,包含了全部依赖的库文件的信息。

你能够选择将 ldd 列出的全部库文件拷贝到镜像中,但这会很难维护,特别是当程序有大量依赖库时。对于 hello world 程序来讲,拷贝库文件彻底没有问题,但对于更复杂的程序(例如使用到 DNS 的程序),就会遇到使人费解的问题:glibc(GNU C library)经过一种至关复杂的机制来实现 DNS,这种机制叫 NSS(Name Service Switch, 名称服务开关)。它须要一个配置文件 /etc/nsswitch.conf 和额外的函数库,但使用 ldd 时不会显示这些函数库,由于这些库在程序运行后才会加载。若是想让 DNS 解析正确工做,必需要拷贝这些额外的库文件(/lib64/libnss_*)。

我我的不建议直接拷贝库文件,由于它很是难以维护,后期须要不断地更改,并且还有不少未知的隐患。

三、使用 busybox:glibc 做为基础镜像

有一个镜像能够完美解决全部的这些问题,那就是 busybox:glibc。它只有 5 MB 大小,而且包含了 glibc 和各类调试工具。若是你想选择一个合适的镜像来运行使用动态连接的程序,busybox:glibc 是最好的选择。

注意:若是你的程序使用到了除标准库以外的库,仍然须要将这些库文件拷贝到镜像中。

4. 总结

最后来对比一下不一样构建方法构建的镜像大小:

  • 原始的构建方法:1.14 GB
  • 使用 ubuntu 镜像的多阶段构建:64.2 MB
  • 使用 alpine 镜像和静态 glibc:6.5 MB
  • 使用 alpine 镜像和动态库:5.6 MB
  • 使用 scratch 镜像和静态 glibc:940 kB
  • 使用 scratch 镜像和静态 musl libc:94 kB

最终咱们将镜像的体积减小了 99.99%

但我不建议使用 sratch 做为基础镜像,由于调试起来很是麻烦,但若是你喜欢,我也不会拦着你。

下篇文章将会着重介绍 Go 语言的镜像精简策略,其中会花很大的篇幅来讨论 alpine 镜像,由于它实在是太酷了,在使用它以前必须得摸清它的底细。

微信公众号

扫一扫下面的二维码关注微信公众号,在公众号中回复◉加群◉便可加入咱们的云原生交流群,和孙宏亮、张馆长、阳明等大佬一块儿探讨云原生技术

相关文章
相关标签/搜索
本站公众号
   欢迎关注本站公众号,获取更多信息