有一種方式可以幫助映像檔快速瘦身,就是使用多階段的建置方式,整個多階段建置的精髓都是在 COPY —from 這段指令。
之前的章節使用 COPY 都是從本機複製檔案到映像檔的檔案系統中,而 COPY —from 則可以讓我們從另一個映像檔複製檔案到現階段的映像檔。
我知道這樣看下來還是會霧煞煞,直接來看 Dockerfile 範例:
FROM alpine:3.16.2 AS builder # 建置階段
RUN echo 'Builder' > /example.txt # 建置階段
FROM alpine:3.16.2 AS tester # 測試階段
COPY --from=builder /example.txt /example.txt # 測試階段
RUN echo 'Tester' >> /example.txt # 測試階段
FROM alpine:3.16.2 # 最終階段
COPY --from=tester /example.txt /example.txt # 最終階段CMD [ "cat", "/example.txt" ] # 最終階段
我們將整個 Dockerfile 分成三個階段,在開始解說前,要先有一個對於 Dockerfile 的基礎認知,只要是用 FROM 作為開頭就可以說是一個新的階段,而在第一個 FROM 到第二個 FROM 之間的指令結果都會停留在第一個階段中,也就是一個 FROM。
建置階段:
首先利用了 alpine:3.16.2 這個映像檔作為基礎,並且簡單的執行了一個 RUN 的指令,作用是把 Builder 這段文字寫入 example.txt 這個檔案,就結束任務了。
測試階段:
這邊 Dockerfile 讀到了第二個 FROM,所以就當作一個新的開始,而我們一樣使用 alpine:3.16.2 這個映像檔作為基礎,但不同的是,我們使用了 COPY --from=builder /example.txt /example.txt
這段指令。
對於 Docker 來說,要從 builder 這個階段複製一份 example.txt 到現在這個階段內並命名為 example.txt,此時 Docker 會去找 builder 這個階段,但其實我們已經把第一階段命名好了,可以看到第一個 FROM 的後面我們用了 AS 這個語法,將第一個階段命名為 builder。
接著再把 Tester 這段文字寫入 example.txt 檔案中,也就遇到第三個 FROM 並結束了第二個階段。
最終階段:
來到最後一個階段,我們使用了 COPY --from=tester /example.txt /example.txt
來把 tester 這個階段的 example.txt 複製過來最終階段,並且命名為 example.txt;做的事情其實和第二階段一樣,只是最後使用了 CMD 並且去讀取 example.txt 這個檔案的內容。
接著先建置這個映像檔:
$ docker image build --tag example .
[+] Building 5.7s (10/10) FINISHED
=> [internal] load build definition from Dockerfile 0.2s
=> => transferring dockerfile: 313B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/alpine:3.16.2 3.8s
=> [auth] library/ruby:pull token for registry-1.docker.io 0.0s
=> [builder 1/2] FROM docker.io/library/alpine:3.16.2@sha256:bc... 0.0s
=> => resolve docker.io/library/alpine:3.16.2@sha..... 0.0s
=> => sha256:bc41182d7ef5ffc53a40b044e72519.... 0.0s
=> => sha256:1304f174557314a7ed9eddb4eab1..... 0.0s
=> => sha256:9c6f0724472873bb50a2ae67a9e7..... 0.0s
=> [builder 2/2] RUN echo 'Builder' > /example.txt 0.7s
=> [tester 2/3] COPY --from=builder /example.txt /example.txt 0.1s
=> [tester 3/3] RUN echo 'Tester' >> /example.txt 0.4s
=> [stage-2 2/2] COPY --from=tester /example.txt /example.txt 0.1s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:d7b4e571b321c9f72696e3f620b64a2859.... 0.0s
=> => naming to docker.io/library/example 0.0s
接著猜猜看,如果我們把這個映像檔運行成容器會發生什麼事呢?用我們學習到現在的 Docker 基本知識猜猜看吧!
公佈答案囉!
$ docker container run example
Builder
Tester
跟你想得一樣嗎?藉由複製前兩個階段的檔案一直傳遞到最後一個階段,並且讀取檔案中的內容,確實都還保留著前兩個階段所寫入的文字。
那這代表什麼呢?
代表著我們能夠在前面的階段將要安裝的套件以及安裝套件所需的編譯工具準備好,並且安裝完應用程式所需的套件,只把安裝好的套件複製到第二個階段,這將會把第一個階段編譯所需要的工具都丟棄,也大幅度地減少了映像檔的大小。
Golang 應用程式的多階段建置
這邊的練習範例我也會放在 GitHub 上面,如果想要自己在本機練習的人可以使用 git clone
的方式把檔案下載到本機。
$ git clone https://github.com/Robeeerto/golang-web-server-multi-stage-practice.git # 不換行
接著我們進入到資料夾內,並且建置映像檔。
$ cd golang-web-server-multi-stage-practice
# 進入資料夾
$ docker image build --tag golang-example .
[+] Building 2.8s (11/11) FINISHED
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 233B 0.0s
=> [internal] load .dockerignore 0.1s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/golang:alpine3.16 0.0s
=> [auth] library/ruby:pull token for registry-1.docker.io 2.5s
=> [1/5] FROM docker.io/library/golang:alpine3.16@sha256:d475ce... 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 290B 0.1s
=> CACHED [2/5] WORKDIR /app 0.0s
=> CACHED [3/5] RUN export GO111MODULE=on && go mod init example.com/m/v2 0.0s
=> CACHED [4/5] COPY main.go ./ 0.0s
=> CACHED [5/5] RUN go build -o ./server 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:db9d62ed38ad68d1bb8f555e376cf9290cd062315.... 0.0s
=> => naming to docker.io/library/golang-example 0.0s
接著將建置完的映像檔運行成容器,先確認這個映像檔是可以使用的。
$ docker container run --publish 8000:8000 --detach golang-example # 不換行
b7f10fba5e0f4f3d670134c44007c0e69795cc2547a9...
接著打開瀏覽器輸入 http://localhost:8000
,應該會看到以下的畫面。
確定可以使用之後,來看一下這個映像檔的大小:
$ docker image list --filter=reference='golang-example'
REPOSITORY TAG IMAGE ID CREATED SIZE
golang-example latest db9d62ed38ad 18 minutes ago 359MB
目前是 359 MB,我們的最終目標可以讓映像檔剩下約 15MB 左右,這樣在傳輸的速度就會超級快,且能達到的目的是一樣的,著手來修改這個 Dockerfile 吧!
FROM golang:alpine3.16
CMD ["/app/server"]
EXPOSE 8000
WORKDIR /app
RUN export GO111MODULE=on && \
go mod init example.com/m/v2
COPY main.go ./
RUN go build -o ./server
首先要寫好 Dockerfile 的多階段建置,對於該程式語言的應用程式需要有基礎的了解,以編譯式的程式語言來說,都需要先把應用程式編譯好,接著只需要執行這個編譯完的檔案就能夠啟動服務。
所以我們預想中的流程是編譯出這個 Golang 的應用程式,並且只把它帶到下一個階段然後執行它,這樣就能大幅度地減少映像檔的容量。
先把第一階段寫出來,需要進入一個叫做 app 的檔案目錄,並且執行 Golang 會使用到的指令,之後複製主要的檔案 main.go 進到映像檔中,然後編譯它。
FROM golang:alpine3.16 AS builder
WORKDIR /app
RUN export GO111MODULE=on && \
go mod init example.com/m/v2
COPY main.go ./
RUN go build -o ./server
這邊就完成了第一階段,接著需要複製第一階段編譯完叫做 server 的檔案到第二階段,並且執行它。
FROM golang:alpine3.16 AS builder
WORKDIR /app
RUN export GO111MODULE=on && \
go mod init example.com/m/v2
COPY main.go ./
RUN go build -o ./server
-----階段分界示意線----- # 不要寫進 Dockerfile
FROM alpine:latest
WORKDIR /app
CMD ["/app/server"]
EXPOSE 8000
COPY --from=builder /app/server /app/server <- 複製第一階段的檔案到最終階段
接著再重新的建置一次映像檔,並且貼上不同的標籤以便等等可以做比較。
$ docker image build --tag golang-min-example .
[+] Building 4.7s (15/15) FINISHED
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 37B 0.0s
=> [internal] load .dockerignore 0.1s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/golang:alpine3.16 4.4s
=> [internal] load metadata for docker.io/library/alpine:latest 0.0s
=> [auth] library/ruby:pull token for registry-1.docker.io 0.0s
=> [builder 1/5] FROM docker.io/library/golang:alpine3.16@sha25... 0.0s
=> [stage-1 1/3] FROM docker.io/library/alpine:latest 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 29B 0.0s
=> CACHED [stage-1 2/3] WORKDIR /app 0.0s
=> CACHED [builder 2/5] WORKDIR /app 0.0s
=> CACHED [builder 3/5] RUN export GO111MODULE=on && go mod.... 0.0s
=> CACHED [builder 4/5] COPY main.go ./ 0.0s
=> CACHED [builder 5/5] RUN go build -o ./server 0.0s
=> CACHED [stage-1 3/3] COPY --from=builder /app/server /app/server 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:30e6f003267f9c910fbd2f3e0f88e93fe61595....... 0.0s
=> => naming to docker.io/library/golang-example 0.0s
接著一樣將其運行成容器,試試看是不是多階段建置也能夠達到相同的效果。
$ docker container rm --force $(docker container ls -aq)
# 先清掉所有容器,避免 port 衝突
$ docker container run --publish 8000:8000 --detach golang-min-example # 不換行
c638511e9dd3f9af5eb195a948bd76caa3...
再來一樣打開瀏覽器輸入 http://localhost:8000
,就會看到和上次啟動容器時一模一樣的畫面,證明這個方式建置的映像檔可以達到一樣的目的。
再來就是要揭曉映像檔容器大小的時刻了,我們來看一下到底差了多少。
$ docker image list --filter=reference='golang-*'
REPOSITORY TAG IMAGE ID CREATED SIZE
golang-min-example latest 30e6f003267 49 minutes ago 12MB
golang-example latest db9d62ed38a 30 minutes ago 359MB
足足差了 347 MB,但做的事情是一模一樣的,這就是有沒有使用多階段建置的差別,別小看這 347 MB,現在所流行的微服務就是透過多個不同的映像檔組成一個完整的應用程式,若是每一個都有 350 MB 的大小,那累積起來的容量差距將更明顯。
而且把不需要的編譯工具丟掉,一方面也提升了應用程式的安全性,當一個應用程式的執行環境愈乾淨的時候,可以攻擊的漏洞就會大幅度地減少。
結語
今天用 Golang 應用程式示範了一下多階段建置所帶來的好處,明天則會稍微提到 .dockerignore
這個檔案,以及如何清除本機上多餘的容器和映像檔。