本文是我们容器技术系列文章的一部分:
- Namespace 和 cgroup 简介及其工作原理
- 构建更小的容器镜像(本文)
容器在现代应用环境中无处不在。开发人员正在以多种方式使用它们:构建容器,将它们推送到仓库,然后让应用在容器中运行。
在本文中,我将会探讨有关容器镜像的内容,具体就是如何让容器镜像变得更小,以及这样做有何意义。同时,我将展示一些示例代码和命令,它们可构建一个极小的容器镜像(用于测试)。
什么是容器镜像?
我看过有关容器镜像的 最好定义 是:
镜像存在无需容器,但容器需要运行镜像才能存在。
这似乎有点绕,但描述的非常准确。容器镜像是包含应用代码的运算对象,通过容器运行时(如 Docker、rkt 和 podman)来“运行”。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
结语
构建小型容器镜像在各种场景中都很有用,例如开发和测试。它可显著缩短新镜像的构建时间,包括将镜像推送到远程仓库所花费的时间。
如前所述,较小的容器镜像(尤其是在不使用基础镜像的情况下)也具有更小的攻击面和更少的依赖项,减少了镜像内无关库、依赖项和其他内容的占用空间。而且在大多数情况下,创建小镜像会带来一种整洁匀称的感觉,这让它成为一件非常酷的事情!