該文章同步發佈於:我的部落格
也歡迎關注我的 Facebook 以及 Instagram 接收軟體相關的資訊!
以及這個 30 天的 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 這個指令的映像層,則其以下的 RUN、WORKDIR、EXPOSE 等等,都將重新建置,也是導致建置時間大幅提升的主因。
白話來說就是 Docker 也不想花那麼多時間去幫你比對每一層的映像層,只要有一層算出來的 ID 找不到匹配的映像層,那就之後每一層都重新建置,也不管你其他的映像層是不是已經有一模一樣的存在了。
這樣子的機制就讓 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 的副作用降到最低,而明天,我們將學習多階段建置的手法,來進一步縮小映像檔的體積。