jlink によるカスタムJREイメージ


はじめに

Java Platform Module System (JPMS) に合わせて導入された jlink。

jlink により、必要なモジュールに絞ったアプリケーションの配布イメージを簡単に作成することができます。

ここでは、コマンドラインから jlink を操作することから初め、Gradle によるイメージ作成方法までを説明します。


Hello World アプリの準備

最初にコマンドラインから jlink コマンドを操作することで、イメージ作成の流れを見てみましょう。

簡単なソースを準備します。

$ mkdir jlink
$ cd jlink
$ touch module-info.java
$ mkdir example
$ touch example/App.java

アプリケーションは Hello World アプリとしますが、コンソール出力だけでは寂しいので、Swing で描いたウインドウに Hello World を出力するアプリにします。

example/App.java を以下のように編集します。

package example;

import javax.swing.*;
import java.awt.*;

public class App {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            JFrame frame = new JFrame("swing");
            frame.setMinimumSize(new Dimension(300, 200));
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            JLabel label = new JLabel("Hello World");
            frame.getContentPane().add(label);
            frame.pack();
            frame.setVisible(true);
        });
    }
}

フレームに Hello World というラベルを追加するだけの簡単なものです。


jlink ではモジュールによる操作が基本になるので、module-info.java も準備します(モジュール定義の無い Automatic Module も扱うことはできます)。

ここでは、swing の依存として java.desktop モジュールの依存を定義します。

module example.app {
    requires java.desktop;
}

今回のアプリケーションには、java.desktop モジュールが必要であることを定義しています。


Hello World アプリのコンパイルと実行

作成したソースをコンパイルしましょう。

$ javac module-info.java example/App.java

以下のようにコンパイルされます(簡単のため、クラスファイルはソースと同じディレクトリにいれてしまっています)。

実行します。

$ java --module-path . --module example.app/example.App

--module-path には現在のディレクトリを指定しています。これにより、モジュールの検索で、今回作成した example.app モジュールを見つけることができるようになります。

$JAVA_HOME/jmods も自動的にモジュールパスに追加されるので、今回必要とする java.desktop モジュール、およびそれが依存するモジュールも見つけることができます(java.base モジュールが解決できない場合、$JAVA_HOME/jmods がモジュール・パスに追加されます)。

アプリケーションを実行することで、以下のようなウインドウで HelloWorld が起動します。

では、ここで作成したアプリケーションからイメージを作成しましょう。


作成したアプリケーション専用のカスタムJREを作成します。

jlink コマンドを以下のように実行します。

$ jlink --module-path . --add-modules example.app --output custom-jre

example.appモジュールが追加された、カスタムのJRE(Java Runtime Environment) が生成されます。

