如何打造更小巧的容器映象

2019-03-27 23:07:00

五種優化 Linux 容器大小和構建更小的映象的方法。

Docker 近幾年的爆炸性發展讓大家逐漸了解到容器和容器映象的概念。儘管 Linux 容器技術在很早之前就已經出現,但這項技術近來的蓬勃發展卻還是要歸功於 Docker 對使用者友好的命令列介面以及使用 Dockerfile 格式輕鬆構建映象的方式。縱然 Docker 大大降低了入門容器技術的難度,但構建一個兼具功能強大、體積小巧的容器映象的過程中,有很多技巧需要了解。

第一步:清理不必要的檔案

這一步和在普通伺服器上清理檔案沒有太大的區別,而且要清理得更加仔細。一個小體積的容器映象在傳輸方面有很大的優勢,同時,在磁碟上儲存不必要的資料的多個副本也是對資源的一種浪費。因此,這些技術對於容器來說應該比有大量專用記憶體的伺服器更加需要。

清理容器映象中的快取檔案可以有效縮小映象體積。下面的對比是使用 dnf 安裝 Nginx 構建的映象,分別是清理和沒有清理 yum 快取檔案的結果:

# Dockerfile with cacheFROM fedora:28LABEL maintainer Chris Collins <[email protected]>RUN dnf install -y nginx-----# Dockerfile w/o cacheFROM fedora:28LABEL maintainer Chris Collins <[email protected]>RUN dnf install -y nginx \        && dnf clean all \        && rm -rf /var/cache/yum-----[chris@krang] $ docker build -t cache -f Dockerfile .  [chris@krang] $ docker images --format "{{.Repository}}: {{.Size}}" | head -n 1cache: 464 MB[chris@krang] $ docker build -t no-cache -f Dockerfile-wo-cache .[chris@krang] $ docker images --format "{{.Repository}}: {{.Size}}"  | head -n 1no-cache: 271 MB

從上面的結果來看,清除快取檔案的效果相當顯著。和清除了後設資料和快取檔案的容器映象相比,不清除的映象體積接近前者的兩倍。除此以外,包管理器快取檔案、Ruby gem 的臨時檔案、nodejs 快取檔案,甚至是下載的原始碼 tarball 最好都全部清理掉。

層:一個潛在的隱患

很不幸(當你往下讀,你會發現這是不幸中的萬幸),根據容器中的層的概念,不能簡單地向 Dockerfile 中寫一句 RUN rm -rf /var/cache/yum 就完事兒了。因為 Dockerfile 的每一條命令都以一個層的形式儲存,並一層層地疊加。所以,如果你是這樣寫的:

RUN dnf install -y nginxRUN dnf clean allRUN rm -rf /var/cache/yum

你的容器映象就會包含三層,而 RUN dnf install -y nginx 這一層仍然會保留著那些快取檔案,然後在另外兩層中被移除。但快取實際上仍然是存在的,當你把一個檔案系統掛載在另外一個檔案系統之上時,檔案仍然在那裡,只不過你見不到也存取不到它們而已。

在上一節的範例中,你會看到正確的做法是將幾條命令連結起來,在產生快取檔案的同一條 Dockerfile 指令裡把快取檔案清理掉:

RUN dnf install -y nginx \        && dnf clean all \        && rm -rf /var/cache/yum

這樣就把幾條命令連成了一條命令,在最終的映象中只佔用一個層。這樣只會浪費一點快取的好處,稍微多耗費一點點構建容器映象的時間,但被清理掉的快取檔案就不會留存在最終的映象中了。作為一個折衷方法,只需要把一些相關的命令(例如 yum installyum clean all、下載檔案、解壓檔案、移除 tarball 等等)連線成一個命令,就可以在最終的容器映象中節省出大量體積,你也能夠利用 Docker 的快取加快開發速度。

層還有一個更隱蔽的特性。每一層都記錄了檔案的更改,這裡的更改並不僅僅已有的檔案累加起來,而是包括檔案屬性在內的所有更改。因此即使是對檔案使用了 chmod 操作也會被在新的層建立檔案的副本。

下面是一次 docker images 命令的輸出內容。其中容器映象 layer_test_1 是在 CentOS 基礎映象中增加了一個 1GB 大小的檔案後構建出來的映象,而容器映象 layer_test_2 是使用了 FROM layer_test_1 語句建立出來的,除了執行一條 chmod u+x 命令沒有做任何改變。

layer_test_2        latest       e11b5e58e2fc           7 seconds ago           2.35 GBlayer_test_1        latest       6eca792a4ebe           2 minutes ago           1.27 GB

如你所見,layer_test_2 映象比 layer_test_1 映象大了 1GB 以上。儘管事實上 layer_test_1 只是 layer_test_2 的前一層,但隱藏在這第二層中有一個額外的 1GB 的檔案。在構建容器映象的過程中,如果在單獨一層中進行移動、更改、刪除檔案,都會出現類似的結果。

專用映象和公用映象

有這麼一個親身經歷:我們部門重度依賴於 Ruby on Rails,於是我們開始使用容器。一開始我們就建立了一個正式的 Ruby 的基礎映象供所有的團隊使用,為了簡單起見(以及在“這就是我們自己在伺服器上瞎鼓搗的”想法的指導下),我們使用 rbenv 將 Ruby 最新的 4 個版本都安裝到了這個映象當中,目的是讓開發人員只用這個單一的映象就可以將使用不同版本 Ruby 的應用程式遷移到容器中。我們當時還認為這是一個雖然非常大但相容性相當好的映象,因為這個映象可以同時滿足各個團隊的使用。

實際上這是費力不討好的。如果維護獨立的、版本略微不同的映象中,可以很輕鬆地實現映象的自動化維護。同時,選擇特定版本的特定映象,還有助於在引入破壞性改變,在應用程式接近生命週期結束前提前做好預防措施,以免產生不可控的後果。龐大的公用映象也會對資源造成浪費,當我們後來將這個龐大的映象按照 Ruby 版本進行拆分之後,我們最終得到了共用一個基礎映象的多個映象,如果它們都放在一個伺服器上,會額外多佔用一點空間,但是要比安裝了多個版本的巨型映象要小得多。

這個例子也不是說構建一個靈活的映象是沒用的,但僅對於這個例子來說,從一個公共映象建立根據用途而構建的映象最終將節省儲存資源和維護成本,而在受益於公共基礎映象的好處的同時,每個團隊也能夠根據需要來做客製化化的設定。

從零開始:將你需要的內容新增到空白映象中

有一些和 Dockerfile 一樣易用的工具可以輕鬆建立非常小的相容 Docker 的容器映象,這些映象甚至不需要包含一個完整的作業系統,就可以像標準的 Docker 基礎映象一樣小。

我曾經寫過一篇關於 Buildah 的文章,我想在這裡再一次推薦一下這個工具。因為它足夠的靈活,可以使用宿主機上的工具來操作一個空白映象並安裝打包好的應用程式,而且這些工具不會被包含到映象當中。

Buildah 取代了 docker build 命令。可以使用 Buildah 將容器的檔案系統掛載到宿主機上並進行互動。

下面來使用 Buildah 實現上文中 Nginx 的例子(現在忽略了快取的處理):

#!/usr/bin/env bashset -o errexit# Create a containercontainer=$(buildah from scratch)# Mount the container filesystemmountpoint=$(buildah mount $container)# Install a basic filesystem and minimal set of packages, and nginxdnf install --installroot $mountpoint  --releasever 28 glibc-minimal-langpack nginx --setopt install_weak_deps=false -y# Save the container to an imagebuildah commit --format docker $container nginx# Cleanupbuildah unmount $container# Push the image to the Docker daemon’s storagebuildah push nginx:latest docker-daemon:nginx:latest

你會發現這裡使用的已經不再是 Dockerfile 了,而是普通的 Bash 指令碼,而且是從框架(或空白)映象開始構建的。上面這段 Bash 指令碼將容器的根檔案系統掛載到了宿主機上,然後使用宿主機的命令來安裝應用程式,這樣的話就不需要把軟體包管理器放置到容器映象中了。

這樣所有無關的內容(基礎映象之外的部分,例如 dnf)就不再會包含在映象中了。在這個例子當中,構建出來的映象大小只有 304 MB,比使用 Dockerfile 構建的映象減少了 100 MB 以上。

