深入剖析Docker鏡像

java江南 發佈 2022-05-13T20:35:28.171781+00:00

大家好,我是喬克,一名一線運維實踐者。 鏡像對於YAML工程師來說都不陌生,每天都在和他打交道,編寫、構建、發布,重複而有趣。 ​在我們編寫一個構建鏡像的Dockerfile之後,只要應用能正常跑起來,便很少再去看這個Dockerfile了(至少我是這樣)。

大家好,我是喬克,一名一線運維實踐者。

鏡像對於YAML工程師來說都不陌生,每天都在和他打交道,編寫、構建、發布,重複而有趣。 ​

在我們編寫一個構建鏡像的dockerfile之後,只要應用能正常跑起來,便很少再去看這個Dockerfile了(至少我是這樣)。對於這個Dockerfile是不是想像中的那麼合理,是不是還可以再優化一下,並沒有做太深入的思考。 ​

本文主要從以下幾個方面帶你深入了解鏡像的知識。

鏡像的基本概念

在了解一件事物的時候,腦海中總是會先問一句「是什麼」,學習Docker鏡像也是同樣的道理,什麼是Docker鏡像? ​

在說Docker鏡像之前,先簡單說說Linux文件系統。 ​

典型的Linux文件系統由bootfsrootfs組成,bootfs會在Kernel加載到內存後umount掉,所以我們進入系統看到的都是rootfs,比如/etc,/prod,/bin等標準目錄。 ​

我們可以把Docker鏡像當成一個rootfs,這樣就能比較形象是知道什麼是Docker鏡像,比如官方的ubuntu:21.10,就包含一套完整的ubuntu:21.10最小系統的rootfs,當然其內是不包含內核的。 ​

Docker鏡像是一個_特殊的文件系統_,它提供容器運行時需要的程序、庫、資源、配置還有一個運行時參數,其最終目的就是能在容器中運行我們的代碼。

以上是從宏觀的的視角去看Docker鏡像是什麼,下面再從微觀的角度來深入了解一下Docker鏡像。假如我們現在只有一個ubuntu:21.10鏡像,如果現在需要一個nginx鏡像,是不是可以直接在這個鏡像中安裝一個nginx,然後這個鏡像是不是就可以變成nginx鏡像? ​

答案是可以的。其實這裡面就有一個分層的概念,底層用的是ubuntu鏡像,然後在上面疊加了一個nginx鏡像,這樣就完成了一個nginx鏡像的構建了,這種情況我們稱ubuntu鏡像為nginx的父鏡像。 ​

這麼說起來還是有點不好理解,介紹完下面的鏡像存儲方式,就好理解了。 ​

鏡像的存儲方式

在說鏡像的存儲方式之前,先簡單介紹一個UnionFS(聯合文件系統,Union File System)。 ​

所謂UnionFS就是把不同物理位置的目錄合併mount到同一個目錄中,然後形成一個虛擬的文件系統。一個最典型的應用就是將一張CD/DVD和一個硬碟的目錄聯合mount在一起,然後用戶就可以對這個只讀的CD/DVD進行修改了。 ​

Docker就是充分利用UnionFS技術,將鏡像設計成分層存儲,現在使用的就是OverlayFS文件系統,它是眾多UnionFS中的一種。 ​

OverlayFS只有lowerupper兩層。顧名思義,upper層在上面,lower層在下面,upper層的優先級高於lower層。 ​

在使用mount掛載overlay文件系統的時候,遵守以下規則。

  • lower和upper兩個目錄存在同名文件時,lower的文件將會被隱藏,用戶只能看到upper的文件。
  • lower低優先級的同目錄同名文件將會被隱藏。
  • 如果存在同名目錄,那麼lower和upper目錄中的內容將會合併。
  • 當用戶修改merge中來自upper的數據時,數據將直接寫入upper中原來目錄中,刪除文件也同理。
  • 當用戶修改merge中來自lower的數據時,lower中內容均不會發生任何改變。因為lower是只讀的,用戶想修改來自lower數據時,overlayfs會首先拷貝一份lower中文件副本到upper中。後續修改或刪除將會在upper下的副本中進行,lower中原文件將會被隱藏。
  • 如果某一個目錄單純來自lower或者lower和upper合併,默認無法進行rename系統調用。但是可以通過mv重命名。如果要支持rename,需要CONFIG_OVERLAY_FS_REDIRECT_DIR。

下面以OverlayFS為例,直面感受一下這種文件系統的效果。

系統:centos 7.9 Kernel:3.10.0

(1)創建兩個目錄loweruppermergework四個目錄

# # mkdir lower upper work merge

其中:

  • lower目錄用於存放lower層文件
  • upper目錄用於存放upper層文件
  • work目錄用於存放臨時或者間接文件
  • merge目錄就是掛載目錄

