示例应用

首先贴出代码例子,我们假设要构建一个 http 服务

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
	"fmt"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
)

func main() {
	fmt.Println("Server Ready")
	router := gin.Default()
	router.GET("/", func(c *gin.Context) {
		c.String(200, "hello world, this time is: "+time.Now().Format(time.RFC1123Z))
	})
router.GET("/github", func(c*gin.Context) {
		_, err := http.Get("https://api.github.com/")
		if err != nil {
			c.String(500, err.Error())
			return
		}
		c.String(200, "access github api ok")
	})

	if err := router.Run(":9900"); err != nil {
		panic(err)
	}
}

说明:

  • 这里选择 Gin 作为例子,是为了演示我们有第三方包条件下要优化构建速度
  • main函数第一行打印了一行字,为了演示后面启动时遇到的一个坑
  • 跟路由打印了时间,为了演示后面遇到的关于时区的坑
  • 路由 github 尝试访问 https://api.github.com,为了演示后面遇到的证书坑

这里我们可以先试一试构建后包的体积

1
2
3
$ go build -o server
$ ls -alh | grep server
-rwxrwxrwx 1 eyas eyas  14.6M May 29 10:26 server

14.6MB,这是一个http服务的 hello world,当然这是因为使用了 gin ,所以有些大,如果用标准包 net/http 写的 hello world,体积大概是接近 7 MB

多阶段构建

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

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

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,然后启动一个新的构建阶段,它以 ubuntu 作为基础镜像,将可执行文件 hello 从上一阶段拷贝到最终的镜像中。最终的镜像大小是 64 MB,比之前的 1.1 GB 减少了 95%:

1
2
3
4
docker images minimage
REPOSITORY          TAG                    ...         SIZE
minimage            hello-c.gcc            ...         1.14GB
minimage            hello-c.gcc.ubuntu     ...         64.2MB

还能不能继续优化?当然能。在继续优化之前,先提醒一下:

在声明构建阶段时,可以不必使用关键词 AS,最终阶段拷贝文件时可以直接使用序号表示之前的构建阶段(从零开始)。也就是说,下面两行是等效的:

1
2
COPY --from=mybuildstage hello .
COPY --from=0 hello .

如果 Dockerfile 内容不是很复杂,构建阶段也不是很多,可以直接使用序号表示构建阶段。一旦 Dockerfile 变复杂了,构建阶段增多了,最好还是通过关键词 AS 为每个阶段命名,这样也便于后期维护。

使用经典的基础镜像

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

COPY –from 使用绝对路径

从上一个构建阶段拷贝文件时,使用的路径是相对于上一阶段的根目录的。如果你使用 golang 镜像作为构建阶段的基础镜像,就会遇到类似的问题。假设使用下面的 Dockerfile 来构建镜像:

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

你会看到这样的报错:

1
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,也不会影响到镜像的构建。例如:

1
2
3
4
5
6
7
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

初步优化

先看看第一个版本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
FROM golang:1.14-alpine as builder
WORKDIR /usr/src/app
ENV GOPROXY=<https://goproxy.cn>
COPY ./go.mod ./
COPY ./go.sum ./
RUN go mod download
COPY . .
RUN go build -ldflags "-s -w" -o server

FROM scratch as runner
COPY --from=builder /usr/src/app/server /opt/app/
CMD ["/opt/app/server"]

说明:

  • 选择 golang:1.14-alpine 作为编译环境,是因为这是体积最小的golang编译环境
  • 设置 GOPROXY 是为了提升构建速度
  • 先复制 go.mod 和 go.sum ,然后 go mod download,是为了防止每次构建都会重新下载依赖包,利用docker构建缓存提升构建速度
  • go build 时加上 -ldflags “-s -w” 去除构建包的调试信息,减小go构建后程序体积,大概能减小 1/4 吧
    • -s: 省略符号表和调试信息
    • -w: 省略DWARF符号表
  • 使用了多阶段构建,也就是 FROM XXX as xxx ,在构建程序包的时候,使用带编译环境的镜像去构建,运行的时候其实完全不需要go的编译环境,所以在运行阶段使用docker的空镜像 scratch 去运行。这部是减小镜像体积最有效的方法了。

好了,下面开始构建镜像

