構建理想容器映象——以CSI為例

阿里妹導讀
本文圍繞阿里雲CSI(Container Storage Interface)映象構建的實際案例,探討了一系列最佳化容器映象的最佳實踐。
何為理想映象
當前容器服務在元件中心已對外提供了數十個元件,每個元件都有一個或多個映象,我們團隊可謂每個人都需要日常和容器映象打交道。然而,我們卻還尚沒有如何構建容器映象的最佳實踐方案,各個元件的構建方法也是五花八門。容器映象作為我們最終交付的構建產物,只要功能能跑起來,我們就滿足了嗎?作為一個專業的容器服務團隊,顯然不應是這樣的,我們還有很多非功能性的需求。
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的工具鏈在這方面提供了非常便利的支援。
他山之石
那麼,針對上述目標,其他國際大廠是怎麼做的呢?本文對Google和AWS開源CSI映象構建方案進行了調查。
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:gcpls gcp/blobs/sha256 | wc -l
  • SBOM不友好,單獨複製二進位制檔案會導致SBOM掃描軟體無法獲知它的來源和版本。
syftoci-dir:gcp
AWS CSI
AWS EBS的CSI外掛[4]為例,這個倉庫的Dockerfile中僅有複製Golang的二進位制這一個步驟,其基礎映象的構建是在另一個專門構建映象的專案[5]中。這個專案很複雜,他承擔了AWS很多元件的基礎映象的構建。這個倉庫總體是基於Buildkit和Dockerfile的,它提供了一個統一的介面,各個元件僅需宣告自己直接依賴的二進位制,相關指令碼就會自動從AWS的AL2 Linux發行版中選擇所需的rpm軟體包,並透過yum –installroot 命令直接安裝到最終映象中。由於是使用包管理安裝的,相關元資料也能得以保留,並可被用於SBOM掃描。但傳統包管理的依賴管理粒度還是稍大,導致安裝完成後還需要一些自定義的清理操作(例如刪除systemd相關軟體),略微增加了複雜性。但總體來說,它的構建結果還是非常理想的。
distroless基礎映象
那麼作為Kubernetes社群幾乎所有社群釋出的映象的基礎映象,distroless本身又是怎麼構建的呢?它使用的是Google的構建工具bazel,甚至在構建時並不依賴任何容器技術,而是直接生成組成映象的各個tar包,並自行生成容器映象的元資料。例如,對於libc和OpenSSL,它基於debian的軟體包,對其deb包中的檔案和控制檔案進行一些變換,最終得到容器映象層使用的軟體包。這麼做確實感覺很理想,在利用現有軟體包的同時,也可對容器內的內容精確控制,包括可以保留deb包中的元資料以供SBOM掃描;同時各個層之間相互獨立,可重現性強。但咱們內部之前從來沒有使用bazel構建的專案,專門為了容器映象搞一個估計也不值得,所以僅僅是參考一下他們的思路。
我的方案
那麼,說了這麼多,我打算把阿里雲的CSI映象構建改造成啥樣呢?除了上述理想映象的特性,我們基於容器服務chorus的buildkit相關基礎設施,構建流程也應該是基於Dockerfile的;此外,CSI是開源軟體,我也希望我們CSI的構建流程能和其他大廠一樣,保持開源並可被任意人審計和復現。歡迎大家Review我的PR build: rewrite dockerfile to distroless #1098[6]
1. 切換為distroless基礎映象
之前我們是基於alinux3的基礎映象,然後直接使用dnf安裝我們所需的軟體包。這麼做有幾個弊端:
  • alinux3映象更新頻次較低,我們需要在映象裡執行dnf upgrade以獲取安全修復。這導致了最終映象大小的不可預期,取決於映象釋出後有多少更新可用。
  • alinux3基礎映象中本身就有很多咱們用不到的軟體包。由於分層結構,就算手動刪除也無法降低映象的大小。