(2)在lowerupper兩個目錄中都放入一些文件,如下:

 # 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

可以看到lowerupper目錄中有相同名字的文件common-file,但是他們的內容不一樣。 ​

(3)將這兩個目錄進行掛載,命令如下:

# mount -t overlay -o lowerdir=lower,upperdir=upper,workdir=work overlay merge

掛載的結果如下:

# 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內容進行了合併,重複文件common-file為uppderdir中的common-file。

(4)在merge目錄中創建一個文件,查看效果

# 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層同樣沒有變化,所有的修改都發生在upper層。 ​

從上面的實驗就可以看到比較有意思的一點:不論上層怎麼變,底層都不會變

Docker鏡像就是存在聯合文件系統的,在構建鏡像的時候,會一層一層的向上疊加,每一層構建完就不會再改變了,後一層上的任何改變都只會發生在自己的這一層,不會影響前面的鏡像層。 ​

我們通過一個例子來進行闡述,如下圖。

具體如下:

  • 基礎L1層有file1和file2兩個文件,這兩個文件都有具體的內容。
  • 到L2層的時候需要修改file2的文件內容並且增加file3文件。在修改file2文件的時候,系統會先判定這個文件在L1層有沒有,從上圖可知L1層是有file2文件,這時候就會把file2複製一份到L2層,然後修改L2層的file2文件,這就是用到了聯合文件系統寫時複製機制,新增文件也是一樣。
  • 到L3層修改file3的時候也會使用寫時複製機制,從L2層拷貝file3到L3層 ,然後進行修改。
  • 然後我們在視圖層看到的file1、file2、file3都是最新的文件。

上面的鏡像層是的。當我們運行容器的時候,Docker Daemon還會動態生成一個讀寫層,用於修改容器里的文件,如下圖。

比如我們要修改file2,就會使用寫時複製機制將file2複製到讀寫層,然後進行修改。同樣,在容器運行的時候也會有一個視圖,當我們把容器停掉以後,視圖層就沒了,但是讀寫層依然保留,當我們下次再啟動容器的時候,還可以看到上次的修改。

值得一提的是,當我們在刪除某個文件的時候,其實並不是真的刪除,只是將其標記為刪除然後隱藏掉,雖然我們看不到這個文件,實際上這個文件會一直跟隨鏡像。 ​

到此對鏡像的分層存儲有一定的認識了?這種分層存儲還使得鏡像的復用、定製變得更容易,就像文章開頭基於ubuntu定製nginx鏡像。 ​

Dockerfile和鏡像的關係

我們經常在應用代碼里編寫Dockerfile來製作鏡像,那Dockerfile和鏡像到底是什麼關係呢?沒有Dockerfile可以製作鏡像嗎? ​

我們先來看一個簡單的Dockerfile是什麼樣的。

FROM ubuntu:latest
ADD run.sh /  
VOLUME /data  
CMD ["./run.sh"]  

通過這幾個命令就可以做出新的鏡像? ​

是的,通過這幾個命令組成文件,docker就可以使用它製作出新的鏡像,這是不是有點像給你一些檸檬、冰糖、金銀花就能製作出一杯檸檬茶一個道理? ​

這個一聯想,Dockerfile和鏡像的關係就清晰明了了。 ​

Dockerfile就是一個原材料,鏡像就是我們想要的產品。當我們想要製作某一個鏡像的時候,配置好Dcokerfile,然後使用docker命令就能輕鬆的製作出來。 ​

那不用Dockerfile可以製作鏡像嗎?

答案是可以的,這時候就需要我們先啟動一個基礎鏡像,通過docker exec命令進入容器,然後安裝我們需要的軟體,最好再使用docker commit生成新的鏡像即可。這種方式就沒有Dockerfile那麼清晰明了,使用起來也比較麻煩。

鏡像和容器的關係

上面說了Dockerfile是鏡像的原材料,在這裡,鏡像就是容器的運行基礎。 ​

容器鏡像和我們平時接觸的操心系統鏡像是一個道理,當我們拿到一個作業系統鏡像,比如一個以iso結尾的centos鏡像,正常情況下,這個centos作業系統並不能直接為我們提供服務,需要我們去安裝配置才行。 ​

容器鏡像也是一樣。 ​

當我們通過Dockerfile製作了一個鏡像,這時候的鏡像是靜態的,並不能為我們提供需要的服務,我們需要通過docker將這個鏡像運行起來,使它從鏡像變成容器,從靜態變成動態。 ​

簡單來說,鏡像是文件,容器是進程。容器是通過鏡像創建的,沒有 Docker 鏡像,就不可能有 Docker 容器,這也是 Docker 的設計原則之一。

鏡像的優化技巧

