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