首页 > 技术文章 > Docker 镜像针对不同语言的精简策略

lizexiong 2021-06-16 12:15 原文

导航:

  这里分为几个部分。

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

  1.Docker减小镜像体积

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

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

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

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

  本文介绍第二部分。

 

1.Go语言镜像精简

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

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

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

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 包,不依赖系统库:

$ go build -tags netgo whatsmyip.go

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

 

2.Alpine镜像探秘

  上篇文章已经对Alpine镜像作了简要的介绍,并保证会在后面的文章中花很大的篇幅来讨论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 需要多长时间,测试命令如下:

time docker run <image> <packagemanager> install tcpdump

  测试结果如下:

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 这么棒,为什么不用它作为所有镜像的基础镜像呢?别急,先一步一步来,为了趟平所有的坑,需要分两种情况来考虑:

  1.使用Alpine作为第二构建阶段(run阶段)的基础镜像

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

 

  run阶段使用Alpine

  将Alpine镜像加入了Dockerfile:

FROM gcc AS mybuildstage
COPY hello.c .
RUN gcc -o hello hello.c

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

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

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镜像中,必须在编译时使用glibc作为动态库。

  所有阶段使用Alpine

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

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

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

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

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如下:

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%。

 

3.Java语言镜像精简

  Java属于编译型语言,但运行时还是要跑在JVM 中。那么对于Java语言来说,该如何使用多阶段构建呢?

  静态还是动态?

  从概念上来看,Java使用的是动态链接,因为Java代码需要调用JVM提供的Java API,这些API的代码都在可执行文件之外,通常是JAR文件或WAR文件。

  然而这些Java库并不是完全独立于系统库的,某些Java函数最终还是会调用系统库,例如打开文件时需要调用open(),fopen()或它们的变体,因此JVM本身可能会与系统库动态链接。

  这就意味着理论上可以使用任意的JVM来运行Java程序,系统标准库是musl libc还是glibc 都无所谓。因此,也就可以使用任意带有JVM的基础镜像来构建Java程序,也可以使用任意带有JVM的镜像作为运行Java程序的基础镜像。

  类文件格式

  Java类文件(Java编译器生成的字节码)的格式会随着版本而变化,且大部分变化都是Java API的变化。还有一部分更改与Java语言本身有关,例如Java 5中添加了泛型,这种变化就可能会导致类文件格式的变化,从而破坏与旧版本的兼容性。

  所以默认情况下,使用给定版本的Java编译器编译的类不能与更早版本的JVM兼容,但可以指定编译器的 -target (Java 8 及其以下版本)参数或者 --release (Java 9 及其以上版本)参数来使用较旧的类文件格式。--release 参数还可以指定类文件的路径,以确保程序运行在指定的JVM 版本中(例如 Java 11),不会意外调用Java 12的API。

  JDK vs JRE

  如果你对大多数平台上的Java打包方式很熟悉,那你应该知道JDK和JRE。

  JRE即Java运行时环境(Java Runtime Environment),包含了运行Java程序所需要的环境,即JVM。

  JDK即Java开发工具包(Java Development Kit),既包含了JRE,也包含了开发Java 程序所需的工具,即Java编译器。

  大多数Java镜像都提供了JDK和JRE两种标签,因此可以在多阶段构建的build阶段使用JDK作为基础镜像,run阶段使用JRE作为基础镜像。

  Java vs OpenJDK

  看研发说用哪个,我们就用哪个

  也可以使用 amazoncorretto,这是 Amazon fork OpenJDK 后打了补丁的版本,号称企业级。

  开始构建

  说了那么多,到底该用哪个镜像呢?这里给出几个参考:

    • openjdk:8-jre-alpine(85MB)
    • openjdk:11-jre(267MB)或者 openjdk:11-jre-slim(204MB)
    • openjdk:14-alpine(338MB)

  如果你想要更直观的数据,可以看我的例子,还是搬出屡试不爽的 『hello world』,只不过这次是 Java 版本:

class hello {
  public static void main(String [] args) {
    System.out.println("Hello, world!");
  }
}

  不同构建方法得到的镜像大小:

    • 使用基础镜像java构建:643MB
    • 使用基础镜像openjdk构建:490MB
    • 多阶段构建,build阶段使用基础镜像openjdk,run阶段使用基础镜像 openjdk:jre:479MB
    • 使用基础镜像amazoncorretto 构建:390MB
    • 多阶段构建,build阶段使用基础镜像 openjdk:11,run阶段使用基础镜像 openjdk:11-jre:267MB
    • 多阶段构建,build阶段使用基础镜像 openjdk:8,run阶段使用基础镜像 openjdk:8-jre-alpine:85MB

 

