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

第一部份侧重介绍多阶段建立(multi-stagebuilds),由于这是镜像精简之路至关重要的一环。在这部份内容中,我会解释静态链接和动态链接的区别,它们对镜像带来的影响,以及怎样防止这些不好的影响。中间会穿插一部份对Alpine镜像的介绍。

第二部份将会针对不同的语言来选择适当的精简策略,其中主要讨论Go,同时也涉及到了Java,Node,Python,Ruby和Rust。这一部份也会详尽介绍Alpine镜像的避坑手册。哪些?你不晓得Alpine镜像有什么坑?我来告诉你。

第三部份将会阐述适用于大多数语言和框架的通用精简策略,比如使用常见的基础镜像、提取可执行文件和减少每一层的容积。同时都会介绍一些愈加古怪或激进的工具,比如Bazel,Distroless,DockerSlim和UPX,尽管这种工具在个别特定场景下能带来奇效,但大多情况下会起到反作用。

本文介绍第一部份。

1.可恶之源

我敢打赌,每一个初次使用自己写好的代码重构Docker镜像的人就会被镜像的容积吓到,来看一个反例。

让我们搬进那种屡试不爽的helloworldC程序:

/* hello.c */
int main () {
  puts("Hello, world!");
  return 0;
}复制代码

并通过下边的Dockerfile建立镜像:

FROM gcc
COPY hello.c .
RUN gcc -o hello hello.c
CMD ["./hello"]复制代码

之后你会发觉建立成功的镜像容积远远超过了1GB。。。由于该镜像包含了整个gcc镜像的内容。

假如使用Ubuntu镜像,安装C编译器,最后编译程序,你会得到一个大约300MB大小的镜像,比前面的镜像小多了。但还是不够小,由于编译好的可执行文件还不到20KB:

$ ls -l hello
-rwxr-xr-x   1 root root 16384 Nov 18 14:36 hello复制代码

类似地,Go语言版本的helloworld会得到相同的结果:

linux ldd -r_linux ldd -r_linux ldd -r

package main
import "fmt"
func main () {
  fmt.Println("Hello, world!")
}复制代码

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

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

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

为了更直观地对比不同镜像的大小,所有镜像都使用相同的镜像名,不同的标签。诸如:hello:gcc,hello:ubuntu,hello:thisweirdtrick等等,这样就可以直接使用命令dockerimageshello列举所有镜像名为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从上一阶段拷贝到最终的镜像中。最终的镜像大小是64MBlinux 电子书,比之前的1.1GB降低了95%:

 → docker images minimage
REPOSITORY          TAG                    ...         SIZE

linux ldd -r_linux ldd -r_linux ldd -r

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为每位阶段命名,这样也易于后期维护。

使用精典的基础镜像

我强烈建议在建立的第一阶段使用精典的基础镜像,这儿精典的镜像指的是CentOS,Debian,Fedora和Ubuntu之类的镜像。你可能还据说过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 .

linux ldd -r_linux ldd -r_linux ldd -r

RUN go build hello.go FROM ubuntu COPY --from=0 /src/hello . CMD ["./hello"]复制代码

最后的疗效还是很惊人的,将镜像的容积直接从800MB增加到了66MB:

 → docker images minimage
REPOSITORY     TAG                              ...    SIZE
minimage       hello-go.golang                  ...    805MB
minimage       hello-go.golang.ubuntu-workdir   ...    66.2MB复制代码

3.FROMscratch的魔力

回到我们的helloworld,C语言版本的程序大小为16kB,Go语言版本的程序大小为2MBlinux ldd -r,这么我们究竟能不能将镜像削减到如此小?能够建立一个只包含我须要的程序,没有任何多余文件的镜像?

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

FROM golang
COPY hello.go .
RUN go build hello.go
FROM scratch
COPY --from=0 /go/hello .
CMD ["./hello"]复制代码

这一次建立的镜像大小刚好就是2MB,可谓完美!

但是,然而,使用scratch作为基础镜像时会带来好多的不便,且听我一一道来。

缺乏shell

scratch镜像的第一个不便是没有shell,这就意味着CMD/RUN句子中不能使用字符串,比如:

...
FROM scratch

linux ldd -r_linux ldd -r_linux ldd -r

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镜像不包含任何调试工具,ls、ps、ping那些统统没有,其实了,shell也没有(上文提过了),你没法使用dockerexec步入容器,也难以查看网路堆栈信息等等。

假如想查看容器中的文件,可以使用dockercp;假如想查看或调试网路堆栈,可以使用dockerrun--netcontainer:,或则使用nsenter;为了更好地调试容器,Kubernetes也引入了一个新概念叫EphemeralContainers,但如今还是Alpha特点。

