构建理想容器镜像——以CSI为例

简介: 本文围绕阿里云CSI(Container Storage Interface)镜像构建的实际案例,探讨了一系列优化容器镜像的最佳实践。

欢迎扫描文末二维码,关注「阿里云开发者」公众号,了解更多技术干货,关于阿里的技术创新均呈现于此。

1. 何为理想镜像


当前容器服务在组件中心已对外提供了数十个组件,每个组件都有一个或多个镜像,我们团队可谓每个人都需要日常和容器镜像打交道。然而,我们却还尚没有如何构建容器镜像的最佳实践方案,各个组件的构建方法也是五花八门。容器镜像作为我们最终交付的构建产物,只要功能能跑起来,我们就满足了吗?作为一个专业的容器服务团队,显然不应是这样的,我们还有很多非功能性的需求。


1.镜像大小:在满足功能需求的前提下,镜像的存储空间占用应当尽量减小。这可以加速镜像使用的各个环节,从镜像的构建,数十个Region间的同步,并最终分发到数万用户的无数节点上,都能因此提速。CSI尤其如此,在新加入的节点上,挂载有相关存储卷的Pod必须等CSI的Pod拉起后才能拉起,因此CSI的镜像大小将直接影响到客户弹性的效率。


2.精简无关软件:这是减小镜像大小的重要方式之一。同时,这也能提升镜像扫描工具(如安全漏洞CVE)的信噪比。这也是Google提出distroless基础镜像[1]的初衷。


3.SBOM收集:SBOM中结构化地记录了容器镜像中所使用的所有软件包,并能构建索引。当新的CVE曝光时,SBOM能帮我们快速从海量的镜像历史版本中找到受其影响的版本,并对应处置。


4.可重现构建:想必部分同学听说了近期也有字节员工在checkpoint中下毒,干扰训练过程的新闻。可重现构建意味着整个从源码到最终制品(容器镜像)的过程应该是完全确定性的。任何人都可以较容易地独立地复现整个构建过程,并得到完全一致的制品,从而验证构建流程没有被入侵。详情可见[2]。Go语言的编译器也在近期达成了完全可重现构建的目标。使用Go语言编写的应用也受益于此,较容易达成可重现的结果。


5.构建速度:在调试的过程中,可能会需要频繁地多次构建镜像。镜像构建速度快对开发体验的帮助是很大的。这主要可以通过两方面来提高:


a.交叉编译:Buildkit自带通过qemu模拟其他指令集来编译软件的功能,但效率是非常低的。若能使用工具链的交叉编译功能,例如,在amd64的机器上直接执行amd64原生的编译器,但调整参数使其输出arm64的二进制,可大大提升构建速度。

b.充分利用缓存:每次编译的内容通常和上次构建都不会相差太多。若能只构建变化过的内容则可大幅提升构建速度。Go的工具链在这方面提供了非常便利的支持。


2. 他山之石

那么,针对上述目标,其他国际大厂是怎么做的呢?本文对Google和AWS开源CSI镜像构建方案进行了调查。


2.1 Google CSI

GCE PD的CSI插件[3]为例,它在Dockerfile中,从gcr.io/distroless/base-debian12基础镜像开始,逐个复制CSI的Golang二进制及其依赖的其他二进制和所有动态库。虽然他基本达成了精简无关软件的目标,但个人认为,这个镜像构建的流程有几个明显的缺点:

  • 在手工维护的源文件中写入所有传递依赖的列表实在不是啥高明之举。虽然可以依赖CI以检查是否有误,但还是需要多轮迭代以修复错误。
  • 每个二进制分别拷贝到不同镜像层中。虽然在更新时,更多层意味着更多复用的可能性,但过多的层可能导致拉取时网络请求数量过多,挂载时overlayfs的层数过多,这都有潜在的性能影响。以当前最新版registry.k8s.io/cloud-provider-gcp/gcp-compute-persistent-disk-csi-driver:v1.15.1为例,拉取时需要下载多达45个blob。


skopeo copy --override-os linux docker://registry.k8s.io/cloud-provider-gcp/gcp-compute-persistent-disk-csi-driver:v1.15.1 oci:gcp
ls gcp/blobs/sha256 | wc -l


  • SBOM不友好,单独复制二进制文件会导致SBOM扫描软件无法获知它的来源和版本。


