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




大纲

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


场景还原

首先,我们通过​​Golang​​和​​C​​的两个​​Hello world​​程序去还原很多开发者第一次使用​​docker build​​所构建出镜像, 并且看看这些的镜像的大小。下面是​​C​​的​​Hello world​​示例程序:

1
2
3
4
5
int main () {
puts("Hello, world!");
return 0;
}

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

1
2
3
4
FROM gcc
COPY hello.c .
RUN gcc -o hello hello.c
CMD ["./hello"]

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

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

同样,对于​​Golang​​程序:

1
2
3
4
5
6
7
package main

import "fmt"

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

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

1
2
➜  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​​文件如下:

1
2
3
4
5
6
FROM gcc AS mybuildstage
COPY hello.c .
RUN gcc -o hello hello.c
FROM ubuntu
COPY --from=mybuildstage hello .
CMD ["./hello"]

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

1
2
3
4
➜  c-hello-world docker images c-hello
REPOSITORY TAG IMAGE ID CREATED SIZE
c-hello gcc.ubuntu d9492a009e98 23 minutes ago 73.9MB
c-hello gcc db0206def0e6 27 minutes ago 1.19GB

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


在声明构建阶段时,可以不显示使用As关键字。后续阶段我们可以使用数字(以 0 开始)从前面的阶段复制文件。在复杂的构建中, 显示定义名称便于后续的维护。

1
2
COPY --from=mybuildstage hello .
COPY --from=0 hello .
使用经典镜像:关于运行阶段的基础镜像的选择,我建议使用一些经典基础镜像,如 Centos,Debian,Fedora,Ubuntu 等, 你可能听过其他简化类型的镜像。COPY --from使用绝对路径:golang镜像默认工作目录是/go,所以我们需要从/go目录复制可执行文件。
1
2
3
4
5
6
FROM 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​​是一个虚拟镜像, 我们不可以直接拉取或运行它,因为它完全为空。我们按照下面示例修改​​Go​​的​​Dockerfile​​文件:

1
2
3
4
5
6
FROM golang
COPY hello.go .
RUN go build hello.go
FROM scratch
COPY --from=0 /go/hello .
CMD ["./hello"]
1
2
3
➜  go-hello-world docker images go-hello
REPOSITORY TAG IMAGE ID CREATED SIZE
go-hello scratch 57ae1b48d6bb 47 seconds ago 2.01MB

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


没有​​shell​

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

1
2
3
4
5
6
FROM golang
COPY hello.go .
RUN go build hello.go
FROM scratch
COPY --from=0 /go/hello .
CMD ./hello

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

1
2
➜  go-hello-world docker run go-hello:scratch.stringsyntax
docker: 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​​镜像中,这个文件显然不存在,​​buysbox​​和​​alpine​​也不包含这个库, ​​busybox​​没有包含标准C库,​​alpine​​使用的是另外版本。通常我们通过以下方式解决找不到库链接的问题。


使用静态链接

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

1
gcc -o hello hello.c -static

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



手动添加库文件

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

1
2
3
4
$ 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​​帮助我们分析镜像的结构,方便后续优化 




微信公众号 - java宝典(java_bible)。