博客 / 詳情

返回

《你不知道的 JAVA》💘 與 Docker 的集成之戀(第二章)

工程思維落地

《你不知道的 JAVA 》系列博客的工程理念與設計模式,已落地成一款 全新設計的 Java 腳手架 ,可與博客配套使用。

書接上文

各位同學,通過上一章 https://segmentfault.com/a/1190000046084433 的內容我們知道了 Docker 的運行機制和緩存的作用原理。這一章節我們書接上文,告訴大家 Docker 的原生緩存無法應對的情況以及處理辦法。

過激的 Layer 緩存

上一章節中,我們提到過利用「 指令的分割與排序」的手段來加速構建過程的方法,如下所示:

FROM node
WORKDIR /app
COPY package.json yarn.lock .    # 先拷貝依賴定義文件
RUN npm install                  # 安裝依賴文件
COPY . .                         # 再拷貝項目文件
RUN npm build                    # 構建鏡像

這種方法看似很完美,實際卻有一個重要缺陷:當你的 package.json 等依賴定義文件發生變化時,當前的 Layer 極其下屬都會失效。也就是説,無論你是增加還是刪除任何一個小依賴,那所有的依賴項都需要重新安裝。
在一個大項目中,任何細微的改動都觸發成千上萬的依賴重新解析與安裝的成本實在太大了;好在任何成熟的構建工具,都提供一個基本功能叫「增量構建」。

增量構建

增量構建包括一系列增量行為,其中就有依賴庫的分析與增量更新。我們還是以 npm 舉例:當你的 package.json 中新增了 axios 這個依賴時,npm 不會重新解析和下載 loadash 和 express 這兩個庫,只會重新下載 axios 這個庫。

package.json:
- lodash@^4.17.21
- express@^4.18.2
- axios@^1.5.0  # 新增

node_modules:
- lodash@4.17.21
- express@4.18.2
- axios@1.5.0   # 增量下載

不用去深究這些工具的執行原理,任何與增量相關的行為都逃不開保存上一次執行結果作為緩存的方法;但上一期所講的 Layer 顯然又無法承擔起這個責任,應該怎麼辦呢🤔?

對了,利用掛載!要持久化容器中的內容,用掛載的方式不就解決了嗎?對於 npm 來説,我們把 node_modles 掛載到宿主機中不就解決這個問題了嗎?

掛載

掛載的確是個好辦法!但是我不得不給你澆一盆冷水。因為通常我們所説的掛載是一個運行時行為。……好吧,什麼是運行時行為🤨?

你可能很熟悉下面這樣的掛載方式:把容器的 /path/in/container 路徑掛載到宿主機的 pwd 命令即當前目錄中。

# bind mount using the -v flag
docker run -v $(pwd):/path/in/container image-name

但請注意,這裏的掛載指的是容器運行時所產生的任何文件的掛載。我們要解決的問題,是在 Dockerfile 編譯時將構建的中間產物掛載到宿主機中持久化保存。而 Docker 的編譯期是一個臨時性的一次性行為,而這時候容器還沒有開始運行呢。

編譯時掛載

如何在編譯時掛載文件呢?其實這個問題困擾了社區很多年,為此社區發明了各種各樣千奇百怪的方案來解決這個問題。不過好消息是混亂的紀元已經過去,現在我們已經已經有了官方的解決方案!

接下來我們將要講到兩個命令,通過這兩個命令的聯合使用便可解決這個令人頭疼的難題。

Bind Mounts

首先一個叫做 bind mounts 的命令可以提供在鏡像編譯時,臨時將宿主機的某個目錄掛載到容器中的功能。這意味着你不再需要 COPY 指令了,而這也意味着這樣的命令不會形成 Layer 也就不存在 Layer 失效的問題。

FROM golang:latest
WORKDIR /app
RUN --mount=type=bind,target=. go build -o /app/hello

在上面這個示例中,當前目錄在 go 構建命令執行前被掛載到構建容器中。在 RUN 指令執行期間,源代碼可在構建容器中使用。指令執行完畢後,加載的文件不會在最終鏡像或緩存中保留。只有 go 構建命令的輸出會保留下來。使用這個命令完成的構建是乾淨的:在鏡像中不需要源代碼,你只需要編譯結果。

Cache Mounts

緊接着的一個命令叫做 cache mounts,它提供了一種編譯時的全自動的掛載行為,自動將容器中的指定目錄掛載到宿主機的磁盤中的行為。具體是哪個盤符的目錄呢?你不需要關心,Docker 會自動處理好宿主機的保存路徑,你只需要告訴他你想要持久化保存的容器目錄就行了。

FROM node:latest
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm npm install

在本例中,npm install 命令使用了 root/.npm 這個默認的緩存位置。將這個緩存位置掛載到宿主機中後,這個持久化的緩存結果會在不同的構建過程中持續存在,因此即使最終重建了層,也只會下載新的或已更改的軟件包。

綜上所述

如果我們聯合這兩個命令,就可以達成這樣一種效果:

  1. 在編譯時,利用 bind mounts 命令將當前源代碼目錄臨時掛載到容器編譯期(這避免了使用 COPY 來創建多餘的層)
  2. 緊接着使用 cache mounts 配合構建命令如:npm install(這由你使用的構建工具來決定)來執行構建並自動把指定的工具使用的緩存目錄掛載到宿主機中;
  3. 然後再利用 bind mounts 用完即拋的特性,把構建時臨時掛載到鏡像中的源代碼等內容從鏡像中清楚掉。
  4. 最終我們得到了一個乾淨的只包含構建產物的鏡像。並且,這一次的構建過程已經成功被緩存了起來,下一次構建時 Docker 會自動利用這個緩存,執行構建工具本身具備的重要功能:增量構建。

那麼重要的問題來了,哪裏能找到這兩個命令聯合使用的 Java 代碼示例呢?
在 https://github.com/ccmjga/mjga-scaffold/blob/main/Dockerfile 中你便可找到它。我已經為你寫好了基於 Java 的構建示例的 Dockerfile,你只需要在今後的項目中照抄即可。

對了,如果你沒有忘記給倉庫一個 Star,便可得好運相隨。😉

性能對比

使用增量構建到底能節約多少時間?在我的項目的測試中,如果不使用增量構建,在完善的網絡環境條件下,每次構建需要 6-7 分鐘時間。如果使用增量構建,第一次構建需要 3-5 分鐘;而後續構建只需要 1 分到 1 分 30 秒,這是非常巨大的性能差距。

寫在最後

  • 我是 Chuck1sn,一個長期致力於現代 Jvm 生態推廣的開發者。
  • 您的回帖、點贊、收藏、就是我持續更新的動力。
  • 舉手之勞的一鍵三連,對我來説是莫大的支持,非常感謝!
  • 關注我的賬號,第一時間收到文章推送。
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.