五種方式:構(gòu)建小巧Docker容器的學(xué)問
譯文【51CTO.com快譯】在本文中,我們將共同了解五種優(yōu)化Linux容器大小并構(gòu)建小巧鏡像的方法。
幾年之前,Docker的爆炸式發(fā)展將容器與容器鏡像概念引入了大眾視野。盡管之前已經(jīng)存在Linux容器,但Docker憑借著用戶友好的命令行界面以及易于理解的Dockerfile格式顯著降低了鏡像的構(gòu)建門檻。但必須承認(rèn)的是,盡管上手難度已經(jīng)有所下降,其中仍存在著一些細(xì)微的差別與技巧,能夠幫助我們構(gòu)建功能強(qiáng)大但卻體積小巧的容器鏡像。
第一關(guān):清理內(nèi)容
下面列舉的部分示例采取與傳統(tǒng)服務(wù)器類似的清理方式,只是具體要求更為嚴(yán)格。鏡像的體積對(duì)于快速移動(dòng)而言至關(guān)重要,而且在磁盤之上存儲(chǔ)多套不必要的數(shù)據(jù)副本無(wú)疑將浪費(fèi)大量資源。因此,我們有必要盡可能利用技術(shù)控制容器鏡像的“身材”。
下面來(lái)看如何從鏡像中刪除緩存文件,從而節(jié)約存儲(chǔ)空間。首先利用dnf以包含及不包含元數(shù)據(jù)的方式安裝Nginx,查看二者之間的鏡像大小區(qū)別; 而后利用yum進(jìn)行緩存清理:
- # Dockerfile with cache
- FROM fedora:28
- LABEL maintainer Chris Collins <collins.christopher@gmail.com>
- RUN dnf install -y nginx
- -----
- # Dockerfile w/o cache
- FROM fedora:28
- LABEL maintainer Chris Collins <collins.christopher@gmail.com>
- 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
可以看到,二者之間的體積存在顯著差異。包含dnf緩存的版本幾乎是不包含元數(shù)據(jù)及緩存的鏡像大小的兩倍。事實(shí)上,工具包管理器緩存、Ruby gem臨時(shí)文件、nodejs緩存、甚至是已下載的源代碼壓縮包都是清理工作的主要對(duì)象。
分層——一個(gè)潛在問題
遺憾的是(或者可以說(shuō)幸運(yùn)的是,具體如后文所述),由于容器以分層方式使用,因此大家無(wú)法簡(jiǎn)單將RUN rm -rf /var/cache/yum 添加到Dockerfile當(dāng)中并就此作罷。Dockerfile中的每條指令都存儲(chǔ)在一個(gè)層中,各層之間的變更最終應(yīng)用于頂層。所以即使您進(jìn)行如下操作:
- RUN dnf install -y nginx
- RUN dnf clean all
- RUN rm -rf /var/cache/yum
……最終仍會(huì)得到三層,其中一層包含所有緩存,兩個(gè)中間層則從鏡像中“移除”緩存。然而,緩存仍然實(shí)際存在,正如當(dāng)您將某一文件系統(tǒng)安裝在另一文件系統(tǒng)之上時(shí),文件就在這里——只是我們無(wú)法查看或者訪問。
需要注意的是,上一節(jié)中的示例將緩存清理鏈接到了生存緩存的同一Dockerfile指令當(dāng)中:
- RUN dnf install -y nginx \
- && dnf clean all \
- && rm -rf /var/cache/yum
這是一條單獨(dú)指令,最終會(huì)成為鏡像中的一層。通過這種方式,您會(huì)丟棄一部分Docker緩存——這意味著鏡像重構(gòu)時(shí)間會(huì)稍長(zhǎng),但緩存數(shù)據(jù)仍將出現(xiàn)在最終鏡像當(dāng)中。作為一種良好的折衷方案,我們只需鏈接相關(guān)命令(例如hum install與hum clean all,或者下載、釋放及移除源tarball等)即可幫助最終鏡像顯著瘦身,同時(shí)繼續(xù)利用Docker緩存加快開發(fā)速度。
然而,這里的層將比前文中提到的更加微妙。因?yàn)殓R像各層記錄了每個(gè)層的具體變化——因此除了添加的文件之外,一切文件修改都將被納入其中。例如,即使更改了文件模式,鏡像中也會(huì)有新層出現(xiàn)以創(chuàng)建該文件的副本。
舉例來(lái)說(shuō),以下docker images輸出結(jié)果顯示出與兩套鏡像相關(guān)的信息。第一套layer_test_1通過將單一1 GB文件添加至基礎(chǔ)CentOS鏡像的方式得出。第二套鏡像layer_test_2則直接由layer_test_1創(chuàng)建而來(lái),只是利用chmod u+x命令變更了該1 GB文件的模式。
- layer_test_2 latest e11b5e58e2fc 7 seconds ago 2.35 GB
- layer_test_1 latest 6eca792a4ebe 2 minutes ago 1.27 GB
如大家所見,新的鏡像較前一套鏡像大出1 GB有余。盡管layer_test_1 實(shí)際上只代表著layer_test_2的前兩層,但第二套鏡像中仍然隱藏著另一個(gè)1 GB的文件。在鏡像構(gòu)建過程當(dāng)中,一切與文件相關(guān)的刪除、移除或更改都會(huì)造成這樣的結(jié)果。
專用鏡像與靈活鏡像
一則軼事:當(dāng)初我們大量采用Ruby on Rails應(yīng)用程序時(shí),同事們開始慢慢接受容器這種新鮮事物。我們的第一項(xiàng)工作就是為所有團(tuán)隊(duì)創(chuàng)建一套官方的Ruby基礎(chǔ)鏡像。為了簡(jiǎn)單起見,我們利用rebenv將四套最新的Ruby版本安裝到了鏡像當(dāng)中,從而允許我們的開發(fā)人員能夠利用單一版本將所有應(yīng)用程序遷移到容器鏡像當(dāng)中。這實(shí)際上帶來(lái)了一套非常龐大但卻比較靈活(至少我們認(rèn)為)的鏡像,其中涵蓋我們各合作團(tuán)隊(duì)間的一切工作基礎(chǔ)。
但事實(shí)證明,這一切都是在浪費(fèi)時(shí)間。維護(hù)特定鏡像的單一修改版本能夠比較輕松地實(shí)現(xiàn)自動(dòng)化,這是因?yàn)闉樘囟ㄧR像選擇特定版本實(shí)際上有助于在引入突破性變更之前意識(shí)到原有應(yīng)用程序已經(jīng)不合適接下來(lái)的需求,從而避免由此發(fā)生嚴(yán)重破壞。此外,過大的鏡像也造成了資源浪費(fèi):當(dāng)我們對(duì)不同Ruby版本進(jìn)行拆分時(shí),我們最終得到了多套共享同一基礎(chǔ)的鏡像。如果將其同時(shí)保存在服務(wù)器之上,相較于包含多個(gè)版本的巨型鏡像,其占用的額外空間其實(shí)并不大,但傳輸速度卻要快得多。
這并不是說(shuō)構(gòu)建靈活性鏡像沒有意義。只是在我們的情況下,創(chuàng)建專用型鏡像最終節(jié)約了存儲(chǔ)空間與維護(hù)時(shí)間,同時(shí)也確保各團(tuán)隊(duì)在享受好處的同時(shí)能夠?qū)灿谢A(chǔ)鏡像做出必要的修改。
從頭開始:將需要的內(nèi)容添加至空白鏡像中
與Dockerfile的用戶友好與易用性類似,還有其他一些工具能夠以極為靈活的方式創(chuàng)建小巧的Docker兼容容器鏡像且無(wú)需完整的操作系統(tǒng)——其小巧程度甚至堪比標(biāo)準(zhǔn)Docker基礎(chǔ)鏡像。
我在之前曾經(jīng)寫過關(guān)于Buildah的文章,這里我也會(huì)再次提及,因?yàn)槠湎喈?dāng)靈活且可利用主機(jī)中的工具從零開始創(chuàng)建鏡像,同時(shí)安裝打包軟件并修改鏡像內(nèi)容。更重要的是,這些工具將永遠(yuǎn)存在于鏡像之外,因此不會(huì)增加鏡像本身的體積。
Buildah取代了docker build命令。有了它,您可以將容器鏡像的文件系統(tǒng)掛載至主機(jī)上,并利用主機(jī)中的工具與其進(jìn)行交互。
讓我們嘗試?yán)蒙厦娴腘ginx示例看看Biuldah的效果(這里暫時(shí)不管緩存):
- #!/usr/bin/env bash
- set -o errexit
- # 創(chuàng)建一個(gè)容器
- container=$(buildah from scratch)
- # 掛載容器文件系統(tǒng)
- mountpoint=$(buildah mount $container)
- # 安裝一個(gè)基礎(chǔ)文件系統(tǒng)與最低軟件包集,以及nginx
- dnf install --installroot $mountpoint --releasever 28 glibc-minimal-langpack nginx --setopt install_weak_deps=false -y
- # 將容器保存為鏡像
- buildah commit --format docker $container nginx
- # 清理
- buildah unmount $container
- # 將鏡像推著至Docker守護(hù)程序進(jìn)行存儲(chǔ)
- buildah push nginx:latest docker-daemon:nginx:latest
大家可能已經(jīng)注意到,這里我們不再使用Dockerfile構(gòu)建鏡像,而是使用簡(jiǎn)單的Bash腳本。我們利用一套從零創(chuàng)建(或空白)鏡像進(jìn)行構(gòu)建。該Bash腳本會(huì)將容器的root文件系統(tǒng)掛載至主機(jī)上的某個(gè)掛載點(diǎn),而后利用主機(jī)命令安裝各軟件包。通過這種方式,軟件包管理器甚至無(wú)需超出容器自身范圍。
如果沒有額外的部分——例如dnf等基礎(chǔ)鏡像中的額外內(nèi)容——那么鏡像本身的大小僅為304 MB,這一體積要比之前利用Dockerfile構(gòu)建的Nginx鏡像小上100多MB。
- [chris@krang] $ docker images |grep nginx
- docker.io/nginx buildah 2505d3597457 4 minutes ago 304 MB
注意:鏡像名稱中之所以包含docker.io部分,是因?yàn)槠浔煌扑椭罝ocker守護(hù)程序的命名空間,但其仍然是利用以上構(gòu)建腳本以本地方式構(gòu)建的鏡像。
考慮到基礎(chǔ)鏡像本身只有300 MB左右,100 MB的節(jié)約幅度顯然相當(dāng)驚人。利用軟件管理器安裝Nginx,也會(huì)帶來(lái)大量的依賴關(guān)系。如果使用由主機(jī)提供的工具進(jìn)行源代碼編譯的處理方式,由于您可以選擇確切的依賴關(guān)系而非引入任何不必要的額外文件,大家將能夠進(jìn)一步節(jié)約存儲(chǔ)空間。
利用Buildah構(gòu)建鏡像能夠有效擺脫完整操作系統(tǒng)以及構(gòu)建工具,從而進(jìn)一步壓縮您的鏡像體積。而對(duì)于某些特定類型的鏡像,我們還可以采取同樣的方法創(chuàng)建出僅包含應(yīng)用程序本身的鏡像。
僅使用靜態(tài)鏈接的二進(jìn)制文件創(chuàng)建鏡像
遵循相同的理念,我們可以進(jìn)一步將管理與構(gòu)建工具從鏡像中清理出去。如果我們擁有必要的專業(yè)知識(shí),且不再需要立足容器內(nèi)部進(jìn)行故障排查,那么我們是否可以棄用Bash?我們還需要GNU核心程序嗎?我們還需要基礎(chǔ)的Linux文件系統(tǒng)嗎?大家可以使用任何編譯語(yǔ)言執(zhí)行此項(xiàng)操作,即利用靜態(tài)鏈接庫(kù)創(chuàng)建二進(jìn)制文件——程序運(yùn)行所需要的一切庫(kù)及函數(shù)都將被復(fù)制并存儲(chǔ)在二進(jìn)制文件當(dāng)中。
這是一種在Golang社區(qū)中擁有一定人氣的處理方式,因此我們這里使用Go應(yīng)用程序進(jìn)行演示。以下是Dockerfile采用一個(gè)小巧的Go Hello-World應(yīng)用程序,并將其編譯在一套FROM golang:1.8鏡像當(dā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"]
最終得到的鏡像包含二進(jìn)制文件、源代碼以及基礎(chǔ)鏡像層,總體積為716 MB。但是,我們的應(yīng)用程序最終真正需要的只有編譯后的二進(jìn)制文件,其他所有內(nèi)容都是多余的。
如果我們?cè)诮y(tǒng)計(jì)時(shí)利用CGO_ENABLED=0禁用cgo,則可創(chuàng)建出一套不打包C庫(kù)的二進(jìn)制文件:
- GOOS=linux CGO_ENABLED=0 go build -a goHelloWorld.go
生成的二進(jìn)制文件可被添加至空的,或者“從頭構(gòu)建”鏡像當(dāng)中:
- FROM scratch
- COPY goHelloWorld /
- CMD ["/goHelloWorld"]
下面,我們來(lái)比較兩套鏡像之間的體積差異:
- [ 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庫(kù)(標(biāo)記為‘builder’),其體積達(dá)到純二進(jìn)制文件鏡像的460倍。而純二進(jìn)制文件鏡像的體積僅為1.55 MB。這意味著如果我們使用由builder構(gòu)建的鏡像,其中將有約713 MB的數(shù)據(jù)根本不必存在。
如果適合,不妨考慮壓縮方法
還有一種方法可以通過將所有命令鏈接至層內(nèi)以節(jié)約空間,就是鏡像壓縮(squash)。在進(jìn)行鏡像壓縮時(shí),您實(shí)際上是在導(dǎo)出鏡像,刪除所有中間層,并將鏡像的當(dāng)前狀態(tài)保存為單一層。這將有效控制鏡像的實(shí)際體積。
過去,我們需要利用一些創(chuàng)造性的解決方案才能將經(jīng)過壓縮的層進(jìn)行還原——例如導(dǎo)出容器內(nèi)容并將其重新導(dǎo)入為單層鏡像,或者利用docker-squash等工具。但從1.13版本開始,Docker引入了一種便利的標(biāo)記——squash,其能夠在構(gòu)建過程中完成同樣的操作:
- FROM fedora:28
- LABEL maintainer Chris Collins <collins.christopher@gmail.com>
- 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
利用docker squash處理這個(gè)多層Dockerfile,我們最終得到了一個(gè)大小為271 MB的鏡像,且功能與之前的鏈接指令鏡像一樣。但這,又帶來(lái)了新的潛在問題。
太過極端:過度壓縮、過度“瘦身”、過度專用
鏡像之間可以進(jìn)行層共享。其基礎(chǔ)可能為x MB,但只需要拉取/存儲(chǔ)一次,其他鏡像就能夠加以使用。進(jìn)行層共享的各鏡像的實(shí)際大小,為基礎(chǔ)層加上特定變化帶來(lái)的差異。通過這種方式,我們能夠以極低的額外空間投入,換取數(shù)千套基于同一鏡像的修改版鏡像。
而這也正是鏡像壓縮或者專用化方法帶來(lái)的弊端。在將鏡像壓縮為單層形式時(shí),我們將徹底失去其與其他鏡像進(jìn)行層共享的機(jī)會(huì)。每套鏡像最終都將與其單一層的體積保持一致。因此,如果大家只需要使用少量鏡像并在其中運(yùn)行大量容器,那么過度壓縮還沒什么問題; 但如果您面對(duì)著多種不同鏡像,那么從長(zhǎng)遠(yuǎn)角度來(lái)看,這最終反而會(huì)消耗您的存儲(chǔ)空間。
讓我們重新審視Nginx壓縮示例,可以看到在這種情況下,“瘦身”過程并不會(huì)帶來(lái)什么問題。我們最終安裝了Fedora與Nginx,清理了緩存,并進(jìn)行了有效壓縮。不過,Nginx本身并沒有多大作用,大家通常需要以自定義方式執(zhí)行各類針對(duì)性操作——例如配置文件、其他軟件包甚至是某些應(yīng)用代碼。而其中每一項(xiàng)操作都會(huì)在Dockerfile中添加更多指令。
如果以傳統(tǒng)方式進(jìn)行鏡像構(gòu)建,那么您將在鏡像中擁有一個(gè)承載Fedora的獨(dú)立基礎(chǔ)鏡像層,一個(gè)安裝有Nginx的層(包含或不包含緩存),而后每項(xiàng)自定義又有自己的層。包含F(xiàn)edora與Nginx等的其他鏡像將能夠共享這些層。
在這種情況下,需要的鏡像為:
- [ App 1 Layer ( 5 MB) ] [ App 2 Layer (6 MB) ]
- [ Nginx Layer ( 21 MB) ] ------------------^
- [ Fedora Layer (249 MB) ]
但如果大家對(duì)該鏡像進(jìn)行壓縮,那么Fedora基礎(chǔ)層也會(huì)被壓縮。基于Fedora的被壓縮鏡像需要釋放相關(guān)Fedora內(nèi)容,這意味著每套鏡像將新增249 MB!
- [ Fedora + Nginx + App 1 (275 MB)] [ Fedora + Nginx + App 2 (276 MB) ]
如果大家構(gòu)建出大量高度專用且超級(jí)小巧的鏡像,那么這絕對(duì)會(huì)帶來(lái)大麻煩。
因?yàn)榕c生活中的其他事務(wù)一樣,適度才是鏡像體積控制的關(guān)鍵所在。而且考慮到鏡像層的工作原理,隨著容器鏡像的壓縮度與專用性逐漸提高,其將無(wú)法與其他相關(guān)鏡像共享基礎(chǔ)鏡像層,而壓縮帶來(lái)的瘦身效果也將因此遞減甚至消失。
經(jīng)過一定程度自定義的鏡像可以共享基礎(chǔ)層。如前文所述,這一基礎(chǔ)層可以是x MB,但只需要進(jìn)行一次拉取/存儲(chǔ),所有鏡像就都能夠?qū)ζ浼右允褂谩K戌R像的有效大小為基礎(chǔ)層加上每種特定變化造成的差異。通過這種方式,我們能夠以極低的額外空間投入,換取數(shù)千套基于同一鏡像的修改版鏡像。
- [ specific app ] [ specific app 2 ]
- [ customizations ]--------------^
- [ base layer ]
但如果您的鏡像壓縮得太狠或者存在太多修改或?qū)S谜{(diào)整,那么我們將不得不面對(duì)大量鏡像。由于這些鏡像之間沒有同一套共享基礎(chǔ)層,因此其將各自占用磁盤上的存儲(chǔ)空間。
- [ specific app 1 ] [ specific app 2 ] [ specific app 3 ]
總結(jié)
我們擁有多種能夠有效減少容器鏡像所需存儲(chǔ)空間與傳輸帶寬的處理方法,但其中最有效的方法無(wú)疑是降低鏡像本身的體積。無(wú)論您選擇單純清理其中的緩存(避免將其保留在中間層內(nèi))、將全部層壓縮為單一層,或者只是在空鏡像中添加靜態(tài)二進(jìn)制文件,大家都有必要花些時(shí)間研究鏡像中可能存在的不必要內(nèi)容,并將其縮小至合理的水平。
原文標(biāo)題:Building tiny container images,作者:Chris Collins
【51CTO譯稿,合作站點(diǎn)轉(zhuǎn)載請(qǐng)注明原文譯者和出處為51CTO.com】