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

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

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

前回、以下の記事で Java 11 の jdeps/jlink を使い、Spring Boot ウェブアプリケーションを動かすための最小構成の Java 実行環境の作成に挑戦しました。一応の解決をみせましたが、--print-module-deps オプションを指定するとエラーになっている部分を深堀りできていませんでした。今回の記事でさらなる解決を目指してみます。

まとめ

  • JDK 内部 API は使わない。
    • 自分たちのコードだけでなく依存するライブラリにも気をつける。
    • 特に JDK 内部 API で削除されたクラス (JDK removed internal API) に依存するライブラリがないか要確認。
  • JDK 内部 API で削除されたクラス (JDK removed internal API) を参照していると、jdeps--print-module-deps オプション指定でエラーになる。
    • 該当の API を利用していないことが明らかであれば、-filter オプションにパッケージ名を正規表現で指定することで回避できる。

使用するツールの概要

  • AdoptOpenJDK
    • jlink: アプリケーション実行に必要なモジュールを含むカスタムランタイムイメージを作成するツール
    • jdeps: コンパイルされたクラスと Jar ファイルの依存性を分析できるツール
  • Gradle

改・依存性を分析する旅

––print-module-deps オプションによるエラーの原因を探る

Spring Boot ウェブアプリケーションに対して jdeps--print-module-deps オプションを指定すると以下のエラーが発生します。(前回の記事の再掲)

task jdeps(type: Exec, dependsOn: compileJava) {
    commandLine(
            'jdeps', '--print-module-deps',
            '--module-path', compileJava.classpath.asPath,
            '--multi-release', targetCompatibility,
            compileJava.destinationDir
    )
}
$ 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)
    :

出力されたスタックトレースをヒントに原因を探すと以下の JDK Bug System のチケットにたどり着きます。

上記のチケットでは、Fix Version/s が 12 のみとなっています。バージョン 12 の jdeps ではエラーにならないのは、この修正が 12 にしか適用されていないためです。(残念ながら 11 へのバックポートの予定はないようです)

エラーの内容が NullPointerException なのはバグです。しかし、チケットの内容を読む限りエラーの発生条件は欠落した依存関係があることのようです。

欠落した依存関係を jdeps で分析する

欠落した依存関係とは何でしょうか。依存モジュールは以下の通りです。(前回の記事の再掲)

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

ここでは、JDK removed internal API/sun.reflect です。JDK 内部 API のうち、sun.reflect パッケージにあるクラスが Java 11 では削除されているため、モジュールを検出できないのです。

どのライブラリが内部 API を参照しているか jdeps で分析する

jdeps--jdk-internals オプションを指定することで JDK 内部 API を呼び出しているクラスを探すことができます。

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

実行することで、JDK 内部 API に依存するクラスを取得することができました。

logback.classic automatic
 [file://path/to/.gradle/caches/modules-2/files-2.1/ch.qos.logback/logback-classic/1.2.3/7c4f3c474fb2c041d8028740440937705ebb473a/logback-classic-1.2.3.jar]
   requires mandated java.base
logback.classic -> JDK removed internal API
   ch.qos.logback.classic.spi.PackagingDataCalculator -> sun.reflect.Reflection                             JDK internal API (JDK removed internal API)
org.apache.tomcat.embed.core automatic
 [file://path/to/.gradle/caches/modules-2/files-2.1/org.apache.tomcat.embed/tomcat-embed-core/9.0.29/207dc9ca4215853d96ed695862f9873001f02a4b/tomcat-embed-core-9.0.29.jar]
   requires mandated java.base
org.apache.tomcat.embed.core -> java.rmi
   org.apache.catalina.mbeans.JmxRemoteLifecycleListener$JmxRegistry -> sun.rmi.registry.RegistryImpl                      JDK internal API (java.rmi)
spring.core automatic
 [file://path/to/.gradle/caches/modules-2/files-2.1/org.springframework/spring-core/5.2.2.RELEASE/bfcf2f6d0494d89db63ae170b8491223c93a88dc/spring-core-5.2.2.RELEASE.jar]
   requires mandated java.base
spring.core -> jdk.unsupported
   org.springframework.objenesis.instantiator.sun.UnsafeFactoryInstantiator -> sun.misc.Unsafe                                    JDK internal API (jdk.unsupported)
   org.springframework.objenesis.instantiator.util.DefineClassHelper$Java8 -> sun.misc.Unsafe                                    JDK internal API (jdk.unsupported)
   org.springframework.objenesis.instantiator.util.UnsafeUtils -> sun.misc.Unsafe                                    JDK internal API (jdk.unsupported)

どうやら Logback の ch.qos.logback.classic.spi.PackagingDataCalculator が削除された sun.reflect.Reflection に依存しているようです。他にも JDK 内部 API に依存していますが、削除予定でまだ削除されていないため今のところは無視します。今後ライブラリの更新で修正されることを期待しましょう。

sun.reflect.Reflection クラスがなくても動作する?

Logback (1.2.3) の PackagingDataCalculator (38行目) を見ると、Reflection.getCallerClass() がなくても動作する旨のコメントがあります。確かに Java 11 でも Logback が使えています。

-filter オプションがあるのでは……

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

$ jdeps --help
    :
  -f <regex>   -filter <regex>    指定のパターンに一致する依存性を
                                     フィルタします。複数回指定された場合、最後のものが
                                    使用されます。
    :

説明からは分かりづらいですが、パッケージ名を正規表現でフィルターできます。

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

フィルターをつけて実行すると、JDK removed internal API/sun.reflect を除外できたことが分かります。

$ gradlew jdeps

> Task :jdeps
   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

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

--print-module-deps オプションを指定すると、欠落した依存関係がある場合にエラーとなることが分かっています。-filter オプションと組み合わせてみます。

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

実行すると、エラーが発生せず jlink--add-modules オプションで指定できる形式で出力することができました。

$ gradlew jdeps
    :
java.base,java.desktop,java.instrument,java.management.rmi,java.naming,java.prefs,java.scripting,java.security.jgss,java.sql,jdk.httpserver,jdk.unsupported

長年の苦労がついに実を結びました。🎉🎉🎉

jdk.jdeps モジュールのクラスを直接的に呼び出すのは?

これまで jdeps コマンドとして実行してきました。実は jdk.jdeps モジュールにコマンドを含んでいます。

しかし、com.sun.tools.jdeps の実装ではモジュールのオブジェクトをイテレートできるような仕組みになっておらず引数に渡す PrintWriter に出力する機能しかありません。そのため、API を直接的に呼び出し、もやもやしながら保守し続けるよりも、コマンドラインツールとして呼び出したほうが平穏な日々を過ごせるのではないかと思います。

もし直接呼び出す場合は、com.sun.tools.jdeps.Main#runcom.sun.tools.jdeps.ModuleExportsAnalyzer あたりがエントリーポイントです。

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

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

jdeps--print-module-deps オプションを指定できるようになったため、わざわざ --list-deps オプションの結果をパースしてカンマ区切りのモジュールのリストを作る必要はなくなりました。ただし、改行をトリムする必要があるのでご注意ください。

ext {
    jreDir = file("${buildDir}/jre")
}
    :
clean {
    delete jreDir
}
    :
task jlink(dependsOn: compileJava) {
    outputs.dir jreDir

    doLast {
        def jdepsStandardOutput = new ByteArrayOutputStream()
        exec {
            commandLine(
                    'jdeps', '--print-module-deps',
                    '-filter', '^sun\\.reflect$',
                    '--module-path', compileJava.classpath.asPath,
                    '--multi-release', targetCompatibility,
                    compileJava.destinationDir
            )
            standardOutput = jdepsStandardOutput
        }

        def modules = jdepsStandardOutput.toString().strip()

        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 実行環境が出力されます。

最小構成の 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);
    }

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

問題なく起動できました。

gradlew bootRunCustomJRE

> Task :bootRunCustomJRE

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.2.2.RELEASE)
    :
2020-01-14 21:04:28.840  INFO 18896 --- [           main] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 1435 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
    :
2020-01-14 21:04:29.291  INFO 18896 --- [           main] com.example.demo.DemoApplication         : Started DemoApplication in 2.591 seconds (JVM running for 3.22)

特定の 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', '--print-module-deps',
                    '-filter', '^sun\\.reflect$',
                    '--module-path', compileJava.classpath.asPath,
                    '--multi-release', targetCompatibility,
                    compileJava.destinationDir
            )
            standardOutput = jdepsStandardOutput
        }

        def modules = jdepsStandardOutput.toString().strip()

        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 環境で作るのはおすすめしません。パーミッション設定を引き継ぎにくいためです。

最後に

アプリケーションのデプロイ方法は以下の本家のドキュメントに詳しいため、例のごとくここでは説明しません。

繰り返しとはなりますが、JDK 内部 API は使ってはいけません。自分たちのコードだけでなく依存するライブラリにも気をつけましょう。特に JDK 内部 API で削除されたクラス (JDK removed internal API) に依存するライブラリがないか確認してください。


まさか翌月に同じネタの記事を書くことになるとは思いませんでした。今回の記事で不安もなくなった形にできたかと思います。ツールの挙動がバージョンアップにより変わっていくと思いますので、あくまでも2020年1月版として参考となれば幸いです。みなさまもぜひ jdeps/jlink にチャレンジしてみてください。

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