介绍了容器使用的一些最佳实践,内容包括如何优化减少镜像的大小,如何提升构建速度(这在CICD中十分重要), 如何管理镜像等。如果有需要的小伙伴,可以一起讨论学习。
大纲
当我们刚开始接触Docker
,并尝试使用docker build
构建镜像时,通常会构建出体积巨大的镜像。而事实上,我们可以通过一些技巧方法减小镜像的大小。本片博文,我将介绍一些优化技巧,同时也会探讨如何在减小镜像大小和可调试性取舍。这些技巧可以分为两部分:第一部分是多阶段构建(multi-stage builds
), 正确使用多阶段构建能够极大减小构建物镜像的大小,同时还会解释静态链接(static link
)和动态链接(dynamic link
)之间的区别以及为什么我们需要了解它们;第二部分是使用一些常见的基础镜像,这些基础镜像仅包含我们所需要的内容,而无需引入其他文件。
场景还原
首先,我们通过Golang
和C
的两个Hello world
程序去还原很多开发者第一次使用docker build
所构建出镜像, 并且看看这些的镜像的大小。
下面是C
的Hello 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
是一个虚拟镜像, 我们不可以直接拉取或运行它,因为它完全为空。我们按照下面示例修改Go
的Dockerfile
文件:
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
,这意味着不能在Dockerfile
中CMD
使用字符串语法(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并将其与程序一起加载,以便程序可以调用这些函数。使用动态链接往往有以下优点:
- 节省存储资源,多个程序共享一个库;
- 节省内存,多个程序运行内存调用同一片内存;
- 维护方便,更新库时,无需重新编译程序;
有些人可能会说节省内存不是动态链接所带来的优点,而是共享库
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
):
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
总结
优化镜像的同时,能够帮助我们更好的理解容器相关核心特性。依我个人的使用的总结经验,主要会从以下几个角度思考是否可以进行优化:
- 是否可以使用多阶段优化;
- 是否可以使用如
scratch
较小的镜像作为基础镜像; - 是否可以移除一些没有必要的层;
- 是否可以合并某些层;
通常,追求最小的镜像并不等同于最佳实践,我们需要综合考虑后续可调试性以及使用成本。一般情况我会偏向使用scratch,alpine
这类的镜像。这类镜像很小的同时也提供了必要的工具和可拓展性。
在学习Docker以及编写Dockerfile时,我们通过工具dive
帮助我们分析镜像的结构,方便后续优化