syft oci-dir:gcp


2.2 AWS CSI

AWS EBS的CSI插件[4]为例,这个仓库的Dockerfile中仅有复制Golang的二进制这一个步骤,其基础镜像的构建是在另一个专门构建镜像的项目[5]中。这个项目很复杂,他承担了AWS很多组件的基础镜像的构建。这个仓库总体是基于Buildkit和Dockerfile的,它提供了一个统一的接口,各个组件仅需声明自己直接依赖的二进制,相关脚本就会自动从AWS的AL2 Linux发行版中选择所需的rpm软件包,并通过yum --installroot 命令直接安装到最终镜像中。由于是使用包管理安装的,相关元数据也能得以保留,并可被用于SBOM扫描。但传统包管理的依赖管理粒度还是稍大,导致安装完成后还需要一些自定义的清理操作(例如删除systemd相关软件),略微增加了复杂性。但总体来说,它的构建结果还是非常理想的。


2.3 distroless基础镜像

那么作为Kubernetes社区几乎所有社区发布的镜像的基础镜像,distroless本身又是怎么构建的呢?它使用的是Google的构建工具bazel,甚至在构建时并不依赖任何容器技术,而是直接生成组成镜像的各个tar包,并自行生成容器镜像的元数据。例如,对于libc和OpenSSL,它基于debian的软件包,对其deb包中的文件和控制文件进行一些变换,最终得到容器镜像层使用的软件包。这么做确实感觉很理想,在利用现有软件包的同时,也可对容器内的内容精确控制,包括可以保留deb包中的元数据以供SBOM扫描;同时各个层之间相互独立,可重现性强。但咱们内部之前从来没有使用bazel构建的项目,专门为了容器镜像搞一个估计也不值得,所以仅仅是参考一下他们的思路。


3. 我的方案

那么,说了这么多,我打算把阿里云的CSI镜像构建改造成啥样呢?除了上述理想镜像的特性,我们基于容器服务chorus的buildkit相关基础设施,构建流程也应该是基于Dockerfile的;此外,CSI是开源软件,我也希望我们CSI的构建流程能和其他大厂一样,保持开源并可被任意人审计和复现。欢迎大家Review我的PR build: rewrite dockerfile to distroless #1098[6]


3.1 切换为distroless基础镜像

之前我们是基于alinux3的基础镜像,然后直接使用dnf安装我们所需的软件包。这么做有几个弊端:

  • alinux3镜像更新频次较低,我们需要在镜像里运行dnf upgrade以获取安全修复。这导致了最终镜像大小的不可预期,取决于镜像发布后有多少更新可用。
  • alinux3基础镜像中本身就有很多咱们用不到的软件包。由于分层结构,就算手动删除也无法降低镜像的大小。

为此,我决定直接切换到受到社区广泛欢迎的distroless镜像。同时debain系交叉编译C的软件也更加方便,如下文所述。


3.2 自动收集依赖,deb包元数据

CSI需要执行很多操作系统相关的操作,传统上这些操作都是直接使用命令完成的,相关命令并没有使用Go语言重写。因此,CSI仍然需要使用很多C语言编写的二进制。这些脚本我使用了和Google CSI类似的方法,在一个更完整的debian镜像中安装,但只手动指定直接依赖,使用脚本收集所有传递依赖,类似AWS CSI。以下附上我使用的脚本,该脚本将所有所需文件收集到/staging-node目录中:


#!/bin/bash
set -e

# This directory is distroless specific, and recognized by syft
mkdir -p /staging-node/var/lib/dpkg/status.d

DEPS=(
    /etc/mke2fs.conf /sbin/{fsck,mkfs,mount,umount}.{ext{2,3,4},xfs,nfs}
    /usr/bin/{mount,umount,lspci,mkdir,chmod,grep,tail,nsenter}
    /usr/sbin/{fsck,mkfs,sfdisk,losetup,blockdev}
    /sbin/dumpe2fs /sbin/resize2fs
    /usr/sbin/xfs_io /usr/sbin/xfs_growfs
)

