

Spring Boot でアプリケーションサーバーを同梱するなら Java 実行環境も添えればいいじゃない (Java 11, 2019年12月版)
こんにちは、ソリューション開発部の柴崎です。
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 形式で起動しましょう。
- Docker なら、
使用するツールの概要
- AdoptOpenJDK
- 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.base
と java.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
にチャレンジしてみてください。
続編かきました。