BLOG | NGINX

构建更小的容器镜像

NGINX-Part-of-F5-horiz-black-type-RGB
Scott van Kalken 缩略图
Scott van Kalken
Published September 10, 2021

本文是我们容器技术系列文章的一部分:

容器在现代应用环境中无处不在。开发人员正在以多种方式使用它们:构建容器,将它们推送到仓库,然后让应用在容器中运行。

在本文中,我将会探讨有关容器镜像的内容,具体就是如何让容器镜像变得更小,以及这样做有何意义。同时,我将展示一些示例代码和命令,它们可构建一个极小的容器镜像(用于测试)。

什么是容器镜像?

我看过有关容器镜像的 最好定义 是:

镜像存在无需容器,但容器需要运行镜像才能存在。

这似乎有点绕,但描述的非常准确。容器镜像是包含应用代码的运算对象,通过容器运行时(如 Dockerrktpodman)来“运行”。Kubernetes 是最流行的容器编排系统,但如果您正在进行本地开发,则会用到上面提到的其他三个工具。

镜像定义部署应用的方式——例如,要公开哪些端口、应用运行时启动(或入口点)等。

为什么镜像越小越好?

较小的容器镜像有三个主要优点:

  • 缩短应用的构建时间。构建时间不仅包括容器构建时间,而且还包括将容器推送到仓库的时间。
  • 内存占用量更小。镜像越小,最终使用的内存就越少。如果使用公共云提供商,这可能不是一个问题;但如果使用笔记本电脑进行开发,这肯定会是一个问题。
  • 更小的攻击面和更少的依赖项(尤其在容器不使用基础镜像的情况下)。由于镜像内的无关库、依赖项和其他内容更少,安全面就更小,占用空间也更小。

较小的容器镜像包含的组件通常较少。这意味着镜像中的非应用代码数量会减少。通常容器镜像中最大数量的“非应用”代码来自于共享库。共享库是一系列软件,用于实施已被(或可能被)多个应用用到的功能。使用共享库意味着无需为每个新应用重复编码相同的功能。一般情况下,共享库是一件好事,因为它们可以将共享代码外部化,因此可以使应用二进制文件变得更小。

在容器中运行单个应用时,无需共享库。毕竟,没有其他应用可以共享代码!

共享库会占用空间,需将其生命周期作为构建过程的一部分单独进行管理。共享库通常随操作系统一起提供,并与使用它们的应用完全分开进行维护。

如果在容器中运行,则无需进行这一常见的分离工作。不分离共享库意味着,作为一名开发人员,您只需包含运行应用所需的组件,仅此而已。在容器环境中,这意味着没有共享库。

缩小镜像大小的工具

传统的容器镜像构建方法是包含并使用预构建的操作系统。如果使用更常用的基础镜像之一,在 Ubuntu 里就会看到类似下面的列表(为了便于阅读,此处分为多行):

# podman imagesREPOSITORY                                     TAG     IMAGE ID     ...
docker.io/library/ubuntu                       latest  9873176a8ff5 ...
docker.io/library/fedora                       latest  055b2e5ebc94 ...
registry.fedoraproject.org/f33/fedora-toolbox  latest  af1f279fed20 ...
    
     ... CREATED       SIZE
     ... 3 weeks ago   75.1 MB
     ... 7 weeks ago   184 MB
     ... 6 months ago  351 MB

正如您所看到的,镜像大小的差异较大:从 Ubuntu 仅 75 MB 到整个 fedora-toolbox镜像的 351 MB。每次运行其中一个镜像时,启动和加载都需要花费时间,这还不包括重新编译应用,并将镜像推送到仓库所花费的构建时间。

减少镜像大小有两种常见的选择:Alpine Linux 和 Red Hat 通用基础镜像 (UBI)。

Alpine Linux 基于 C 标准库 (libc) 的 musl 实现和 BusyBox,后者是一个最小内核,但仍包含大量工具。使用 musl-libc 意味着,您必须重新编译应用代码才能使用 Alpine Linux,如果您无法访问应用源代码就不行了。

来自 Red Hat 的 UBI 采用的是另一种方法。UBI 是一组标准化容器镜像,包含一组供开发人员使用的运行时。

