透過 Multiple Stage Builds 編譯出最小的 Docker Image

June 9, 2018

Photo by frank mckenna on Unsplash

I'm a Docker rookie 🐣. 本文章是記錄使用 Multi Stage Builds 的心得,若有錯誤歡迎告知,謝謝!

Docker 提供非常便利的 Dockerfile 讓我們可以自定義映像檔的環境設定、本機端的檔案複製、命令的執行等等,但是事實上,我們在製作這些容器的映像檔時,可能會需要安裝編譯環境、或是其他的依賴套件,不知不覺會造成映像檔非常的肥大。

前陣子在開發簡易 591 租屋網搜尋網站時,就打算借這個機會來學習 Docker,在看了文件之後,大概對 Docker 使用上有些概念了,也搜尋一些文章來看,例如怎麼撰寫一個好的 Dockerfile,剛好在 CodeTengu 碼天狗ISSUE 87 中,看到 @vinta 前輩有分享一篇 How to write excellent Dockerfiles 的文章,覺得真的很受用啊,內容最後也提到了 Multi Stage Builds,所以也順便了解一下。

在搜尋了一些文章後,才了解 Multi Stage Builds 這件事,簡單來說,以前的作法需要多個 Dockerfile 來處理,@kevingo 前輩的 Go-Small-Docker-Image 就是一個很好的範例,詳細可以閱讀 README 了解,或者也可以閱讀 @appleboy 前輩的「用 Docker Multi-Stage 編譯出 Go 語言最小 Image」。

Docker 是在 17.05 版本後提供了 Multi Stage Builds 的 feature,翻成中文稱為「多階段構建」,在 17.05 版本之前,如果要做到多階段構建這件事情,如上所述,你需要多個 Dockerfile 才能完成,如果環境的構建比較複雜的話,可能需要維護多個 Dockerfile,而 Multi Stage Builds 可以解決需要維護多個 Dockerfile 的問題。

撰寫 Dockerfile

Build Stage

FROM golang:alpine AS builder

WORKDIR /go/src/fiveN1

RUN set -ex; \
    apk add --no-cache curl git \
    && curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh \
    && dep version

COPY . .

RUN dep ensure -v -vendor-only \
    && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

在 Build Stage 的部分:

  1. FROM golang:alpine:使用 golang 官方的映像檔
  2. WORKDIR /go/src/fiveN1:指定工作的目錄
  3. Run set -ex; \...:安裝 curlgit,以及 Go 套件管理 dep
  4. COPY . .:複製我目錄的檔案到容器內的目標位置,也就是上面 WORKDIR 所指定的目錄位置
  5. RUN dep ensure -v -vendor-only \...:安裝相關套件並編譯

第一階段(Build Stage)安裝相關套件後,並編譯檔案,Go 編譯出來的結果是一個 Binary 的可執行檔案。實際上,我們所需要的就是這個 Binary 的檔案,其餘都是不需要的。

在第一行可以注意到後面使用了一個 AS 的關鍵字,我們可以為 FROM 加上一個 <NAME> 名稱來作為標示,預設上,每個 Stage 是不會有名稱的,而是用數字作為代表,例如 Dockerfile 內有好幾個 FROM,由上到下的順序就是從 0n,使用 AS <NAME> 可以讓 Dockerfile 更加易讀明白。

Final Stage

FROM alpine

RUN apk --no-cache add ca-certificates

WORKDIR /root/

COPY --from=builder /go/src/fiveN1/app .

EXPOSE 8000

CMD ["./app"]

在 Final Stage 部分:

  1. FROM alpine:使用 alpine 的映像檔
  2. RUN apk --no-cache add ca-certificates:安裝 ca-certificates 套件
  3. WORKDIR /root/:指定工作目錄
  4. COPY --from=builder /go/src/fiveN1/app .:這段非常關鍵,注意到使用了一個參數 --from,這裡的 builder 也就上方 Build Stage 所產生的 artifacts,我們在 builder 的時候將目錄指定為 /go/src/fiveN1/,在編譯後會產生一個 app 的檔案,我們把它複製到 Final Stage 的目錄來
  5. EXPOSE 8000:開放 Port 8000 端口
  6. CMD ["./app"]:執行 Binary 檔案

COPY --from=builder 會複製先前在 Build Stage 所產出的 artifacts 到目前的 Stage,但它們並不會被保留在 Final Stage,這樣一來就可以有效的減少映像檔的大小囉!

執行 Docker 映像檔的建立

我們將上面的 Dockerfile 整理如下:

# BUILD STAGE
FROM golang:alpine AS builder

WORKDIR /go/src/fiveN1

RUN set -ex; \
    apk add --no-cache curl git \
    && curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh \
    && dep version

COPY . .

RUN dep ensure -v -vendor-only \
    && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

# FINAL STAGE
FROM alpine

RUN apk --no-cache add ca-certificates

WORKDIR /root/

COPY --from=builder /go/src/fiveN1/app .

EXPOSE 8000

CMD ["./app"]

我們為映像檔加上名稱和 tag,並使用現在目錄下的 Dockerfile,接著執行 docker build 的指令:

$ docker build -t neighborhood999/docker-fiven1-demo .

構建映像檔的 Log 輸出結果如下:

Sending build context to Docker daemon  10.24kB
Step 1/11 : FROM golang:alpine AS builder
 ---> 52d894fca6d4
Step 2/11 : WORKDIR /go/src/fiveN1
Removing intermediate container 53dc21b7eba9
 ---> 7b8a3a6ec6de
Step 3/11 : RUN set -ex;     apk add --no-cache curl git     && curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh     && dep version
 ---> Running in cc3537cc7ede
+ apk add --no-cache curl git
fetch http://dl-cdn.alpinelinux.org/alpine/v3.7/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.7/community/x86_64/APKINDEX.tar.gz
(1/6) Installing libssh2 (1.8.0-r2)
(2/6) Installing libcurl (7.60.0-r1)
(3/6) Installing curl (7.60.0-r1)
(4/6) Installing expat (2.2.5-r0)
(5/6) Installing pcre2 (10.30-r0)
(6/6) Installing git (2.15.2-r0)
Executing busybox-1.27.2-r7.trigger
OK: 19 MiB in 18 packages
+ curl https://raw.githubusercontent.com/golang/dep/master/install.sh
+ sh
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0ARCH = amd64
100  4594  100  4594    0     0   3179      0  0:00:01  0:00:01 --:--:--  3179
OS = linux
Will install into /go/bin
Fetching https://github.com/golang/dep/releases/latest..
Release Tag = v0.4.1
Fetching https://github.com/golang/dep/releases/tag/v0.4.1..
Fetching https://github.com/golang/dep/releases/download/v0.4.1/dep-linux-amd64..
Setting executable permissions.
Moving executable to /go/bin/dep
+ dep version
dep:
 version     : v0.4.1
 build date  : 2018-01-24
 git hash    : 37d9ea0a
 go version  : go1.9.1
 go compiler : gc
 platform    : linux/amd64
Removing intermediate container cc3537cc7ede
 ---> 9f66d10378ec
Step 4/11 : COPY . .
 ---> 83a8135a7802
Step 5/11 : RUN dep ensure -v -vendor-only     && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
 ---> Running in e4fb8efbaabb
(1/6) Wrote github.com/vinta/[email protected]
(2/6) Wrote golang.org/x/[email protected]
(3/6) Wrote github.com/google/[email protected]
(4/6) Wrote github.com/andybalholm/[email protected]
(5/6) Wrote github.com/neighborhood999/[email protected]
(6/6) Wrote github.com/PuerkitoBio/[email protected]
Removing intermediate container e4fb8efbaabb
 ---> 86e8d32cf39b
Step 6/11 : FROM alpine
 ---> 3fd9065eaf02
Step 7/11 : RUN apk --no-cache add ca-certificates
 ---> Running in f5d70d603698
fetch http://dl-cdn.alpinelinux.org/alpine/v3.7/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.7/community/x86_64/APKINDEX.tar.gz
(1/1) Installing ca-certificates (20171114-r0)
Executing busybox-1.27.2-r7.trigger
Executing ca-certificates-20171114-r0.trigger
OK: 5 MiB in 12 packages
Removing intermediate container f5d70d603698
 ---> c226c28e5180
Step 8/11 : WORKDIR /root/
Removing intermediate container 05582c41c40d
 ---> 1ffe05b3b07c
Step 9/11 : COPY --from=builder /go/src/fiveN1/app .
 ---> 12c5349b3dbe
Step 10/11 : EXPOSE 8000
 ---> Running in a65c82c7c81a
Removing intermediate container a65c82c7c81a
 ---> 44c86c4b3185
Step 11/11 : CMD ["./app"]
 ---> Running in 5f9061907f1c
Removing intermediate container 5f9061907f1c
 ---> 24f5e7bb146d
Successfully built 24f5e7bb146d
Successfully tagged neighborhood999/docker-fiven1-demo:latest

最後使用 docker images 看看構建後的映像檔大小:

$ docker images

REPOSITORY                           TAG                 IMAGE ID            CREATED             SIZE
neighborhood999/docker-fiven1-demo   latest              24f5e7bb146d        5 minutes ago       14.3MB

哇!只有 14.3MB,看起來是成功啦!完整的範例請參考我的 GitHub docker-fiveN1

Reference