Docker Image Best Practice & Optimization

Docker 的 Slogan 就是快速的 build ship run。而其中 building 和 shipping 大概是最花時間的步驟了!為什麼明明一個簡單的 Image 卻有 500MB 的大小呢呢?今天就來看看創建一個優質 Docker Image 的各種方法。

Docker Image Best Practice & Optimization

Docker 的 Slogan 就是快速的 build ship run。而其中 buildingshipping 大概是最花時間的步驟了!為什麼明明一個簡單的 Image 卻有 500MB 的大小呢呢?今天就來看看創建一個優質 Docker Image 的各種方法。

以下所有影片都是 asciinema,所以任何指令和 output 都可以直接複製下來貼到你自己的終端機 Terminal 上。

Docker Image Layers 分層式結構 - UnionFS

UnionFS - Union File System
Layer 階層

首先必須先了解 Docker 和 UnionFS 的關係。簡而言之每一層 Layer 包含許多檔案,而 Docker Image 就是一層疊一層的集合體。看看以下的示意圖:

以上圖為例,Image 本身有三層,並且每層都有各自的檔案。一個完整的 Image 本身所有的階層都是 read-only (只讀) 的,但 Docker 會在上面加上一個 read-write (讀寫皆可)  的第四層。幾乎 Dockerfile 的每一行都會建立起一層。

首先來證明創建出的大小是和上面敘述的一樣

而了解分層式結構為什麼那麼重要呢?假如在示意圖中的 Layer 3 將 Layer 1 的 file1 刪除,那最後 Image 的大小會是多少呢?(答案是不變)

總結就是即便我們在後面的階層中刪除了一些檔案,那些檔案依然會留在我們的原先的階層裡面。因為 Image 是階層的總體,所以即便後面刪除了也只是多了一個新的階層而已,Image 的大小並不會減少

以此為起點,接下來來看看有哪些方法可以減少 Image 的大小。

Normalize Image Layers 標準化映像階層

一個 Image 最多可以有 127 個階層,根據不同的 Storage Drive 可以有更多的階層數量,但若是有超過這個數量可能會限制一個 Image 可以被創建的地方 (因為不是所有的 Storage Drive 都可以)。

上面說過在 UnionFS 裡,任何加到階層裡的檔案都會一直存在於那個階層,即便在後的階層裡將其刪除 rm 也一樣。下面來證明一下:

這理介紹一個實用的小工具 dive,用來檢視 Image 的各種細項。這裡我們用來看看 Image 的效率 efficiency。

圖中看到刪除後的效率從 100% 變成 43% 而已,並且相較原先 Image 浪費了 7.3 MB 的空間。那有哪些方法能更快速的創建出高效的 Image 呢?

Build Context

# Git
.git
.gitignore
.gitattributes
.github

# Docker
Dockerfile
docker-compose.yml
.dockerignore

# Documentation
README.md
CHANGELOG.md
CONTRIBUTING.md
CODE_OF_CONDUCT.md
SECURITY.md

# macOS (optional)
.DS_Store

# Visual Studio Code (optional)
.vscode
常見的 .dockerignore 檔案

在創建 Image 時,最常見的就是跑 docker build . ,這時前面的 . 是在告訴 docker 將當前路徑用作 root filesystem。這代表在每一次創建 Image 時 docker 都必須先將當前路徑裡的所有資料和檔案載入 build context,考慮到多數人習慣將 Dockerfile 放在專案的最上層,這可能是幾百 MB 甚至幾 GB 的大小。舉例來說一個 nodejs 的專案也許只有 2MB 的 code 但 node_modules 卻是好幾 GB 的大小,但這些在創建 Image 時正常並不會被 COPY,而是會在 Image 裡面額外跑 npm install ,這導致 docker 必須要載入多餘幾 GB 的資料。

改善這個的方法有許多種,其一是將 Dockerfile 放在適合的路徑,讓創建 Image 時不會載入多餘的資料。再來是 .dockerignore 檔案,將不許要被載入的檔案和資料夾列入 .dockerignore 來指示 docker 在創建 Image 時忽略這些檔案。

Commands merge 合併指令

