一篇文章帶你深入剖析Docker鏡像
作者:喬克
公眾號:運維開發(fā)故事
知乎:喬克叔叔
大家好,我是喬克,一名一線運維實踐者。
鏡像對于YAML工程師來說都不陌生,每天都在和他打交道,編寫、構(gòu)建、發(fā)布,重復(fù)而有趣。
在我們編寫一個構(gòu)建鏡像的Dockerfile之后,只要應(yīng)用能正常跑起來,便很少再去看這個Dockerfile了(至少我是這樣)。對于這個Dockerfile是不是想象中的那么合理,是不是還可以再優(yōu)化一下,并沒有做太深入的思考。
本文主要從以下幾個方面帶你深入了解鏡像的知識。
鏡像的基本概念
在了解一件事物的時候,腦海中總是會先問一句“是什么”,學(xué)習(xí)Docker鏡像也是同樣的道理,什么是Docker鏡像?
在說Docker鏡像之前,先簡單說說Linux文件系統(tǒng)。
典型的Linux文件系統(tǒng)由bootfs和rootfs組成,bootfs會在Kernel加載到內(nèi)存后umount掉,所以我們進入系統(tǒng)看到的都是rootfs,比如/etc,/prod,/bin等標(biāo)準(zhǔn)目錄。
我們可以把Docker鏡像當(dāng)成一個rootfs,這樣就能比較形象是知道什么是Docker鏡像,比如官方的ubuntu:21.10,就包含一套完整的ubuntu:21.10最小系統(tǒng)的rootfs,當(dāng)然其內(nèi)是不包含內(nèi)核的。
Docker鏡像是一個_特殊的文件系統(tǒng)_,它提供容器運行時需要的程序、庫、資源、配置還有一個運行時參數(shù),其最終目的就是能在容器中運行我們的代碼。
以上是從宏觀的的視角去看Docker鏡像是什么,下面再從微觀的角度來深入了解一下Docker鏡像。假如我們現(xiàn)在只有一個ubuntu:21.10鏡像,如果現(xiàn)在需要一個nginx鏡像,是不是可以直接在這個鏡像中安裝一個nginx,然后這個鏡像是不是就可以變成nginx鏡像?
答案是可以的。其實這里面就有一個分層的概念,底層用的是ubuntu鏡像,然后在上面疊加了一個nginx鏡像,這樣就完成了一個nginx鏡像的構(gòu)建了,這種情況我們稱ubuntu鏡像為nginx的父鏡像。
這么說起來還是有點不好理解,介紹完下面的鏡像存儲方式,就好理解了。
鏡像的存儲方式
在說鏡像的存儲方式之前,先簡單介紹一個UnionFS(聯(lián)合文件系統(tǒng),Union File System)。
所謂UnionFS就是把不同物理位置的目錄合并mount到同一個目錄中,然后形成一個虛擬的文件系統(tǒng)。一個最典型的應(yīng)用就是將一張CD/DVD和一個硬盤的目錄聯(lián)合mount在一起,然后用戶就可以對這個只讀的CD/DVD進行修改了。
Docker就是充分利用UnionFS技術(shù),將鏡像設(shè)計成分層存儲,現(xiàn)在使用的就是OverlayFS文件系統(tǒng),它是眾多UnionFS中的一種。
OverlayFS只有l(wèi)ower和upper兩層。顧名思義,upper層在上面,lower層在下面,upper層的優(yōu)先級高于lower層。
在使用mount掛載overlay文件系統(tǒng)的時候,遵守以下規(guī)則。
- lower和upper兩個目錄存在同名文件時,lower的文件將會被隱藏,用戶只能看到upper的文件。
- lower低優(yōu)先級的同目錄同名文件將會被隱藏。
- 如果存在同名目錄,那么lower和upper目錄中的內(nèi)容將會合并。
- 當(dāng)用戶修改merge中來自upper的數(shù)據(jù)時,數(shù)據(jù)將直接寫入upper中原來目錄中,刪除文件也同理。
- 當(dāng)用戶修改merge中來自lower的數(shù)據(jù)時,lower中內(nèi)容均不會發(fā)生任何改變。因為lower是只讀的,用戶想修改來自lower數(shù)據(jù)時,overlayfs會首先拷貝一份lower中文件副本到upper中。后續(xù)修改或刪除將會在upper下的副本中進行,lower中原文件將會被隱藏。
- 如果某一個目錄單純來自lower或者lower和upper合并,默認(rèn)無法進行rename系統(tǒng)調(diào)用。但是可以通過mv重命名。如果要支持rename,需要CONFIG_OVERLAY_FS_REDIRECT_DIR。
下面以O(shè)verlayFS為例,直面感受一下這種文件系統(tǒng)的效果。
系統(tǒng):CentOS 7.9 Kernel:3.10.0
(1)創(chuàng)建兩個目錄lower、upper、merge、work四個目錄
- # # mkdir lower upper work merge
其中:
- lower目錄用于存放lower層文件
- upper目錄用于存放upper層文件
- work目錄用于存放臨時或者間接文件
- merge目錄就是掛載目錄
(2)在lower和upper兩個目錄中都放入一些文件,如下:
- # echo "From lower." > lower/common-file
- # echo "From upper." > upper/common-file
- # echo "From lower." > lower/lower-file
- # echo "From upper." > upper/upper-file
- # tree
- .
- ├── lower
- │ ├── common-file
- │ └── lower-file
- ├── merge
- ├── upper
- │ ├── common-file
- │ └── upper-file
- └── work
可以看到lower和upper目錄中有相同名字的文件common-file,但是他們的內(nèi)容不一樣。
(3)將這兩個目錄進行掛載,命令如下:
- # mount -t overlay -o lowerdir=lower,upperdir=upper,workdir=work overlay merge
掛載的結(jié)果如下:
- # tree
- .
- ├── lower
- │ ├── common-file
- │ └── lower-file
- ├── merge
- │ ├── common-file
- │ ├── lower-file
- │ └── upper-file
- ├── upper
- │ ├── common-file
- │ └── upper-file
- └── work
- └── work
- # cat merge/common-file
- From upper.
可以看到兩者共同目錄common-dir內(nèi)容進行了合并,重復(fù)文件common-file為uppderdir中的common-file。
(4)在merge目錄中創(chuàng)建一個文件,查看效果
- # echo "Add file from merge" > merge/merge-file
- # tree
- .
- ├── lower
- │ ├── common-file
- │ └── lower-file
- ├── merge
- │ ├── common-file
- │ ├── lower-file
- │ ├── merge-file
- │ └── upper-file
- ├── upper
- │ ├── common-file
- │ ├── merge-file
- │ └── upper-file
- └── work
- └── work
可以看到lower層沒有變化,新增的文件會新增到upper層。
(5)修改merge層的lower-file,效果如下
- # echo "update lower file from merge" > merge/lower-file
- # tree
- .
- ├── lower
- │ ├── common-file
- │ └── lower-file
- ├── merge
- │ ├── common-file
- │ ├── lower-file
- │ ├── merge-file
- │ └── upper-file
- ├── upper
- │ ├── common-file
- │ ├── lower-file
- │ ├── merge-file
- │ └── upper-file
- └── work
- └── work
- # cat upper/lower-file
- update lower file from merge
- # cat lower/lower-file
- From lower.
可以看到lower層同樣沒有變化,所有的修改都發(fā)生在upper層。
從上面的實驗就可以看到比較有意思的一點:不論上層怎么變,底層都不會變。
Docker鏡像就是存在聯(lián)合文件系統(tǒng)的,在構(gòu)建鏡像的時候,會一層一層的向上疊加,每一層構(gòu)建完就不會再改變了,后一層上的任何改變都只會發(fā)生在自己的這一層,不會影響前面的鏡像層。
我們通過一個例子來進行闡述,如下圖。
具體如下:
- 基礎(chǔ)L1層有file1和file2兩個文件,這兩個文件都有具體的內(nèi)容。
- 到L2層的時候需要修改file2的文件內(nèi)容并且增加file3文件。在修改file2文件的時候,系統(tǒng)會先判定這個文件在L1層有沒有,從上圖可知L1層是有file2文件,這時候就會把file2復(fù)制一份到L2層,然后修改L2層的file2文件,這就是用到了聯(lián)合文件系統(tǒng)寫時復(fù)制機制,新增文件也是一樣。
- 到L3層修改file3的時候也會使用寫時復(fù)制機制,從L2層拷貝file3到L3層 ,然后進行修改。
- 然后我們在視圖層看到的file1、file2、file3都是最新的文件。
上面的鏡像層是死的。當(dāng)我們運行容器的時候,Docker Daemon還會動態(tài)生成一個讀寫層,用于修改容器里的文件,如下圖。
比如我們要修改file2,就會使用寫時復(fù)制機制將file2復(fù)制到讀寫層,然后進行修改。同樣,在容器運行的時候也會有一個視圖,當(dāng)我們把容器停掉以后,視圖層就沒了,但是讀寫層依然保留,當(dāng)我們下次再啟動容器的時候,還可以看到上次的修改。
值得一提的是,當(dāng)我們在刪除某個文件的時候,其實并不是真的刪除,只是將其標(biāo)記為刪除然后隱藏掉,雖然我們看不到這個文件,實際上這個文件會一直跟隨鏡像。
到此對鏡像的分層存儲有一定的認(rèn)識了?這種分層存儲還使得鏡像的復(fù)用、定制變得更容易,就像文章開頭基于ubuntu定制nginx鏡像。
Dockerfile和鏡像的關(guān)系
我們經(jīng)常在應(yīng)用代碼里編寫Dockerfile來制作鏡像,那Dockerfile和鏡像到底是什么關(guān)系呢?沒有Dockerfile可以制作鏡像嗎?
我們先來看一個簡單的Dockerfile是什么樣的。
- FROM ubuntu:latest
- ADD run.sh /
- VOLUME /data
- CMD ["./run.sh"]
通過這幾個命令就可以做出新的鏡像?
是的,通過這幾個命令組成文件,docker就可以使用它制作出新的鏡像,這是不是有點像給你一些檸檬、冰糖、金銀花就能制作出一杯檸檬茶一個道理?
這個一聯(lián)想,Dockerfile和鏡像的關(guān)系就清晰明了了。
Dockerfile就是一個原材料,鏡像就是我們想要的產(chǎn)品。當(dāng)我們想要制作某一個鏡像的時候,配置好Dcokerfile,然后使用docker命令就能輕松的制作出來。
那不用Dockerfile可以制作鏡像嗎?
答案是可以的,這時候就需要我們先啟動一個基礎(chǔ)鏡像,通過docker exec命令進入容器,然后安裝我們需要的軟件,最好再使用docker commit生成新的鏡像即可。這種方式就沒有Dockerfile那么清晰明了,使用起來也比較麻煩。
鏡像和容器的關(guān)系
上面說了Dockerfile是鏡像的原材料,在這里,鏡像就是容器的運行基礎(chǔ)。
容器鏡像和我們平時接觸的操心系統(tǒng)鏡像是一個道理,當(dāng)我們拿到一個操作系統(tǒng)鏡像,比如一個以iso結(jié)尾的centos鏡像,正常情況下,這個centos操作系統(tǒng)并不能直接為我們提供服務(wù),需要我們?nèi)グ惭b配置才行。
容器鏡像也是一樣。
當(dāng)我們通過Dockerfile制作了一個鏡像,這時候的鏡像是靜態(tài)的,并不能為我們提供需要的服務(wù),我們需要通過docker將這個鏡像運行起來,使它從鏡像變成容器,從靜態(tài)變成動態(tài)。
簡單來說,鏡像是文件,容器是進程。容器是通過鏡像創(chuàng)建的,沒有 Docker 鏡像,就不可能有 Docker 容器,這也是 Docker 的設(shè)計原則之一。
鏡像的優(yōu)化技巧
上面介紹了什么是鏡像、鏡像的存儲方式以及Dockerfile和鏡像、鏡像和容器之間關(guān)系,這節(jié)主要介紹我們在制作鏡像的時候有哪些技巧可以優(yōu)化鏡像。
Docker鏡像構(gòu)建通過docker build命令觸發(fā),docker build會根據(jù)Dockerfile文件中的指令構(gòu)建Docker鏡像,最終的Docker鏡像是由Dockerfile中的命令所表示的層疊加起來的,所以從Dockerfile的制作到鏡像的制作這一系列之間都有可以優(yōu)化和注意的地方。
鏡像優(yōu)化可以分兩個方向:
- 優(yōu)化鏡像體積
- 優(yōu)化構(gòu)建速度
優(yōu)化鏡像體積
優(yōu)化鏡像體積主要就是從制作Dockerfile的時候需要考慮的事情。
上面以及介紹過鏡像是分層存儲的,每個鏡像都會有一個父鏡像,新的鏡像都是在父鏡像的基礎(chǔ)之上構(gòu)建出來的,比如下面的Dockerfile。
- FROM ubuntu:latest
- ADD run.sh /
- VOLUME /data
- CMD ["./run.sh"]
這段Dockerfile的父鏡像是ubuntu:latest,在它的基礎(chǔ)之上添加腳本然后組成新的鏡像。
所以在優(yōu)化體積方面,可以從以下幾個方面進行考慮。
(1)選擇盡可能小的基礎(chǔ)鏡像
在Docker hub上的同一個基礎(chǔ)鏡像會存在多個版本,如果可以,我建議你使用alpine版本,這個版本的鏡像是經(jīng)過許多優(yōu)化,減少了很多不必要的包,節(jié)約了體積。這里就以常用的openjdk鏡像為例,簡單看一下它們的大小差別。
首先在Docker hub上可以看到openjdk:17-jdk和openjdk:17-jdk-alpine的鏡像大小,如下:
可以看到同一個版本alpine版本的鏡像比正常的版本小50MB左右,所以用這兩個做基礎(chǔ)鏡像構(gòu)建出來的鏡像大小也會有差別。
但是是不是所有基礎(chǔ)鏡像都選alpine版本呢?
不是的,alpine鏡像也會有很多坑,比如。
- 使用alpine版本鏡像容易出錯,因為這個版本鏡像經(jīng)過了大量的精簡優(yōu)化,很多依賴庫都沒有,如果程序需要依賴動態(tài)鏈接庫就容易報錯,比如Go中的cgo調(diào)用。
- 域名解析行為跟 glibc 有差異,Alpine 鏡像的底層庫是 musl libc,域名解析行為跟標(biāo)準(zhǔn) glibc 有差異,需要特殊作一些修復(fù)配置,并且有部分選項在 resolv.conf 中配置不支持。
- 運行 bash 腳本不兼容,因為沒有內(nèi)置 bash,所以運行 bash 的 shell 腳本會不兼容。
所以使用alpine鏡像也需要好好斟酌一下,在實際應(yīng)用中,如果要使用alpine鏡像,最好在其上做一些初始化,把需要的依賴、庫、命令等先封裝進去制作成新的基礎(chǔ)鏡像,其他應(yīng)用再以這個基礎(chǔ)鏡像為父鏡像進行操作。
(2)鏡像層數(shù)盡量少
上面說過鏡像是分層存儲的,如果上層需要修改下層的文件需要使用寫時復(fù)制機制,而且下層的文件依然存在并不會消失,如果層數(shù)越多,鏡像的體積相應(yīng)的也會越大。
比如下面的Dockerfile。
- FROM ubuntu:latest
- RUN apt update
- RUN apt install git -y
- RUN apt install curl -y
- ADD run.sh /
- CMD ["./run.sh"]
這個Dockerfile能跑起來嗎?完全沒問題,但是這樣寫是不是就會導(dǎo)致鏡像的層數(shù)非常多?
拋開父鏡像ubuntu:latest本身的層不說,上面的Dockerfile足足增加了5層。在Dockerfile中是支持命令的合并的,我們可以把上面的Dockerfile改成如下。
- FROM ubuntu:latest
- RUN apt update && \
- apt install git -y && \
- apt install curl -y
- ADD run.sh /
- CMD ["./run.sh"]
這樣一改,就把鏡像的層數(shù)從5層降低至3層,而且整個邏輯并沒有改變。
說明:在 Docker1.10 后有所改變,只有 RUN、COPY、ADD 指令會創(chuàng)建層,其他指令會創(chuàng)建臨時的中間鏡像,不會直接增加構(gòu)建的鏡像大小 。
(3)刪除不必要的軟件包
在制作鏡像的時候,腦海中始終要想起一句話:鏡像盡可能的保持精簡。這樣也有助于提高鏡像的移植性。
比如下面的Dockerfile。
- FROM ubuntu:latest
- COPY a.tar.gz /opt
- RUN cd /opt && \
- tar xf a.tar.gz
- CMD ["./run.sh"]
在這個鏡像中,我們從外部拷貝了一個壓縮文件a.tar.gz,在解壓過后我們并沒有把這個原始包刪除掉,它依然會占用著空間,我們可以把這個Dockerfile改成如下。
- FROM ubuntu:latest
- COPY a.tar.gz /opt
- RUN cd /opt && \
- tar xf a.tar.gz && \
- rm -f a.tar.gz
- CMD ["./run.sh"]
這樣不僅得到了我們想要的文件,也沒有保留不必要的軟件包。
(4)使用多階段構(gòu)建
這個不是必須。
為什么這么說呢?因為多階段構(gòu)建主要是為了解決編譯環(huán)境留下的多余文件,使最終的鏡像盡可能小。那為什么說不是必須呢,因為這種情況很多時候都會在做CI的時候給分開,編譯是編譯的步驟,構(gòu)建是構(gòu)建的步驟,所以我說不是必須。
但是這種思路是非常好的,可以通過一個Dockerfile將編譯和構(gòu)建都寫進去,如下。
- FROM golang AS build-env
- ADD . /go/src/app
- WORKDIR /go/src/app
- RUN go get -u -v github.com/kardianos/govendor
- RUN govendor sync
- RUN GOOS=linux GOARCH=386 go build -v -o /go/src/app/app-server
- FROM alpine
- RUN apk add -U tzdata
- RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
- COPY --from=build-env /go/src/app/app-server /usr/local/bin/app-server
- EXPOSE 8080
- CMD [ "app-server" ]
其主要是通過在Dockerfile中定義多個FROM基礎(chǔ)鏡像來實現(xiàn)多階段,階段之間可以通過索引或者別名來引用。
優(yōu)化鏡像體積就總結(jié)這4點,如果你有更多更好的方法,歡迎溝通交流。
優(yōu)化構(gòu)建速度
當(dāng)制作好Dockerfile之后,就需要構(gòu)建鏡像了,很多時候看著構(gòu)建的速度就著急,那有什么辦法可以優(yōu)化一下呢?這里從以下幾個方面進行表述。
(1)優(yōu)化網(wǎng)絡(luò)速度
網(wǎng)絡(luò)是萬惡之源。比如許多人的基礎(chǔ)鏡像都是直接從docker hub上拉取,如果一臺機器是第一次拉是非常緩慢的,這時候我們可以先把docker hub上的鏡像放到本地私有倉庫,這樣在同一個網(wǎng)絡(luò)環(huán)境中,拉取速度會比直接到docker hub上拉取快1萬倍。
還有一個鏡像分發(fā)技術(shù),比如阿里的dragonfly,充分采用了p2p的思想,提高鏡像的拉取分發(fā)速度。
(2)優(yōu)化上下文
不知道你有沒有注意到,當(dāng)我們使用docker build構(gòu)建鏡像的時候,會發(fā)送一個上下文給Docker daemon,如下:
- # docker build -t test:v1 .
- Sending build context to Docker daemon 11.26kB
- Step 1/2 : FROM ubuntu
- ......
原來在使用docker build構(gòu)建鏡像的時候,會把Dockerfile同級目錄下的所有文件都發(fā)送給docker daemon,后續(xù)的操作都是在這個上下文中發(fā)生。
所以,如果你Dockerfile的同級目錄存在很多不必要的文件,不僅會增加內(nèi)存開銷,還會拖慢整個構(gòu)建速度,那有什么辦法進行優(yōu)化嗎?
這里提供兩種方法:
- 如果Dockerfile必須放在代碼倉庫的根目錄,這時候可以在這個目錄下添加一個.dockerignore文件,在里面添加需要忽略的文件和文件夾,這樣在發(fā)送上下文的時候就不會發(fā)送不必要的文件了。
- 重新創(chuàng)建一個新的目錄放置Dockerfile,保持這個目錄整潔干凈。
(3)充分使用緩存
Docker鏡像是分層存儲的,在使用docker build構(gòu)建鏡像的時候會默認(rèn)使用緩存,在構(gòu)建鏡像的時候,Docker都會先從緩存中去搜索要使用的鏡像,而不是創(chuàng)建新的鏡像,其規(guī)則是:從該基本鏡像派生的所有子鏡像,與已在緩存中的鏡像進行比較,以查看是否其中一個是使用完全相同的指令構(gòu)建的。如果不一樣,則緩存失效,重新構(gòu)建。
簡單歸納就以下三個要素:
- 父鏡像沒有變化
- 構(gòu)建的指令沒有變化
- 添加的文件沒有變化
只要滿足這三個要素就會使用到緩存,加快構(gòu)建速度。
上面從體積和效率上分別介紹了Docker鏡像的優(yōu)化和注意事項,如果嚴(yán)格按照這種思路進行鏡像設(shè)計,你的鏡像是能接受考驗的,而且面試的時候也是能加分的。
鏡像的安全管理
上面聊了那么多鏡像相關(guān)的話題,最后再來說說鏡像安全的問題。
鏡像是容器的基石,是應(yīng)用的載體。最終我們的鏡像是為業(yè)務(wù)直接或者間接的提供服務(wù),做過運維的同學(xué)應(yīng)該都為自己的操作系統(tǒng)做過安全加固,鏡像其實也需要。
這里不闡述操作系統(tǒng)加固方面的知識,僅僅只針對容器來說。
(1)保持鏡像精簡
精簡不等于安全。
但是精簡的鏡像可以在一定程度上規(guī)避一些安全問題,都知道,一個操作系統(tǒng)中是會安裝非常多的軟件,這些軟件每天都會暴露不同的漏洞,這些漏洞就會成為不懷好意之人的目標(biāo)。我們可以把鏡像看成是一個縮小版的操作系統(tǒng),同理,鏡像里面的軟件越少,越精簡,其漏洞暴露的風(fēng)險就更低。
(2)使用非root用戶
容器和虛擬機之間的一個關(guān)鍵區(qū)別是容器與主機共享內(nèi)核。在默認(rèn)情況下,Docker 容器運行在 root 用戶下,這會導(dǎo)致泄露風(fēng)險。因為如果容器遭到破壞,那么主機的 root 訪問權(quán)限也會暴露。
所以我們在制作鏡像的時候要使用非root用戶,比如下面一個java服務(wù):
- FROM openjdk:8-jre-alpine
- RUN addgroup -g 1000 -S joker && \
- adduser joker -D -G joker -u 1000 -s /bin/sh
- USER joker
- ADD --chown=joker springboot-helloworld.jar /home/joker/app.jar
- EXPOSE 8080
- WORKDIR /home/joker
- CMD exec java -Djava.security.egd=file:/dev/./urandom -jar app.jar
(3)對鏡像進行安全掃描
在容器注冊中心運行安全掃描可以為我們帶來額外的價值。除了存放鏡像,鏡像注冊中心定期運行安全掃描可以幫助我們找出薄弱點。Docker 為官方鏡像和托管在 Docker Cloud 的私有鏡像提供了安全掃描。
當(dāng)然還有其他的倉庫也有集成安全掃描工具,比如Harbor新版本已經(jīng)可以自定義鏡像掃描規(guī)則,也可以定義攔截規(guī)則,可以有效的發(fā)現(xiàn)鏡像漏洞。
(4)要時常去查看安全結(jié)果
大家有沒有這種感覺,我加了很多東西,但是感覺不到?
我有時候就有這種感覺,比如我給某個應(yīng)用加了監(jiān)控,然后就不管了,以至于我根本不知道或者不在乎這個監(jiān)控到底怎么樣。
假如我們對鏡像進行了安全掃描,安裝了一些工具,一定要去查看每個安全結(jié)果,而不是掃了就完了。
總結(jié)
小小的鏡像就有這么多道道,不看不知道,一看嚇一跳。
本文主要從Docker鏡像的概念說起,然后結(jié)合一些實際的場景進行對比分析闡述更深層次的實現(xiàn)過程,有助于幫助大家理解Docker鏡像。