1
2
3
4
$ docker build -t server .
...
Successfully built 8d3b91210721
Successfully tagged server:latest

到了这一步,构建成功,看看镜像大小

1
2
$ docker images
server          latest         8d3b91210721      1 minutes ago        11MB

11MB,还行,现在运行一下

1
2
$ docker run -p 9900:9900 server
standard_init_linux.go:211: exec user process caused "no such file or directory"

发现启动报错了,而且main函数的第一行打印语句都没有出现,所以整个程序完全没有运行。错误原因是缺少库依赖文件。这其实是构建的 go 程序还依赖底层的 so 库文件,不信可以在物理机编译后看看它的依赖

1
2
3
4
5
6
$ go build -o server
$ ldd server
        linux-vdso.so.1 (0x00007ffcfb775000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f9a8dc47000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9a8d856000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f9a8de66000)

这是不是跟我们的认知有点出入呢,说好无依赖的呢,结果还是有几个依赖库文件呢,虽然这几个依赖都是最底层的,一般操作系统都会有,可谁叫我们选了 scratch,这个镜像里面除了linux内核以外真的什么都没了。

这是因为go build 是默认启用 CGO 的,不信你可以试试这个命令 go env CGO_ENABLED,在 CGO 开启情况下,无论代码有没有用CGO,都会有库依赖文件,解决方法也很简单,手动指定关闭CGO就行,而且包体积并不会增加哦,还会减少呢

1
2
3
$ CGO_ENABLED=0 go build -o server
$ ldd server
        not a dynamic executable

Go 语言程序编译时会将所有必须的依赖编译到二进制文件中,但也不能完全肯定它使用的是静态链接,因为 Go 的某些包是依赖系统标准库的,例如使用到 DNS 解析的包。只要代码中导入了这些包,编译的二进制文件就需要调用到某些系统库,为了这个需求,Go 实现了一种机制叫 cgo,以允许 Go 调用 C 代码,这样编译好的二进制文件就可以调用系统库。

也就是说,如果 Go 程序使用了 net 包,就会生成一个动态的二进制文件,如果想让镜像能够正常工作,必须将需要的库文件复制到镜像中,或者直接使用 busybox:glibc 镜像。

当然,你也可以禁止 cgo,这样 Go 就不会使用系统库,使用内置的实现来替代系统库(例如使用内置的 DNS 解析器),这种情况下生成的二进制文件就是静态的。可以通过设置环境变量 CGO_ENABLED=0 来禁用 cgo,例如:

1
2
3
4
5
6
7
8
FROM golang
COPY whatsmyip.go .
ENV CGO_ENABLED=0
RUN go build whatsmyip.go

FROM scratch
COPY --from=0 /go/whatsmyip .
CMD ["./whatsmyip"]

由于编译生成的是静态二进制文件,因此可以直接跑在 scratch 镜像中

当然,也可以不用完全禁用 cgo,可以通过 -tags 参数指定需要使用的内建库,例如 -tags netgo 就表示使用内建的 net 包,不依赖系统库:

1
go build -tags netgo whatsmyip.go

这样指定之后,如果导入的其他包都没有用到系统库,那么编译得到的就是静态二进制文件。也就是说,只要还有一个包用到了系统库,都会开启 cgo,最后得到的就是动态二进制文件。要想一劳永逸,还是设置环境变量 CGO_ENABLED=0 吧。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
FROM golang:1.14-alpine as builder
WORKDIR /usr/src/app
ENV GOPROXY=<https://goproxy.cn>
COPY ./go.mod ./
COPY ./go.sum ./
RUN go mod download
COPY . .
-RUN go build -ldflags "-s -w" -o server
+RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o server

FROM scratch as runner
COPY --from=builder /usr/src/app/server /opt/app/
CMD ["/opt/app/server"]

改动点: go build 前加了 CGO_ENABLED=0

1
2
3
4
5
6
7
8
$ docker build -t server .
...
Successfully built a81385160e25
Successfully tagged server:latest
$ docker run -p 9900:9900 server
[GIN-debug] GET    /                         --> main.main.func1 (3 handlers)
[GIN-debug] GET    /github                   --> main.main.func2 (3 handlers)
[GIN-debug] Listening and serving HTTP on :9900

scratch解决运行环境时区与证书问题

正常启动了,我们访问一下试试,访问之前看看当前时间

