使用 jlink 容器化應用程式

Du Spirit
Java Magazine 翻譯系列
15 min readMay 14, 2024

一個大大協助應用程式容器化的 JDK 工具

Translated from “Containerizing Apps with jlink” By Nicolas Fränkel, Java Magazine May/June 2019. Copyright Oracle Corporation.

如果你曾嘗試使用 Java 模組,你可能會發現模組化並不容易。第一個障礙可能是模組化你的應用程式,但許多問題是因第三方函式庫模組化的現狀引起。這是不幸的,因為一旦應用程式模組化,它能發行成可在一個精簡化的 JDK中獨立運行的執行檔,在容器化的時代,這意味著較小的容器映像檔。

在本文中,我將解釋如何使用 jlink,一個自 Java 9 開始提供的命令列工具,可用來建立容易容器化的 Java 執行檔。我會從模組的快速概述開始,接著會展示如何使用 jlink 建立可單獨運行的執行檔,以及在建立 Docker 容器時,jlink 帶來的好處。

本文中的完整原始碼和檔案都在 GitHub,文中較大的專案的原始碼和配置檔一樣可在 GitHub 上找到。

模組化與 jlink

一個類別 A 可能在編譯期 (編譯器檢查相依是否存在於編譯器用的 classpath 中) 或執行期 (JRE 試圖於執行期用的 classpath 解析相同的相依) 使用其他類別,例如 java.util.List

一個問題是,JRE 提供許多類別,其中部分不會被應用程式所用,但依舊同捆在一起。例如,應用程式伺服器以無視窗模式運行,但仍同捆圖形套件,例如:javax.swing

另一個因 JRE 相依性引起的問題是 Java 的可見度管理。要讓 ch.franel.b 套件中的類別 B,可以看見在 ch.frankel.a 套件中的類別 A,類別 A 必須是 public 可見。考慮到這點,第三方 JAR 函式庫不可能乾淨地將 API 類別和內部的類別分離到不同的套件。歷史上,內部使用的套件仰賴暗示的命名,像是 ch.frankel.c.internal,但無法用技術強制執行這約束。

這節根據 Mikalai Zaikin 的建議,在 Nicolai Parlog 協助下作者做了更新。

Java 9 試圖透過另一種方式管理存取來解決問題:模組。有數種模組:

  • Java 和 JDK 模組:前者與 java 的子套件關聯,後者則與 jdk 子套件關聯,兩者都由 JDK 提供。
  • 明確應用程式模組:一個 JAR 檔可透過在跟目錄提供一個 module-info 的類別變成一個模組化的 JAR 檔。在模組系統中,這些被稱作明確應用程式模組:之所以明確是因為 metadata 的的一類別,而之所以應用程式是因為它們不是來自 JDK。
  • 自動模組:一個非模組化的 JAR 檔會被視為自動模組,如果它被放在模組路徑中。你可以在 JAR 檔內的 MANIFEST.MF 檔案中加入 Automatic-Module-Name 項目,這資訊作為模組的名稱。這是推薦的做法,若沒這麼做,預設情況下,會用 JAR 檔的名稱推論模組名稱。若你維護一個函式庫,請在清單中加入自動模組盟稱。
  • 未命名模組:當使用 classpath,每個未模組化的 JAR 都是未命名模組的一部分。

一個 Java 9 模組化的應用程式會利用每個 JAR 檔根目錄的 module-info 類別檔,這檔案是個特別的清單 —— 模組定義 —— 包含模組名稱,定義模組需要的相依性,以及公開的 API、提供的服務等 metadata。運行時,載入器讀取這份清單只載入需要的模組。

為了減少映像檔的大小,你可以善用模組系統,僅發布 JDK 所需的模組。

有這設計,可能減少 JDK 部分不需要的模組,這是 jlink 的任務。如官方文件所述:你可以用 jlink 工具組裝與優化模組的集合及相依性成為一個客製的運行映像檔。

jlink 讓你用應用程式的模組組態產生一個隨應用程式一起交付的客製 JRE,用同樣的機制,它同樣允許你將應用程式創建成可執行檔,因此是完全自給自足,不需要依賴目標系統有相容的 JRE。