jlink の代表的なオプションは以下となります。

  • --module-path modulepath : モジュール・パスを指定(-p)
    • デフォルトのモジュール・パスは `$JAVA_HOME/jmods
    • オプションが指定されていても java.base モジュールを解決できない場合は $JAVA_HOME/jmods をモジュール・パスに追加
  • --add-modules mod [, mod...] : 指定のモジュールを追加
  • --compress={0|1|2} : リソースを圧縮(0: 圧縮なし, 1: 定数文字列の共有, 2: ZIP)
  • --no-header-files : ヘッダー・ファイルを除外
  • --no-man-pages : マニュアル・ページを除外
  • --output : 出力先を指定


カスタムJREは以下のようになります。

このカスタムJREは、example.appモジュールと、それが依存するモジュールが含まれており、bin にある java コマンドでアプリケーションを単独で実行できます。

custom-jre/bin/java --module example.app/example.App

カスタムのJREを実行することで、以下のようなウインドウが起動します。

このカスタムJREは、プラットフォームに Java が存在せずとも、単独で起動するアプリケーションになっており、カスタムJREをzipアーカイブとして単独配布することができます。


モジュールイメージ

先程生成したカスタムJREのlib ディレクトリには modules というファイルが生成されています(以下は表示上、 lib ディレクトリのファイルを省略したものです)。

このmodules に含まれるモジュールを確認しましょう。

以下のように確認できます。

$ custom-jre/bin/java --list-modules

example.app
java.base@20.0.1
java.datatransfer@20.0.1
java.desktop@20.0.1
java.prefs@20.0.1
java.xml@20.0.1

ランタイム・イメージ内のモジュールがリストされます。

作成した example.app モジュールと、それが依存するモジュールのみが含まれていることが確認できます(ソースファイルとクラスファイルを同じディレクトリに入れてしまっているので、ソースファイルまで含まれていますが、通常は別ディレクトリになるので、ソースファイルは含みません)。


直接 modules の中身を確認するには jimage コマンドで対象の modules を指定します。

$ jimage list --verbose custom-jre/lib/modules | head -n 15
jimage: custom-jre/lib/modules

Module: example.app
Offset       Size       Compressed Entry
      117678       1423          0 example/App.class
      117157        521          0 example/App.java
       24522        223          0 module-info.class
      110960         49          0 module-info.java

Module: java.base
Offset       Size       Compressed Entry
      119101         41          0 META-INF/services/java.nio.file.spi.FileSystemProvider
      119142       1357          0 apple/security/AppleProvider$1.class
      120499       2003          0 apple/security/AppleProvider$ProviderService.class

大量なので最初の15行だけ表示しました。modules に含まれるファイルが確認できます。

jimageextract を指定すれば、modules に含まれるファイルを取り出すこともできます。

Module: の行に絞ることで、含まれるモジュールを確認することができます。

$ jimage list custom-jre/lib/modules | grep Module:
Module: example.app
Module: java.base
Module: java.datatransfer
Module: java.desktop
Module: java.prefs
Module: java.xml

example.app が依存する java.desktop が、推移依存するモジュールを含めて表示されていることが確認できます。

なお、java.prefs はなぜかAPIドキュメントに記載されていませんが、以下のように java.desktop が依存するモジュールです。

module java.desktop {
    requires java.prefs;
    requires transitive java.datatransfer;
    requires transitive java.xml;
    ...


ランチャーを生成

jlink--launcher を指定することで、アプリケーション用のランチャーを生成できます。

以下のように実行すれば

jlink --module-path . --add-modules example.app --output custom-jre --launcher app=example.app/example.App

bin 内に app というランチャーが生成されます。

ランチャーを使うことで、java コマンドを使うことなくアプリケーションを実行できます。

$ custom-jre/bin/app

同じようにアプリケーションが起動します。


Gradle からイメージを作成

コマンドライン操作で、jlink コマンドの操作は理解できましたが、通常の開発をコマンドラインから行うことはありません。

ここでは Gradle でイメージ作成を行ってみましょう。

プロジェクトを作成します。

$ mkdir example
$ cd example
$ gradle init --type java-application --dsl kotlin --test-framework junit-jupiter --project-name example --package example

テストコードは今回は使わないので削除してしまいます。

module-info.java ファイルを準備します。

$ rm app/src/test/java/example/AppTest.java
$ touch app/src/main/java/module-info.java

app/build.gradle.kts は以下のようなります。

plugins {
    application
}

repositories {
    mavenCentral()
}

dependencies {
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(20))
    }
}

application {
    mainModule.set("example.app")
    mainClass.set("example.App")
}

example/App.javamodule-info.java は前述と同様の内容に編集します。 同じ内容なのでここでは省略します。

ここまでで、./gradlew run によりアプリケーションが起動できるようになっているはずです。


では、jlink の操作に移りましょう。といっても、Exec タスクの commandLine でコマンドを指定するだけです。

以下のようになります。

tasks.register<Exec>("jlink") {
  group = "Build"
  description = "Generate Java runtime image"
  dependsOn("jar")

  val javaHome = System.getProperty("java.home")
  val buildDir = layout.buildDirectory.get().asFile

  doFirst {
    delete("$buildDir/image")
  }
  commandLine(
    "$javaHome/bin/jlink",
    "--module-path",
        listOf("$buildDir/libs",
        configurations.runtimeClasspath.get().asPath,
        "$javaHome/jmods").joinToString(File.pathSeparator),
    "--add-modules",
        application.mainModule.get(),
    "--launcher",
        "${application.applicationName}=${application.mainModule.get()}/${application.mainClass.get()}",
    "--output",
        "$buildDir/image",
    "--strip-debug", "--no-man-pages", "--compress", "2")
}

先のコマンドライン操作の例に加え、jlinkコマンドには --strip-debug, --no-man-pages, --compress などを指定しました。これによりイメージサイズの削減が行なえます。

作成した jlink タスクを実行します。

$ ./gradlew jlink

以下のようにapp/build/image にランタイムイメージが作成されます。

ランタイムイメージの作成だけであれば、独自タスクを定義することで事足りますし、プラグインに頼る必要はありません(プラグインがメンテナンスされなくなるケースも多いので)。

非モジュール・ライブラリとの相互運用や、異なるプラットフォーム向けのランタイムイメージを同時に作成したいといった場合には、プラグイン使ってしまったほうが簡単です。


org.beryx.jlink プラグインを使ってみましょう。

plugins {
    application
    id("org.beryx.jlink") version "2.26.0"
}

repositories {
    mavenCentral()
}

dependencies {
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(20))
    }
}

application {
    mainModule.set("example.app")
    mainClass.set("example.App")
}

プラグインにより、jlink タスクが登録されるので、以下のようにイメージ作成が可能です。

$ ./gradlew jlink

jlinkZip タスクにより、イメージがzipアーカイブとして作成されます。

$ ./gradlew jlinkZip

さらに jpackage タスクで(jpackageコマンドによる)インストーラを作成することもできます。

$ ./gradlew jpackage

jpackage は Windows の場合は別途 WiX を導入しておく必要があります。

jpackageコマンドについては以下を参照してください。

blog1.mammb.com

以下のように配布用パッケージ image.zip、インストーラapp-1.0.dmg が生成されているのが確認できます。

その他 org.beryx.jlink プラグインについては以下の公式ドキュメントを参照してください。

badass-jlink-plugin.beryx.org


まとめ

今更感はありますが、Java Platform Module System (JPMS) で導入された jlink コマンドの使い方について、コマンドライン操作から、Gradle での利用までを説明しました。

アプリケーションの配布の他、Docker イメージに含めるスリムなJRE作成に役立ちます。ただし、ランタイムに脆弱性が発見された場合に、その更新が困難になる点については十分注意する必要があります。