减小镜像体积-docker最佳实践

2021-01-14 14:51:52 浏览数 (1)

介绍了容器使用的一些最佳实践,内容包括如何优化减少镜像的大小,如何提升构建速度(这在CICD中十分重要), 如何管理镜像等。如果有需要的小伙伴,可以一起讨论学习。

大纲

当我们刚开始接触Docker,并尝试使用docker build构建镜像时,通常会构建出体积巨大的镜像。而事实上,我们可以通过一些技巧方法减小镜像的大小。本片博文,我将介绍一些优化技巧,同时也会探讨如何在减小镜像大小和可调试性取舍。这些技巧可以分为两部分:第一部分是多阶段构建(multi-stage builds), 正确使用多阶段构建能够极大减小构建物镜像的大小,同时还会解释静态链接(static link)和动态链接(dynamic link)之间的区别以及为什么我们需要了解它们;第二部分是使用一些常见的基础镜像,这些基础镜像仅包含我们所需要的内容,而无需引入其他文件。

场景还原

首先,我们通过GolangC的两个Hello world程序去还原很多开发者第一次使用docker build所构建出镜像, 并且看看这些的镜像的大小。

下面是CHello world示例程序:

12345

int main () { puts("Hello, world!"); return 0;}

通过以下的Dockerfile文件构建镜像:

1234

FROM gccCOPY hello.c .RUN gcc -o hello hello.cCMD ["./hello"]

可以发现一个简单的Hello World程序,最终构建出的镜像大小超过了1G。主要是由于我们使用了gcc基础镜像。如果我们使用Ubuntu镜像,安装C编译器,然后编译程序,最终构建出镜像大小只有300MB,和第一次相比,减小了不少, 但这对于一个实际只有 12KB 的二进制文件来说,仍然大的难以接受。

12

➜ c-hello-world ls -l hello-rwxr-xr-x 1 donggang staff 12556 6 15 11:35 hello

同样,对于Golang程序:

1234567

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

通过golang镜像构建的最终镜像大小超过了 800MB,而实际执行文件的大小只有 2MB。

12

➜ go-hello-world ls -l go-hello-world-rwxr-xr-x 1 donggang staff 2174056 6 15 11:40 go-hello-world

通过以上两个示例,我们急需一些方法来改善我们的构建以减小最终产物的体积。接下来, 我们通过一些方法将最终产物的大小缩小 99.8%(注意:这有时会我们调试程序带来很大问题)。

下面会通过不同的 tag 来标识优化后构建的镜像,如hello:gcc,hello:ubuntu,hello:thisweirdtrick, 这样通过docker image hello,可以方便比较镜像的大小。

多阶段构建

通常,我们首先是通过多阶段构建来减小镜像的大小,往往这也是最有效的方法。不过,我们需要注意,如果处理不当, 可能会造成构建的镜像无法运行。

多阶段构建的核心概念很简单:“我不要包括 C 或者 Go 的编译器和整个构建辅助工具,我仅仅想要可执行文件”。我们添加构建阶段来改造之前的C演示程序, 具体的Dockerfile文件如下:

123456

FROM gcc AS mybuildstageCOPY hello.c .RUN gcc -o hello hello.cFROM ubuntuCOPY --from=mybuildstage hello .CMD ["./hello"]

我们使用gcc作为基础镜像编译hello.c程序,这一阶段为编译阶段mybuildstage。然后,我们开始定义新的阶段执行阶段, 这个阶段使用ubuntu镜像,这个阶段我们将上个阶段的构建产物hello可执行文件复制到指定目录中,最终构建出的镜像只有64MB, 这减少了大约95%的大小:

1234

➜ c-hello-world docker images c-helloREPOSITORY TAG IMAGE ID CREATED SIZEc-hello gcc.ubuntu d9492a009e98 23 minutes ago 73.9MBc-hello gcc db0206def0e6 27 minutes ago 1.19GB

通过多阶段构建,我们已经极大地优化了最终产物的大小。关于多阶段构建还有一些需要注意的点:

  • 在声明构建阶段时,可以不显示使用As关键字。后续阶段我们可以使用数字(以 0 开始)从前面的阶段复制文件。在复杂的构建中, 显示定义名称便于后续的维护。 1 2COPY --from=mybuildstage hello . COPY --from=0 hello .
  • 使用经典镜像:关于运行阶段的基础镜像的选择,我建议使用一些经典基础镜像,如 Centos,Debian,Fedora,Ubuntu 等, 你可能听过其他简化类型的镜像。
  • COPY --from使用绝对路径:golang镜像默认工作目录是/go,所以我们需要从/go目录复制可执行文件。 1 2 3 4 5 6FROM golang COPY hello.go . RUN go build hello.go FROM ubuntu COPY --from=0 /go/hello . CMD ["./hello"]

使用Scratch镜像

回到之前Hello World示例程序,C版本大小16KB,Go版本是2MB,那么问题来了,我们可以获取同样大小的镜像吗?可不可以构建出一个镜像, 其中只包括最终的可执行文件呢?答案是肯定的,通过使用scratch作为运行阶段的基础镜像,注意scratch是一个虚拟镜像, 我们不可以直接拉取或运行它,因为它完全为空。我们按照下面示例修改GoDockerfile文件:

123456

FROM golangCOPY hello.go .RUN go build hello.goFROM scratchCOPY --from=0 /go/hello .CMD ["./hello"]

123

➜ go-hello-world docker images go-helloREPOSITORY TAG IMAGE ID CREATED SIZEgo-hello scratch 57ae1b48d6bb 47 seconds ago 2.01MB