上面介紹了什麼是鏡像、鏡像的存儲方式以及Dockerfile和鏡像、鏡像和容器之間關係,這節主要介紹我們在製作鏡像的時候有哪些技巧可以優化鏡像。 ​

Docker鏡像構建通過docker build命令觸發,docker build會根據Dockerfile文件中的指令構建Docker鏡像,最終的Docker鏡像是由Dockerfile中的命令所表示的層疊加起來的,所以從Dockerfile的製作到鏡像的製作這一系列之間都有可以優化和注意的地方。 ​

鏡像優化可以分兩個方向:

  • 優化鏡像體積
  • 優化構建速度

優化鏡像體積

優化鏡像體積主要就是從製作Dockerfile的時候需要考慮的事情。 ​

上面以及介紹過鏡像是分層存儲的,每個鏡像都會有一個父鏡像,新的鏡像都是在父鏡像的基礎之上構建出來的,比如下面的Dockerfile。

FROM ubuntu:latest
ADD run.sh /  
VOLUME /data  
CMD ["./run.sh"]  

這段Dockerfile的父鏡像是ubuntu:latest,在它的基礎之上添加腳本然後組成新的鏡像。 ​

所以在優化體積方面,可以從以下幾個方面進行考慮。 ​

(1)選擇儘可能小的基礎鏡像

在docker hub上的同一個基礎鏡像會存在多個版本,如果可以,我建議你使用alpine版本,這個版本的鏡像是經過許多優化,減少了很多不必要的包,節約了體積。這裡就以常用的openjdk鏡像為例,簡單看一下它們的大小差別。

首先在Docker hub上可以看到openjdk:17-jdkopenjdk:17-jdk-alpine的鏡像大小,如下: ​

可以看到同一個版本alpine版本的鏡像比正常的版本小50MB左右,所以用這兩個做基礎鏡像構建出來的鏡像大小也會有差別。 ​

但是是不是所有基礎鏡像都選alpine版本呢? ​

不是的,alpine鏡像也會有很多坑,比如。

  • 使用alpine版本鏡像容易出錯,因為這個版本鏡像經過了大量的精簡優化,很多依賴庫都沒有,如果程序需要依賴動態連結庫就容易報錯,比如Go中的cgo調用。
  • 域名解析行為跟 glibc 有差異,Alpine 鏡像的底層庫是 musl libc,域名解析行為跟標準 glibc 有差異,需要特殊作一些修復配置,並且有部分選項在 resolv.conf 中配置不支持。
  • 運行 bash 腳本不兼容,因為沒有內置 bash,所以運行 bash 的 shell 腳本會不兼容。

所以使用alpine鏡像也需要好好斟酌一下,在實際應用中,如果要使用alpine鏡像,最好在其上做一些初始化,把需要的依賴、庫、命令等先封裝進去製作成新的基礎鏡像,其他應用再以這個基礎鏡像為父鏡像進行操作。 ​

(2)鏡像層數儘量少

上面說過鏡像是分層存儲的,如果上層需要修改下層的文件需要使用寫時複製機制,而且下層的文件依然存在並不會消失,如果層數越多,鏡像的體積相應的也會越大。 ​

比如下面的Dockerfile。

FROM ubuntu:latest
RUN apt update
RUN apt install git -y
RUN apt install curl -y
ADD run.sh /
CMD ["./run.sh"]

這個Dockerfile能跑起來嗎?完全沒問題,但是這樣寫是不是就會導致鏡像的層數非常多? ​

拋開父鏡像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"]

這樣一改,就把鏡像的層數從5層降低至3層,而且整個邏輯並沒有改變。 ​

說明:在 Docker1.10 後有所改變,只有 RUN、COPY、ADD 指令會創建層,其他指令會創建臨時的中間鏡像,不會直接增加構建的鏡像大小 。

(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)使用多階段構建

這個不是必須。 ​

為什麼這麼說呢?因為多階段構建主要是為了解決編譯環境留下的多餘文件,使最終的鏡像儘可能小。那為什麼說不是必須呢,因為這種情況很多時候都會在做CI的時候給分開,編譯是編譯的步驟,構建是構建的步驟,所以我說不是必須。 ​

但是這種思路是非常好的,可以通過一個Dockerfile將編譯和構建都寫進去,如下。

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基礎鏡像來實現多階段,階段之間可以通過索引或者別名來引用。

優化鏡像體積就總結這4點,如果你有更多更好的方法,歡迎溝通交流。

優化構建速度

當製作好Dockerfile之後,就需要構建鏡像了,很多時候看著構建的速度就著急,那有什麼辦法可以優化一下呢?這裡從以下幾個方面進行表述。 ​

(1)優化網絡速度

網絡是萬惡之源。比如許多人的基礎鏡像都是直接從docker hub上拉取,如果一台機器是第一次拉是非常緩慢的,這時候我們可以先把docker hub上的鏡像放到本地私有倉庫,這樣在同一個網絡環境中,拉取速度會比直接到docker hub上拉取快1萬倍。 ​