declare -A FILE_PACKAGES
for line in $(dpkg-query --show --showformat 'PKG_NAME:${Package}\n${db-fsys:Files}'); do
    if [[ "$line" = PKG_NAME:* ]]; then
        pkg=${line#PKG_NAME:}
    else
        FILE_PACKAGES[$line]=$pkg
    fi
done
echo "indexed ${#FILE_PACKAGES[@]} files"

MTIME=0
gather_dep() {
    local source target t pkg copyright
    # resolve all but last component of symlink
    source=$(realpath "$(dirname "$1")")/$(basename "$1")

    # find the package that contains the source
    pkg=${FILE_PACKAGES[$source]}
    if [ -z "$pkg" ] && [[ "$source" = /usr/* ]]; then
        # retry without /usr prefix
        # use source path matching dpkg for SBOM to work, because /lib is not linked to /usr/lib in distroless
        source="${source#/usr}"
        pkg=${FILE_PACKAGES[$source]}
    fi
    if [ -z "$pkg" ]; then
        echo "failed to find package for $source"
        return 1
    fi
    if [ -e "/base$source" ]; then
        echo "$source already exist in base"
        return 0
    fi

    [ -e "/staging-node$source" ] && return 0
    if [ -h "$source" ]; then
        target=$(realpath "$source")
        echo "gathering link $source => $target"
        gather_dep "$target"
    fi
    echo "gathering dep $pkg: $source"
    t=$(stat -c '%Y' "$source")
    [ "$t" -gt "$MTIME" ] && MTIME=$t
    cp -dp --parents "$source" /staging-node

    # deb package metadata is useful for SBOM
    [ -e "/staging-node/var/lib/dpkg/status.d/$pkg" ] && return 0
    echo "installing deb package $pkg metadata"
    dpkg-query --status "$pkg" > "/staging-node/var/lib/dpkg/status.d/$pkg"
    dpkg-query --control-show "$pkg" md5sums > "/staging-node/var/lib/dpkg/status.d/$pkg.md5sums"
    copyright="/usr/share/doc/$pkg/copyright"
    if [ -e "$copyright" ]; then
        echo "installing deb package $pkg copyright"
        cp -dp --parents "$copyright" /staging-node
    fi
}
for f in "${DEPS[@]}"; do
    if ! [ -e "$f" ]; then
        echo "$f does not exist"
        continue
    fi
    gather_dep "$f"
done

mapfile -t LIBS < <(ldd /staging-node/{usr/,}{bin,sbin}/* 2>/dev/null | grep -Po '(?<= => )[^ ]+' | sort -u)
for f in "${LIBS[@]}"; do
    gather_dep "$f"
done
echo "latest mtime is $(date --date "@$MTIME" --iso-8601=seconds)"
find /staging-node -type d -exec touch --date="@$MTIME" {} +
touch --date="@$MTIME" /staging-node/var/lib/dpkg/status.d/*


这其中有几个坑点:


  • debain镜像中,/lib是到/usr/lib目录的符号链接,bin,sbin目录也是同理。但distroless中并不是这样,他们都是相互独立的目录。那么,划归一下如何呢:
  • 所有文件都拷贝到/usr下的目录里,反正他们都在默认path里。确实能用,但由于deb的元数据中可能记录的是/bin里的路径,导致SBOM无法正确将文件和软件包关联起来。
  • 那么,在distroless镜像里,也创建相关符号链接如何呢?确实能修复SBOM的问题,能关联上了。但distroless的基础镜像里/lib和/usr/lib里都是有文件的,如果在其中一个地方创建符号链接,那么这个链接在overlayfs挂载后就会隐藏那个目录下原来的文件,运行时相关库就加载不了。


我的解决方法是:复制前在deb的数据库中搜索这个文件,并只使用deb记录的标准化的路径执行所有后续操作。


  • 使用deb-query命令收集deb的相关元数据到/var/lib/dpkg/status.d目录,每个包使用不同的文件。这个目录看起来是distroless特有的,debian默认是将所有包的数据都写入/var/lib/dpkg/status这一个文件里。但这样就做不到不同软件分别独立在不同镜像层里了。神奇的是,syft这个SBOM扫描的工具还特意支持了这个目录。(这就是受社区影响力啊)
  • 识别依赖的时候,我并没有像AWS那样,使用软件包声明的依赖,而是使用ldd命令直接查找所有依赖的动态库。如此可以真正最小化依赖,绝无一个多余的文件,也同时节省了事后清理的步骤。诚然,这样有漏掉使用dlopen之类的方法动态依赖的库,或者其他配置/数据文件的风险,但大多二进制都已经有Google的长期验证,并且CSI现在也有较为完善的回归测试,风险可控。
  • 为了可重现构建考虑,不能将构建当时的时间保留在文件的inode上。为此,我在复制文件时保留了原有的时间戳,同时,将所有动态创建的目录和文件的修改时间设置为所有deb包中的文件修改时间的最大值。


3.3 deb包下载版本的可重现

通常我们在Dockerfile中安装依赖时,都直接apt-get update && apt-get install xxx 这样的话,apt会获取构建当时最新版本的软件包。为了实现可重现构建,可以依赖[7]提供的服务,获取当时的版本,以确保未来构建时也可以安装完全相同的软件包。debian镜像中的debian.sources文件中的注释已写明了构建镜像时使用的snapshot。可以直接复用这个。


echo 'Acquire::Check-Valid-Until false;' > /etc/apt/apt.conf.d/snapshot && \
sed -i '/^URIs:/d; s|^# \(http://snapshot.debian.org/\)|URIs: \1|' /etc/apt/sources.list.d/debian.sources && \
apt-get update


3.4 C语言交叉编译

blkid这个程序新版本有集成一些我们想要的优化,但Debian最新版本还没有集成,于是我们选择了自己编译并打包到镜像里。之前使用Alinux3的时候,我们使用的是qemu指令集模拟编译的,编译效率比较低。但得益于Debian打包的交叉编译器,及其Multiarch设计,交叉编译C的程序变得更简单了。(blkid甚至没啥二进制依赖,不需要安装其他架构的包)

以下这段Dockerfile可以安装任意构建/目标架构的组合的gcc编译器,交叉编译或非交叉编译通用!


FROM build-0 as build-util-linux-amd64
ENV HOST=x86_64-linux-gnu

FROM build-0 as build-util-linux-arm64
ENV HOST=aarch64-linux-gnu

FROM build-util-linux-$TARGETARCH as build-util-linux
ARG BUILDARCH
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-cache-$BUILDARCH \
    --mount=type=cache,target=/var/lib/apt,sharing=locked,id=apt-lib-$BUILDARCH <<EOF
#!/bin/bash
apt-get update && apt-get install -y gcc-${HOST//_/-}
EOF


以下也附上我们编译blkid使用的脚本,注意我们只给./configure加了一个--host=$HOST参数就完成了交叉编译的支持!


ADD --link --checksum=sha256:59e676aa53ccb44b6c39f0ffe01a8fa274891c91bef1474752fad92461def24f \
    https://www.kernel.org/pub/linux/utils/util-linux/v2.40/util-linux-2.40.1.tar.xz /src.tar.xz
RUN mkdir -p /src && tar -C /src --strip-components=1 -xf /src.tar.xz

RUN <<EOF
set -e
cd /src
SOURCE_DATE_EPOCH=$(stat -c %Y /src.tar.xz)
export SOURCE_DATE_EPOCH
echo "util-linux released at $(date --date "@$SOURCE_DATE_EPOCH" --iso-8601=seconds)"
./configure --disable-all-programs --enable-blkid --enable-libblkid --prefix=/usr/local \
    --disable-nls --disable-bash-completion --disable-asciidoc --disable-dependency-tracking --disable-static --host=$HOST
make -j
make install-strip DESTDIR=/out
cd /out/usr/local && rm -r include share lib/pkgconfig
EOF


3.5 缓存友好的Go编译

go build 命令已经封装了编译器的实际调用,它也已经集成了构建缓存功能,所以我们如果直接在本地构建,只改一点代码的话,是非常快的。然而容器镜像构建时,通常都是重新构建,并不会用到上次构建的缓存。这时候就要用到RUN --mount=type=cache 这个buildkit相比docker build的新功能了,它可以将之前构建时产生的数据带到下一次构建。对于Go语言的构建来说,这是我实践出来的好用的方法:


FROM --platform=$BUILDPLATFORM golang:1.22.3 as build
WORKDIR /go/src/github.com/kubernetes-sigs/alibaba-cloud-csi-driver
ARG TARGETARCH
ARG TARGETOS
RUN --mount=type=bind,target=. \
    --mount=type=cache,target=/root/.cache/go-build \
    GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 \
    go build -o /out/plugin.csi.alibabacloud.com


只要挂载了/root/.cache/go-build目录即可自动复用缓存,设置相关环境变量即可交叉编译,非常简单。


3.6 缓存友好的apt包安装

在镜像构建中,apt软件包的安装有时会占据大量时间。尤其是在国内,访问部分源的速度非常感人。为此,十分有必要尽量避免重复的下载操作:


FROM debian:bookworm-20241016-slim as debian

ARG TARGETARCH
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-cache-$TARGETARCH \
    --mount=type=cache,target=/var/lib/apt,sharing=locked,id=apt-lib-$TARGETARCH \
    rm -f /etc/apt/apt.conf.d/docker-clean && \
    echo 'Acquire::Check-Valid-Until false;' > /etc/apt/apt.conf.d/snapshot && \
    sed -i '/^URIs:/d; s|^# \(http://snapshot.debian.org/\)|URIs: \1|' /etc/apt/sources.list.d/debian.sources && \
    apt-get update && \
    apt-get install -y nfs-common e2fsprogs xfsprogs pciutils fdisk


这就是我安装软件包的方法,无论是软件包列表,还是实际deb文件,均无需重复下载。


3.7 Controller和Node共用镜像层

CSI同时运行于中心侧和节点侧,分别负责不同部分的工作,但它们使用的Go二进制是一样的。然而,节点侧需要额外的二进制以执行和操作系统相关的操作,但中心侧却因为安全需求,最好不要带上它们。之前的方案是维护多份dockerfile文件。那我这次也顺便将它们合二为一,节点侧直接在中心侧的镜像的基础上,再加几层来存放这些额外的二进制。这样不但减少了重复代码,降低维护成本,由于层复用,也能节省构建,存储和拉取相关镜像的成本,特别是当两者运行于同一个节点上时。


4. 优化效果

那么,让我们来看看上述优化的效果吧。


4.1 镜像大小

最直观的比较就是镜像大小了,让我们将当前CSI发布的最新版本与优化后的版本的主镜像进行对比:


镜像

下载大小

解压后大小

registry-cn-hangzhou.ack.aliyuncs.com/acs/csi-plugin:v1.31.1-e749bf2-aliyun

240M

581MB

registry-cn-hangzhou.ack.aliyuncs.com/acs/csi-plugin:v1.31.2-0b2ccf6-aliyun (最新发布版本)

125M

412MB

registry-cn-hangzhou.ack.aliyuncs.com/test-public/csi-plugin:v1.5.0-187-gbac52000 (优化后)

46M

142MB


可见,镜像大小有非常明显的下降,下载大小仅有最新版的37%。同时也验证了之前所说,使用alinux基础镜像会导致镜像大小不可预期,例如最近两个版本的下载大小相差了约1倍。


注:测试镜像均为arm64架构,因为是在arm架构的Macbook上测试的。arm64的镜像通常比amd64的略微小一点。

测试方法:

下载大小:


skopeo copy --override-os linux docker://$IMAGE oci-archive:csi.tar
ls -lh csi.tar

解压后大小:


podman pull $IMAGE
podman images $IMAGE


4.2 SBOM扫描

可以验证,所有deb包均能被正常识别。除了在镜像中自己编译的blkid外,其余所有文件均能正常和deb包关联。添加参数--select-catalogers "+sbom-cataloger" 后,syft也能读取到我手动在镜像中写入到blkid到SBOM。


4.3 构建速度

最常见的情况下,修改单行Go源代码重新构建镜像大概仅需要8秒,其中go build 约4秒。体验可以说是很不错了。

修改依赖的apt包时,经过验证也仅需要下载新增依赖的包。BTW,可以修改buildkit的配置,提升一些缓存的大小,可以减少需要重新编译/下载的次数。


5. 结语

愿本文能助你向客户交付更加理想的容器镜像。提升用户体验,避免安全问题,体现专业水平,获取用户信任。


参考链接:

[1]参考一

[2]参考二

[3]参考三

[4]参考四

[5]参考五

[6]参考六

[7]参考七




来源  |  阿里云开发者公众号

作者  |  元昌




作者介绍
目录