如何减小 Docker 镜像的大小

问题

首先来看一个例子,构建一个 C 语言版的 hello world 镜像:

1
2
3
4
5
/* hello.c */
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"]

然后执行 docker build -t hello-world . 构建一个名为 hello-world 的镜像,然而以这种方式构建的镜像的大小竟然有1.19 GB:

1
2
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-world latest b11e170bd1d2 6 minutes ago 1.19GB

因为这种构建方式生成的镜像会同时包含 gcc 镜像的内容,查看 gcc 镜像大小发现达到了1.19 GB:

1
2
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
gcc latest 21f378ba43ec 11 days ago 1.19GB

如果我们把基础镜像换成 Ubuntu 并安装 gcc 编译 hello.c 重新构建镜像,最后的镜像大小为213 MB:

1
2
3
4
5
FROM ubuntu
COPY hello.c .
RUN apt-get update && apt-get install gcc -y
RUN gcc -o hello hello.c
CMD ["./hello"]
1
2
REPOSITORY          TAG                 IMAGE ID            CREATED              SIZE
hello-world latest 42f17d1d12a5 About a minute ago 213MB

虽然新镜像相比1.19 GB有大幅减少,但相比于 hello-world 程序本身的大小(17k)来说,213 MB依然是个庞大的数字:

1
2
$ ls hello -hl
-rwxr-xr-x 1 root root 17K Aug 4 13:54 hello

解决

Multi-stage

对于 hello-world 这个镜像来说,我们真正需要的只是最终的可执行程序,而并不关心中间的编译过程,如果能将编译阶段作为一个临时阶段而并不包含在最终的镜像中,则可有效减少最终的镜像大小。针对此,Docker 在 17.05 版本开始提供了名为 multi-stage 构建的功能。我们将原来的 Dockerfile 稍作修改,将原来的编译阶段抽取为一个 stage,然后将编译好的可执行文件复制到最终的 stage 中:

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

最终的镜像大小只有73.9 MB:

1
2
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-world latest 7dd2b51c53b2 7 minutes ago 73.9MB

FROM scratch

在上一步中,我们使用 Ubuntu 作为基础镜像来运行 hello-world,相比于一个可执行程序,Ubuntu 依然过于庞大,有没有比 Ubuntu 更轻量的镜像呢?有,那就是 scratch,这表示一个空的镜像,继续将 Dockerfile 稍作修改:

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

最终的镜像大小只有16.4 KB:

1
2
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-world latest 676253b0e9c4 31 minutes ago 16.4kB

不过在运行该镜像时却提示错误:

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

这是因为这种方式构建出的镜像缺少 hello-world 运行时依赖的库。我们可以在编译 hello-world 时通过指定 -static 参数将依赖的库包含到最后的可执行文件中来解决这个问题:

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

不过包含了依赖的库后最终镜像的大小也上涨为945 KB:

1
2
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-world latest e6a1fccc2de7 9 seconds ago 945kB

另外,如果不想将依赖的库包含到最终的镜像中,可以使用 busybox:glibc 这个基础镜像,该镜像包含了 C 语言的标准库,有了这个镜像在编译 hello-world 时则无需指定 -static 参数:

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

不过由于该镜像本身有一定大小,最终镜像的大小达到了5.22 MB:

1
2
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-world latest e2f2c0544800 7 seconds ago 5.22MB

总结

通过 multi-stage 构建可以有效的减少 Docker 镜像的大小,而基础镜像的选择则要具体情况分析,在满足需求的情况下选择合理的基础镜像。

参考