為此,我決定直接切換到受到社群廣泛歡迎的distroless映象。同時debain系交叉編譯C的軟體也更加方便,如下文所述。
2. 自動收集依賴,deb包元資料
CSI需要執行很多作業系統相關的操作,傳統上這些操作都是直接使用命令完成的,相關命令並沒有使用Go語言重寫。因此,CSI仍然需要使用很多C語言編寫的二進位制。這些指令碼我使用了和Google CSI類似的方法,在一個更完整的debian映象中安裝,但隻手動指定直接依賴,使用指令碼收集所有傳遞依賴,類似AWS CSI。以下附上我使用的指令碼,該指令碼將所有所需檔案收集到/staging-node目錄中:
#!/bin/bashset -e# This directory is distroless specific, and recognized by syftmkdir -p /staging-node/var/lib/dpkg/status.dDEPS=( /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_PACKAGESfor line in $(dpkg-query --show --showformat 'PKG_NAME:${Package}\n${db-fsys:Files}'); doif [[ "$line" = PKG_NAME:* ]]; then pkg=${line#PKG_NAME:}else FILE_PACKAGES[$line]=$pkgfidoneecho"indexed ${#FILE_PACKAGES[@]} files"MTIME=0gather_dep() {localsource target t pkg copyright# resolve all but last component of symlinksource=$(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 distrolesssource="${source#/usr}" pkg=${FILE_PACKAGES[$source]}fiif [ -z "$pkg" ]; thenecho"failed to find package for $source"return 1fiif [ -e "/base$source" ]; thenecho"$source already exist in base"return 0fi [ -e "/staging-node$source" ] && return 0if [ -h "$source" ]; then target=$(realpath "$source")echo"gathering link $source => $target" gather_dep "$target"fiecho"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 0echo"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" ]; thenecho"installing deb package $pkg copyright" cp -dp --parents "$copyright" /staging-nodefi}for f in"${DEPS[@]}"; doif ! [ -e "$f" ]; thenecho"$f does not exist"continuefi gather_dep "$f"donemapfile -t LIBS < <(ldd /staging-node/{usr/,}{bin,sbin}/* 2>/dev/null | grep -Po '(?<= => )[^ ]+' | sort -u)for f in"${LIBS[@]}"; do gather_dep "$f"doneecho"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. 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
4. C語言交叉編譯
blkid這個程式新版本有整合一些我們想要的最佳化,但Debian最新版本還沒有整合,於是我們選擇了自己編譯並打包到映象裡。之前使用Alinux3的時候,我們使用的是qemu指令集模擬編譯的,編譯效率比較低。但得益於Debian打包的交叉編譯器,及其Multiarch設計,交叉編譯C的程式變得更簡單了。(blkid甚至沒啥二進位制依賴,不需要安裝其他架構的包)
以下這段Dockerfile可以安裝任意構建/目標架構的組合的gcc編譯器,交叉編譯或非交叉編譯通用!
FROM build-0 as build-util-linux-amd64ENV HOST=x86_64-linux-gnuFROM build-0 as build-util-linux-arm64ENV HOST=aarch64-linux-gnuFROM build-util-linux-$TARGETARCH as build-util-linuxARG BUILDARCHRUN --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/bashapt-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.xzRUN mkdir -p /src && tar -C /src --strip-components=1 -xf /src.tar.xzRUN <<EOFset -ecd /srcSOURCE_DATE_EPOCH=$(stat -c %Y /src.tar.xz)export SOURCE_DATE_EPOCHecho"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=$HOSTmake -jmake install-strip DESTDIR=/outcd /out/usr/local && rm -r include share lib/pkgconfigEOF
5. 快取友好的Go編譯
go build 命令已經封裝了編譯器的實際呼叫,它也已經集成了構建快取功能,所以我們如果直接在本地構建,只改一點程式碼的話,是非常快的。然而容器映象構建時,通常都是重新構建,並不會用到上次構建的快取。這時候就要用到RUN –mount=type=cache 這個buildkit相比docker build的新功能了,它可以將之前構建時產生的資料帶到下一次構建。對於Go語言的構建來說,這是我實踐出來的好用的方法:
FROM--platform=$BUILDPLATFORM golang:1.22.3 as buildWORKDIR/go/src/github.com/kubernetes-sigs/alibaba-cloud-csi-driverARGTARGETARCHARGTARGETOSRUN--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目錄即可自動複用快取,設定相關環境變數即可交叉編譯,非常簡單。
6. 快取友好的apt包安裝
在映象構建中,apt軟體包的安裝有時會佔據大量時間。尤其是在國內,訪問部分源的速度非常感人。為此,十分有必要儘量避免重複的下載操作:
FROM debian:bookworm-20241016-slim as debianARG TARGETARCHRUN --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檔案,均無需重複下載。
7. Controller和Node共用映象層
CSI同時運行於中心側和節點側,分別負責不同部分的工作,但它們使用的Go二進位制是一樣的。然而,節點側需要額外的二進位制以執行和作業系統相關的操作,但中心側卻因為安全需求,最好不要帶上它們。之前的方案是維護多份dockerfile檔案。那我這次也順便將它們合二為一,節點側直接在中心側的映象的基礎上,再加幾層來存放這些額外的二進位制。這樣不但減少了重複程式碼,降低維護成本,由於層複用,也能節省構建,儲存和拉取相關映象的成本,特別是當兩者運行於同一個節點上時。
最佳化效果
那麼,讓我們來看看上述最佳化的效果吧。
映象大小
最直觀的比較就是映象大小了,讓我們將當前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.tarls -lh csi.tar
解壓後大小:
podman pull $IMAGEpodman images $IMAGE
SBOM掃描
可以驗證,所有deb包均能被正常識別。除了在映象中自己編譯的blkid外,其餘所有檔案均能正常和deb包關聯。新增引數–select-catalogers "+sbom-cataloger" 後,syft也能讀取到我手動在映象中寫入到blkid到SBOM。
構建速度
最常見的情況下,修改單行Go原始碼重新構建映象大概僅需要8秒,其中go build 約4秒。體驗可以說是很不錯了。
修改依賴的apt包時,經過驗證也僅需要下載新增依賴的包。BTW,可以修改buildkit的配置,提升一些快取的大小,可以減少需要重新編譯/下載的次數。
結語
願本文能助你向客戶交付更加理想的容器映象。提升使用者體驗,避免安全問題,體現專業水平,獲取使用者信任。
參考連結:
[1]https://github.com/GoogleContainerTools/distroless
[2]https://reproducible-builds.org/
[3]https://github.com/kubernetes-sigs/gcp-compute-persistent-disk-csi-driver/blob/master/Dockerfile
[4]https://github.com/kubernetes-sigs/aws-ebs-csi-driver/blob/master/Dockerfile
[5]https://github.com/aws/eks-distro-build-tooling/blob/main/eks-distro-base/Dockerfile.minimal-base-csi-ebs
[6]https://github.com/kubernetes-sigs/alibaba-cloud-csi-driver/pull/1098
[7]https://snapshot.debian.org/
多媒體資料儲存與分發
多媒體資料儲存與分發解決方案融合物件儲存 OSS、內容分發 CDN 、智慧媒體管理 IMM 等產品能力,解決客戶多媒體資料儲存、處理、加速、分發等業務問題,進而實現低成本、高穩定性的業務目標。本技術解決方案以搭建一個多媒體資料儲存與分發服務為例,搭建一個多媒體資料儲存與分發服務。
點選閱讀原文檢視詳情。

相關文章