新聞中心
五種優(yōu)化 linux 容器大小和構(gòu)建更小的鏡像的方法。

Docker 近幾年的爆炸性發(fā)展讓大家逐漸了解到容器和容器鏡像的概念。盡管 Linux 容器技術(shù)在很早之前就已經(jīng)出現(xiàn),但這項(xiàng)技術(shù)近來的蓬勃發(fā)展卻還是要?dú)w功于 Docker 對(duì)用戶友好的命令行界面以及使用 Dockerfile 格式輕松構(gòu)建鏡像的方式??v然 Docker 大大降低了入門容器技術(shù)的難度,但構(gòu)建一個(gè)兼具功能強(qiáng)大、體積小巧的容器鏡像的過程中,有很多技巧需要了解。
第一步:清理不必要的文件
這一步和在普通服務(wù)器上清理文件沒有太大的區(qū)別,而且要清理得更加仔細(xì)。一個(gè)小體積的容器鏡像在傳輸方面有很大的優(yōu)勢,同時(shí),在磁盤上存儲(chǔ)不必要的數(shù)據(jù)的多個(gè)副本也是對(duì)資源的一種浪費(fèi)。因此,這些技術(shù)對(duì)于容器來說應(yīng)該比有大量專用內(nèi)存的服務(wù)器更加需要。
清理容器鏡像中的緩存文件可以有效縮小鏡像體積。下面的對(duì)比是使用 dnf 安裝 Nginx 構(gòu)建的鏡像,分別是清理和沒有清理 yum 緩存文件的結(jié)果:
# Dockerfile with cache
FROM fedora:28
LABEL maintainer Chris Collins
RUN dnf install -y nginx
-----
# Dockerfile w/o cache
FROM fedora:28
LABEL maintainer Chris Collins
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 1
cache: 464 MB
[chris@krang] $ docker build -t no-cache -f Dockerfile-wo-cache .
[chris@krang] $ docker images --format "{{.Repository}}: {{.Size}}" | head -n 1
no-cache: 271 MB
從上面的結(jié)果來看,清理緩存文件的效果相當(dāng)顯著。和清除了元數(shù)據(jù)和緩存文件的容器鏡像相比,不清除的鏡像體積接近前者的兩倍。除此以外,包管理器緩存文件、Ruby gem 的臨時(shí)文件、nodejs 緩存文件,甚至是下載的源碼 tarball 最好都全部清理掉。
層:一個(gè)潛在的隱患
很不幸(當(dāng)你往下讀,你會(huì)發(fā)現(xiàn)這是不幸中的萬幸),根據(jù)容器中的層的概念,不能簡單地向 Dockerfile 中寫一句 RUN rm -rf /var/cache/yum 就完事兒了。因?yàn)?Dockerfile 的每一條命令都以一個(gè)層的形式存儲(chǔ),并一層層地疊加。所以,如果你是這樣寫的:
RUN dnf install -y nginx
RUN dnf clean all
RUN rm -rf /var/cache/yum
你的容器鏡像就會(huì)包含三層,而 RUN dnf install -y nginx 這一層仍然會(huì)保留著那些緩存文件,然后在另外兩層中被移除。但緩存實(shí)際上仍然是存在的,當(dāng)你把一個(gè)文件系統(tǒng)掛載在另外一個(gè)文件系統(tǒng)之上時(shí),文件仍然在那里,只不過你見不到也訪問不到它們而已。
在上一節(jié)的示例中,你會(huì)看到正確的做法是將幾條命令鏈接起來,在產(chǎn)生緩存文件的同一條 Dockerfile 指令里把緩存文件清理掉:
RUN dnf install -y nginx \
&& dnf clean all \
&& rm -rf /var/cache/yum
這樣就把幾條命令連成了一條命令,在最終的鏡像中只占用一個(gè)層。這樣只會(huì)浪費(fèi)一點(diǎn)緩存的好處,稍微多耗費(fèi)一點(diǎn)點(diǎn)構(gòu)建容器鏡像的時(shí)間,但被清理掉的緩存文件就不會(huì)留存在最終的鏡像中了。作為一個(gè)折衷方法,只需要把一些相關(guān)的命令(例如 yum install 和 yum clean all、下載文件、解壓文件、移除 tarball 等等)連接成一個(gè)命令,就可以在最終的容器鏡像中節(jié)省出大量體積,你也能夠利用 Docker 的緩存加快開發(fā)速度。
層還有一個(gè)更隱蔽的特性。每一層都記錄了文件的更改,這里的更改并不僅僅已有的文件累加起來,而是包括文件屬性在內(nèi)的所有更改。因此即使是對(duì)文件使用了 chmod 操作也會(huì)被在新的層創(chuàng)建文件的副本。
下面是一次 docker images 命令的輸出內(nèi)容。其中容器鏡像 layer_test_1 是在 CentOS 基礎(chǔ)鏡像中增加了一個(gè) 1GB 大小的文件后構(gòu)建出來的鏡像,而容器鏡像 layer_test_2 是使用了 FROM layer_test_1 語句創(chuàng)建出來的,除了執(zhí)行一條 chmod u+x 命令沒有做任何改變。
layer_test_2 latest e11b5e58e2fc 7 seconds ago 2.35 GB
layer_test_1 latest 6eca792a4ebe 2 minutes ago 1.27 GB
如你所見,layer_test_2 鏡像比 layer_test_1 鏡像大了 1GB 以上。盡管事實(shí)上 layer_test_1 只是 layer_test_2 的前一層,但隱藏在這第二層中有一個(gè)額外的 1GB 的文件。在構(gòu)建容器鏡像的過程中,如果在單獨(dú)一層中進(jìn)行移動(dòng)、更改、刪除文件,都會(huì)出現(xiàn)類似的結(jié)果。
專用鏡像和公用鏡像
有這么一個(gè)親身經(jīng)歷:我們部門重度依賴于 Ruby on Rails,于是我們開始使用容器。一開始我們就建立了一個(gè)正式的 Ruby 的基礎(chǔ)鏡像供所有的團(tuán)隊(duì)使用,為了簡單起見(以及在“這就是我們自己在服務(wù)器上瞎鼓搗的”想法的指導(dǎo)下),我們使用 rbenv 將 Ruby 最新的 4 個(gè)版本都安裝到了這個(gè)鏡像當(dāng)中,目的是讓開發(fā)人員只用這個(gè)單一的鏡像就可以將使用不同版本 Ruby 的應(yīng)用程序遷移到容器中。我們當(dāng)時(shí)還認(rèn)為這是一個(gè)雖然非常大但兼容性相當(dāng)好的鏡像,因?yàn)檫@個(gè)鏡像可以同時(shí)滿足各個(gè)團(tuán)隊(duì)的使用。
實(shí)際上這是費(fèi)力不討好的。如果維護(hù)獨(dú)立的、版本略微不同的鏡像中,可以很輕松地實(shí)現(xiàn)鏡像的自動(dòng)化維護(hù)。同時(shí),選擇特定版本的特定鏡像,還有助于在引入破壞性改變,在應(yīng)用程序接近生命周期結(jié)束前提前做好預(yù)防措施,以免產(chǎn)生不可控的后果。龐大的公用鏡像也會(huì)對(duì)資源造成浪費(fèi),當(dāng)我們后來將這個(gè)龐大的鏡像按照 Ruby 版本進(jìn)行拆分之后,我們最終得到了共享一個(gè)基礎(chǔ)鏡像的多個(gè)鏡像,如果它們都放在一個(gè)服務(wù)器上,會(huì)額外多占用一點(diǎn)空間,但是要比安裝了多個(gè)版本的巨型鏡像要小得多。
這個(gè)例子也不是說構(gòu)建一個(gè)靈活的鏡像是沒用的,但僅對(duì)于這個(gè)例子來說,從一個(gè)公共鏡像創(chuàng)建根據(jù)用途而構(gòu)建的鏡像最終將節(jié)省存儲(chǔ)資源和維護(hù)成本,而在受益于公共基礎(chǔ)鏡像的好處的同時(shí),每個(gè)團(tuán)隊(duì)也能夠根據(jù)需要來做定制化的配置。
從零開始:將你需要的內(nèi)容添加到空白鏡像中
有一些和 Dockerfile 一樣易用的工具可以輕松創(chuàng)建非常小的兼容 Docker 的容器鏡像,這些鏡像甚至不需要包含一個(gè)完整的操作系統(tǒng),就可以像標(biāo)準(zhǔn)的 Docker 基礎(chǔ)鏡像一樣小。
我曾經(jīng)寫過一篇關(guān)于 Buildah 的文章,我想在這里再一次推薦一下這個(gè)工具。因?yàn)樗銐虻撵`活,可以使用宿主機(jī)上的工具來操作一個(gè)空白鏡像并安裝打包好的應(yīng)用程序,而且這些工具不會(huì)被包含到鏡像當(dāng)中。
Buildah 取代了 docker build 命令??梢允褂?Buildah 將容器的文件系統(tǒng)掛載到宿主機(jī)上并進(jìn)行交互。
下面來使用 Buildah 實(shí)現(xiàn)上文中 Nginx 的例子(現(xiàn)在忽略了緩存的處理):
#!/usr/bin/env bash
set -o errexit
# Create a container
container=$(buildah from scratch)
# Mount the container filesystem
mountpoint=$(buildah mount $container)
# Install a basic filesystem and minimal set of packages, and nginx
dnf install --installroot $mountpoint --releasever 28 glibc-minimal-langpack nginx --setopt install_weak_deps=false -y
# Save the container to an image
buildah commit --format docker $container nginx
# Cleanup
buildah unmount $container
# Push the image to the Docker daemon’s storage
buildah push nginx:latest docker-daemon:nginx:latest
你會(huì)發(fā)現(xiàn)這里使用的已經(jīng)不再是 Dockerfile 了,而是普通的 Bash 腳本,而且是從框架(或空白)鏡像開始構(gòu)建的。上面這段 Bash 腳本將容器的根文件系統(tǒng)掛載到了宿主機(jī)上,然后使用宿主機(jī)的命令來安裝應(yīng)用程序,這樣的話就不需要把軟件包管理器放置到容器鏡像中了。
這樣所有無關(guān)的內(nèi)容(基礎(chǔ)鏡像之外的部分,例如 dnf)就不再會(huì)包含在鏡像中了。在這個(gè)例子當(dāng)中,構(gòu)建出來的鏡像大小只有 304 MB,比使用 Dockerfile 構(gòu)建的鏡像減少了 100 MB 以上。
[chris@krang] $ docker images |grep nginx
docker.io/nginx buildah 2505d3597457 4 minutes ago 304 MB
注:這個(gè)鏡像是使用上面的構(gòu)建腳本構(gòu)建的,鏡像名稱中前綴的 docker.io 只是在推送到鏡像倉庫時(shí)加上的。
對(duì)于一個(gè) 300MB 級(jí)別的容器基礎(chǔ)鏡像來說,能縮小 100MB 已經(jīng)是很顯著的節(jié)省了。使用軟件包管理器來安裝 Nginx 會(huì)帶來大量的依賴項(xiàng),如果能夠使用宿主機(jī)直接從源代碼對(duì)應(yīng)用程序進(jìn)行編譯然后構(gòu)建到容器鏡像中,節(jié)省出來的空間還可以更多,因?yàn)檫@個(gè)時(shí)候可以精細(xì)的選用必要的依賴項(xiàng),非必要的依賴項(xiàng)一概不構(gòu)建到鏡像中。
Tom Sweeney 有一篇文章《用 Buildah 構(gòu)建更小的容器》,如果你想在這方面做深入的優(yōu)化,不妨參考一下。
通過 Buildah 可以構(gòu)建一個(gè)不包含完整操作系統(tǒng)和代碼編譯工具的容器鏡像,大幅縮減了容器鏡像的體積。對(duì)于某些類型的鏡像,我們可以進(jìn)一步采用這種方式,創(chuàng)建一個(gè)只包含應(yīng)用程序本身的鏡像。
使用靜態(tài)鏈接的二進(jìn)制文件來構(gòu)建鏡像
按照這個(gè)思路,我們甚至可以更進(jìn)一步舍棄容器內(nèi)部的管理和構(gòu)建工具。例如,如果我們足夠?qū)I(yè),不需要在容器中進(jìn)行排錯(cuò)調(diào)試,是不是可以不要 Bash 了?是不是可以不要 GNU 核心套件了?是不是可以不要 Linux 基礎(chǔ)文件系統(tǒng)了?如果你使用的編譯型語言支持靜態(tài)鏈接庫,將應(yīng)用程序所需要的所有庫和函數(shù)都編譯成二進(jìn)制文件,那么程序所需要的函數(shù)和庫都可以復(fù)制和存儲(chǔ)在二進(jìn)制文件本身里面。
這種做法在 Golang 社區(qū)中已經(jīng)十分常見,下面我們使用由 Go 語言編寫的應(yīng)用程序進(jìn)行展示:
以下這個(gè) Dockerfile 基于 golang:1.8 鏡像構(gòu)建一個(gè)小的 Hello World 應(yīng)用程序鏡像:
FROM golang:1.8
ENV GOOS=linux
ENV appdir=/go/src/gohelloworld
COPY ./ /go/src/goHelloWorld
WORKDIR /go/src/goHelloWorld
RUN go get
RUN go build -o /goHelloWorld -a
CMD ["/goHelloWorld"]
構(gòu)建出來的鏡像中包含了二進(jìn)制文件、源代碼以及基礎(chǔ)鏡像層,一共 716MB。但對(duì)于應(yīng)用程序運(yùn)行唯一必要的只有編譯后的二進(jìn)制文件,其余內(nèi)容在鏡像中都是多余的。
如果在編譯的時(shí)候通過指定參數(shù) CGO_ENABLED=0 來禁用 cgo,就可以在編譯二進(jìn)制文件的時(shí)候忽略某些函數(shù)的 C 語言庫:
GOOS=linux CGO_ENABLED=0 go build -a goHelloWorld.go
編譯出來的二進(jìn)制文件可以加到一個(gè)空白(或框架)鏡像:
FROM scratch
COPY goHelloWorld /
CMD ["/goHelloWorld"]
來看一下兩次構(gòu)建的鏡像對(duì)比:
[ chris@krang ] $ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
goHello scratch a5881650d6e9 13 seconds ago 1.55 MB
goHello builder 980290a100db 14 seconds ago 716 MB
從鏡像體積來說簡直是天差地別了?;?golang:1.8 鏡像構(gòu)建出來帶有 goHelloWorld 二進(jìn)制的鏡像(帶有 builder 標(biāo)簽)體積是基于空白鏡像構(gòu)建的只包含該二進(jìn)制文件的鏡像的 460 倍!后者的整個(gè)鏡像大小只有 1.55MB,也就是說,有 713MB 的數(shù)據(jù)都是非必要的。
正如上面提到的,這種縮減鏡像體積的方式在 Golang 社區(qū)非常流行,因此不乏這方面的文章。Kelsey Hightower 有一篇文章專門介紹了如何處理這些庫的依賴關(guān)系。
壓縮鏡像層
除了前面幾節(jié)中講到的將多個(gè)命令鏈接成一個(gè)命令的技巧,還可以對(duì)鏡像進(jìn)行壓縮。鏡像壓縮的實(shí)質(zhì)是導(dǎo)出它,刪除掉鏡像構(gòu)建過程中的所有中間層,然后保存鏡像的當(dāng)前狀態(tài)為單個(gè)鏡像層。這樣可以進(jìn)一步將鏡像縮小到更小的體積。
在 Docker 1.13 之前,壓縮鏡像層的的過程可能比較麻煩,需要用到 docker-squash 之類的工具來導(dǎo)出容器的內(nèi)容并重新導(dǎo)入成一個(gè)單層的鏡像。但 Docker 在 Docker 1.13 中引入了 --squash 參數(shù),可以在構(gòu)建過程中實(shí)現(xiàn)同樣的功能:
FROM fedora:28
LABEL maintainer Chris Collins
RUN dnf install -y nginx
RUN dnf clean all
RUN rm -rf /var/cache/yum
[chris@krang] $ docker build -t squash -f Dockerfile-squash --squash .
[chris@krang] $ docker images --format "{{.Repository}}: {{.Size}}" | head -n 1
squash: 271 MB
通過這種方式使用 Dockerfile 構(gòu)建出來的鏡像有 271MB 大小,和上面連接多條命令的方案構(gòu)建出來的鏡像體積一樣,因此這個(gè)方案也是有效的,但也有一個(gè)潛在的問題,而且是另一種問題。
“什么?還有另外的問題?”
好吧,有點(diǎn)像以前一樣的問題,以另一種方式引發(fā)了問題。
過頭了:過度壓縮、太小太專用了
容器鏡像之間可以共享鏡像層?;A(chǔ)鏡像或許大小上有幾 Mb,但它只需要拉取/存儲(chǔ)一次,并且每個(gè)鏡像都能復(fù)用它。所有共享基礎(chǔ)鏡像的實(shí)際鏡像大小是基礎(chǔ)鏡像層加上每個(gè)特定改變的層的差異內(nèi)容,因此,如果有數(shù)千個(gè)基于同一個(gè)基礎(chǔ)鏡像的容器鏡像,其體積之和也有可能只比一個(gè)基礎(chǔ)鏡像大不了多少。
因此,這就是過度使用壓縮或?qū)S苗R像層的缺點(diǎn)。將不同鏡像壓縮成單個(gè)鏡像層,各個(gè)容器鏡像之間就沒有可以共享的鏡像層了,每個(gè)容器鏡像都會(huì)占有單獨(dú)的體積。如果你只需要維護(hù)少數(shù)幾個(gè)容器鏡像來運(yùn)行很多容器,這個(gè)問題可以忽略不計(jì);但如果你要維護(hù)的容器鏡像很多,從長遠(yuǎn)來看,就會(huì)耗費(fèi)大量的存儲(chǔ)空間。
回顧上面 Nginx 壓縮的例子,我們能看出來這種情況并不是什么大的問題。在這個(gè)鏡像中,有 Fedora 操作系統(tǒng)和 Nginx 應(yīng)用程序,沒有緩存,并且已經(jīng)被壓縮。但我們一般不會(huì)使用一個(gè)原始的 Nginx,而是會(huì)修改配置文件,以及引入其它代碼或應(yīng)用程序來配合 Nginx 使用,而要做到這些,Dockerfile 就變得更加復(fù)雜了。
如果使用普通的鏡像構(gòu)建方式,構(gòu)建出來的容器鏡像就會(huì)帶有 Fedora 操作系統(tǒng)的鏡像層、一個(gè)安裝了 Nginx 的鏡像層(帶或不帶緩存)、為 Nginx 作自定義配置的其它多個(gè)鏡像層,而如果有其它容器鏡像需要用到 Fedora 或者 Nginx,就可以復(fù)用這個(gè)容器鏡像的前兩層。
[ App 1 Layer ( 5 MB) ] [ App 2 Layer (6 MB) ]
[ Nginx Layer ( 21 MB) ] ------------------^
[ Fedora Layer (249 MB) ]
如果使用壓縮鏡像層的構(gòu)建方式,F(xiàn)edora 操作系統(tǒng)會(huì)和 Nginx 以及其它配置內(nèi)容都被壓縮到同一層里面,如果有其它容器鏡像需要使用到 Fedora,就必須重新引入 Fedora 基礎(chǔ)鏡像,這樣每個(gè)容器鏡像都會(huì)額外增加 249MB 的大小。
[ Fedora + Nginx + App 1 (275 MB)] [ Fedora + Nginx + App 2 (276 MB) ]
當(dāng)你構(gòu)建了大量在功能上趨于分化的的小型容器鏡像時(shí),這個(gè)問題就會(huì)暴露出來了。
就像生活中的每一件事一樣,關(guān)鍵是要做到適度。根據(jù)鏡像層的實(shí)現(xiàn)原理,如果一個(gè)容器鏡像變得越小、越專用化,就越難和其它容器鏡像共享基礎(chǔ)的鏡像層,這樣反而帶來不好的效果。
對(duì)于僅在基礎(chǔ)鏡像上做微小變動(dòng)構(gòu)建出來的多個(gè)容器鏡像,可以考慮共享基礎(chǔ)鏡像層。如上所述,一個(gè)鏡像層本身會(huì)帶有一定的體積,但只要存在于鏡像倉庫中,就可以被其它容器鏡像復(fù)用。這種情況下,數(shù)千個(gè)鏡像也許要比單個(gè)鏡像占用更少的空間。
[ specific app ] [ specific app 2 ]
[ customizations ]--------------^
[ base layer ]
一個(gè)容器鏡像變得越小、越專用化,就越難和其它容器鏡像共享基礎(chǔ)的鏡像層,最終會(huì)不必要地占用越來越多的存儲(chǔ)空間。
[ specific app 1 ] [ specific app 2 ] [ specific app 3 ]
總結(jié)
減少處理容器鏡像時(shí)所需的存儲(chǔ)空間和帶寬的方法有很多,其中最直接的方法就是減小容器鏡像本身的大小。在使用容器的過程中,要經(jīng)常留意容器鏡像是否體積過大,根據(jù)不同的情況采用上述提到的清理緩存、壓縮到一層、將二進(jìn)制文件加入在空白鏡像中等不同的方法,將容器鏡像的體積縮減到一個(gè)有效的大小。
新聞標(biāo)題:如何打造更小巧的容器鏡像
標(biāo)題URL:http://m.fisionsoft.com.cn/article/cciigip.html


咨詢
建站咨詢