還有一個鏡像分發技術,比如阿里的dragonfly,充分採用了p2p的思想,提高鏡像的拉取分發速度。 ​

(2)優化上下文

不知道你有沒有注意到,當我們使用docker build構建鏡像的時候,會發送一個上下文給Docker daemon,如下:

# docker build -t test:v1 .
Sending build context to Docker daemon  11.26kB
Step 1/2 : FROM ubuntu
......

原來在使用docker build構建鏡像的時候,會把Dockerfile同級目錄下的所有文件都發送給docker daemon,後續的操作都是在這個上下文中發生。 ​

所以,如果你Dockerfile的同級目錄存在很多不必要的文件,不僅會增加內存開銷,還會拖慢整個構建速度,那有什麼辦法進行優化嗎? ​

這裡提供兩種方法:

  • 如果Dockerfile必須放在代碼倉庫的根目錄,這時候可以在這個目錄下添加一個.dockerignore 文件,在裡面添加需要忽略的文件和文件夾,這樣在發送上下文的時候就不會發送不必要的文件了。
  • 重新創建一個新的目錄放置Dockerfile,保持這個目錄整潔乾淨。

(3)充分使用緩存

Docker鏡像是分層存儲的,在使用docker build構建鏡像的時候會默認使用緩存,在構建鏡像的時候,Docker都會先從緩存中去搜索要使用的鏡像,而不是創建新的鏡像,其規則是:從該基本鏡像派生的所有子鏡像,與已在緩存中的鏡像進行比較,以查看是否其中一個是使用完全相同的指令構建的。如果不一樣,則緩存失效,重新構建。

簡單歸納就以下三個要素:

  • 父鏡像沒有變化
  • 構建的指令沒有變化
  • 添加的文件沒有變化

只要滿足這三個要素就會使用到緩存,加快構建速度。

上面從體積和效率上分別介紹了Docker鏡像的優化和注意事項,如果嚴格按照這種思路進行鏡像設計,你的鏡像是能接受考驗的,而且面試的時候也是能加分的。

鏡像的安全管理

上面聊了那麼多鏡像相關的話題,最後再來說說鏡像安全的問題。 ​

鏡像是容器的基石,是應用的載體。最終我們的鏡像是為業務直接或者間接的提供服務,做過運維的同學應該都為自己的作業系統做過安全加固,鏡像其實也需要。 ​

這裡不闡述作業系統加固方面的知識,僅僅只針對容器來說。 ​

(1)保持鏡像精簡

精簡不等於安全。

但是精簡的鏡像可以在一定程度上規避一些安全問題,都知道,一個作業系統中是會安裝非常多的軟體,這些軟體每天都會暴露不同的漏洞,這些漏洞就會成為不懷好意之人的目標。我們可以把鏡像看成是一個縮小版的作業系統,同理,鏡像裡面的軟體越少,越精簡,其漏洞暴露的風險就更低。 ​

(2)使用非root用戶

容器和虛擬機之間的一個關鍵區別是容器與主機共享內核。在默認情況下,Docker 容器運行在 root 用戶下,這會導致泄露風險。因為如果容器遭到破壞,那麼主機的 root 訪問權限也會暴露。

所以我們在製作鏡像的時候要使用非root用戶,比如下面一個java服務:

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 的私有鏡像提供了安全掃描。

當然還有其他的倉庫也有集成安全掃描工具,比如Harbor新版本已經可以自定義鏡像掃描規則,也可以定義攔截規則,可以有效的發現鏡像漏洞。

(4)要時常去查看安全結果

大家有沒有這種感覺,我加了很多東西,但是感覺不到? ​

我有時候就有這種感覺,比如我給某個應用加了監控,然後就不管了,以至於我根本不知道或者不在乎這個監控到底怎麼樣。 ​

假如我們對鏡像進行了安全掃描,安裝了一些工具,一定要去查看每個安全結果,而不是掃了就完了。 ​

總結

小小的鏡像就有這麼多道道,不看不知道,一看嚇一跳。 ​

本文主要從Docker鏡像的概念說起,然後結合一些實際的場景進行對比分析闡述更深層次的實現過程,有助於幫助大家理解Docker鏡像。


小夥伴們有興趣想了解內容和更多相關學習資料的請點讚收藏+評論轉發+關注我,後面會有很多乾貨。我有一些面試題、架構、設計類資料可以說是程式設計師面試必備!

所有資料都整理到網盤了,需要的話歡迎下載!私信我回復【111】即可免費獲取






























































































































作者:喬克

連結:https://mdnice.com/writing/ddbb8f439f944c129e14bbfff1d60780

關鍵字: