什麼是 Docker multi stage?

👀 6 min read 👀

大家好,我是 Cindy,前一陣子因爲工作夥伴們遇到的問題,而研究了一下 Docker 的 multi-stage build,
故事是這樣的:

小夥伴們開發了一個 gem,push 到客戶的 GitLab,遇到了兩次 pipelines 失敗的情況:

  • 第一次,在 GitLab runner 開始進行 test stage 的 bundle install 的時候,因為沒有 pull GitLab repository 的權限,而無法下載客製化的 gem。
    • 解決方法:在 .gitlab-ci.yml 的 before_script 做 GitLab runner ssh 的設定,並將 $SSH_PRIVATE_KEY 放在 GitLab runner 的環境變數中,詳細可參考 Using SSH keys with GitLab CI/CD
  • 第二次,在 GitLab runner 要進行部署的時候,現況 GitLab runner 會進行 docker build 將 Rails 打包成 image 之後,再 push 到 AWS 的 ECR,接著會用 ecs-deploy 的指令進行部署,將先前 push 到 ECR 上的 Rails image 部署到 AWS 的 ECS,這邊在進行 docker build 的時候,會在 Dockerfile 中執行 bundle install,因為 Dockerfile 沒有 pull GitLab repository 的權限,而無法下載客製化的 gem。
    • 解決方法:在 GitLab runner 執行 docker build 的時候,將 GitLab runner 的 $SSH_PRIVATE_KEY 當作參數傳進 Dockerfile 中。

到這邊大概是整個故事,但 docker multi stage 還沒出現噎,讓我娓娓道來,上面提到的 在 GitLab runner 執行 docker build 的時候,將 GitLab runner 的 $SSH_PRIVATE_KEY 當作參數傳進 Dockerfile 中。 這個解決方法有一個前提,就是我們必須確定 $SSH_PRIVATE_KEY 的傳遞是安全的!也就是說這把 key 不應該出現在 image 的任何記錄之中,否則只要拿到 image 就拿到 key 了。

先說結論:用 multi-stage build 可以解決 key 被記錄下來的問題,但 multi-stage 想要解決的主要問題其實是因為 build image 太大。

想知道為什麼就繼續往下看吧~

Docker image layers

先跟大家介紹個名詞,Docker image layers,在 Dockerfile 中的每個指令會產生一個 image layer,且每層 image layer 會記錄比上一個 image layer 有哪些檔案的差異,層層堆疊上去,然後把檔案加總起來,我們可以從這些 layers 中查看 build image 的所有檔案紀錄。

接下來示範一下要怎麼看這些檔案裡面的全部內容,例如我已經有一個叫做 railsapp 的 image,執行以下步驟可觀察 image 的全貌:

  1. 執行 docker save -o railsapp.tar railsappdocker save 的指令將 image 存成一個壓縮檔。

  2. 執行 tar -zxvf railsapp.tar 進行解壓縮。

  3. 打開 manifest.json,到這邊就可以看到 image 有幾個 layer 了!

  4. 打開 Config a2a3bd208cd4fc2d2c847f2cc0cd241d6171c9b432db844f00bd47dc681df436.json,順道一提我們看到的這些檔案就是符合 OCI 的格式(可以簡單理解成容器化技術開放出的一定格式),可以從這個 json 檔看到有很多的紀錄,包含了環境變數、執行過的 command 等等。

  5. cd e14687248cea12d8112c3102c8d16604829553403933cda75612e49d5724d027 進去其中一層 layer,然後 tar -zxvf layer.tar 解壓縮看看,結果如下:

    可以看到這邊就是一些資料夾和檔案,也就是我們在執行 image 的時候裡面的資料夾和檔案。

到這邊跟大家說明了,docker image 裡面包含的東西,接下來才是這篇文章的主角(鋪成也太長)。

multi-stage builds

在 Docker 的文件中的 Best practices for writing Dockerfiles 這篇文章就有建議大家使用 multi-stage builds,那麼究竟什麼是 multi-stage builds 呢? Use multi-stage builds 的文章中提到,在 multi-stage 之前,如果我們有需要兩個 image 的情況,可能會需要寫一個 script 先 build 第一個 image,產生一個 container 將需要的資料夾或檔案複製出來,然後再 build 第二個 image,最後再刪掉中間使用到的資料夾或檔案。

multi-stage 解決了上述麻煩的步驟,讓我們可以在同一個 Dockerfile 撰寫數個中間會用到的 image,而不會被保存下來,文件的範例如下:

1
2
3
4
5
6
7
8
9
10
11
12
# syntax=docker/dockerfile:1
FROM golang:1.16
WORKDIR /go/src/github.com/alexellis/href-counter/
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=0 /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]

第 2 到 6 行其實就是第一個要 build 的 image,而第 8 到 12 行就是第二個要 build 的 image,而 COPY --from=0 /go/src/github.com/alexellis/href-counter/app . 就是將我們需要的第一個 image 的資料夾複製到第二個 image 之中,而第一個 image 因為是中間使用到的 image,所以並不會在最後生成的 image layers 之中!

大家發現了嗎?先前有提到 image layers 中有各種紀錄,而 multi-stage 中的中間 image 不會產生 layer 也就是不會有紀錄的意思囉!所以我們可以用 multi-stage 的方式在中間的 image 拿到 key 之後進行 bundle install,而將需要的檔案複製到最後產生的 image 之中,就不用擔心 image 裡會有 key 了!就像先前提到的,這並不是 multi-stage 想要解決的問題,主要是因為少了這些中間 image layers 也就意味著 image 的 size 是會變小的!

結論

  • 小技巧:利用 multi-stage 可以傳遞 key 而不會被記錄在 image 紀錄之中,善用 multi-stage 可以大幅降低 image 的 size。

  • 其他 Docker 傳遞私密資訊的方式:

    • --secret
      在新版 Docker Engine API (v1.39+) 中,可將外部檔案的 secret 帶進 Dockerfile 之中而不會被存在紀錄之中,但前提是要用 BuildKit 才可以使用。使用方式如下:

      1
      2
      3
      # syntax=docker/dockerfile:1.0.0-experimental
      FROM alpine
      RUN --mount=type=secret,id=mysite.key command-to-run

      docker build --secret id=mysite.key,src=path/to/mysite.key .

    • --ssh
      在新版 Docker Engine API (v1.39+) 中,可以將現有的 SSH agent connection 或 key 傳遞進 Dockerfile,跟上面一樣要是 BuildKit 才可以用。使用方式如下:

      1
      2
      3
      4
      5
      6
      7
      8
      # syntax=docker/dockerfile:experimental
      FROM alpine
      # install ssh client and git
      RUN apk add --no-cache openssh-client git
      # download public key for github.com
      RUN mkdir -p -m 0600 ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts
      # clone our private repository
      RUN --mount=type=ssh git clone git@github.com:myorg/myproject.git myproject

      docker build --ssh default .

    詳細可參考: