导航:

  这里分为几个部分。

  相关转载云原生:米开朗基杨

  1.Docker减小镜像体积

  2.Docker镜像针对不同语言的精简策略

  对于刚接触容器的人来说,他们很容易被自己制作的 Docker 镜像体积吓到,我只需要一个几MB的可执行文件而已,为何镜像的体积会达到1GB 以上?本文将会介绍几个技巧来帮助你精简镜像,同时又不牺牲开发人员和运维人员的操作便利性。本系列文章将分为三个部分:

  第一部分着重介绍多阶段构建(multi-stage builds),因为这是镜像精简之路至关重要的一环。在这部分内容中,我会解释静态链接和动态链接的区别,它们对镜像带来的影响,以及如何避免那些不好的影响。中间会穿插一部分对 Alpine 镜像的介绍。

  第二部分将会针对不同的语言来选择适当的精简策略,其中主要讨论Go,同时也涉及到了Java,Node,Python,Ruby 和 Rust。这一部分也会详细介绍Alpine镜像的避坑指南。

  本文介绍第一部分。

1.万恶之源

  我敢打赌,每一个初次使用自己写好的代码构建 Docker 镜像的人都会被镜像的体积吓到,来看一个例子。

  让我们搬出那个屡试不爽的 hello world C 程序:

/* hello.c */
int main () {
puts("Hello, world!");
return 0;
}

  并通过下面的 Dockerfile 构建镜像:

FROM gcc
COPY hello.c .
RUN gcc -o hello hello.c
CMD ["./hello"]

  然后你会发现构建成功的镜像体积远远超过了1GB。因为该镜像包含了整个gcc镜像的内容。

  如果使用Ubuntu镜像,安装C编译器,最后编译程序,你会得到一个大概300MB大小的镜像,比上面的镜像小多了。但还是不够小,因为编译好的可执行文件还不到20KB:

$ ls -l hello
-rwxr-xr-x 1 root root 16384 Nov 18 14:36 hello

  类似地,Go 语言版本的 hello world 会得到相同的结果:

package main

import "fmt"

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

  使用基础镜像golang构建的镜像大小是800MB,而编译后的可执行文件只有2MB大小:

$ ls -l hello
-rwxr-xr-x 1 root root 2008801 Jan 15 16:41 hello

  还是不太理想,有没有办法大幅度减少镜像的体积呢?往下看。

  注意: 为了更直观地对比不同镜像的大小,所有镜像都使用相同的镜像名,不同的标签。例如:hello:gcc,hello:ubuntu,hello:thisweirdtrick 等等,这样就可以直接使用命令docker images hello 列出所有镜像名为hello的镜像,不会被其他镜像所干扰。

2.多阶段构建

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

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

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从上一阶段拷贝到最终的镜像中。最终的镜像大小是64MB,比之前的1.1GB减少了95%:

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

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

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

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

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

  使用经典的基础镜像

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

  使用绝对路径(COPY --from)

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

FROM golang
COPY hello.go .
RUN go build hello.go
FROM ubuntu
COPY --from=0 hello .
CMD ["./hello"]

  你会看到这样的报错:

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

FROM golang
WORKDIR /src
COPY hello.go .
RUN go build hello.go
FROM ubuntu
COPY --from=0 /src/hello .
CMD ["./hello"]

  最后的效果还是很惊人的,将镜像的体积直接从800MB降低到了66MB:

docker images minimage
REPOSITORY TAG ... SIZE
minimage hello-go.golang ... 805MB
minimage hello-go.golang.ubuntu-workdir ... 66.2MB

3.FROM scratch的魔力

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

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

FROM golang
COPY hello.go .
RUN go build hello.go
FROM scratch
COPY --from=0 /go/hello .
CMD ["./hello"]

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

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

  缺少shell

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

...
FROM scratch
COPY --from=0 /go/hello .
CMD ./hello

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

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

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程序也是跑不起来的(例如用到了网络相关的工具包),你会遇到类似于下面的错误:

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:

gcc -o hello hello.c -static

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

  如果使用alpine镜像作为基础镜像来编译,得到的可执行文件会更小(< 100kB),下篇文章会详述。编译完的可执行文件大小为760kB,相比于之前的16kB是大了好多,这是因为可执行文件中包含了其运行所需要的库文件。编译完的程序就可以跑在scratch镜像中了。

  2.拷贝库文件到镜像中

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

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。它只有5MB大小,并且包含了glibc和各种调试工具。如果你想选择一个合适的镜像来运行使用动态链接的程序,busybox:glibc是最好的选择。

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

4.总结

  最后来对比一下不同构建方法构建的镜像大小:

    • 原始的构建方法:1.14 GB
    • 使用ubuntu镜像的多阶段构建:64.2 MB
    • 使用alpine镜像和静态 glibc:6.5 MB
    • 使用alpine镜像和动态库:5.6 MB
    • 使用scratch镜像和静态glibc:940 kB
    • 使用scratch镜像和静态 musl libc:94 kB

  最终我们将镜像的体积减少了99.99%。

  但我不建议使用sratch作为基础镜像,因为调试起来非常麻烦,但如果你喜欢,我也不会拦着你。

  下篇文章将会着重介绍 Go 语言的镜像精简策略,其中会花很大的篇幅来讨论 alpine 镜像,因为它实在是太酷了,在使用它之前必须得摸清它的底细。

