导航:

  这里分为几个部分。

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

  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. mysql整型后面的()宽度

    int(5)这个5表示显示宽度 如果超出宽度则正常显示,所以人为指定显示宽度意义不大

  2. [bug] logback error FileNotFoundException

    问题 在gitee上下载的项目,运行报错 原因 原程序中设置了日志保存路径,我的电脑没有,需要手动创建 参考 https://blog.csdn.net/danchaofan0534/article/ ...

  3. 攻防世界(三)Web_php_unserialize

    攻防世界系列:Web_php_unserialize 0x01.代码审计 1.类Demo中struct().destruct()函数分别在代码执行开始和结束时调用.而wakeup函数会在代码执行过程中 ...

  4. Java JOptionPane 对话框

    如果你对 Java 控制台界面下的输入数据和打印输出结果感到有些乏味和厌倦,希望能够像其他计算机软件一样有一个 GUI 界面(图形用户界面). 那么 JOptionPane 对话框也许会让你眼前一亮, ...

  5. Kubernetes集群搭建 ver1.20.5

    目录 部署方式 1. 基础环境准备 1.1 基础初始化 1.2 安装docker 2. 部署harbor及haproxy高可用反向代理 2.1 镜像加速配置 2.2 高可用master可配置 3. 初 ...

  6. SystemVerilog MCDF比较器

    checker肩负了模拟设计行为和功能检查任务. 功能: 缓存从各个monitor手机到的数据. ton过比较器检查实际收集到的DUT输出端口数据是否同reference module(参考模型)产生 ...

  7. libevent中的事件机制

    libevent是事件驱动的网络库,事件驱动是他的核心,所以理解事件驱动对于理解整个网络库有很重要的意义.       本着从简入繁,今天分析下单线程最简单的事件触发.通过sample下的event- ...

  8. Go语言网络通信---string与int互转,int64与[]byte互转,int直接互转,string与[]byte互转

    string与int互转 #string到int int,err:=strconv.Atoi(string) #string到int64 int64, err := strconv.ParseInt( ...

  9. Spring的controller接受Date类型数据,接受枚举类型数据

    1. Controller接收Date类型的数据 核心使用@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") 来将传递过来的时间字符串 ...

  10. mysql 多字段联合唯一索引

    6个字段组成联合唯一索引 索引名称code_level_metric_type_week_year sql语句联合索引 CREATE UNIQUE INDEX code_level_metric_ty ...