奠定基礎

讓我們從最簡單的應用程式 Hello World 來檢視 jlink,這就是全部

public class Main {

public static void main(String[] args) {
System.out.println("Hello world");
}
}

很以否認,Docker 是當今最流行的發布渠道,要發布這樣的 Hello World 應用程式,使用 Docker 會有助益,因為我的目標是建立單一 Dockerfile 並確保最終映像檔盡可能小,一個多階段建置是需要的。

作為提醒,一個多階段的 Docker 建置,允許您將多個階段串接在一起,後續的階段可以使用前個階段的建置結果。此外,每個階段可以繼承不同的基底映像檔,且你可為每個階段命名,因為用名字比用索引來引用一個階段較容易。多階段建置的最主要好處,是每個階段使用最相關的映像檔,因此可以在建置過程中得到最小的映像檔。

下面是一個示範用的 Dockerfile,展示如何用 Maven 建立一個 Hello World 的映像檔,我假設這專案使用 Maven 相容的結構:

在這檔案,第一行 [譯註:上面的範例移除了檔案名稱,因此,少一行] 標示多階段建置的的第一階段,使用一個 Maven 映像檔並標記為 build,指令 mvn package 以預設名稱 jlink-ground-up-1.0-SNAPSHOT.jar 產生 JAR 檔。

在以 FROM 開始的那一行,你可以看到第二個也是最後一個階段,使用可能是最小映像檔的其中一個,Linux 的 Alpine 發行版。沒有 Java 11 的 Alpine 映像檔,但有一個 Java 12 的。可惜的是,沒有 JRE 版,只有 JDK 版。接下來的 COPY 陳述句使用第一階段產生的 JAR 檔。

接著,你建立映像檔:

$ docker build -t jlink:1.0 .

這方法的主要問題是 Docker 映像檔巨大,因為即使是一個 Hello World 應用程式,它嵌入完整的 JDK,事實上,與 OpenJDK 12 的大小相比,應用程式的大小微不足道:

$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
openjdk 12-alpine 8f180304fad9 7 days ago 336MB
jlink 1.0 7c612235f308 About a minute ago 336MB

為了減少映像檔的大小,你可以善用模組系統,僅發布 JDK 中有需要用到的模組。

發布客製啟動器

Hello World 應用程式非常簡單,除 java.base,不需要其它模組,這模組是自動加入的,就像是 java.lang 套件是隱含引入的一樣。

要發布一個客製的小執行檔,第一步是將應用程式轉移到模組系統,如前所述,jlink 只適用於模組化的應用程式,因為它仰賴 module-info 檔案。

因為這應用只需要 java.base 模組,建立一個 module-info.java 模組描述器是很直接的:

// module-info.java
module ch.frankel.jlink {
exports ch.frankel.blog.jlink;
}

jlink 主要功能是最佳化應用程式,僅保留需要的模組。此外,它還可將優化的應用程式建立成一個獨立運行的執行檔。因為我們的應用程式正利用模組系統,所以可以建立專屬的啟動器。

然而,jlink 需要一個既有的 JAR 來發揮其功能:

$ mvn clean package

一切準備就緒,是時候使用 jlink。請注意,就像是 javajavac 指令,jlink 需要指定選項,以下是建立 Hello World 客制執行檔的指令:

$ jlink --add-modules ch.frankel.blog.jlink \
--module-path ${JAVA_HOME}/jmods:target/jlink-ground-up-1.1.jar \
--output target/jlink-image \
--launcher hello=ch.frankel.jlink/ch.frankel.blog.jlink.Main

讓我們看一下這些選項:

  • 第一行透過名字定義所需要的模組,應設成我們應用程式的模組名稱。
  • 第二行指定模組路徑。Java 9 之前的應用程式使用 classpath,模組相容的應用成是使用模組路徑。就像是 classpath,模組路徑參考路徑元素尋找相依的模組。目前,必須引用每個模組的路徑,包含 JDK 提供的以及 JAR 檔。
  • 第三行指定輸出目錄。
  • 最後一行指定客製發布的進入點,它的格式由幾個部分組成:最終可執行檔的名稱、一個 = 符號、模組名稱,以及一個 /,緊接著是 Main 類別的完整限定類別名稱。