尽管有如此多杂七杂八的方式可以帮助我们调试容器,但它们会将事情显得愈发复杂,我们追求的是简单,越简单越好。

折中一下可以选择busybox或alpine镜像来代替scratch,尽管它们多了这么几MB,但从整体来看linux开发培训,这只是牺牲了少量的空间来换取调试的便利性,还是很值得的。

缺乏libc

这是最难解决的问题。使用scratch作为基础镜像时,Go语言版本的helloworld跑得很轻快,C语言版本就不行了,或则换个更复杂的Go程序也是跑不上去的(比如用到了网路相关的工具包),你会碰到类似于下边的错误:

standard_init_linux.go:211: exec user process caused "no such file or directory"复制代码

从报错信息可以看出缺乏文件,但没有告诉我们究竟缺乏什么文件,虽然这种文件就是程序运行所必需的动态库(dynamiclibrary)。

这么,哪些是动态库?为何须要动态库?

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

90年代的程序大多使用的是静态链接,由于当时的程序大多数都运行在软驱或则盒式磁带上,但是当时根本不存在标准库。这样程序在运行时与函数库再无成见,移植便捷。但对于Linux这样的分时系统,会在在同一块硬碟上并发运行多个程序,这种程序基本上还会用到标准的C库,这时使用动态链接的优点就彰显下来了。使用动态链接时,可执行文件不包含标准库文件,只包含到这种库文件的索引。比如,某程序依赖于库文件libtrigonometry.so中的cos和sin函数,该程序运行时都会按照索引找到并加载libtrigonometry.so,之后程序就可以调用这个库文件中的函数。

使用动态链接的益处显而易见:

节约c盘空间,不同的程序可以共享常见的库。节约显存,共享的库只需从c盘中加载到显存一次,之后在不同的程序之间共享。更易于维护,库文件更新后,不须要重新编译使用该库的所有程序。

严格来说,动态库与共享库(sharedlibraries)相结合就能达到节约显存的功效。Linux中动态库的扩充名是.so(sharedobject),而Windows中动态库的扩充名是.DLL(Dynamic-linklibrary)。

回到最初的问题,默认情况下,C程序使用的是动态链接,Go程序也是。里面的helloworld程序使用了标准库文件libc.so.6,所以只有镜像中包含该文件,程序能够正常运行。使用scratch作为基础镜像肯定是不行的,使用busybox和alpine也不行,由于busybox不包含标准库,而alpine使用的标准库是musllibc,与你们常用的标准库glibc不兼容,后续的文章会详尽剖析,这儿就不赘言了。

linux ldd -r_linux ldd -r_linux ldd -r

这么该怎么解决标准库的问题呢?有三种方案。

1、使用静态库

我们可以让编译器使用静态库编译程序,办法有好多,假如使用gcc作为编译器,只需加上一个参数-static:

$ gcc -o hello hello.c -static复制代码

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

假如使用alpine镜像作为基础镜像来编译,得到的可执行文件会更小(<100kB),上篇文章会详谈。

2、拷贝库文件到镜像中

为了找出程序运行须要什么库文件,可以使用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列举的所有库文件拷贝到镜像中,但这会很难维护,非常是当程序有大量依赖库时。对于helloworld程序来说,拷贝库文件完全没有问题,但对于更复杂的程序(比如使用到DNS的程序),还会碰到令人吃惊的问题:glibc(GNUClibrary)通过一种相当复杂的机制来实现DNS,这些机制叫NSS(NameServiceSwitch,名称服务开关)。它须要一个配置文件/etc/nsswitch.conf和额外的函数库,但使用ldd时不会显示这种函数库,由于这种库在程序运行后才能加载。假如想让DNS解析正确工作,必需要拷贝这种额外的库文件(/lib64/libnss_*)。

我个人不建议直接拷贝库文件,由于它十分无法维护,后期须要不断地修改,并且还有好多未知的隐患。

3、使用busybox:glibc作为基础镜像

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

注意:假如你的程序使用到了除标准库之外的库,依然须要将这种库文件拷贝到镜像中。

4.总结

最后来对比一下不同建立方式建立的镜像大小:

最终我们将镜像的容积减小了99.99%。

但我不建议使用sratch作为基础镜像,由于调试上去十分麻烦,但假如你喜欢,我也不会拦着你。

上篇文章将会侧重介绍Go语言的镜像精简策略,其中会花很大的篇幅来讨论alpine镜像,由于它实在是太酷了,在使用它之前必须得摸透它的原委。

Tagged:
Author

这篇优质的内容由TA贡献而来

刘遄

《Linux就该这么学》书籍作者,RHCA认证架构师,教育学(计算机专业硕士)。

发表回复