使用 Alpine Linux 开发的镜像通常更小,在本列表中仅有 just 5.87 MB

# podman imagesREPOSITORY                                    TAG     IMAGE ID     ...
registry.access.redhat.com/ubi8/ubi           latest  8215cb84fa58 ...
registry.access.redhat.com/ubi8/ubi-minimal   latest  3f32499d4f3a ...
docker.io/library/alpine                      latest  d4ff818577bc ...
    
     ... CREATED      SIZE
     ... 2 weeks ago  234 MB
     ... 2 weeks ago  105 MB
     ... 3 weeks ago  5.87 MB

Alpine 和 UBI 解决的是同一问题(如何构建更小的镜像?),但从不同的起点着手。Alpine 从一个极小的代码库开始,仅添加所需的工具。而 UBI 则从一个更大的操作系统开始,并将其精简到最基本的部分。

使用最小镜像构建

构建最小镜像之前,您当然需要一个应用。在本文中,我用 C 编写了以下简单应用(事实上,我为一位同事写过此应用,他想要部署数千个容器来进行 Istio 测试,因此需要一个微型容器镜像)。

也许最值得注意的是,它实际上什么都没做!它仅调用了 pause()函数并等待信号。

# more pausle.c#include <unistd.h>
int main(void) {return pause(); }

作为一个示例,这似乎是一个很奇怪的应用,但它恰好能够很好地说明我有关镜像大小的观点。由于应用很小,所以它对容器的大小只有非常小的影响。

一般情况下,我会运行此 gcc 命令来编译应用,并进行 几个优化

# gcc -Os -fdata-sections -ffunction-sections -fipa-pta -W1,--gc-sections -W1,-O1 -W1,--as-needed -W1,--strip-all pausle.c -o pausle-dynamic

结果是一个非常小的二进制文件 (仅有 15 KB)

# ls -lh pausle-dynamic
-rwxr-xr-x. 1 root root 15K Jul 22 22:00 pausle-dynamic

此应用采用动态链接。也就是说,它需要操作系统上的共享库才能运行。这一点可以通过运行 ldd 命令来检查。

# ldd pausle-dynamic        linux-vdso.so.1 (0x00007fffafbe3000)
        libc.so.6 => /lib64/libc.so.6 (0x00007fb193983000)
        /lib64/ld-linux-x86-64.so.2 (0x00007fb193b5d000)

为了将其构建到容器中,需要使用包含共享库的基础镜像。我使用了 podman 和 Dockerfile 来进行构建,因为它们的使用非常广泛。

# more DockerfileFROM registry.access.redhat.com/ubi8/ubi-minimal

ADD pausle-dynamic /
CMD ["/pausle-dynamic"]

我使用了 RedHat UBI 最小镜像(步骤 1),并添加了我自己预编译的应用,让容器在开始后运行它(步骤 3

# podman build --tag=pausle-dynamic .STEP 1: FROM registry.access.redhat.com/ubi8/ubi-minimal
STEP 2: ADD pausle-dynamic /
--> 344589591c7
STEP 3: CMD ["/pausle-dynamic"]
STEP 4: COMMIT pausle-dynamic
--> 1f72538cf84
1f72538cf84c10ae525e545fb5596840f09d277eccaffae46f6b6a3815339c8b

Podman 镜像命令显示新镜像的大小 (105 MB) 没有超过 ubi-minimal 基础镜像,因为我的应用仅增加了 adds 15 KB

# podman imagesREPOSITORY                                   TAG     IMAGE ID     ...
localhost/pausle-dynamic                     latest  1f72538cf84c ...
registry.access.redhat.com/ubi8/ubi-minimal  latest  3f32499d4f3a ...
docker.io/library/alpine                     latest  d4ff818577bc ...
    
     ... CREATED             SIZE
     ... About a minute ago  105 MB
     ... 4 weeks ago         105 MB
     ... 5 weeks ago         5.87 MB

运行此镜像时,可以看到它是可用的并且正在运行。

# podman run -d pausle-dynamic5905d1ae4dc00b37f47ae4dlef4c7d99d8d5e1bd781da3b1decc436b10f5663b
# podman ps -a
CONTAINER ID  IMAGE                              COMMAND       ...
5905d1ae4dc0  localhost/pausle-dynamic:latest  /pausle-dynamic ...
     ... CREATED        STATUS
     ... 4 seconds ago  Up 4 seconds ago

可以在新容器内执行 shell,查看我的二进制文件:

# podman exec -it 5905d1ae4dc0 /bin/bash# ldd pausle-dynamic
        linux-vdso.so.1 (0x00007ffd89762000)
        libc.so.6 => /lib64/libc.so.6 (0x00007fa65a658000)
        /lib64/ld-linux-x86-64.so.2 (0x00007fa65aa1d000)

该应用与我编译并构建到容器中的应用相同。因为它是动态链接的,所以需要在操作系统中运行共享库。这意味着,我的应用需要包含这些库的基础镜像才能运行。这增加了容器的大小,所以它仍然不是我想要的最小值。

无镜像构建

可以做两件事来使这个容器镜像小得多。可以静态链接我的代码,意味着我的应用将共享库“捆绑”到二进制文件中。

运行此命令来静态链接:

# gcc -Os -s static -ffunction-sections -fipa-pta -W1,--gc-sections pausle.c -o pausle-dynamic# strip pausle-static
# ls-lh pausle-static
-rwxr-xr-x. 1 root root 697K Jul 22 22:31 pausle-static

ls 命令显示由此生成的应用二进制文件大小为 697 KB ,比动态链接的应用大很多,原因是库被捆绑到应用中。

现在,如果运行 ldd 命令来显示共享库,将会收到消息,提示可执行文件不是动态的。

# ldd pausle-static        not a dynamic executable

在 Dockerfile 中,使用特殊 no-op 关键字 scratch 来表明没有使用基础镜像。

# more DockerfileFROM scratch

ADD pausle-static /
CMD ["/pausle-static"]

现在,运行 podman build 命令,

#  按与之前相同的方式构建镜像:STEP 1: FROM scratch
STEP 2: ADD pausle-static /
--> 7fb16e85314
STEP 3: CMD ["/pausle-static"]
STEP 4: COMMIT pausle-static
--> f7f7c833975
f7f7c83397545aef51e0ad665def03040d5a06adf50651133184864bd1adaed4

容器的构建方式与动态可执行文件完全相同,但生成的镜像比动态镜像小得多 – 只有 716 KB, 仅比静态编译的二进制文件本身 (697 KB)大一点。

# podman imagesREPOSITORY                                     TAG     IMAGE ID     ...
localhost/pausle-static                        latest  f7f7c8339754 ...
localhost/pausle-dynamic                       latest  1f72538cf84c ...
    
     ... CREATED         SIZE
     ... 2 minutes ago   716 kB
     ... 20 minutes ago  105 MB

初始化容器并确认它正在运行:

# podman run -d pausle-static1748d59be199a797aa01f569b10445787d3f40439fb0b404b22e4226f4f44e09
# podman ps -a
CONTAINER ID   IMAGE                           COMMAND        ...
1748d59be199  localhost/pausle-static:latest  /pausle-static ...
     ... CREATED        STATUS
     ... 5 seconds ago  Up 6 seconds ago

如果尝试在容器内执行 shell 或运行 ls 命令,将会收到错误消息,提示容器中没有其他应用。这是因为容器不包含操作系统或基础镜像。

# podman exec -it 1748d59be199 /bin/bashError: executable file `/bin/bash` not found in $PATH: No such file or directory: OCI not found
# podman exec -it 1748d59be199 /bin/ls
Error: executable file `/bin/ls` not found in $PATH: No such file or directory: OCI not found

结语

构建小型容器镜像在各种场景中都很有用,例如开发和测试。它可显著缩短新镜像的构建时间,包括将镜像推送到远程仓库所花费的时间。

如前所述,较小的容器镜像(尤其是在不使用基础镜像的情况下)也具有更小的攻击面和更少的依赖项,减少了镜像内无关库、依赖项和其他内容的占用空间。而且在大多数情况下,创建小镜像会带来一种整洁匀称的感觉,这让它成为一件非常酷的事情!


"This blog post may reference products that are no longer available and/or no longer supported. For the most current information about available F5 NGINX products and solutions, explore our NGINX product family. NGINX is now part of F5. All previous NGINX.com links will redirect to similar NGINX content on F5.com."