一旦你建立可執行檔,你可以用這指令啟動它:

$ target/jlink-image/bin/hello

正如預期,指令在標準輸出裝置上印出 Hello World

好消息是,有個既有的 Maven 插件可以幫你以聲明式的方式管理模組路徑:ModiTect。

和前面一樣,目標是將客製的發布封裝在 Docker 印象檔中。讓我們相應地調整 Dockerfile,如下所示:

下個問題是這額外步驟對最終 Docker 映像檔的大小有影響嗎?我們將其與前次沒有使用 jlink 建置的結果比較:

REPOSITORY     TAG    IMAGE ID         CREATED              SIZE
jlink 1.0 7c612235f308 About a minute ago 336MB
jlink 1.1 e590fb6697e7 14 hours ago 53MB

哇~ 相較於先前未模組化的應用程式,節省了 283 MB。若覺得 53 MB 對於 Hello World 應用程式來說還是有點多,要記住,這個發布包含 JVM 的所有功能:即時編譯器 (JIT) 與垃圾回收管理。如果需要,暫停一下,品味這勝利,當你準備好,請進入下個章節。

自動化客製啟動器的產生

前述的 jlink 指令非常冗長,然而,當相依的模組數量增加,它還將變得更瑣碎,這情況和 Java 編譯器相似:在日常生活中,開發人員鮮少直接使用編譯器,他們偏好使用像 Maven 這樣的建置工具。使用 Maven,相依性在 POM 檔案中描述,Java 編譯器插件會替你管理 classpath,每次編譯手動管理 classpath 是十分棘手的。

好消息是有一個現有的 Maven 插件可以幫你以宣告式的方式管理模組路徑:ModiTect,在本文的剩餘部分將使用這個插件。

除其它功能,這插件提供 create-runtime-image 目標,可以建立起動器,以下是個 POM 片段,以可重複使用的方式,建立如前所述的客製啟動器:

comment 1 註解的那行將執行綁定在 package 階段,有 comment 2 註解的那行呼叫 create-runtime-image goal,有 comment 3 註解的那幾行會被轉譯成 jlink 指令的選項,有 comment 4 註解的那行,緊接的兩行你應該輸入成一行 (包含連字號),分成兩行顯示是為了符合頁面。

Table 1 展示組態的宣告如何對應到 jlink 指令的選項:

Table 1. 組態與 jlink 指令選項的關係

有這些資訊加到 POM 中,Dockerfile 可進一步簡化:

現在,您擁有了一個可重複執行的構建流程,創建客製的發布版本。

添加相依模組

讓我們增強應用程式並改善程式碼。你可能知道,使用 System.out.print() 是不明智的 [譯註:於真實環境中,確實是,但若僅作為範例程式,仍是方便的選擇]。如果以除錯為目的,它們無法設定,在正式環境仍會被寫入。讓我們用適當的日誌框架取代這行。

作為我的日誌框架,模組化的 Simple Logging Facade for Java (SLF4J) 是我的選擇,這選擇需要我添加兩個相依:API 與一個實作。

為此,添加以下內容到 POM 檔中:

接著更新 module-info.java檔案:

module ch.frankel.jlink {
requires org.slf4j;
exports ch.frankel.blog.jlink;
}

為了使用 jlink,還需要將 SLF4J 加到 --module-path,如下所示:

當相依的數量增加,這方法很快就變得乏味,容易忘記一個相依,而且在 <dependencies> 與這裡更新版本也很瑣碎。

較好的替代方案是將每個相依複製到專用目錄中,用這個目錄作為模組路徑的一部分,下面的程式碼展示如何在 POM 檔設定建置流程,在每次執行時自動進行此操作:

注意,你只需設定一次模組路徑,如下所示,因為每個相依都會被複製到 modules 目錄:

添加非模組的相依

可惜,jlink 只能用於模組,如果不是所有的相依都是模組,它會失敗。唯一的修正方式是製作一個客製的 module-info.java,編譯,然後更新進相依的 JAR 檔案。你可以使用 jdeps 工具,它含在 JDK 中,用來決定模組檔案的內容,即需要宣告的相依項目。

以下示範用法:

$ jdeps --generate-module-info \
. \
$M2_REPO/org.apache…/commons-lang3-3.8.1.jar

The first line specifies that jdeps should generate the module-info.java file, the second line is the output directory, and the third line is the target JAR file to be analyzed. [The path was shortened to fit the page. —Ed.]

第一行指定 jdeps 應該要產生 module-info.java,第二行指定輸出的目錄,第三行則是要分析的目標 JAR 檔 [編按:路徑被縮短以符合頁面]。

此指令產生如下的檔案:

注意,目錄的名稱不是隨機的:它取自 JAR 中 manifest 檔案的 Automatic-Module-Name 屬性,如果缺少該屬性,模組系統會根據 JAR 檔的名字自動推導出模組名稱,這可能不太適合 [譯註:Java 9 推出至今,生態圈使用模組的比例不算低,但也不算高,不支援的函式庫,也不會有這屬性就是了]。

jdeps 不負責編譯,但 ModiTect 插件提供一個目標來完成它,讓我們相應地更新 POM 檔案:

In this file, the line with comment 1 binds plugin execution to the package phase; the line with comment 2 calls the add-module-info goal; the section that starts with the line that has comment 3 specifies the target dependency to update; and the section that starts with the line that has comment 4 shows that in the additional module information section, only the name is required.

在這檔案中,有 comment 1 註解的那行將執行綁定在 package 階段,有 comment 2 註解的那行呼叫 add-module-info,從帶有 comment 3 註解開始的那一節,指定要更新的相依,帶有 comment 4 註解開始的那一節,加入額外的模組資訊,只有名稱是必填的。

commons-lang3 的相依在兩方面都很單純:它的 manifest 中有 Automatic-Module-Name,且它沒有外部相依。因此,它相當容易使用。

If you were to replace commons-lang3 with the Guava library, for example, you would simply change the library name in the plugin. However, jdeps would explore the whole dependency tree and all of Guava’s dependencies would need to be modularized as well, just as Guava itself would. This configuration would be quite verbose, but unfortunately it would be necessary. I’ve posted a copy of the pom.xml file.

假如,你要用 Guava 函式庫代替 commons-lang3,你只需在插件中更改函式庫的名稱,然而,jdeps 會探索整個相依樹,所有 Guava 的相依也需要模組化,就像 Guava 本身也是如此。這配置將非常囉嗦,但不幸的是,它是必要的。我發布了一份 pom.xml 的副本

結論

在本文中,我使用 jlink 為一個簡單的應用程式建立客製的啟動器,如我們所見,jlink 能讓你建立僅包含所需模組的啟動器,要使用 jlink,應用程式也需要模組化。

在這點上,整個流程的複雜度取決於相依在模組化的相容性,如果相依都已經模組化,一切都很簡單,如果不是,在繼續之前須將它們轉換成模組,幸運的是,ModiTect 插件停工這樣的功能。

我希望這文章能幫助你為你的應用建立更小的發布,更適合容器化。

譯者的告白

容器和雲端開始流行的時候,有不少類似的聲音:Java 已經不適合雲端了,它需要 JVM 太笨重了,但我當時不這麼認為,JVM 確實有自身的問題,但同時也帶來許多優點,只需要針對雲端優化,依然是很好的選項。

果然,Java 生態圈在雲端這個方向,做了不少優化,像是更適合雲端環境的記憶體回收管理 (G1),更快的啟動速度 (原生映像檔 GraalVM),更輕量的映像檔 (模組化及 jlink),這也是我當初選擇翻譯的主題,有興趣的可以參閱:

在經過幾年的努力,像是 Spring Boot 也在 3.2 開始支援 GraalVM 原生映像檔,大幅減少 Spring Boot 應用的啟動時間。

我也看過超過 1.5 GB 的 Docker 映像檔,會這麼大,是因為使用了許多 npm 套件,套件又相依更多的套件,若相依都有好好模組化,能有更精簡 JRE,Docker 映像檔勢必會更輕量。

--

--