1
2
3
4
5
6
7
8
$ date
Fri May 29 13:11:28 CST 2020

$ curl <http://localhost:9900>
hello world, this time is: Fri, 29 May 2020 05:18:28 +0000

$ curl <http://localhost:9900/github>
Get "https://api.github.com/": x509: certificate signed by unknown authority

发现有问题

  • 当前系统时间是 13:11:28 ,但是根据由显示的时间是 05:11:53,其实是docker 容器内的时区不对,默认是 0 时区,可是我们国家是 东8区
  • 尝试访问 https://api.github.com/ 这是 https 站点,报证书错误

解决问题

  • 在容器放置根证书
  • 设置容器时区

原因在于 scratch 镜像并不包含任何 时区信息 ,我们需要从本地系统中复制一份。

由于 scratch 镜像几乎不包含任何东西,甚至没有 mkdir 命令。 因此,我们需要对时区信息进行打包,然后再通过 ADD 指令进行添加,以此绕过目录创建:

1
2
$ tar -chzf zoneinfo.tar.gz /usr/share/zoneinfo
tar: Removing leading '/' from member names

这时,时区信息问题已经解决了,但是发起 HTTPS 请求还存在问题, 原因是 scratch 镜像不包含任何 SSL CA 证书。

接下来,从 https://curl.haxx.se/docs/caextract.html 下载 CA 证书:

1
curl -o cacert.pem https://curl.haxx.se/ca/cacert.pem
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
FROM golang:1.14-alpine as builder
WORKDIR /usr/src/app
ENV GOPROXY=<https://goproxy.cn>
+RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \

* apk add --no-cache ca-certificates tzdata
COPY ./go.mod ./
COPY ./go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o server

FROM scratch as runner
+COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/src/app/server /opt/app/
CMD ["/opt/app/server"]

在 builder 阶段,安装了 ca-certificates tzdata 两个库,在runner阶段,将时区配置和根证书复制了一份

1
2
3
4
5
6
7
8
$ docker build -t server .
...
Successfully built e0825838043d
Successfully tagged server:latest
$ docker run -p 9900:9900 server
[GIN-debug] GET    /                         --> main.main.func1 (3 handlers)
[GIN-debug] GET    /github                   --> main.main.func2 (3 handlers)
[GIN-debug] Listening and serving HTTP on :9900

访问一下试试

1
2
3
4
5
6
7
8
$ date
Fri May 29 13:27:16 CST 2020

$ curl <http://localhost:9900>
hello world, this time is: Fri, 29 May 2020 13:27:16 +0800

$ curl <http://localhost:9900/github>
access github api ok

一切正常了,看看当前镜像大小

1
2
$ docker images
server          latest         e0825838043d      9 minutes ago        11.3MB

才 11.3MB,已经很小了,但是,还可以更小,就是把构建后的包再压缩一次

upx进一步减小体积

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
FROM golang:1.14-alpine as builder
WORKDIR /usr/src/app
ENV GOPROXY=<https://goproxy.cn>
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \

* apk add --no-cache ca-certificates tzdata

* apk add --no-cache upx ca-certificates tzdata
COPY ./go.mod ./
COPY ./go.sum ./
RUN go mod download
COPY . .
-RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o server
+RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o server &&\
* upx --best server -o _upx_server && \
* mv -f _upx_server server

FROM scratch as runner
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/src/app/server /opt/app/
CMD ["/opt/app/server"]

在 builder 阶段,安装了 upx ,并且go build 完成后,使用 upx 压缩了一下,执行一下构建,你会发现这个构建时间变长了,这是因为我给 upx 设置的参数是 –best ,也就是最大压缩级别,这样压缩出来的后会尽可能的小,如果嫌慢,可以降低压缩级别从 -1 到 -9 ,数字越大压缩级别越高,也越慢。我使用 –best 构建完成后看看镜像体积。

1
2
3
4
5
6
$ docker build -t server .
...
Successfully built 80c3f3cde1f7
Successfully tagged server:latest
$ docker images
server          latest         80c3f3cde1f7      1 minutes ago        4.26MB

这下子可小了,才 4.26MB,再去试试那两个接口,一切正常。优化到此结束。

