容器化部署越來越多的用於企業的生產環境中,如何構建可靠、安全、最小化的 Docker
鏡像也就越來越重要。本文將針對該問題,通過原理加實踐的方式,從頭到腳幫你擼一遍。文章比較長,主要通過五個部分對容器鏡像進行講解。分別是:
-
鏡像的構建
講解了鏡像的手動構建與自動構建過程。 -
鏡像的存儲與UnionFS聯合文件系統
講解了鏡像的分層結構以及UnionFS聯合文件系統,以及鏡像層在UnionFS上的實現。 -
最小化鏡像構建
講解了爲什麼需要最小化鏡像,同時如何進行最小化構建。 -
容器鏡像的加固
容器鏡像加固的具體方式。 -
容器鏡像的審查
高質量的項目中容器鏡像也需要向代碼一樣進行審查。
讀者可以根據各自情況選擇性閱讀。
原文發自我的個人網站: GitDiG.com,參考鏈接構建安全可靠、最小化的 Docker 鏡像.
1. 構建鏡像
1.1 手動構建
手動構建 Docker
鏡像的流程圖,如下:
現在依次按照流程採用命令行的方式手動構建一個簡單的Docker
鏡像。
1.1.1 創建容器並增加文件
取busybox
作爲本次試驗的基礎鏡像,因爲它足夠小,大小才 1.21MB
。
$: docker run -it busybox:latest sh
/ # touch /newfile
/ # exit
通過以上的操作,我們完成了流程圖的前三步。創建了一個新容器,並在該容器上創建了一個新問題。只是,我們退出容器後,容器也不見了。當然容器不見了,並不表示容器不存在了,Docker
已經自動保存了該容器。如果在創建時,未顯示設置容器名稱,可以通過以下方式查找該消失的容器。
# 列出最近創建的容器
$: docker container ls -n 1
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c028c091f964 busybox:latest "sh" 13 minutes ago Exited (0) 27 seconds ago upbeat_cohen
# 查詢容器的詳情
$: docker container inspect c028c091f964
...
1.1.2 提交變更生成鏡像
手動構建鏡像,很簡單。先找到發生變更的容器對象,對其變更進行提交。提交完成後,鏡像也就生成了。不過此時的鏡像只有一個自動生成的序列號唯一標識它。爲了方便鏡像的檢索,需要對鏡像進行命名以及標籤化處理。
命令行操作如下:
# 提交變更, 構建鏡像完成
$: docker commit -a JayL -m "add newfile" c028c091f964
sha256:01603f50694eb62e965e85cae2e2327240e4a68861bd0e98a4fb4ee27b403e6d
# 對鏡像進行命名, 原鏡像ID取前幾位就可以了
$: docker image tag 01603f50694eb62e9 busybox:manual
# 驗證新鏡像
$: docker run busybox:manual ls -al newfile
-rw-r--r-- 1 root root 0 Jun 15 05:25 newfile
通過以上兩步過程就完成了Docker
鏡像手動創建。非常簡單是不是。但是也非常麻煩,必須先創建新容器在提交變更,生成鏡像。整個過程完全可以通過腳本化處理,這也是下節要說的,自動化構建Docker
鏡像。
1.2 自動化構建
1.2.1 Dockerfile 構建
自動化構建Docker
鏡像,Docker
公司提供的不是SHELL腳本的方式,而是通過定義一套獨立的語法來描述整個構建過程, 通過該語法編輯的文件,稱爲 Dockerfile
。 自動化構建鏡像就是通過編寫Dockerfile
文件構建的。
同樣完成上面的工作,用Dockerfile
寫出來就是:
FROM busybox:latest
RUN touch /newfile
至於更加詳細的Dockerfile
語法,請參見官方指南。
完成Dockerfile
編寫後,通過命令觸發構建。整個過程,腳本化出來就是:
$: mkdir autobuild && cd autobuild
$: cat <<EOF > Dockerfile
FROM busybox:latest
RUN touch /newfile
EOF
$: docker build -t busybox:autobuild .
2 鏡像的存儲
2.1 鏡像的組成
Docker 鏡像是由一組只讀的鏡像層Image Layer
組成的。而Docker 容器則是在Docker 鏡像的基礎之上,增加了一層:容器層Container Layer
。容器層Container Layer
是可讀寫的。如果對該容器層Container Layer
進行commit
提交操作,該層就變成了新的鏡像層Image Layer
。新的Docker Image
也就構建出來了。
$: mkdir layer && cd layer && touch newfile
$: cat <<EOF > Dockerfile
FROM scratch
ADD newfile .
EOF
$: docker build -t layer .
以下官網提供的圖示可以很清楚的看出鏡像與容器之間的聯繫與區別:
具體某個鏡像的組成Layer
可以通過如下命令進行查詢:
# 鏡像的構建層歷史
$: docker history busybox:autobuild
IMAGE CREATED CREATED BY SIZE COMMENT
845cc5130d2c 17 minutes ago /bin/sh -c touch /newfile 0B
ef46e0caa533 4 days ago /bin/sh -c #(nop) CMD ["sh"] 0B
<missing> 4 days ago /bin/sh -c #(nop) ADD file:1067e5a... in / 1.21MB
不難看出,鏡像busybox:autobuild
一共執行了從底往上的三次層構建。具體構建的指令可以通過第三列的命令得出。<missing>
的意思是:該層是在其它系統上構建的,在本地是不可用的。只需要忽略就好。
2.2 Union FileSystem
要了解 Docker 鏡像的存儲首先必須瞭解聯合文件系統 UnionFS
(Union File System),所謂UnionFS
就是把不同物理位置的目錄合併mount
到同一個目錄中。UnionFS
的具體實現有很多種:
- 早期的UFS
- AUFS
-
OverlayFS
- overlay
- overlay2
具體Docker
宿主機上使用那種UnionFS
文件系統驅動,可以通過如下命令查詢:
$: docker info | grep Storage
Storage Driver: overlay2
overlay2
是一種更現代的聯合文件系統 UnionFS
,它比overlay
的早期版本在穩定與性能上都有很大提升。所以一般最新的Docker
採用的存儲驅動使用的都是overlay2
。
爲了方便演示UnionFS
文件系統,如果是MacOS系統,建議安裝Docker Machine
開啓一臺新的虛擬機操作,排除因爲Docker for MacOS
運行在虛擬機上的各種環境干擾。具體Docker Machine
的安裝請自行查閱相關文檔。
首先創建一臺新的Docker Machine
:
# 創建
$: docker-machine create ufs
...
Docker is up and running!
# 登錄
$: docker-machine ssh ufs
... ok
# 查詢 overlay
$: cat /proc/filesystems | grep overlay
nodev overlay
通過確認,這臺Docker Machine
是支持UnionFS
文件系統的,使用的是overlay
存儲驅動。 既然UnionFS
就是把不同物理位置的目錄合併mount到同一個目錄中.現在我們通過命令行的方式實現一下Docker
官網提供UnionFS
的原理圖。
從圖中可以看出,我們需要提供兩個目錄,分別代表Container Layer
和Image Layer
。目錄名稱,取圖示右部的名稱:
- 目錄
upper
, 代表Container Layer
- 目錄
lower
, 代表Image Layer
除了這兩個目錄以外,通過UnionFS
掛載目錄還需要兩個目錄:
- 目錄
merged
, 代表掛載目錄,即合併後的目錄 - 目錄
work
, 必須爲空目錄,是overlay
存儲驅動掛載所需的工作目錄。
通過命令行實現圖示中的文件夾結構:
# 創建一個測試目錄
$: mkdir demo && cd demo
# 創建子目錄與文件
$: mkdir upper lower merged work
$: touch lower/file1 lower/file2 lower/file3
$: touch upper/file2 upper/file4
# 通過文件內容區分以下file2
$: echo lower > lower/file2
$: echo upper > upper/file2
# 未掛載
$: ls merged
迄今爲止,一切都是常規文件系統操作。現在通過mount
命令進行UnionFS
文件系統的目錄掛載.
# 目錄合併掛載到merged
$: sudo mount -t overlay overlay -olowerdir=lower,upperdir=upper,workdir=work merged
# 掛載完成後
$: ls merged
file1 file2 file3 file4
# file2 使用的是頂層 upper 的file2 文件
$: cat merged/file2
upper
下面再分別通過文件的增刪改加深對UnionFS
文件系統的理解:
- 新增文件
# 新增文件
$: touch merges/file5
$: ls merged/
file1 file2 file3 file4 file5
# 新增文件寫在頂層的 upper 文件夾
$: ls upper/
file2 file4 file5
$: ls lower/
file1 file2 file3
- 修改文件
# 修改文件 CoW 技術
$: echo mod > merged/file1
$: ls upper/
file1 file2 file4 file5
$: cat upper/file1
mod
$: cat lower/file1
- 刪除文件
# 刪除文件
$: rm merged/file1
$: ls -al upper | grep file1
c--------- 1 root root 0, 0 Jun 17 10:41 file1
$: ls -al lower | grep file1
-rw-r--r-- 1 docker staff 0 Jun 17 10:15 file1
實際操作完成以上過程,相信你對於UnionFS
文件系統有了更加直觀的感受。你可能會問, Docker Image
的底層鏡像是由一組Layer
組成的,多個底層目錄在UnionFS
中如何掛載?其實很簡單,只需要通過:
分隔即可。
# 多層目錄: lower1 / lower2 / lower3
$: sudo mount -t overlay overlay -olowerdir=lower1:lower2:lower3,upperdir=upper,workdir=work merged
掛載完成後,lower1
/ lower2
/ lower3
之間的層疊順序又是怎樣,讀者可以自行測試一下。
最後,我們查詢一下系統的掛載列表,
mount | grep overlay
overlay on /home/docker/demo/merged type overlay (rw,relatime,lowerdir=lower,upperdir=upper,workdir=work)
從現有輸出可知目前我們docker-machine
中僅掛載了一個overlay
目錄。
2.3 鏡像的存儲
現在我們在這臺新的docker-machine
上構建一個1.2
中所描述的Docker
鏡像: busybox:autobuild
。
$: mkdir autobuild && cd autobuild
$: cat <<EOF > Dockerfile
FROM busybox:latest
RUN touch /newfile
EOF
$: docker build -t busybox:autobuild .
# 完成構建後,現在系統中有兩個docker image
$: docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
busybox autobuild 2e32da74b3ad 4 seconds ago 1.22MB
busybox latest e4db68de4ff2 2 days ago 1.22MB
構建完成後,我們直接看一下docker-machine
上的文件系統的掛載情況:
# docker 無容器運行
$: mount
...
/dev/sda1 on /mnt/sda1/var/lib/docker type ext4 (rw,relatime,data=ordered)
# docker 運行容器時
# 重新開啓新會話,運行一個容器實例 `docker run -it busybox:autobuild sh`
$: mount
...
/dev/sda1 on /mnt/sda1/var/lib/docker type ext4 (rw,relatime,data=ordered)
overlay on /mnt/sda1/var/lib/docker/overlay2/a54541dd24971b9491a54b43cdf51f4ef9c87c1cd29748bb3fe64dedafd91b56/merged type overlay (rw,relatime,lowerdir=/mnt/sda1/var/lib/docker/overlay2/l/KLGL6INSJ2UBLMAUP5B4IORUTG:/mnt/sda1/var/lib/docker/overlay2/l/BGIT3WQZVII4Z2THF35I6T5V5O:/mnt/sda1/var/lib/docker/overlay2/l/6GZ2NT4UQT6EQK3IT4IGMBXU4T,upperdir=/mnt/sda1/var/lib/docker/overlay2/a54541dd24971b9491a54b43cdf51f4ef9c87c1cd29748bb3fe64dedafd91b56/diff,workdir=/mnt/sda1/var/lib/docker/overlay2/a54541dd24971b9491a54b43cdf51f4ef9c87c1cd29748bb3fe64dedafd91b56/work)
shm on /mnt/sda1/var/lib/docker/containers/e50f19c5bde3fe53cde3729de92f75b74323f7ebb506b0635eb76dd5b81e080a/mounts/shm type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
nsfs on /var/run/docker/netns/3c464f8003e8 type nsfs (rw)
對比輸出,能夠很明顯的看到,暫僅關注 overlay
掛載情況。得出:
- 掛載後的目錄是:
/mnt/sda1/var/lib/docker/overlay2/a54541dd24971b9491a54b43cdf51f4ef9c87c1cd29748bb3fe64dedafd91b56/merged
- 容器Layer是:
/mnt/sda1/var/lib/docker/overlay2/a54541dd24971b9491a54b43cdf51f4ef9c87c1cd29748bb3fe64dedafd91b56/diff
- 鏡像Layer是:
/mnt/sda1/var/lib/docker/overlay2/l/KLGL6INSJ2UBLMAUP5B4IORUTG
/mnt/sda1/var/lib/docker/overlay2/l/BGIT3WQZVII4Z2THF35I6T5V5O
/mnt/sda1/var/lib/docker/overlay2/l/6GZ2NT4UQT6EQK3IT4IGMBXU4T
其中鏡像Layer使用的是軟連接。同樣的信息,我們可以通docker inspect
查詢出來。
$: docker inspect <container-id> -f '{{.GraphDriver.Data.MergedDir}}'
$: docker inspect <container-id> -f '{{.GraphDriver.Data.UpperDir}}'
$: docker inspect <container-id> -f '{{.GraphDriver.Data.LowerDir}}'
輸出的路徑就是具體Docker
鏡像的存儲位置。
3. 最小化 Docker 鏡像
3.1 爲什麼要最小化 Docker 鏡像
最小化 Docker
鏡像的原因可總結出以下幾條:
- 省錢,減少網絡傳輸流量,節省鏡像存儲空間
- 省時,加速鏡像部署時間
- 安全,有限功能降低被攻擊的可能性
- 環保,垃圾都分類了,浪費資源可恥
3.2 如何構建最小化 Docker 鏡像
按 1.3
、1.4
中所討論的鏡像的組成原理與存儲, 最小化 Docker
鏡像的主要途徑總結下來也就兩條:
- 縮減鏡像的Layer大小
- 減少鏡像的Layer層數
先從簡單的減少鏡像Layer的層數開始。
3.3 減少鏡像的 Layer 層數
3.3.1 組合命令
在定義Dockerfile
的時候,每一條指令都會對應一個新的鏡像層。通過docker history
命令就可以查詢出具體Docker
鏡像構建的層以及每層使用的指令。爲了減少鏡像的層數,在實際構建鏡像時,通過使用&&
連接命令的執行過程,將多個命令定義到一個構建指令中執行。如:
FROM debian:stable
WORKDIR /var/www
RUN apt-get update && \
apt-get -y --no-install-recommends install curl \
ca-certificates && \
apt-get purge -y curl \
ca-certificates && \
apt-get autoremove -y && \
apt-get clean
3.3.2 壓縮鏡像層
除了通過將多命令通過&&
連接到一個構建指令外,在Docker
鏡像的構建過程中,還可以通過--squash
的方式,開啓鏡像層的壓縮功能,將多個變化的鏡像層,壓縮成一個新的鏡像層。
具體命令就如下:
$: docker build --squash -t <image> .
3.4 縮減鏡像的 Layer 大小
3.4.1 選擇基礎鏡像
縮減Layer的大小需要從頭開始,即選擇什麼樣的基礎鏡像作爲初始鏡像。一般情況下,大家都會從以下三個基礎鏡像開始。
- 鏡像 scratch(空鏡像), 大小 0B
- 鏡像 busybox(空鏡像 + busybox), 大小 1.4MB
- 鏡像 alpine (空鏡像 + busybox + apk), 大小 3.98MB
鏡像 busybox 通過busybox
程序提供一些基礎的Linux系統操作命令,鏡像 alpine則是在次基礎上提供了apk
包管理命令,方便安裝各類工具及依賴包。廣泛使用的鏡像基本都是鏡像 alpine。鏡像 busybox更適合一些快速的實驗場景。而鏡像 scratch空鏡像,因爲不提供任何輔助工具,對於不依賴任何第三方庫的程序是合適的。因爲鏡像 scratch空鏡像本身不提供任何container OS
,所以程序是運行在Docker Host
即宿主機上的,只是利用了Docker
技術提供的隔離技術而已。
細心的讀者可能會發現,在MacOS
上編譯的程序,採用鏡像 scratch空鏡像時,容器運行會報錯:。那是因爲,Docker for Mac
是運行在Linux
虛擬機上的緣故。所以不可以直接構建MacOS
格式的可執行程序在Docker for Mac
上採用空鏡像的方式運行。
3.4.2 多階段構建鏡像
多階段構建 Multi-Stage Build
是 Docker 17.05
版本開始引入的新特性。通過將原先僅一個階段構建的鏡像查分成多個階段。之所以多階段構建鏡像能夠縮減鏡像的大小,是因爲發佈程序在編譯期相關的依賴包以及臨時文件並不是最終發佈鏡像所需要的。通過劃分不同的階段,構建不同的鏡像,最終鏡像則取決於我們真正需要發佈的實體是什麼。
FROM golang:1.11-alpine3.7 AS builder
WORKDIR /app
COPY main.go .
RUN go build -o server .
FROM alpine:3.7
WORKDIR /app
COPY --from=builder /app .
CMD ["./server"]
如上的Dockerfile
就是多階段構建,在builder
階段使用的基礎鏡像是golang:1.11-alpine3.7
,顯然是因爲編譯期的需要,對於發佈真正的server
程序是完全沒必要的。通過多階段構建鏡像的方式就可以僅僅打包需要的實體構成鏡像。
除了多階段構建以外,如果你還想忽略鏡像中一些冗餘文件,還可以通過.dockerignore
的方式在文件中定義出來。功能和.gitignore
類似。
4. 加固 Docker 鏡像
最小化Docker
鏡像的構建完成了,但是,我們的工作卻仍未結束。我們還需要對鏡像進行加固處理。
4.1 鏡像內容可尋址標識符(CAIID)
鏡像內容可尋址標識符(Content addressable image identifiers), 可以對來源基礎鏡像內容進行校驗,確保沒有被第三方篡改。具體的操作方式,就是在構建自己鏡像的同時,對基礎鏡像內容進行內容的sha256
摘要值進行設置,防止在不知情的情況下被篡改。
首先,得出具體鏡像的正確sha256
摘要值.
# 通過命令查詢出具體鏡像的sha256摘要
$: docker inspect busybox:autobuild -f "{{.RepoDigests}}"
sha256:9b63a0eaaed5e677bb1e1b29c1a97268e6c9e6fee98b48badf0f168ae72a51dc
再在Dockerfile
定義時,設置基礎鏡像的sha256
摘要值
FROM busybox@sha256:9b63a0eaaed5e677bb1e1b29c1a97268e6c9e6fee98b48badf0f168ae72a51dc
...
注意:鏡像內容可尋址標識符的獲取必須經過一次 push 或者 pull 操作,即在鏡像註冊服務上發佈後,纔可以通過以上 inspect 命令查詢出結果。如果僅僅是本地的鏡像,無法通過 inpect 命令獲取。當然僅僅是本地使用的鏡像,鏡像內容可尋址標識符也是沒必要的。
4.2 用戶權限
容器一旦創建出來,其默認使用的用戶是可以在鏡像中進行設置的。通過設置必要的鏡像默認用戶,可以限制其在容器中的執行權限。在某種程度上也就進行提升了鏡像的安全級別。不過,這需要根據具體的業務發佈情況進行設置,常規情況下,基礎鏡像都還是root用戶作爲默認用戶
。
安全原則:構建鏡像本身是爲了特定的應用定製的,默認情況下應該儘可能的降低用戶權限。
4.3 SUID與SGID問題
除了鏡像本身設置必要的默認用戶以外,在鏡像中,還會存在一類程序,即使是通過普通用戶執行,但在運行時會以更高級別的權限執行。就是系統針對可執行文件與目錄提供的SUID與SGID特殊權限。
通過對可執行文件設置SUID或SGID屬性,原本執行命令的用戶會切換成爲命令的所有者或是所屬組的權限進行執行。也就是提升了執行命令的權限。
在實際的鏡像構建中,應該儘可能的避免此類權限提升造成的可能的漏洞。建議鏡像構建時,掃描鏡像內是否存在此類執行文件,如果存在儘可能的刪除。刪除命令可參考:
# 鏡像構建過程中增加對特殊權限可執行文件的掃描並刪除
RUN for i in $(find / -type f \( -perm +6000 -o -perm +2000 \)); \
do chmod ug-s $i; done
5. 審查 Docker 鏡像
正如Code Review
一樣,代碼審查可以大大提升企業項目的質量。容器鏡像同樣作爲開發人員或是運維人員的產出物,對其進行審查也是必要的。
雖然我們可以通過docker
命令結合文件系統瀏覽的方式進行容器鏡像的審查,但其過程需要人工參與,很難做到自動化,更別提將鏡像審查集成到CI過程中了。但一個好的工具可以幫我們做到這點。
推薦一個非常棒的開源項目dive,具體安裝請參考其項目頁。它不但可以方便我們查詢具體鏡像層的詳細信息,還可以作爲CI
持續集成過程中的鏡像審查之用。使用它可以大大提升我們審查鏡像的速度,並且可以將這個過程做成自動化。
該項目的具體動態操作圖示如下:
如果作爲鏡像審查之後,可以進行如下命令操作:
$: CI=true dive <image-id>
Fetching image... (this can take a while with large images)
Parsing image...
Analyzing image...
efficiency: 95.0863 %
wastedBytes: 671109 bytes (671 kB)
userWastedPercent: 8.2274 %
Run CI Validations...
Using default CI config
PASS: highestUserWastedPercent
SKIP: highestWastedBytes: rule disabled
PASS: lowestEfficiency
從輸出信息可以得到很多有用的信息,集成的CI
過程也就非常容易了。 dive
本身就提供了.dive-ci
作爲項目的CI
配置:
rules:
# If the efficiency is measured below X%, mark as failed.
# Expressed as a percentage between 0-1.
lowestEfficiency: 0.95
# If the amount of wasted space is at least X or larger than X, mark as failed.
# Expressed in B, KB, MB, and GB.
highestWastedBytes: 20MB
# If the amount of wasted space makes up for X% or more of the image, mark as failed.
# Note: the base image layer is NOT included in the total image size.
# Expressed as a percentage between 0-1; fails if the threshold is met or crossed.
highestUserWastedPercent: 0.20
集成到CI
中,增加以下命令即可:
$: CI=true dive <image-id>
鏡像審查和代碼審查類似,是一件開始抵制,開始後就欲罷不能的事。這件事宜早不宜遲。對於企業與個人而言均百利而無一害。