Spring Boot でアプリケーションサーバーを同梱するなら Java 実行環境も添えればいいじゃない (Java 11, 2019年12月版)

Gradle, Groovy, Java, Spring Boot, プログラミング, 開発環境・ツール

こんにちは、ソリューション開発部の柴崎です。

Spring Boot でウェブアプリケーションを実行可能な Jar/War 形式として作る場合、依存ライブラリだけでなくアプリケーションサーバーも単一のファイルに含まれます。ライブラリだけでなくミドルウェアも同時にアップデートにできる便利な世界になりました。同じように Java 実行環境も簡単にアップデートしたいというのは当然の発想かと思います。

Project Jigsaw の登場により、必要なモジュールのみで構成された Java 実行環境を作ることできるようになり、Java 実行環境のディスク使用量を節約できます。使用容量が減ることにより、Java 実行環境も併せて配布しやすくなります。Docker を使うという場合でも、より小さいイメージを作れる可能性がありますので、方法を知っておいて損はないかと思います。

まとめ

  • 2019年12月時点で、AdoptOpenJDK 11.0.5+10 の jdeps を使う場合は --print-module-deps オプションを指定するとエラーになるので --list-deps オプションの結果をパースして jlink--add-modules オプションに渡しましょう。
  • Spring Boot アプリケーションと jlink で作った最小構成の Java 実行環境をまとめてデプロイし……
    • Docker なら、ENTRYPOINT に配置した java コマンドを指定します。オーバーヘッドを無くすために Jar ファイルは展開しておくとよいでしょう。
    • サービス化するなら、環境変数または config ファイルの JAVA_HOME 変数に配置した java コマンドを指定し、Unix/Linux 環境で実行可能な Jar 形式で起動しましょう。

使用するツールの概要

  • AdoptOpenJDK
    • jlink: アプリケーション実行に必要なモジュールを含むカスタムランタイムイメージを作成するツール
    • jdeps: コンパイルされたクラスと Jar ファイルの依存性を分析できるツール
    • jar: クラスやリソースのアーカイブを作ったり参照するツール
    • jshell: Java プログラミング言語を対話しながら評価できる REPL なツール
  • Gradle

依存性を分析する旅

ひとまず以下のバージョンを使ってライブラリの依存性を解析してみます。後述しますが、JDK のバージョンによって jdeps の挙動が異なりますので、使うツールや参考となる記事のバージョンをよく確認してからの作業をおすすめします。

$ java -version
openjdk version "11.0.5" 2019-10-15
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.5+10)
OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.5+10, mixed mode)

依存ライブラリを確認する

Spring Boot を使っている場合、Gradle や Maven などのビルドツールを使っていると思います。本記事ではビルドツールの説明は割愛しますが、以下が動く実行環境をつくればよいわけです。

$ gradlew dependencies
     :
runtimeClasspath - Runtime classpath of source set 'main'.
\--- org.springframework.boot:spring-boot-starter-web -> 2.2.2.RELEASE
     +--- org.springframework.boot:spring-boot-starter:2.2.2.RELEASE
     |    +--- org.springframework.boot:spring-boot:2.2.2.RELEASE
     |    |    +--- org.springframework:spring-core:5.2.2.RELEASE
     |    |    |    \--- org.springframework:spring-jcl:5.2.2.RELEASE
     |    |    \--- org.springframework:spring-context:5.2.2.RELEASE
     |    |         +--- org.springframework:spring-aop:5.2.2.RELEASE
     |    |         |    +--- org.springframework:spring-beans:5.2.2.RELEASE
    : 

Java のすべてのモジュールを確認する

AdoptOpenJDK 11.0.5+10 の Windows 版 JRE をダウンロード・展開するとディスク使用量は 100 MB 弱でした。必要なモジュールのみで構成された Java 実行環境を作る前にすべてのモジュールがどのくらいあるか確認しておきます。

$ jshell
|  JShellへようこそ -- バージョン11.0.5
|  概要については、次を入力してください: /help intro
jshell> import java.lang.module.*
jshell> ModuleFinder.ofSystem().findAll().stream().
   ...>   map(ModuleReference::descriptor).
   ...>   map(ModuleDescriptor::name).
   ...>   sorted().
   ...>   filter(peek -> true). // 中間演算を評価するため
   ...>   peek(System.out::println).
   ...>   count()
java.base
java.compiler
java.datatransfer
java.desktop
java.instrument
java.logging
java.management
java.management.rmi
java.naming
java.net.http
java.prefs
java.rmi
java.scripting
java.se
java.security.jgss
java.security.sasl
java.smartcardio
java.sql
java.sql.rowset
java.transaction.xa
java.xml
java.xml.crypto
jdk.accessibility
jdk.aot
jdk.attach
jdk.charsets
jdk.compiler
jdk.crypto.cryptoki
jdk.crypto.ec
jdk.crypto.mscapi
jdk.dynalink
jdk.editpad
jdk.hotspot.agent
jdk.httpserver
jdk.internal.ed
jdk.internal.jvmstat
jdk.internal.le
jdk.internal.opt
jdk.internal.vm.ci
jdk.internal.vm.compiler
jdk.internal.vm.compiler.management
jdk.jartool
jdk.javadoc
jdk.jcmd
jdk.jconsole
jdk.jdeps
jdk.jdi
jdk.jdwp.agent
jdk.jfr
jdk.jlink
jdk.jshell
jdk.jsobject
jdk.jstatd
jdk.localedata
jdk.management
jdk.management.agent
jdk.management.jfr
jdk.naming.dns
jdk.naming.rmi
jdk.net
jdk.pack
jdk.rmic
jdk.scripting.nashorn
jdk.scripting.nashorn.shell
jdk.sctp
jdk.security.auth
jdk.security.jgss
jdk.unsupported
jdk.unsupported.desktop
jdk.xml.dom
jdk.zipfs
$2 ==> 71

java.lang.module.ModuleFinder#ofSystem() で実行中のシステムのモジュールを取得できます。必要なモジュールが少なければ使用容量の削減が期待できます。(注: peek で出力するために filter を追加していますが普段はこんなコードを書いてはいけません)

実行可能 Jar 形式のファイルを jdeps で分析する……?

Spring Boot アプリケーションは、実行可能な単一ファイルの形式としてビルドされます。ひとまず --list-deps オプションで依存するモジュールを出力してみましょう。このファイルを jdeps で指定すればすべての依存が確認できるのではないかと期待できます。

$ jdeps --list-deps demo-0.0.1-SNAPSHOT.jar
   java.base
   java.logging

しかし期待する結果にはなりませんでした。明らかに依存するモジュールが足りません。これは Jar ファイルの中身を確認すれば納得できると思います。

$ jar -tf demo-0.0.1-SNAPSHOT.jar
    :
org/springframework/boot/loader/JarLauncher.class
    :
BOOT-INF/classes/com/example/demo/DemoApplication.class
    :
BOOT-INF/lib/spring-boot-starter-web-2.2.2.RELEASE.jar
    :

依存ライブラリやアプリケーションのクラスは BOOT-INF 以下に配置されており、依存ライブラリとして認識できません。Jar ファイルの直下に配置されている Spring Boot の Launcher のみが認識され、分析結果が java.basejava.logging となったわけです。

実行可能なファイルの形式については、以下のページに詳しく記載されています。

依存ライブラリを列挙して jdeps で分析する

実行可能 Jar 形式だと jdeps で分析できないことが分かりました。代わりにすべての依存ライブラリを jdeps にオプションとして渡し解析することを考えます。--module-path に依存ライブラリをパス区切りで指定できます。依存ライブラリをすべて列挙するのは面倒なので、build.gradle に以下の task を追加して実行します。

task jdeps(type: Exec, dependsOn: compileJava) {
    commandLine(
            'jdeps', '--list-deps',
            '--module-path', compileJava.classpath.asPath,
            compileJava.destinationDir
    )
}

しかし実行するとエラーになりました。

$ gradlew jdeps
> Task :jdeps FAILED
エラー: log4j-api-2.12.1.jarはマルチリリースjarファイルですが--multi-releaseオプションが設定されていません

マルチリリース Jar が含まれていたようです。--multi-release には targetCompatibility とでも指定しておきましょう。

task jdeps(type: Exec, dependsOn: compileJava) {
    commandLine(
            'jdeps', '--list-deps',
            '--module-path', compileJava.classpath.asPath,
            '--multi-release', targetCompatibility,
            compileJava.destinationDir
    )
}

実行することで、依存モジュールを取得することができました。

$ gradlew jdeps
> Task :jdeps
   JDK removed internal API/sun.reflect
   java.base
   java.desktop
   java.instrument
   java.logging
   java.management
   java.management.rmi
   java.naming
   java.prefs
   java.rmi/sun.rmi.registry
   java.scripting
   java.security.jgss
   java.sql
   java.xml
   jdk.httpserver
   jdk.unsupported

Java のすべてのモジュールと比べれば、小さい実行環境が作れそうです。

–print-module-deps オプションがあるのでは……

ここで jdeps に渡せるオプションを見直すと、AdoptOpenJDK 11.0.5+10 には --print-module-deps オプションがあることに気付きます。

$ jdeps --help
    :
--print-module-deps           モジュール依存性のカンマ区切りリスト
                                 を出力する--list-reduced-depsと同じです。
                                 この出力は、これらのモジュールとその推移的な
                                 依存性を含むカスタム・イメージを作成するために
                                 jlink --add-modulesで使用できます。
    :

最終的に jlink に渡すパラメータの形式にしておくとあとで楽できそうです。以下のように設定を変えてみます。

task jdeps(type: Exec, dependsOn: compileJava) {
    commandLine(
            'jdeps', '--print-module-deps',
            '--module-path', compileJava.classpath.asPath,
            '--multi-release', targetCompatibility,
            compileJava.destinationDir
    )
}

しかしながら、実行してみると以下のように NullPointerException 例外が発生します。

$ gradlew jdeps
    :
Exception in thread "main" java.lang.NullPointerException
        at jdk.jdeps/com.sun.tools.jdeps.ModuleGraphBuilder.requiresTransitive(ModuleGraphBuilder.java:124)
        at jdk.jdeps/com.sun.tools.jdeps.ModuleGraphBuilder.buildGraph(ModuleGraphBuilder.java:110)
        at jdk.jdeps/com.sun.tools.jdeps.ModuleGraphBuilder.reduced(ModuleGraphBuilder.java:65)
        at jdk.jdeps/com.sun.tools.jdeps.ModuleExportsAnalyzer.modules(ModuleExportsAnalyzer.java:124)
        at jdk.jdeps/com.sun.tools.jdeps.ModuleExportsAnalyzer.run(ModuleExportsAnalyzer.java:97)
        at jdk.jdeps/com.sun.tools.jdeps.JdepsTask$ListModuleDeps.run(JdepsTask.java:1023)
        at jdk.jdeps/com.sun.tools.jdeps.JdepsTask.run(JdepsTask.java:560)
        at jdk.jdeps/com.sun.tools.jdeps.JdepsTask.run(JdepsTask.java:519)
        at jdk.jdeps/com.sun.tools.jdeps.Main.main(Main.java:49)
    :

LTS であるバージョン 11 (AdoptOpenJDK 11.0.5+10) の jdeps では、このような挙動になっています。ただし、バージョン 12 (AdoptOpenJDK 12.0.2+10) ならエラーにならず、カンマ区切りのモジュールのリストを出力します。しかしながら LTS の 11 とは別に 12 の jdeps を用意するのも手間です。今日のところは、--list-deps オプションの結果をパースして jlink に渡すこととしましょう。

Java 実行環境を最小化する旅

最小構成の Java 実行環境を作る

必要なモジュールが分かったので jlink を実行してみます。アプリケーションをコンパイルする JDK と同じ OS 用の Java 実行環境を作ってみましょう。jdeps--list-deps オプションの結果をパースしてカンマ区切りのモジュールのリストを作り jlink--add-modules に渡します。jlink には可能な限り使用容量が減るようなオプションを指定します。

ext {
    jreDir = file("${buildDir}/jre")
}
    :
clean {
    delete jreDir
}
    :
task jlink(dependsOn: compileJava) {
    outputs.dir jreDir
    doLast {
        def jdepsStandardOutput = new ByteArrayOutputStream()
        exec {
            commandLine(
                    'jdeps', '--list-deps',
                    '--module-path', compileJava.classpath.asPath,
                    '--multi-release', targetCompatibility,
                    compileJava.destinationDir
            )
            standardOutput = jdepsStandardOutput
        }
        def modules = jdepsStandardOutput.toString().findAll(~/(java|jdk)(\.[a-z]+)+/).join(',')
        delete jreDir
        exec {
            commandLine(
                    'jlink',
                    '--compress', '2',
                    '--strip-debug',
                    '--no-header-files',
                    '--no-man-pages',
                    '--vm', 'server',
                    '--add-modules', modules,
                    '--output', jreDir
            )
        }
    }
}

実行することで ${buildDir}/jre 以下に Java 実行環境が出力されます。このときディスク使用量は 40 MB 強でした。すべてのモジュールを含んだ Java 実行環境ではディスク使用量が 100 MB 弱でしたので、半分以下のサイズにできたようです。

最小構成の Java 実行環境で起動するか確かめる

最小構成の Java 実行環境はできましたが、本当に起動するのか確かめてみましょう。実行する Java 実行環境のモジュールが削減されているか確かめるために以下のクラスを作成します。(注: peek で出力するために filter を追加していますが普段はこんなコードを書いてはいけません)

package com.example.demo;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.lang.module.ModuleDescriptor;
import java.lang.module.ModuleFinder;
import java.lang.module.ModuleReference;
@Component
public class SystemModules {
    @PostConstruct
    public void init() {
        final var count = ModuleFinder.ofSystem().findAll().stream()
                .map(ModuleReference::descriptor)
                .map(ModuleDescriptor::name)
                .sorted()
                .filter(peek -> true) // 中間演算を評価するため
                .peek(System.out::println)
                .count();
        System.out.println(count);
    }
}

jlink で作成した Java 実行環境を使って起動することを確認してみましょう。

task bootRunCustomJRE(group: 'application', type: Exec, dependsOn: [bootJar, jlink]) {
    commandLine("${jreDir}/bin/java", '-jar', bootJar.archiveFile.get().asFile)
}

起動できました。モジュールの数も削減されています。

$ gradlew bootRunCustomJRE
Starting a Gradle Daemon, 1 incompatible and 3 stopped Daemons could not be reused, use --status for details
> Task :bootRunCustomJRE
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.2.2.RELEASE)
    :
2019-12-25 19:06:51.213  INFO 13076 --- [           main] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 1280 ms
java.base
java.datatransfer
java.desktop
java.instrument
java.logging
java.management
java.management.rmi
java.naming
java.prefs
java.rmi
java.scripting
java.security.jgss
java.security.sasl
java.sql
java.transaction.xa
java.xml
jdk.httpserver
jdk.unsupported
18
    :
2019-12-25 19:06:51.655  INFO 13076 --- [           main] com.example.demo.DemoApplication         : Started DemoApplication in 2.38 seconds (JVM running for 2.853)

特定の OS 向けの Java 実行環境を作りたいのだけれど……

上記の例では、アプリケーションをコンパイルする JDK と同じ OS 用の Java 実行環境を作りました。残念なことに Java 実行環境は、Linux、Windows、Mac それぞれに異なります。ビルドは Linux 環境で、デプロイ先が Windows 環境になる場合など上記のままでは困ります。jlink には --module-path オプションがあり、各 OS 環境用の JMOD ファイルがあるディレクトリを指定することで指定の環境用の Java 実行環境を作ることができます。JDK をダウンロードして最小構成の Java 実行環境を作るまで自動化してみましょう。

ext {
    jreDir = file("${buildDir}/jre-win")
    jdkArchiveUrl = 'https://github.com/AdoptOpenJDK/openjdk11-binaries/releases/download/jdk-11.0.5%2B10/OpenJDK11U-jdk_x86-32_windows_hotspot_11.0.5_10.zip'
    jdkArchiveFile = file("${buildDir}/jdk-win.zip")
    jdkJModDir = file("${buildDir}/jdk-win-jmods")
}
    :
clean {
    delete jreDir
    delete jdkArchiveFile
    delete jdkJModDir
}
    :
task jlink(dependsOn: compileJava) {
    outputs.dir jreDir
    outputs.file jdkArchiveFile
    outputs.dir jdkJModDir
    doLast {
        def jdepsStandardOutput = new ByteArrayOutputStream()
        exec {
            commandLine(
                    'jdeps', '--list-deps',
                    '--module-path', compileJava.classpath.asPath,
                    '--multi-release', targetCompatibility,
                    compileJava.destinationDir
            )
            standardOutput = jdepsStandardOutput
        }
        def modules = jdepsStandardOutput.toString().findAll(~/(java|jdk)(\.[a-z]+)+/).join(',')
        delete jdkArchiveFile
        ant.get(src: jdkArchiveUrl, dest: jdkArchiveFile)
        def jdkArchiveTree = jdkArchiveFile.name.endsWith('.tar.gz')
                ? tarTree(resources.gzip(jdkArchiveFile))
                : zipTree(jdkArchiveFile)
        delete jdkJModDir
        copy {
            from jdkArchiveTree
            into jdkJModDir
            include '**/jmods/*.jmod'
            includeEmptyDirs = false
            eachFile { path = name }
        }
        delete jreDir
        exec {
            commandLine(
                    'jlink',
                    '--compress', '2',
                    '--strip-debug',
                    '--no-header-files',
                    '--no-man-pages',
                    '--vm', 'server',
                    '--add-modules', modules,
                    '--output', jreDir,
                    '--module-path', jdkJModDir
            )
        }
    }
}

ちなみに、Linux と Mac 環境の Java 実行環境を Windows 環境で作るのはおすすめしません。パーミッション設定を引き継ぎにくいためです。

最後に

ここまで来れば、後は簡単です。Spring Boot アプリケーションと一緒に最小構成の Java 実行環境を所定の場所に配置してください。詳しい方法は本家のドキュメントに詳しいのでここでは説明しません。

Docker なら、ENTRYPOINT に配置した java コマンドを指定します。オーバーヘッドを無くすために Jar ファイルは展開しておくとより良いと思います。

サービス化するなら、環境変数または config ファイルの JAVA_HOME 変数に配置した java コマンドを指定し、Unix/Linux 環境で実行可能な Jar 形式で起動しましょう。以下のように最小構成の Java 実行環境も併せたアーカイブを作るのも良いでしょう。

bootJar {
    launchScript()
}
    :
task assembleCustomJRE(group: 'build', type: Zip, dependsOn: [bootJar, jlink]) {
    from(bootJar.archiveFile) {
        rename { "${archiveBaseName.get()}.jar" }
    }
    from(jreDir) {
        into 'jre'
    }
}

一年以上前から jdeps/jlink にチャレンジしては悩まされを繰り返してきましたが、なんとか形にできたかと思います。ツールの挙動がバージョンアップにより変わっていくと思いますので、あくまでも2019年12月版として参考となれば幸いです。みなさまもぜひ jdeps/jlink にチャレンジしてみてください。

  • 株式会社アークシステムの来訪管理・会議室予約システム BRoomHubs
  • 低コスト・短納期で提供するまるごとおまかせZabbix