RUN apt-get update \
 && apt-get install -y git \
 && git clone <online-project> \
 && rm -rf /var/lib/apt/lists/* \
 && apt-get remove git -y
只需要用 git 拉一個之後需要用的 Code,那可以在後面將 git 移除

上面說道在 Dockerfile 中每一行 RUN  COPY  ADD 都會生成一個階層,而在 docker 創建 Image 時會常常包括許多從安裝各種 package 的步驟和編譯代碼等等,這些都是非常花時間的步驟。如果能將這類型的指令在同一個 RUN 裡面合併起來的話,那生成的階層就只會有一個了。舉例來說像上面一樣用 && 把指令串起來,不但可以減少階層的數量,還能預防刪除的檔案被留在階層內。

Delete Caches 刪除快取

APK: ... && rm -rf /etc/apk/cache
YUM: ... && rm -rf /var/cache/yum
APT: ... && rm -rf /var/cache/apt

在大多數 Dockerfile 中,安裝各種 package 都是免不了的步驟,在安裝後如果能在同一個 RUN 指示中刪除各種 caches,就可以避免這寫多餘的資料了。上面是各種常見的 package manager 存放快取的地方。

💡 Dive 工具的 efficiency 只會找出各種 ghost files(在之後階層被刪除的檔案),像這種被遺忘的檔案是不會被找出來的。

Squashing the image

squash 後的 Image 只有Base Image 的 5.61MB,所以把浪費的空間都移除了

使用 --squash 是 docker v1.13 以上的一個測試功能 Experimental Feature。Docker 會在最後將所有的階層合為一個階層 (圖中的 merge sh)。因為只有一個階層,這確保後期刪除的檔案確實不存在於 Image 中。

Choose a Base Image

Common Linux distro and node distro example

Base Image 就是 Dockerfile 中 FROM 所指的 Image,是創建一個新 Image 的基底。上圖中會發現各種 Linux 發行版的 Image 大小差距很大,特別是 alpine 只需要 5.61MB 的大小,和其他 Base Image 相比真的非常小。一般的 application 如果沒有特定需求要使用各種 Dependency,其實使用 alpine 是較佳的選擇。

圖中下面兩行是包含 node.js 的 Image。許多 Developer 在挑選 Image 時會直接到 Dockerhub 上直接隨便找個有 node.js 或其他編程語言的 Image 使用,殊不知其中的差別非常巨大。像是圖中的 node:10.16.3-buster 就是一個在 debian@buster 發行版上安裝了 nodejs@10.16.3 的 Image,其大小將進到了 1GB(想像每一次要部署新東西時首先要下載 1GB 大小的 Image)。而最下面的 distroless Image 雖然也是基於 debian 發行版,卻把不必要的 Packages 全部移除了,只留下 node.js 運行必要的相關東西。

💡 distroless 是由 Google 維持的一個 Project,所以他所使用的 Image Registry 並不是 dockerhub 而是 GCR。

Scratch

FROM scratch 在 Dockerfile 中代表完從零開始創建這個 Image。這種方法創建起來的 Image 大小無疑是最小且最優化的。但這個方法也稍微比較有難度,最常見的就是 Image 內只需要一個簡單的 binary。

上面的 Asciinema 影片用於演示一段 Golang 編譯出的 binary app ,創建時用 From scratch 也可以運行,而且 Image 的大小和 binary 大小一模一樣。

Multi-Stage Builds

FROM golang:1.15.7-buster as builder
WORKDIR /go/src/github.com/ewocker/simple-website/
RUN go get -d -v golang.org/x/net/html  
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM alpine:latest  
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/ewocker/simple-website/app .
CMD ["./app"]  

我個人認為 Multi-Stage Build 是創建 Image 的最優辦法,這種方式不但簡單而且也非常容易。當然並不是所有 Image 都適合這種方法,但大多數時候都能使用 Multi-Stage Build 來操作。用上面的 Dockerfile 舉個例子,上面分別有兩個 From 指示,第一個我稱其為 builder Image 並將其用於編譯軟體,第二個則是我實際上創建出的 Image。這樣我在本機上無需安裝 Golang 就可以編譯出 Golang 的 Binary 了,而且雖然編譯時雖然是使用 golang:1.15.7-buster 但實際上最後創建出的 Image 卻是使用 alpine 的。簡而言之 Multi-Stage Build 的好處就是可以無視編譯時環境所需的 Image 大小,甚至可以在其中跑 Test 和做 Code Coverage,最後只要將編譯好的產物用 COPY --from= 複製進最後的 Image 即可。

其他方法

優化 Docker Image 的方式有很多種,除了上述的幾種靠自己知識的方法外,也有許多開源的 Project 專門於優化 Image 的,像是 Docker-slim 等之類的。這裡因為涉及到更多複雜的概念像是 Seccomp 和 AppArmor 等等的自動生成,所以暫時不對 docker-slim 多做介紹。