[chris@krang] $ docker images |grep nginxdocker.io/nginx      buildah      2505d3597457    4 minutes ago         304 MB

註:這個映象是使用上面的構建指令碼構建的,映象名稱中字首的 docker.io 只是在推播到映象倉庫時加上的。

對於一個 300MB 級別的容器基礎映象來說,能縮小 100MB 已經是很顯著的節省了。使用軟體包管理器來安裝 Nginx 會帶來大量的依賴項,如果能夠使用宿主機直接從原始碼對應用程式進行編譯然後構建到容器映象中,節省出來的空間還可以更多,因為這個時候可以精細的選用必要的依賴項,非必要的依賴項一概不構建到映象中。

Tom Sweeney 有一篇文章《用 Buildah 構建更小的容器》,如果你想在這方面做深入的優化,不妨參考一下。

通過 Buildah 可以構建一個不包含完整作業系統和程式碼編譯工具的容器映象,大幅縮減了容器映象的體積。對於某些型別的映象,我們可以進一步採用這種方式,建立一個只包含應用程式本身的映象。

使用靜態連結的二進位制檔案來構建映象

按照這個思路,我們甚至可以更進一步捨棄容器內部的管理和構建工具。例如,如果我們足夠專業,不需要在容器中進行排錯偵錯,是不是可以不要 Bash 了?是不是可以不要 GNU 核心套件了?是不是可以不要 Linux 基礎檔案系統了?如果你使用的編譯型語言支援靜態連結庫,將應用程式所需要的所有庫和函數都編譯成二進位制檔案,那麼程式所需要的函數和庫都可以複製和儲存在二進位制檔案本身裡面。

這種做法在 Golang 社群中已經十分常見,下面我們使用由 Go 語言編寫的應用程式進行展示:

以下這個 Dockerfile 基於 golang:1.8 映象構建一個小的 Hello World 應用程式映象:

FROM golang:1.8ENV GOOS=linuxENV appdir=/go/src/gohelloworldCOPY ./ /go/src/goHelloWorldWORKDIR /go/src/goHelloWorldRUN go getRUN go build -o /goHelloWorld -aCMD ["/goHelloWorld"]

構建出來的映象中包含了二進位制檔案、原始碼以及基礎映象層,一共 716MB。但對於應用程式執行唯一必要的只有編譯後的二進位制檔案,其餘內容在映象中都是多餘的。

如果在編譯的時候通過指定引數 CGO_ENABLED=0 來禁用 cgo,就可以在編譯二進位制檔案的時候忽略某些函數的 C 語言庫:

GOOS=linux CGO_ENABLED=0 go build -a goHelloWorld.go

編譯出來的二進位制檔案可以加到一個空白(或框架)映象:

FROM scratchCOPY goHelloWorld /CMD ["/goHelloWorld"]

來看一下兩次構建的映象對比:

[ chris@krang ] $ docker imagesREPOSITORY      TAG             IMAGE ID                CREATED                 SIZEgoHello     scratch     a5881650d6e9            13 seconds ago          1.55 MBgoHello     builder     980290a100db            14 seconds ago          716 MB

從映象體積來說簡直是天差地別了。基於 golang:1.8 映象構建出來帶有 goHelloWorld 二進位制的映象(帶有 builder 標籤)體積是基於空白映象構建的只包含該二進位制檔案的映象的 460 倍!後者的整個映象大小只有 1.55MB,也就是說,有 713MB 的資料都是非必要的。

正如上面提到的,這種縮減映象體積的方式在 Golang 社群非常流行,因此不乏這方面的文章。Kelsey Hightower 有一篇文章專門介紹了如何處理這些庫的依賴關係。

壓縮映象層

除了前面幾節中講到的將多個命令連結成一個命令的技巧,還可以對映象進行壓縮。映象壓縮的實質是匯出它,刪除掉映象構建過程中的所有中間層,然後儲存映象的當前狀態為單個映象層。這樣可以進一步將映象縮小到更小的體積。

在 Docker 1.13 之前,壓縮映象層的的過程可能比較麻煩,需要用到 docker-squash 之類的工具來匯出容器的內容並重新匯入成一個單層的映象。但 Docker 在 Docker 1.13 中引入了 --squash 引數,可以在構建過程中實現同樣的功能:

FROM fedora:28LABEL maintainer Chris Collins <[email protected]>RUN dnf install -y nginxRUN dnf clean allRUN rm -rf /var/cache/yum[chris@krang] $ docker build -t squash -f Dockerfile-squash --squash .[chris@krang] $ docker images --format "{{.Repository}}: {{.Size}}"  | head -n 1squash: 271 MB

通過這種方式使用 Dockerfile 構建出來的映象有 271MB 大小,和上面連線多條命令的方案構建出來的映象體積一樣,因此這個方案也是有效的,但也有一個潛在的問題,而且是另一種問題。

“什麼?還有另外的問題?”

好吧,有點像以前一樣的問題,以另一種方式引發了問題。

過頭了:過度壓縮、太小太專用了

容器映象之間可以共用映象層。基礎映象或許大小上有幾 Mb,但它只需要拉取/儲存一次,並且每個映象都能複用它。所有共用基礎映象的實際映象大小是基礎映象層加上每個特定改變的層的差異內容,因此,如果有數千個基於同一個基礎映象的容器映象,其體積之和也有可能只比一個基礎映象大不了多少。

因此,這就是過度使用壓縮或專用映象層的缺點。將不同映象壓縮成單個映象層,各個容器映象之間就沒有可以共用的映象層了,每個容器映象都會佔有單獨的體積。如果你只需要維護少數幾個容器映象來執行很多容器,這個問題可以忽略不計;但如果你要維護的容器映象很多,從長遠來看,就會耗費大量的儲存空間。

回顧上面 Nginx 壓縮的例子,我們能看出來這種情況並不是什麼大的問題。在這個映象中,有 Fedora 作業系統和 Nginx 應用程式,沒有快取,並且已經被壓縮。但我們一般不會使用一個原始的 Nginx,而是會修改組態檔,以及引入其它程式碼或應用程式來配合 Nginx 使用,而要做到這些,Dockerfile 就變得更加複雜了。

如果使用普通的映象構建方式,構建出來的容器映象就會帶有 Fedora 作業系統的映象層、一個安裝了 Nginx 的映象層(帶或不帶快取)、為 Nginx 作自定義設定的其它多個映象層,而如果有其它容器映象需要用到 Fedora 或者 Nginx,就可以複用這個容器映象的前兩層。

[   App 1 Layer (  5 MB) ]          [   App 2 Layer (6 MB) ][   Nginx Layer ( 21 MB) ] ------------------^[ Fedora  Layer (249 MB) ]  

如果使用壓縮映象層的構建方式,Fedora 作業系統會和 Nginx 以及其它設定內容都被壓縮到同一層裡面,如果有其它容器映象需要使用到 Fedora,就必須重新引入 Fedora 基礎映象,這樣每個容器映象都會額外增加 249MB 的大小。

[ Fedora + Nginx + App 1 (275 MB)]      [ Fedora + Nginx + App 2 (276 MB) ]  

當你構建了大量在功能上趨於分化的的小型容器映象時,這個問題就會暴露出來了。

就像生活中的每一件事一樣,關鍵是要做到適度。根據映象層的實現原理,如果一個容器映象變得越小、越專用化,就越難和其它容器映象共用基礎的映象層,這樣反而帶來不好的效果。

對於僅在基礎映象上做微小變動構建出來的多個容器映象,可以考慮共用基礎映象層。如上所述,一個映象層本身會帶有一定的體積,但只要存在於映象倉庫中,就可以被其它容器映象複用。這種情況下,數千個映象也許要比單個映象占用更少的空間。

[ specific app   ]      [ specific app 2 ][ customizations ]--------------^[ base layer     ]

一個容器映象變得越小、越專用化,就越難和其它容器映象共用基礎的映象層,最終會不必要地占用越來越多的儲存空間。

 [ specific app 1 ]     [ specific app 2 ]      [ specific app 3 ]

總結

減少處理容器映象時所需的儲存空間和頻寬的方法有很多,其中最直接的方法就是減小容器映象本身的大小。在使用容器的過程中,要經常留意容器映象是否體積過大,根據不同的情況採用上述提到的清除快取、壓縮到一層、將二進位制檔案加入在空白映象中等不同的方法,將容器映象的體積縮減到一個有效的大小。