NGINX.COM
Web Server Load Balancing with NGINX Plus

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

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

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

什么是容器镜像?

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

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

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

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

为什么镜像越小越好?

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

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

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

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

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

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

缩小镜像大小的工具

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

# podman images
REPOSITORY                                     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 images
REPOSITORY                                    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 Dockerfile
FROM 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 images
REPOSITORY                                   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-dynamic
5905d1ae4dc00b37f47ae4dlef4c7d99d8d5e1bd781da3b1decc436b10f5663b
# 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 Dockerfile
FROM 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 images
REPOSITORY                                     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-static
1748d59be199a797aa01f569b10445787d3f40439fb0b404b22e4226f4f44e09
# 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/bash
Error: 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

结语

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

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

Hero image
免费白皮书:
NGINX 企阅版全解析

助力企业用户规避开源治理风险,应对开源使用挑战

关于作者

Scott van Kalken

Solution Architect

关于 F5 NGINX

F5, Inc. 是备受欢迎的开源软件 NGINX 背后的商业公司。我们为现代应用的开发和交付提供一整套技术。我们的联合解决方案弥合了 NetOps 和 DevOps 之间的横沟,提供从代码到用户的多云应用服务。访问 nginx-cn.net 了解更多相关信息。