你可能在想,這個之前不就提過了嗎?
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
這個指令的映像層,則其以下的 RUN
、WORKDIR
、EXPOSE
等等,都將重新建置,也是導致建置時間大幅提升的主因。
白話來說就是 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 的副作用降到最低,而明天,將學習多階段建置的手法,來進一步縮小映像檔的體積。