在專案開發時,通常會執行代碼檢查、單元測試、編譯、執行等等。

在 docker 還沒出現之前,這些流程都是在本機上跑 (版本 0),因為環境的不一致可能導致不同主機執行結果不相同。將這些流程步驟撰寫成 dockerfile 直接在容器裡執行就可以解決上述問題 (版本 1),但也衍伸出了新的問題。為了執行代碼檢查和編譯,下載了許多套件或是相依工具,造成映像檔肥大,很多套件或是相依工具在部署時是不需要的。

如果把這些步驟拆成兩個階段思考 (版本 2)

  • 開發整合階段:代碼檢查、單元測試、編譯等等使用。
  • 部署階段:跑起執行檔,將此兩階段寫成兩個 dockerfile,並且建構出兩個映像檔,只要拿後者映像檔去部署即可。 版本 2 成功解決了部署映像檔大小的問題,可是在必須維護兩個 dockerfile 還有中間流程所需的 script,Docker 17.05 所推出的 Multi-stage build 正好能解決此問題 (版本 3)。

以下則是 版本 0 進化到 版本 3 的過程及解釋。

範例 go 專案,版本 0:沒使用 docker

此專案使用 gin 實作 http server 只要對它請求 GET /ping ,則會回覆 pong, 以下是專案結構:

> tree example
example
├── go.mod
├── go.sum
├── main.go
└── main_test.go 

在完成程式碼撰寫之後,執行以下步驟

  1. 使用 go vet 檢查
  2. 使用 staticcheck 檢查
  3. 跑完測試
  4. 編譯專案
  5. 執行
$ go vet ./...
$ staticcheck ./...
$ go test ./...
$ go build -o ./server
$ ./server

Dockerfile 版本一: 全部都塞在一個映像檔

FROM golang:1.16.0-alpine3.13
# go module
ENV GO111MODULE=on CGO_ENABLED=0

# install staticcheck binary
RUN go install honnef.co/go/tools/cmd/staticcheck@v0.1.2

WORKDIR /workspace/example

# download dependency
COPY go.mod go.sum .
RUN  go mod download


# Copy source code
COPY . ./


RUN go vet ./... && staticcheck ./... && go test ./... && go build -o /bin/server

把映像檔做出來

$ docker image build -t amikai/example:v1  .

為了要對此專案進行代碼檢查和編譯,必須將相關工具以及依賴下載好,而這些依賴及工具都會佔用到空間,觀察 container 裡 /go (go 相關依賴) 就佔了 328.7 MB (使用 du -sh /go 觀察),而整個映像檔是 687 MB,映像檔大小還有相當大的改善空間。

Dockerfile 版本二:拆成兩個階段思考

把版本一的 dockfile 拆成兩個階段思考:

  • 從建構的階段來想:為了確保每個人在代碼檢查以及編譯的環境是統一的,減少環境不同所造成的問題。
  • 從部署的階段來想:最後只要留下需要編譯後所產生的執行檔,其他相關的依賴和工具都是不必要的,編譯完後放在那只是佔位置。

把這兩個階段的事情,拆成兩個映像檔:

  • 建構階段 (Dockerfile):此階段是對原始碼進行檢查及編譯等等,不太需要考慮映像檔大小,反正不會拿去部署。
  • 部署階段 (Dockerfile.build): 此在意的是映像檔大小,所以留下編譯後的執行檔即可。

具體的做法就是先做出一個 Dockerfile.build 去 build 出執行檔,把此執行檔取出,再寫一個 Dockerfile 將此執行檔放入,開始執行。在使用一個 build.sh 將上述事情自動化。

Dockerfile.build

FROM golang:1.16.0-alpine3.13
# go module
ENV GO111MODULE=on CGO_ENABLED=0

# install staticcheck binary
RUN go install honnef.co/go/tools/cmd/staticcheck@v0.1.2

WORKDIR /workspace/example

# download dependency
COPY go.mod go.sum .
RUN  go mod download

# Copy source code
COPY . ./

RUN go vet ./... && staticcheck ./... && go test ./... && go build -o /bin/server

Dockerfile

FROM alpine:latest

WORKDIR /root/

COPY server .

ENTRYPOINT ["./server"]

build.sh

#!/bin/sh
echo Building amikai/example:build

docker build -t amikai/example:build . -f Dockerfile.build
docker container create --name extract amikai/example:build 
docker container cp extract:/bin/server ./server
docker container rm -f extract

echo Building amikai/example:v2
docker image build --no-cache -t amikai/example:v2  .

執行 build.sh 將 example:v2 的映像檔做出來,這個映像檔大小是 15 MB,已經和版本一的 687 MB 有很大的進步空間。

雖然最終產出的映像檔大小有極大的改善,但為了這個映像檔,必須維護三個檔案,在微服務的世界裡,如果一個容器裝一個服務,就必須維護 服務數量 * 3 的檔案。

而且是兩個映像檔分別都會佔用到硬碟空間,也就是說雖然拿來部署的映像檔改善了大小,但總體佔用的硬碟空間和版本一是幾乎一樣的。

Dockerfile 版本三: multi-stage builds

docker 在 17.05 提出 multi-stage builds,整個 dockerfile 可以分割成好幾個 stage,每個 stage 由 FROM 作為開始,也就是說 FROM 除了選擇基底映像檔之外,還有新 stage 起始的意義。

multi-stage 功能讓我們可以將此 stage 所需要的檔案,複製到下個 stage 做其他事,則最終做出來的映像檔只會留下所需的檔案。

版本三的 dockerfile 如下,有兩個 stage:

  1. builder stage: 以 golang 為基底的映像檔,用來做原始碼檢查、測試,並且編譯出執行檔
  2. release stage: 將 builder stage 的執行檔複製過來,並且執行
FROM golang:1.16.0-alpine3.13 AS builder
WORKDIR /workspace/example
ENV GO111MODULE=on CGO_ENABLED=0
# download dependency
COPY go.mod go.sum .
RUN  go mod download
RUN go install honnef.co/go/tools/cmd/staticcheck@v0.1.2
COPY . .
RUN go vet ./... && staticcheck ./... && go test ./... && go build -o /bin/server


FROM alpine:latest AS release
# Copy from builder
COPY --from=builder /bin/server /bin/server
ENTRYPOINT ["./bin/server"]

把映像檔做出來

$ docker image build -t amikai/example:v3  .

版本三的映像檔大小為 15 MB,解決了版本二不需要維護三個檔案也解決了版本一產出肥大映像檔的痛點。multi-stage builds 真是個有感的功能呀。

實驗環境

golang

 $ go version
go version go1.16 darwin/amd64

docker

 $ docker version
Client: Docker Engine - Community
 Cloud integration: 1.0.7
 Version:           20.10.2
 API version:       1.41
 Go version:        go1.13.15
 Git commit:        2291f61
 Built:             Mon Dec 28 16:12:42 2020
 OS/Arch:           darwin/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.2
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.13.15
  Git commit:       8891c58
  Built:            Mon Dec 28 16:15:28 2020
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.4.3
  GitCommit:        269548fa27e0089a8b8278fc4fc781d7f65a939b
 runc:
  Version:          1.0.0-rc92
  GitCommit:        ff819c7e9184c13b7c2607fe6c30ae19403a7aff
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
 Kubernetes:
  Version:          v1.16.15-gke.7800
  StackAPI:         Unknown

此為範例專案最終完成版,並且可以透過 git 記錄看到版本一和版本二的內容。

sha256sum

5321e3a3d67195f7c44f7c1e76ca229717fcd49ebb3359528038745670f68aba  example.tar.gz

Reference