构建完的最终镜像的大小只有2MB。同样执行成功。是不是什么时候都可以使用scratch作为运行阶段的基础镜像呢?当然不行,在使用scratch作为基础镜像时需要注意以下几点。

没有shell

scratch镜像没有shell,这意味着不能在DockerfileCMD使用字符串语法(RUN也是):

123456

FROM golangCOPY hello.go .RUN go build hello.goFROM scratchCOPY --from=0 /go/hello .CMD ./hello

如果我们执行以上构建出的镜像,会提示以下错误:

12

➜ go-hello-world docker run go-hello:scratch.stringsyntaxdocker: Error response from daemon: OCI runtime create failed: container_linux.go:349: starting container process caused "exec: "/bin/sh": stat /bin/sh: no such file or directory": unknown.

这是因为RUN,CMD中使用字符串语法,这些参数会传递给/bin/sh,CMD ./hello最终会执行/bin/sh -c "./hello"。而scratch中没有shell。解决方法就是使用JSON语法,使用JSON语法时,Docker会直接执行而不是通过shell执行。

没有调试工具

因为scratch是空的,所以构建出的镜像不包含任何工具,如ls,ps,ping等,我们也就无法进入到该容器(docker exec)中。

严格意义上,我们仍然可以通过一些方法进行容器故障排错,我们可以使用docker cp从容器中获取文件,使用docker run –net container与网络堆栈进行交互, 以及使用像nsenter这样的工具。如果使用新版本kubernetes,也可以利用ephemeral container这一特性,

一种解决方法是使用busybox或者alpine作为基础镜像,这些镜像只有几MB大小,但提供一些方便调试的工具。

没有libc

这个问题往往很难解决,简单的Go Hello World能够使用scratch基础镜像执行,但是C Hello World和一些其他复杂的Go程序(使用net包,或者使用sqlite), 往往不能成功执行,会产生如以下的报错:

1

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

似乎是缺少了一些文件导致的,但是又没具体指出缺失了什么文件。其实这是因为缺失了必要动态库文件dynamic library, 程序编译成功运行时,需要使用一些库,如C Hello World中的puts。在90年代,通常使用静态链接的方式static linking, 这意味着程序使用的库将包含在最终的二进制文件中,在使用软盘分发程序和没有标准库的情况下,这种方式十分方便, 但是在linux分时系统流行后,这种无法共享公共库的方式很快不再流行。

如今大部分情况下,使用动态链接。使用动态链接编译的程序,最终二进制文件不包含具体的库,而只包含对依赖库的引用,例如一个程序需要libtrigonometry.so中的cos和sin和tan函数。执行程序时,系统会查找该libtrigonometry.so并将其与程序一起加载,以便程序可以调用这些函数。使用动态链接往往有以下优点:

  1. 节省存储资源,多个程序共享一个库;
  2. 节省内存,多个程序运行内存调用同一片内存;
  3. 维护方便,更新库时,无需重新编译程序;

有些人可能会说节省内存不是动态链接所带来的优点,而是共享库shared library。关于具体的细节大家可以参考具体文档。

回到上面的示例程序,默认情况C使用动态链接,使用某些包的Go程序也是如此,上述程序使用标准C库,该库位于libc.so.6文件中, 所以需要在镜像中包含该文件,C Hello World才能正常执行。而scratch镜像中,这个文件显然不存在,buysboxalpine也不包含这个库, busybox没有包含标准C库,alpine使用的是另外版本。通常我们通过以下方式解决找不到库链接的问题。

使用静态链接

我们可以使用静态链接,这取决于我们具体使用的构建工具,如果使用gcc,可以通过-static实现静态链接:

1

gcc -o hello hello.c -static

最终构建的二进制文件大小760KB而不16KB,主要是嵌入的库文件导致镜像变大,但是运行镜像时,将不再会报错。

手动添加库文件

首先通过一些工具,可以得到程序正在使用哪些库(ldd,mac下使用otool):

1234

$ 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与虚拟动态共享对象有关, 该机制主要用于加速某些系统调用,而ld-linux-x86-64.so.2则是动态链接器本身。

我们可以手动将上面所有的库文件添加到镜像中,也能成功执行。这种方式对于后续维护是灾难性的,同时对于大型的程序(GUI), 通过这种方式,我们往往会力不从心。所以不推荐使用这种方式。

使用busybox:glibc之类的镜像

上述例子,我们可以通过busybox:glibc作为基础镜像,它只有5MB,这个镜像提供了GNU C Libray(glibc), 这样可以使用动态链接,运行这些程序。

但是如果我们的程序还使用了其他库,仍然需要手动安装。

通过优化,我们最终将一个超过1GB的文件优化到只有几十KB:

  • 使用gcc镜像:1.14GB
  • 多阶段构建,使用gcc和ubuntu镜像:64.2MB
  • 静态链接,使用alpine:6.5MB
  • 动态链接,使用alpine:5.6MB
  • 静态链接,使用scratch:940KB
  • 静态musl二进制文件,使用scratch:94KB

总结

优化镜像的同时,能够帮助我们更好的理解容器相关核心特性。依我个人的使用的总结经验,主要会从以下几个角度思考是否可以进行优化:

  1. 是否可以使用多阶段优化;
  2. 是否可以使用如scratch较小的镜像作为基础镜像;
  3. 是否可以移除一些没有必要的层;
  4. 是否可以合并某些层;

通常,追求最小的镜像并不等同于最佳实践,我们需要综合考虑后续可调试性以及使用成本。一般情况我会偏向使用scratch,alpine这类的镜像。这类镜像很小的同时也提供了必要的工具和可拓展性。

在学习Docker以及编写Dockerfile时,我们通过工具dive帮助我们分析镜像的结构,方便后续优化

0 人点赞