Docker 映像檔(image)大小在 production 環境中也是相當重要的一環。

如果 Docker 映像檔太大,不僅佔用傳輸頻寬,也會拉長部署(deployment)的時間,因此如何優化 Docker 映像檔案大小是一門重要課題。

優化 Docker 映像檔大小有若干種方法,其中一種為 multistage build, 不過 Docker 官方文件所提供的 multistage builds 範例卻無法正常運作,本篇將修正該範例並實際體驗 multistage builds 的效果。

本文環境

Multistage builds

談 Multistage builds 之前先看以下的 Dockerfile 範例:

FROM golang:1.16
WORKDIR /go/src/github.com/alexellis/href-counter/
COPY ./app.go ./
RUN GO111MODULE=off go get -d -v golang.org/x/net/html && \
    GO111MODULE=auto CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . && \
    apt-get install ca-certificates
CMD ["./app"]

上述 Dockerfile 嘗試以 golang 1.16 的 Docker 映像檔編譯 alexellis/href-counter, 從範例中可以看到 RUN 指令下載 go module 以及執行編譯 go 執行檔,更透過 apt-get 安裝 ca-certificates 套件,最後產出 1 個執行檔(executable) app

如欲編譯請先下載 alexellis/href-counter, 並修改其 Dockerfile 成上述範例,指令如下:

$ git clone https://github.com/alexellis/href-counter
$ cd href-counter
$ vim Dockerfile

該範例編譯完成後約 989MB, 相對於編譯完成的可執行檔 app 來說,可說是相當龐大:

$ docker build -t alexellis2/href-counter:latest .
$ docker images
REPOSITORY                                                       TAG                                                     IMAGE ID       CREATED          SIZE
alexellis2/href-counter                                          latest                                                  2013ff215d52   29 seconds ago   989MB

之所以造成如此大的映像檔,其原因在於 golang 1.16 的映像檔本身就佔據至少 900MB 的空間,如果能夠使用更小映像檔就肯定能夠有效將映像檔變小。

如果我們更進一步檢視編譯流程,其實可以發現整個過程只有最後產出的可執行檔以及安裝 ca-certificates 套件是必須的,要是能夠把最終編譯完成的可執行檔以及安裝 ca-certificates 的步驟放進去 1 個最小的 Docker 映像檔內的話,那麼映像檔將可以變得更小。

要達成這個目標,可以將步驟拆解成以下 2 個步驟:

  1. 編譯可執行檔
  2. 搬移步驟 1 產出的可執行檔至合適的 docker 映像檔內

這就是 Multistage builds 的思路。

Multistage builds

Multistage builds 是 Docker Engine 17.05 之後支援的 1 項新功能,除了能夠讓開發者能夠優化 Docker 映像檔之外,也能夠讓 Dockerfile 的管理更結構化以及更好閱讀,這是得益於能夠透過 multistage builds 將 Docker 映像檔拆解成多個步驟的原因。

Multistage builds are useful to anyone who has struggled to optimize Dockerfiles while keeping them easy to read and maintain.

不過實際執行 Docker 官方文件所提供的範例將會出現以下錯誤:

 => [internal] load build definition from Dockerfile                                                                            ...(略)...
 => CACHED [builder 4/5] COPY app.go    ./                                                                                      0.0s
 => ERROR [builder 5/5] RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .                                    0.5s
------
 > [builder 5/5] RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .:
#13 0.485 go: go.mod file not found in current directory or any parent directory; see 'go help modules'
------
executor failed running [/bin/sh -c CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .]: exit code: 1

上述錯誤是由於 golang 1.16 之後 go module 預設為開啟的原因,因此我們需要稍作修改將 go module 的功能關掉,再進行編譯方可成功。

接下來的 Dockerfile 範例同樣需先下載 alexellis/href-counter, 並修改其 Dockerfile 成下方 Docker multistage builds 範例,指令如下:

$ git clone https://github.com/alexellis/href-counter
$ cd href-counter

Docker multistage builds 範例如下:

FROM golang:1.16 AS builder
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN GO111MODULE=off go get -d -v golang.org/x/net/html
COPY app.go    ./
RUN GO111MODULE=auto 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/alexellis/href-counter/app ./
CMD ["./app"]

上述 Dockerfile 範例中 AS builder 即是 multistage builds 所支援的功能,讓開發者能夠為映像檔編譯步驟命名, AS builder 為將該映像檔命名為 builder, 然後該映像檔只負責編譯 go 的可執行欓 app, 也就是 RUN GO111MODULE=auto CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . 的部分。

緊接著, COPY --from=builder /go/src/github.com/alexellis/href-counter/ 也是 multistage builds 所支援的功能,讓我們從 builder 映像檔(也就是 --from=builder 的部分)中將可執行檔 /go/src/github.com/alexellis/href-counter/app 複製到另 1 個 Docker 映像檔 /root/ 路徑底下,變成第 2 個 Docker 映像檔,同時這個映像檔是以 alpine 為基底,該映象檔相當小,也能夠大幅度縮小最終的映像檔所佔用的空間,如此就完成 multistage builds 了。

編譯指令如下:

$ docker build -t alexellis2/href-counter:latest .

編譯成功的話,可以輸入指令 docker images 查看,可以發現最終的映像檔僅有 12.7 MB 的大小:

$ docker images
REPOSITORY                                      TAG       IMAGE ID       CREATED         SIZE
alexellis2/href-counter                         latest    31063291ead7   2 days ago      12.7MB

總結

Multistage builds 相當適合像 golang, C, C++ 等需要編譯的程式語言,善用該功能除了可以讓 Dockerfile 更加結構化之外,搭配合適的映像檔也能夠有效優化 Docker 映像欓的大小。

Docker 官方文件中也提及更多關於 multistage builds 的用法,其中也有在 Docker Engine 17.05 以前是如何透過其他方式完成 multistage builds 的作法,有空的話也可以閱讀閱讀該文件

以上, happy coding!

References

https://docs.docker.com/develop/develop-images/multistage-build/