4.解释型语言镜像精简

  对于诸如Node、Python、Rust之类的解释型语言来说,情况就比较复杂一点了。先来看看Alpine镜像。

  Alpine镜像

  对于解释型语言来说,如果程序仅用到了标准库或者依赖项和程序本身使用的是同一种语言,且无需调用C库和外部依赖,那么使用Alpine作为基础镜像一般是没有啥问题的。一旦你的程序需要调用外部依赖,情况就复杂了,想继续使用Alpine镜像,就得安装这些依赖。根据难度可以划分为三个等级:

    • 简单:依赖库有针对Alpine的安装说明,一般会说明需要安装哪些软件包以及如何建立依赖关系。但这种情况非常罕见,原因前面也提到了,Alpine的软件包数量比大多数流行的发行版要少得多。
    • 中等:依赖库没有针对Alpine的安装说明,但有针对别的发行版的安装说明。我们可以通过对比找到与别的发行版的软件包相匹配的Alpine软件包(假如有的话)。
    • 困难:依赖库没有针对Alpine的安装说明,但有针对别的发行版的安装说明,但是 Alpine也没有与之对应的软件包。这种情况就必须从源码开始构建!

  最后一种情况最不推荐使用Alpine作为基础镜像,不但不能减小体积,可能还会适得其反,因为你需要安装编译器、依赖库、头文件等等。更重要的是,构建时间会很长,效率低下。如果非要考虑多阶段构建,就更复杂了,你得搞清楚如何将所有的依赖编译成二进制文件,想想就头大。因此一般不推荐在解释型语言中使用多阶段构建。

  有一种特殊情况会同时遇到Alpine的绝大多数问题:将Python用于数据科学。numpy 和pandas之类的包都被预编译成了wheel,wheel是Python新的打包格式,被编译成了二进制,用于替代Python传统的egg文件,可以通过pip直接安装。但这些wheel都绑定了特定的C库,这就意味着在大多数使用glibc的镜像中都可以正常安装,但Alpine镜像就不行,原因你懂得,前面已经说过了。如果非要在Alpine中安装,你需要安装很多依赖,重头构建,耗时又费力,有一篇文章专门解释了这个问题:使用 Alpine构建Pyhton镜像会将构建速度拖慢50倍!。

  既然Alpine镜像这么坑,那么是不是只要是Python写的程序就不推荐使用Alpine镜像来构建呢?也不能完全这么肯定,至少Python用于数据科学时不推荐使用Alpine,其他情况还是要具体情况具体分析,如果有可能,还是可以试一试Alpine的。

  :slim镜像

  如果实在不想折腾,可以选择一个折中的镜像xxx:slim。slim镜像一般都基于Debian 和glibc,删除了许多非必需的软件包,优化了体积。如果构建过程中需要编译器,那么slim 镜像不适合,除此之外大多数情况下还是可以使用slim作为基础镜像的。

  下面是主流的解释型语言的Alpine镜像和slim镜像大小对比:

Image            Size
---------------------------
node             939 MB
node:alpine      113 MB
node:slim        163 MB
python           932 MB
python:alpine    110 MB
python:slim      193 MB
ruby             842 MB
ruby:alpine       54 MB
ruby:slim        149 MB

  再来举个特殊情况的例子,同时安装 matplotlib,numpy 和 pandas,不同的基础镜像构建的镜像大小如下:

Image and technique         Size
--------------------------------------
python                      1.26 GB
python:slim                  407 MB
python:alpine                523 MB
python:alpine multi-stage    517 MB

  可以看到这种情况下使用Alpine并没有任何帮助,即使使用多阶段构建也无济于事。

  但也不能全盘否定Alpine,比如下面这种情况:包含大量依赖的Django应用。

Image and technique         Size
--------------------------------------
python                      1.23 GB
python:alpine                636 MB
python:alpine multi-stage    391 MB

  最后来总结一下:到底使用哪个基础镜像并不能盖棺定论,有时使用Alpine效果更好,有时反而使用slim效果更好,如果你对镜像体积有着极致的追求,可以这两种镜像都尝试一下。相信随着时间的推移,我们就会积累足够的经验,知道哪种情况该用Alpine,哪种情况该用slim,不用再一个一个尝试。

 

5.Rust语言镜像精简

  Rust是最初由Mozilla设计的现代编程语言,并且在Web和基础架构领域中越来越受欢迎。Rust 编译的二进制文件动态链接到C库,可以正常运行于Ubuntu、Debian 和 Fedora之类的镜像中,但不能运行于busybox:glibc中。因为Rust二进制需要调用libdl库,busybox:glibc中不包含该库。

  还有一个rust:alpine镜像,Rust编译的二进制也可以正常运行其中。

  如果考虑编译成静态链接,可以参考Rust官方文档。在Linux上需要构建一个特殊版本的Rust编译器,构建的依赖库就是musl libc,你没有看错,就是Alpine中的那个musl libc。如果你想获得更小的镜像,请按照文档中的说明进行操作,最后将生成的二进制文件扔进scratch镜像中就好了。

 

6.总结

  本系列文章的前两部分介绍了优化Docker镜像体积的常用方法,以及如何针对不同类型的语言运用这些方法。最后一部分将会介绍如何在减少镜像体积的同时,还能减少I/O和内存使用量,同时还会介绍一些虽然与容器无关但对优化镜像有帮助的技术。

推荐阅读