Website logo

Robert Chang

技術部落格

Docker - 建置映像檔的快取機制

你可能在想,這個之前不就提過了嗎?

Docker 為了提升建置的速度和儲存空間的優化,在每一個映像層都分別都賦予了以 SHA 算出來獨一無二的 ID,以便 Docker 來辨識是否有相同的映像層,若是你有這樣的想法,那代表前幾天寫的都沒有白費,沒錯!Docker 在建置映像檔的快取機制是這樣子沒錯,那如果檔案系統有更動呢?來做個實驗吧!

這邊在建置一次剛剛一模一樣的映像檔:

# 確保你還在 docker-whoami 的資料夾中
$ docker image build --tag whoami .
[+] Building 0.6s (10/10) FINISHED
 => [internal] load build definition from Dockerfile                       0.1s
 => => transferring dockerfile: 37B                                        0.0s
 => [internal] load .dockerignore                                          0.0s
 => => transferring context: 2B                                            0.0s
 => [internal] load metadata for docker.io/library/ruby:3.1.2-alpine       0.0s
 => [1/5] FROM docker.io/library/ruby:3.1.2-alpine@sha256:499a31......     0.3s
 => [internal] load build context                                          0.0s
 => => transferring context: 1.99kB                                        0.0s
 => CACHED [2/5] RUN apk add --update --no-cache build-base curl           0.0s
 => CACHED [3/5] WORKDIR /app                                              0.0s
 => CACHED [4/5] COPY . .                                                  0.0s
 => CACHED [5/5] RUN gem install bundler:2.3.19 && bundle install...       0.0s
 => exporting to image                                                     0.0s
 => => exporting layers                                                    0.0s
 => => writing image sha256:7925639f0e50aa0da9d23674fb2bfb08970....        0.0s
 => => naming to docker.io/library/whoami

可以看到藉由 Docker 優異的快取機制,現在整個建置的過程只剩下 0.6 秒,大約節省了 50 倍的時間。

接著請你們打開編輯器,並且將 Dockerfile 中的環境變數 AUTHOR 改成你們自己的英文名字,並且重新在建置一次映像檔。

# Dockerfile
FROM ruby:3.1.2-alpine
ENV AUTHOR=換成你的英文名字

RUN apk add --update --no-cache \
    build-base \
    curl

WORKDIR /app

COPY . .

RUN gem install bundler:2.3.19 && \
    bundle install -j4 --retry 3 && \
    bundle clean --force && \
    find /usr/local/bundle -type f -name '*.c' -delete && \
    find /usr/local/bundle -type f -name '*.o' -delete && \
    rm -rf /usr/local/bundle/cache/*.gem

EXPOSE 3000

CMD ["bundle", "exec", "ruby", "whoami.rb", "-p", "3000", "-o", "0.0.0.0"]

接著再重新建置一次:

$ docker image build --tag whoami .
[+] Building 39.9s (11/11) FINISHED
 => [internal] load build definition from Dockerfile                       0.0s
 => => transferring dockerfile: 529B                                       0.0s
 => [internal] load .dockerignore                                          0.0s
 => => transferring context: 2B                                            0.0s
 => [internal] load metadata for docker.io/library/ruby:3.1.2-alpine       2.6s
 => [auth] library/ruby:pull token for registry-1.docker.io                0.0s
 => [internal] load build context                                          0.0s
 => => transferring context: 2.48kB                                        0.0s
 => CACHED [1/5] FROM docker.io/library/ruby:3.1.2-alpine@sha256:499a31..  0.0s
 => [2/5] RUN apk add --update --no-cache     build-base     curl         11.8s
 => [3/5] WORKDIR /app                                                     0.2s
 => [4/5] COPY . .                                                         0.2s
 => [5/5] RUN gem install bundler:2.3.19 && bundle install...             20.0s
 => exporting to image                                                     5.0s
 => => exporting layers                                                    4.9s
 => => writing image sha256:e496a661edc22d1f59e4401650ec702abee....        0.0s
 => => naming to docker.io/library/whoami

驚天大發現,我的 0.6 秒怎麼變成快 40 秒了,發生了什麼事呢?唯一快取到的也只有 FROM 那一層映像層。

這是因為改動映像層 ( 我們把原先的 AUTHOR 這個變數換成您的英文名字 ) 進而造成 SHA 算出來的 ID 有異,使得映像檔找不到匹配的映像層而是重新建置新的映像層。

你可能會想說,就重新建置 ENV 那一層就好啦,其他的映像層都沒有改動,檔案也沒有變化,為什麼還要多花那麼多時間重新建置呢?

其實在 Docker 建置映像層中還有一個有趣的機制,若是上層的映像層重新建置,則其以下的所有映像層都將重新建置,在這個案例中,我們更動了 ENV 這個指令的映像層,則其以下的 RUNWORKDIREXPOSE 等等,都將重新建置,也是導致建置時間大幅提升的主因。

白話來說就是 Docker 也不想花那麼多時間去幫你比對每一層的映像層,只要有一層算出來的 ID 找不到匹配的映像層,那就之後每一層都重新建置,也不管你其他的映像層是不是已經有一模一樣的存在了。

這樣子的機制就讓 Dockerfile 撰寫的順序變得十分的重要。

重新整理 Dockerfile 的執行順序

為了讓重新建置的副作用降到最低,我們要調整一下 Dockerfile 的指令順序,指令執行的順序不會影響到容器的啟動,所以不用擔心,但還是有些小地方需要注意。

唯一一個不會更動的就是 FROM 這個指令,之前的章節也有提過,所有的映像檔都是透過另一個映像檔作為基底,所以 FROM 絕對是要擺在最上面的。

而在思考如何擺放位置的時候,變動機率越低的指令就會放在越上面,可以讓重新建置的副作用降到最低。

首先,變動機率最低的就是 CMD 以及 EXPOSE 這兩個指令,啟動一個應用程式的初始指令基本上都會相同,即便更換了版本,或是檔案做了什麼異動,啟動的方式都還是大同小異。

EXPOSE 則是在設定好後就很少會進行變動,舉例來說,nginx 也不會突然變成開 678 port,而你自己建置的應用程式也應該會有固定啟動的 port 才對。

所以現在的 Dockerfile 變成這樣:

# Dockerfile
FROM ruby:3.1.2-alpine
ENV AUTHOR=robertchang <- 看異動情況取捨

EXPOSE 3000 <- 移動到上面

CMD ["bundle", "exec", "ruby", "whoami.rb", "-p", "3000", "-o", "0.0.0.0"] <- 移動到上面

RUN apk add --update --no-cache \
    build-base \
    curl

WORKDIR /app

COPY . .

RUN gem install bundler:2.3.19 && \
    bundle install -j4 --retry 3 && \
    bundle clean --force && \
    find /usr/local/bundle -type f -name '*.c' -delete && \
    find /usr/local/bundle -type f -name '*.o' -delete && \
    rm -rf /usr/local/bundle/cache/*.gem

至於 ENV 的異動頻率就要視自己手邊的專案而定,或是可以透過在容器啟動時傳入 ( 傳入環境變數在啟動 postgres 這個服務的時候就有使用過了 ),總之環境變數有許多種放入容器的方式,怎麼取捨完全是看個人喜好。

接著關於 RUN apk ..WORKDIR 這兩個之間的取捨,肯定是 WORKDIR 會放在比較上面的位置,畢竟我們有可能會需要新的套件,所以 RUN apk .. 這件事情的異動頻率就會比 WORKDIR 來得高,所以在經過一番調整後,會變成這樣。

# Dockerfile
FROM ruby:3.1.2-alpine
ENV AUTHOR=robertchang <- 看異動情況取捨

EXPOSE 3000 <- 移動到上面

CMD ["bundle", "exec", "ruby", "whoami.rb", "-p", "3000", "-o", "0.0.0.0"] <- 移動到上面

WORKDIR /app <- 移動到上面

RUN apk add --update --no-cache \
    build-base \
    curl

COPY . .

RUN gem install bundler:2.3.19 && \
    bundle install -j4 --retry 3 && \
    bundle clean --force && \
    find /usr/local/bundle -type f -name '*.c' -delete && \
    find /usr/local/bundle -type f -name '*.o' -delete && \
    rm -rf /usr/local/bundle/cache/*.gem

接著是 RUN apk ... 以及 COPY 之間的取捨,常理來說,COPY 的異動頻率會比安裝套件來得高,畢竟在開發的情況下,檔案會一直有變動,導致雖然指令本身都是 COPY . . ,但因為編輯過的檔案會導致算出來的 SHA ID 完全不同,進而觸發重新建置的副作用。

而最後則是 RUN gem install ... ,對於不熟悉 Ruby 的朋友們,稍微解釋一下,gem 是 Ruby 圈中的套件管理系統,會根據 Gemfile 這個檔案所描述的套件進行安裝;可以想像成 JavaScript 圈中的 yarn 以及 npm 這類的工具根據 package.json 進行安裝是一樣的道理,亦或是 Rust 圈中的 Cargo 等等。

姑且不論這個指令詳細的功能,這不在我們的討論範圍,但我們知道他是一個安裝套件的指令就可以了,所以可能會想像成和 RUN apk ... 是一樣的概念,進而想要把它往上移動,這時就會發生錯誤,讓我們以身試誤,看看錯誤訊息是什麼吧!

# Dockerfile
FROM ruby:3.1.2-alpine
ENV AUTHOR=robertchang <- 看異動情況取捨

EXPOSE 3000 <- 移動到上面

CMD ["bundle", "exec", "ruby", "whoami.rb", "-p", "3000", "-o", "0.0.0.0"] <- 移動到上面

WORKDIR /app <- 移動到上面

RUN apk add --update --no-cache \
    build-base \
    curl

RUN gem install bundler:2.3.19 && \ <- 移動到上面
    bundle install -j4 --retry 3 && \
    bundle clean --force && \
    find /usr/local/bundle -type f -name '*.c' -delete && \
    find /usr/local/bundle -type f -name '*.o' -delete && \
    rm -rf /usr/local/bundle/cache/*.gem

COPY . .

接著存檔後,進行建置的動作。

$ docker image build --tag whoami .
[+] Building 39.9s (11/11) FINISHED
 => [internal] load build definition from Dockerfile                       0.1s
 => => transferring dockerfile: 529B                                       0.0s
 => [internal] load .dockerignore                                          0.1s
 => => transferring context: 2B                                            0.0s
 => [internal] load metadata for docker.io/library/ruby:3.1.2-alpine       2.9s
 => [auth] library/ruby:pull token for registry-1.docker.io                0.1s
 => [internal] load build context                                          0.0s
 => => transferring context: 2.48kB                                        0.0s
 => CACHED [1/5] FROM docker.io/library/ruby:3.1.2-alpine@sha256:499a31..  0.0s
 => [2/5] WORKDIR /app                                                     0.1s
 => [3/5] RUN apk add --update --no-cache build-base curl                 19.5s
 => ERROR [4/5] RUN gem install bundler:2.3.19 && bundle install...        2.9s
------
 > [4/5] RUN gem install bundler:2.3......
#8 2.526 Successfully installed bundler-2.3.19
#8 2.526 1 gem installed
#8 2.838 Could not locate Gemfile
------
executor failed running [/bin/sh -c gem install bundler:2.3.19 && bundle....

在安裝套件的時候出錯了,可以看到錯誤訊息是 Could not locate Gemfile,也就是它找不到那個可以去參照的說明書來安裝套件。

而原因非常簡單,之前有提過映像層是一層接著一層堆疊起來的,所以下層會具備上層所擁有的檔案系統以及安裝過的套件,而在 RUN gem install ... 的當下,我們還沒有把本機的檔案 COPY 到建置的過程中,進而導致執行 RUN gem install ... 的當下根本找不到參照的檔案。

而在本章的開頭我有提到指令執行的順序不會影響到容器的啟動,指的是我們把 CMD 以及 EXPOSE 等等的指令往前放並不會導致容器啟動時出現問題。

但這次把 COPY . . 放到最後所產生的錯誤並不是 Docker 本身所導致,而是我們再利用這個機制上沒有搞清楚整個建置的運行軌跡所致。

所以最終這個 Dockerfile 能夠訂正到影響最小的版本就是:

# Dockerfile
FROM ruby:3.1.2-alpine
ENV AUTHOR=robertchang <- 看異動情況取捨

EXPOSE 3000 <- 移動到上面

CMD ["bundle", "exec", "ruby", "whoami.rb", "-p", "3000", "-o", "0.0.0.0"] <- 移動到上面

WORKDIR /app <- 移動到上面

RUN apk add --update --no-cache \
    build-base \
    curl

COPY . .

RUN gem install bundler:2.3.19 && \
    bundle install -j4 --retry 3 && \
    bundle clean --force && \
    find /usr/local/bundle -type f -name '*.c' -delete && \
    find /usr/local/bundle -type f -name '*.o' -delete && \
    rm -rf /usr/local/bundle/cache/*.gem

在這個情形下,就只有更動本機會被複製的檔案才會觸發重新建置的副作用,已經算是把副作用的影響範圍降到最低了。

結語

今天示範了如何透過調整順序將建置 Docker Image 的副作用降到最低,而明天,將學習多階段建置的手法,來進一步縮小映像檔的體積。

上一篇文章Docker - 建置映像檔

下一篇文章Docker - 多階段建置映像檔