最终的Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
FROM golang:1.14-alpine as builder
WORKDIR /usr/src/app
ENV GOPROXY=<https://goproxy.cn>
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
  apk add --no-cache upx ca-certificates tzdata
COPY ./go.mod ./
COPY ./go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o server &&\
upx --best server -o_upx_server && \
mv -f_upx_server server

FROM scratch as runner
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/src/app/server /opt/app/
CMD ["/opt/app/server"]

scratch

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

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

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"]

这一次构建的镜像大小正好就是 2 MB,堪称完美!

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

缺少 shell

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

1
2
3
4
...
FROM scratch
COPY --from=0 /go/hello .
CMD ./hello

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

1
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 中执行,也就是说,下面这两条语句是等效的:

1
2
CMD ./hello
CMD /bin/sh -c "./hello"

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

缺少调试工具

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

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

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

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

缺少 libc

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

1
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 中的 cos 和 sin 函数,该程序运行时就会根据索引找到并加载 libtrigonometry.so,然后程序就可以调用这个库文件中的函数。

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

  1. 节省磁盘空间,不同的程序可以共享常见的库。
  2. 节省内存,共享的库只需从磁盘中加载到内存一次,然后在不同的程序之间共享。
  3. 更便于维护,库文件更新后,不需要重新编译使用该库的所有程序。

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

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

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

1、使用静态库

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

1
gcc -o hello hello.c -static

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

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

2、拷贝库文件到镜像中

为了找出程序运行需要哪些库文件,可以使用 ldd 工具:

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 与一种叫做 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_*)。

我个人不建议直接拷贝库文件,因为它非常难以维护,后期需要不断地更改,而且还有很多未知的隐患。

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

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

注意:如果你的程序使用到了除标准库之外的库,仍然需要将这些库文件拷贝到镜像中。

Alpine

Alpine 是众多 Linux 发行版中的一员,和 CentOS、Ubuntu、Archlinux 之类一样,只是一个发行版的名字,号称小巧安全,有自己的包管理工具 apk。

与 CentOS 和 Ubuntu 不同,Alpine 并没有像 Red Hat 或 Canonical 之类的大公司为其提供维护支持,软件包的数量也比这些发行版少很多(如果只看开箱即用的默认软件仓库,Alpine 只有 10000 个软件包,而 Ubuntu、Debian 和 Fedora 的软件包数量均大于 50000。)

容器崛起之前,Alpine 还是个无名之辈,可能是因为大家并不是很关心操作系统本身的大小,毕竟大家只关心业务数据和文档,程序、库文件和系统本身的大小通常可以忽略不计。

容器技术席卷整个软件产业之后,大家都注意到了一个问题,那就是容器的镜像太大了,浪费磁盘空间,拉取镜像的时间也很长。于是,人们开始寻求适用于容器的更小的镜像。对于那些耳熟能详的发行版(例如 Ubuntu、Debian、Fedora)来说,只能通过删除某些工具(例如 ifconfig 和 netstat)将镜像体积控制在 100M 以下。而对于 Alpine 而言,什么都不用删除,镜像大小也就只有 5M 而已。

Alpine 镜像的另一个优势是包管理工具的执行速度非常快,安装软件体验非常顺滑。诚然,在传统的虚拟机上不需要太关心软件包的安装速度,同一个包只需要装一次即可,无需不停重复安装。容器就不一样了,你可能会定期构建新镜像,也可能会在运行的容器中临时安装某些调试工具,如果软件包的安装速度很慢,会很快消磨掉我们的耐心。

为了更直观,我们来做个简单的对比测试,看看不同的发行版安装 tcpdump 需要多长时间,测试命令如下:

1
🐳 → time docker run <image> <packagemanager> install tcpdump

测试结果如下:

1
2
3
4
5
6
7
8
9
Base image           Size      Time to install tcpdump
---------------------------------------------------------

alpine:3.11          5.6 MB      1-2s
archlinux:20200106   409 MB      7-9s
centos:8             237 MB      5-6s
debian:10            114 MB      5-7s
fedora:31            194 MB    35-60s
ubuntu:18.04          64 MB      6-8s

如果你想了解更多关于 Alpine 的内幕,可以看看 Natanel Copa 的演讲。

好吧,既然 Alpine 这么棒,为什么不用它作为所有镜像的基础镜像呢?别急,先一步一步来,为了趟平所有的坑,需要分两种情况来考虑:

  1. 使用 Alpine 作为第二构建阶段(run 阶段)的基础镜像
  2. 使用 ALpine 作为所有构建阶段(run 阶段和 build 阶段)的基础镜像

run 阶段使用 Alpine

带着激动的心情,将 Alpine 镜像加入了 Dockerfile:

1
2
3
4
5
6
7
FROM gcc AS mybuildstage
COPY hello.c .
RUN gcc -o hello hello.c

FROM alpine
COPY --from=mybuildstage hello .
CMD ["./hello"]

第一个坑来了,启动容器出现了错误:

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

这个报错在上篇文章已经见识过了,上篇文章的场景是使用 scratch 镜像作为 C 语言程序的基础镜像,错误的原因是 scratch 镜像中缺少动态库文件。可是为什么使用 Alpine 镜像也有报错,难道它也缺少动态库文件?

也不完全是,Alpine 使用的也是动态库,毕竟它的设计目标之一就是占用更少的空间。但 Alpine 使用的标准库与大多数发行版不同,它使用的是 musl libc,这个库相比于 glibc 更小、更简单、更安全,但是与大家常用的标准库 glibc 并不兼容。

你可能又要问了:『既然 musl libc 更小、更简单,还特么更安全,为啥其他发行版还在用 glibc?』

因为 glibc 有很多额外的扩展,并且很多程序都用到了这些扩展,而 musl libc 是不包含这些扩展的。详情可以参考 musl 的文档。

也就是说,如果想让程序跑在 Alpine 镜像中,必须在编译时使用 musl libc 作为动态库。

所有阶段使用 Alpine

为了生成一个与 musl libc 链接的二进制文件,有两条路:

  • 某些官方镜像提供了 Alpine 版本,可以直接拿来用。
  • 还有些官方镜像没有提供 Alpine 版本,我们需要自己构建。

golang 镜像就属于第一种情况,golang:alpine 提供了基于 Alpine 构建的 Go 工具链。

构建 Go 程序可以使用下面的 Dockerfile:

1
2
3
4
5
6
7
FROM golang:alpine
COPY hello.go .
RUN go build hello.go

FROM alpine
COPY --from=0 /go/hello .
CMD ["./hello"]

生成的镜像大小为 7.5M,对于一个只打印 『hello world』的程序来说确实有点大了,但我们可以换个角度:

  • 即使程序很复杂,生成的镜像也不会很大。
  • 包含了很多有用的调试工具。
  • 即使运行时缺少某些特殊的调试工具,也可以迅速安装。

Go 语言搞定了,C 语言呢?并没有 gcc:alpine 这样的镜像啊。只能以 Alpine 镜像作为基础镜像,自己安装 C 编译器了,Dockerfile 如下:

1
2
3
4
5
6
7
8
FROM alpine
RUN apk add build-base
COPY hello.c .
RUN gcc -o hello hello.c

FROM alpine
COPY --from=0 hello .
CMD ["./hello"]

必须安装 build-base,如果安装 gcc,就只有编译器,没有标准库。build-base 相当于 Ubuntu 的 build-essentials,引入了编译器、标准库和 make 之类的工具。

最后来对比一下不同构建方法得到的 『hello world』镜像大小:

  • 使用基础镜像 golang 构建:805MB

  • 多阶段构建,build 阶段使用基础镜像 golang,run 阶段使用基础镜像 ubuntu:66.2MB

  • 多阶段构建,build 阶段使用基础镜像 golang:alpine,run 阶段使用基础镜像 alpine:7.6MB

  • 多阶段构建,build 阶段使用基础镜像 golang,run 阶段使用基础镜像 scratch:2MB

最终镜像体积减少了 99.75%,相当惊人了。再来看一个更实际的例子,上一节提到的使用 net 的程序,最终的镜像大小对比:

  • 使用基础镜像 golang 构建:810MB
  • 多阶段构建,build 阶段使用基础镜像 golang,run 阶段使用基础镜像 ubuntu:71.2MB
  • 多阶段构建,build 阶段使用基础镜像 golang:alpine,run 阶段使用基础镜像 alpine:12.6MB
  • 多阶段构建,build 阶段使用基础镜像 golang,run 阶段使用基础镜像 busybox:glibc:12.2MB
  • 多阶段构建,build 阶段使用基础镜像 golang 并使用参数 CGO_ENABLED=0,run 阶段使用基础镜像 ubuntu:7MB 镜像体积仍然减少了 99%。

建议选择alpile

要减小镜像体积,首先多阶段构建这很重要,这样就可以把编译环境和运行环境分开。

另外,选择 scratch 这个镜像其实很不明智,它虽然很小,但是它太原始了,里面什么工具都没有,程序启动后,连容器都进不去,就算进去了什么都做不了。所以就算一昧的追求尽可能小的镜像体积,也不建议选择 scratch 作为运行环境,我暂时只踩到小部分的坑,后面还有更多坑没踩,我也没有兴趣继续踩 scratch 的坑。

建议选择 alpine ,alpine 的镜像大小是 5.61MB 这个大小其实还是镜像解压后的大小,实际上下载镜像的时候,只需要下载 2.68 MB 。还有,上文所有我说的镜像体积,全都是指解压后的镜像体积,和实际上传下载时的体积是不一样的,docker自己会压缩一次再传输镜像 还有个很小的镜像是 busybox,它的体积是 1.22MB,下载 705.6 KB ,有大部分的linux命令可用,但是运行环境还是很原始,有兴趣可以去尝试

无论是 alpine 还是 busybox ,他们都会上述时区和证书问题,同样按照上面方法就能解决,切换到 alpine 或者 busybox 也很简单,只需要修改 runner 基础镜像就行

1
2
-FROM scratch as runner
+FROM alpine as runner

或者

1
2
-FROM scratch as runner
+FROM busybox as runne

最终版本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
FROM golang:1.13.5-alpine3.10 AS builder

WORKDIR /build
RUN adduser -u 10001 -D app-runner

ENV GOPROXY https://goproxy.cn
COPY go.mod .
COPY go.sum .
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build -a -o your-application .

FROM alpine:3.10 AS final

RUN apk update --no-cache && apk add --no-cache ca-certificates tzdata
ENV TZ Asia/Shanghai
WORKDIR /app
COPY --from=builder /build/your-application /app/
COPY --from=builder /etc/passwd /etc/passwd

USER app-runner
ENTRYPOINT ["/app/your-application"]

首先,这个dockerfile分为builder和final两部分。

builder选择了golang:1.13.5-alpine3.10作为编译的基础镜像,相比于golang:1.13, 一方面是因为它体积更小,另一方面是我发现golang:1.13的编译结果,在alpine:3.10中会报not found的错误,虽说有人提供了其它的解决方案,但是能直接避免,为啥不避免呢。

1
RUN adduser -u 10001 -D app-runner

接着是创建了一个app-runner的用户, -D表示无密码。 此用户的信息是是需要拷到final中,作为应用程序的启动用户。这是为了避免使用container中的默认用户root,那可是有安全漏洞的,详细解释,可以参考这篇medium上的文章Processes In Containers Should Not Run As Root 再下面的四行,

1
2
3
4
ENV GOPROXY <https://goproxy.cn>
COPY go.mod .
COPY go.sum .
RUN go mod download

是配置了国内的代理,安装依赖包了。这里用go mod download的好处是下次构建镜像文件时,当go.mod和go.sum没有改变时,它是有缓存的,可以避免重复下载依赖包,加快构建。

builder的最后,就是把当前目录的文件拷过去,编译代码了。

1
2
COPY . .
RUN CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build -a -o your-application .

final选择了alpine:3.10,一方面是体积小,只有5m;另一方面也是和构建镜像的alpine版本保持一致。

接下来几行没啥说的,就是把构建结果、配置文件(有的话)和用户的相关文件拷过去。

下面的这步一定不要忘记了,

1
USER app-runner

没有它,container启动时就是用root用户启动了!!! 如果被攻击了,那黑客可是就有root权限了(不要问我为啥会被攻击)。

最后,设置一个ENTRYPOINT,完事!

如果你程序的启动过程比较复杂,或者是要在启动时根据环境变量的值做不同的操作,那还是写个shell文件吧。

参考

构建 Golang 应用最小 Docker 镜像 两个奇技淫巧,将 Docker 镜像体积减小 99% Docker 镜像制作教程:针对不同语言的精简策略 手把手教你写一个完美的Golang Dockerfile