Docker减小镜像体积的更多相关文章

  1. 前端 Docker 镜像体积优化

    如果 2019 年技术圈有十大流行词,容器化肯定占有一席之地,随着 Docker 的风靡,前端领域应用到 Docker 的场景也越来越多,本文主要来讲述下开源的分布式图数据库 Nebula Graph ...

  2. docker容器共享宿主机环境,从而为镜像体积减负

    一.背景介绍 响应公司技术发展路线,开发的服务均需要将打成docker镜像,使用docker进行统一管理.可是随着服务越来越多,镜像也越来越多.每次制作镜像的时候都需要将依赖打进容器,这样一个jre的 ...

  3. docker 微镜像-alpine

    刚想找maven自动发布项目到tomcat, 突然看到个好玩的, docker微镜像 -- alpine 直接粘一段: Alpine Linux Docker镜像基于Alpine Linux操作系统, ...

  4. Docker - 定制镜像

    Dockerfile Docker Hub拥有大量高质的官方镜像:可直接使用的服务类镜像.语言应用镜像.基础操作系统镜像等,满足绝大部分需求. 此外,可以通过定制镜像的方式来满足实际使用中的特定需求. ...

  5. Docker容器镜像瘦身的三个小窍门(转)

    [转自:http://dockone.io/article/8174] 在构建Docker容器时,我们应尽可能减小镜像的大小.使用共享层的镜像尺寸越小,其传输和部署速度越快. 不过在每个RUN语句都会 ...

  6. docker之镜像管理命令

    一.docker image 镜像管理命令 指令 描述ls 列出本机镜像build 构建镜像来自Dockerfilehistory 查看镜像历史inspect 显示一个或多个镜像详细信息pull 从镜 ...

  7. 3.Docker 操作镜像

    获取镜像 之前提到过,Docker Hub 上有大量的高质量的镜像可以用,这里我们就说一下怎么获取这些镜像. 从 Docker 镜像仓库获取镜像的命令是 docker pull.其命令格式为: doc ...

  8. Docker:镜像操作和容器操作

    镜像操作 列出镜像: $ sudo docker images REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE hello-world latest 0a6b ...

  9. Docker的镜像

    镜像是容器的运行基础,容器是镜像运行后台的形态 镜像的概念 镜像是一个包含程序运行必要依赖环境和代码的只读文件,它采用分层的文件系统,将每一次改变以读写层的形式增加到原来的只读文件上 镜像的系统结构 ...

随机推荐

  1. [bug] Python:“TabError: inconsistent use of tabs and spaces in indentation”

    原因 代码中混用了Tab和4个空格 参考 https://blog.csdn.net/dongdong9223/article/details/82745068

  2. [Linux] Shell请求网页

    文件描述符 0:标准输入 1:标准输出 2:报错输出 举例 1 exec 8<> /dev/tcp/www.baidu.com/80 2 echo -e "GET / HTTP/ ...

  3. 获取显卡硬件信息lspci -vnn | grep VGA -A 12

    lspci -vnn | grep VGA -A 12 lshw -C display lshw -c video | grep configuration glxinfo | grep OpenGL ...

  4. 华为eNSP模拟器— telnet实验

    华为eNSP模拟器-telnet实验 一.实验一 路由交换之间实现telnet登陆 实验拓扑 实验目的: 路由器作为 telnet 服务器 交换机作为客户端去连接路由器 实验步骤: 路由器配置 < ...

  5. 067.Python框架Django之DRF视图类

    一 关于视图类的一下概念 drf除了在数据序列化部分简写代码以外,还在视图中提供了简写操作.所以在django原有的django.views.View类基础上,drf封装了多个子类出来提供给我们使用. ...

  6. 058.Python前端Django与Ajax

    一 Ajax简介 AJAX(Asynchronous Javascript And XML)翻译成中文就是"异步Javascript和XML".即使用Javascript语言与服务 ...

  7. 007.Python循环语句while循环嵌套

    1 使用两个循环打印十行小星星 j = 0 while j<10: # 打印一行十个小星星 i = 0 while i<10: print("*",end=" ...

  8. JavaEE 学大数据是否掌握 JavaSE 和 Linux 就够了?

    引言 如果你是学习大数据的童靴,可能经常在网上看到一些公众号或博客告诉你,学习大数据基础部分只需要掌握 JavaSE 和 Linux 就够了,至于 JavaWeb 和 JavaEE 简单了解一下就可以 ...

  9. Qt 设置中文

    1. 前言 在编写Qt应用程序时,有时会希望能直接设置中文字符串到界面,总结下其设置方法. 2. 设置中文 1)运行环境Qt5.5 VS2013 2)首先,查看需要设置中文的文件是否为UTF-8格式, ...

  10. scrapy异常状态码处理

    scrapy异常状态码处理 在setting.py中加入 scrapy  的 state 默认只处理200到300之间 # 403状态的响应不被自动忽略,对403 的响应做个性化处理 HTTPERRO ...