Smaller, Faster-starting Container Images With jlink and AppCDS

原文はこちら。
The original article was written by Gunnar Morling (Principal Software Engineer, Red Hat).
https://www.morling.dev/blog/smaller-faster-starting-container-images-with-jlink-and-appcds/

数ヶ月前に、シンプルなQuarkusアプリケーションを使いながら、アプリケーションクラスデータ共有(AppCDS)を使用してJavaアプリケーションの起動時間を高速化する方法について書きました。

Building Class Data Sharing Archives with Apache Maven
https://www.morling.dev/blog/building-class-data-sharing-archives-with-apache-maven/
https://logico-jp.io/2020/12/20/building-class-data-sharing-archives-with-apache-maven/
JEP 350: Dynamic CDS Archives
https://openjdk.java.net/jeps/350
Quarkus – supersonic subatomic Java
https://quarkus.io/
quarkus.package.create-appcds
https://quarkus.io/guides/maven-tooling#quarkus-package-pkg-package-config_quarkus.package.create-appcds

それ以来、この分野ではかなりの進歩がありました。Quarkus 1.6では、AppCDSのビルトインサポートが導入され、プロジェクトをビルドする際に-Dquarkus.package.create-appcds=trueオプションを指定するだけで、ターゲットフォルダ内にAppCDSファイルが見つかります。

しかし、Java 9で追加されたjlinkツールを使用して作成されたカスタムJavaランタイムイメージとAppCDSを組み合わせる場合、物事はより難しくなります。

jlink
https://docs.oracle.com/javase/jp/15/docs/specs/man/jlink.html
https://docs.oracle.com/en/java/javase/15/docs/specs/man/jlink.html

特にLinuxコンテナを使ったJavaアプリケーションのデプロイメントについて考えた場合、カスタムランタイムイメージとAppCDSの組み合わせは非常に魅力的です。コンテナイメージに完全な Java ランタイムを入れるのではなく、アプリケーションが実際に必要とする JDK モジュールのみを追加します。このようにしてイメージサイズを節約した分(の一部)は、コンテナイメージに AppCDS アーカイブを追加するために使用することができます。その結果、コンテナイメージは以前よりも小さくなり、コンテナレジストリへのプッシュ、Kubernetes クラスタ内のワーカーノードへの配布などが高速になり、起動も大幅に速くなります。

しかし、AppCDS アーカイブは、後にアプリケーションを実行するために使用される Java ランタイムと全く同じもので作成されなければならないという課題があります。jlink の場合、これはカスタムランタイムイメージ自体が AppCDS アーカイブを生成するために使用されなければならないことを意味します。言い換えれば、Quarkusビルドが生成するデフォルトのアーカイブは、残念ながらjlinkイメージでは使用できません。この記事の目的は以下の通りです。

  • Quarkusを使ったシンプルなJava CRUDアプリケーション用のカスタムランタイムイメージを作成するために必要な手順を説明します。
    Quarkus with AppCDS
    https://github.com/gunnarmorling/quarkus-cds
  • このカスタムランタイムイメージとアプリケーション自体を使ってLinuxコンテナイメージを構築する方法を紹介します。
  • このアプローチが、フルJavaランタイムを使用したコンテナイメージと比較して、サイズと起動時間の点で比較します。

Creating a Modular Runtime Image for a Quarkus Application

Java モジュールシステム (JPMS) に完全に移植された Java アプリケーションだけが jlink の恩恵を受けることができるというのは、よくある誤解です。しかし、Simon Ritterが以下のエントリで説明しているように、実際にはそうではありません。カスタムランタイムイメージを使ってアプリケーションを実行する上で、アプリケーションを完全にモジュール化する必要はありません。

Using jlink to Build Java Runtimes for non-Modular Applications
https://medium.com/azulsystems/using-jlink-to-build-java-runtimes-for-non-modular-applications-9568c5e70ef4

確かに、適切なJavaモジュールだけで構成されていればランタイムイメージの作成は少し簡単ですが、明示的にどのJDK(または他の)モジュールを含むべきか指定すれば、ランタイムイメージの作成自体は可能です。アプリケーションは、完全な Java ランタイムで実行するのと同じように、従来のクラスパスを使って実行できます。では、どのJDKモジュールを追加すればいいのでしょうか?この質問に答えるには、jdepsツールが便利です。

jdeps
https://docs.oracle.com/javase/jp/15/docs/specs/man/jdeps.html
https://docs.oracle.com/en/java/javase/15/docs/specs/man/jdeps.html

その--print-module-depsオプションを使うと、与えられたJARのセットに対して、どの(JDK)モジュールに依存しているのか、そしてカスタムランタイムイメージに入れる必要があるのはどれなのかを判断することができます。

前のブログ記事の例のアプリケーションをmvn clean verifyでビルドしたので、以下のようにjdepsを起動してみましょう。

jdeps --print-module-deps \
    --class-path target/lib/* \
    target/todo-manager-1.0.0-SNAPSHOT-runner.jar

ですが、結果はエラーになりました…。

Error: com.sun.istack.istack-commons-runtime-3.0.10.jar is a multi-release jar file but --multi-release option is not set

なるほど、マルチリリースJAR向けにどのコードバージョンを解析するかを知らせる必要がありますね。

JEP 238: Multi-Release JAR Files
https://openjdk.java.net/jeps/238

jdeps --print-module-deps \
    --multi-release 15 \
    --class-path target/lib/* \
    target/todo-manager-1.0.0-SNAPSHOT-runner.jar

ちょっと進展しましたが、まだ問題がありますね。

Exception in thread "main" java.lang.module.FindException: Module java.xml.bind not found, required by java.ws.rs

これはちょっとおかしいですね。 org.jboss.spec.javax.ws.rs.jboss-jaxrs-api_2.1_spec-2.0.1.Final.jarというファイルは、module-info.class記述子を持つ明示的なモジュールで、モジュールjava.xml.bindを参照しているのですが、これはモジュールパスにはありません。JAX-RS API JARはクラスパスの一部であり、モジュールパスではないことを考えると、なぜこれがここでフラグが立てられるのか、私にはよくわかりません。しかし、それは大きな問題ではありません。JAXB API(これもクラスパスで提供されています)を単にモジュールパスに追加すればよいのです。

すでに明示的なモジュールである他のいくつかの依存関係についても同じ問題が発生するため、以下のような設定になってしまいました。

jdeps --print-module-deps \
    --multi-release 15 \
    --module-path target/lib/jakarta.activation.jakarta.activation-api-1.2.1.jar:target/lib/org.reactivestreams.reactive-streams-1.0.3.jar:target/lib/org.jboss.spec.javax.xml.bind.jboss-jaxb-api_2.3_spec-2.0.0.Final.jar \
    --class-path target/lib/* \
    target/todo-manager-1.0.0-SNAPSHOT-runner.jar

別の問題は、欠落した依存関係です。

...
org.postgresql.util.internal.Nullness              -> org.checkerframework.dataflow.qual.Pure            not found
org.wildfly.common.wildfly-common-1.5.4.Final-format-001.jar
   org.wildfly.common.Substitutions$Target_Branch     -> com.oracle.svm.core.annotate.AlwaysInline          not found
...

よく見てみると、これらはコンパイル時のみの依存関係(Checkerフレームワークのアノテーションのようなもの)、もしくは今回のケースには関係のないオプション機能の依存関係のいずれかです。 --ignore-missing-deps スイッチを使って、jpdesの呼出しをしつつも安全に無視できます。

The Checker Framework
https://checkerframework.org/

jdeps --print-module-deps \
    --ignore-missing-deps \
    --multi-release 15 \
    --module-path target/lib/jakarta.activation.jakarta.activation-api-1.2.1.jar:target/lib/org.reactivestreams.reactive-streams-1.0.3.jar:target/lib/org.jboss.spec.javax.xml.bind.jboss-jaxb-api_2.3_spec-2.0.0.Final.jar \
    --class-path target/lib/* \
    target/todo-manager-1.0.0-SNAPSHOT-runner.jar

必要なJDKモジュールが最終的に表示されます。

java.base,java.compiler,java.instrument,java.naming,java.rmi,java.security.jgss,java.security.sasl,java.sql,jdk.jconsole,jdk.unsupported

つまり、OpenJDK 15を構成するおよそ60個のモジュールから、このアプリケーションで必要なのはたった10個ということです。これらのモジュールのみを含むカスタムランタイムイメージを作ればかなりのスペース節約になります。

Why is a Particular Module Required?

モジュールリストを見ると、なんでこのモジュールが実際に必要なのか、と思うことでしょう。例えば、jdk.jconsoleを使ってこのアプリケーションは何をやっているのでしょうか。この詳細を知る場合においても、jdepsが役に立ちます。--print-module-deps スイッチを付けずに再度jdepsを実行すると、興味深いモジュール参照をgrepできます。

jdeps <…> | grep jconsole

org.jboss.narayana.jta.narayana-jta-5.10.6.Final.jar -> jdk.jconsole
com.arjuna.ats.arjuna.tools.stats ->
com.sun.tools.jconsole jdk.jconsole

この場合、Narayana トランザクションマネージャから jconsole への依存関係が一つあります。詳細に依存しますが、そのようなライブラリのメンテナに連絡して、この依存関係が本当に必要なのか、それとも(問題のコードを別のモジュールに移動させるなどして)回避できるのかを議論するのもよいでしょう。結果としてカスタムランタイムイメージのサイズがさらに小さくなる可能性があります。

必要なモジュールのリストを使えば、実際のランタイムイメージの生成はとても簡単です。

$JAVA_HOME/bin/jlink \
  --add-modules java.base,java.compiler,java.instrument,java.naming,java.rmi,java.security.jgss,java.security.sasl,java.sql,jdk.jconsole,jdk.unsupported \
  --compress 2 --no-header-files --no-man-pages \  # ①
  --output target/runtime-image   # ②
ランタイムを圧縮するだけでなく、ヘッダーファイルやmanページの省略により、さらにランタイムイメージのサイズを小さくできます。
作成したランタイムイメージの出力先

後でアプリケーションクラス用の動的な AppCDS アーカイブを作成するために、イメージ自体のすべてのクラスのクラスデータアーカイブを追加する必要があります。これに失敗すると、このようなエラーメッセージが表示されます。

Error occurred during initialization of VM
DynamicDumpSharedSpaces is unsupported when base CDS archive is not loaded

この手順はあまり文書による説明がないので、この時点でちょっと進みが遅くなってしまいました。しかしいつものことながらOpenJDKコミュニティを頼ることができます。Twitterで尋ねてみたところ、Claes Redestadが方向性を示してくれました。

./target/runtime-image/bin/java -Xshare:dump

ありがとう、Claes!このおかげで、ベースクラスのデータアーカイブをtarget/runtime-image/lib/server/classes.jsaに作成できました。ランタイムイメージは12MBほど増えたとはいえ、現時点で63MBほどなので、悪くないですね。

Adding an AppCDS Archive to a Custom Runtime Image

カスタムJavaランタイムイメージを作成したので、そこにAppCDSアーカイブを追加してみましょう。JDK 13では動的なAppCDSアーカイブが導入されているので、-XX:ArchiveClassesAtExitオプションを指定してアプリケーションを実行するだけの簡単なステップです。

cd target  # ①

mkdir runtime-image/cds  # ②

# ③
runtime-image/bin/java \
  -XX:ArchiveClassesAtExit=runtime-image/cds/app-cds.jsa \
  -jar todo-manager-1.0.0-SNAPSHOT-runner.jar

cd ..
後でアプリケーションを実行する際に使われるクラスパスは、AppCDSアーカイブビルド時に使われるクラスパスと同一でなければなりません(正確に言えばクラスパスの接頭辞が同一である必要がある)。ゆえに、-jar target/*-runner.jarではなく、ターゲットディレクトリを変更し、-jar *-runner.jarを使って実行する必要があります。
AppCDSアーカイブを格納するためのフォルダを作成
ランタイムイメージのjavaバイナリを使ってアプリケーションを起動し、アプリケーション終了時にAppCDSアーカイブを作成します。

これにより、target/runtime-image/cds/app-cds.jsaにCDSアーカイブが作成されます。次のステップで、Docker や podman などを使ってビルドした Linux コンテナイメージにこのアーカイブを追加できます。jlink はクロスプラットフォームビルドをサポートしていますが(例えばmacOS上でLinuxコンテナ用のカスタムランタイムイメージをビルドできます)、AppCDS の場合はそうではないことに注意してください。つまり、コンテナ化されたアプリケーションで使用するための AppCDS アーカイブは Linux 上でビルドする必要があります。自分自身がLinux上で実行しておらず、WindowsやmacOS上で実行している場合は、ビルドプロセス全体をコンテナにしてしまえば、この目的を達成できます。

Podman
https://podman.io/

Creating a Linux Container Image

At this point we have built our actual application, a custom Java runtime image with the required JDK modules, and an AppCDS archive for the application’s classes. The final step is to put everything into a Linux container image, which is quickly done via a small Dockerfile:この時点で、実際のアプリケーション、必要なJDKモジュールが入ったカスタムJavaランタイムイメージ、アプリケーションクラスのAppCDSアーカイブができました。最後のステップはこれら全てをLinuxコンテナイメージに入れることです。これは小さなDockerfileで速やかに実施できます。 

FROM registry.fedoraproject.org/fedora-minimal:33

COPY target/runtime-image /opt/todo-manager/jdk
COPY target/lib/* /opt/todo-manager/lib/
COPY target/todo-manager-1.0.0-SNAPSHOT-runner.jar /opt/todo-manager
COPY todo-manager.sh /opt/todo-manager

ENTRYPOINT [ "/opt/todo-manager/todo-manager.sh" ]

ここではFedoraのminimalベースイメージを使っていますが、このイメージはコンテナイメージ用のよいベースイメージです。サイズはおよそ120 MBで、効率的に配布するには十分小さいながら、完全な Linux ディストリビューションの柔軟性を提供しています。例えば、必要に応じてツールを追加できます。

registry.fedoraproject.org/fedora-minimal
https://registry.fedoraproject.org/repo/fedora-minimal/tags/

Even Smaller Container Images

コンテナイメージのサイズをさらに縮小したい場合や冒険心がある場合は、Alpine Linuxをベースイメージとして使用することを検討できますが、問題は、ISO CとPOSIX標準APIの実装として、Alpineは(JDKで使用されている)glibcの代わりにmuslが付属していることです。OpenJDK Portolaプロジェクトは、Alpine と musl への移植版を提供することを目的としています。しかし、JDK 15の時点では、この移植版のGAビルドはまだ存在しません。JDK 16では、Alpine/musl対応の早期アクセスビルドが利用可能です。

Alpine Linux
https://alpinelinux.org/
Portola Project
https://openjdk.java.net/projects/portola/
OpenJDK 16 Early Access Build
https://jdk.java.net/16/

コンテナイメージを小さくするためのもう一つのオプションとして、jibの利用がありますが、これもQuarkusでサポートされています(jibがカスタムランタイムイメージとAppCDSでどのように動作するのかをまだ試してはいません)。

jib
https://github.com/GoogleContainerTools/jib
Jib
https://quarkus.io/guides/container-image#jib

また、ベースイメージのサイズは、実際にはあまり気にならないということも指摘しておきましょう。コンテナイメージはレイヤー化されたファイルシステムを使用しているため、コンテナイメージをPushしたりPullしたりする際に、通常は安定したベースイメージレイヤーの再配布が不要なのです。

コンテナのエントリポイントであるtodo-manager.shは、基本的なシェルスクリプトで、実際のJavaアプリケーションをJavaランタイムイメージを使って開始します。

#!/bin/bash

export PATH="/opt/todo-manager/jdk/bin:${PATH}"

cd /opt/todo-manager && \  # ①
  exec java -Xshare:on -XX:SharedArchiveFile=jdk/cds/app-cds.jsa -jar \  # ②
  todo-manager-1.0.0-SNAPSHOT-runner.jar
todo-manager ディレクトリに移動し、CDSアーカイブ作成時と同じJARのパスを渡す
Specifying the archive name; the -Xshare:on isn’t strictly needed, it’s used here though to ensure the process will fail if something is wrong with the CDS archive, instead of silently not using itアーカイブ名を指定する。このとき-Xshare:onは厳密には必要ではないが、ここではプロセスがCDSアーカイブに何か問題あった場合に、だんまりにならず、失敗したことを保証するために使っている

Let’s See Some Numbers!

最後に、数値を比較してみましょう。ToDo管理アプリケーションを様々な方法でコンテナ化した場合の、コンテナイメージのサイズと起動時間です。今回は4つの異なるアプローチを試してみました。

  • OpenJDK 11 + RHEL UBI 8.3イメージ(ユニバーサルベースイメージ)、新たなQuarkusアプリケーション用に作成されたデフォルトのDockerfileに従ったもの
  • OpenJDK 15フルイメージ + Fedora 33(RHEL ベースイメージ用の OpenJDK 15 パッケージがまだないため)
  • OpenJDK 15 カスタムランタイムイメージ + Fedora 33
  • OpenJDK 15 カスタムランタイムイメージ + AppCDS + Fedora 33

Red Hat Universal Base Images
https://developers.redhat.com/products/rhel/ubi

以下は、ホストOSとしてFedora 33を使用するHetzner Cloud CX4インスタンス(4 vCPU、16 GB RAM)で実行した結果です。

Hetzner Cloud
https://www.hetzner.com/cloud

Container Image Sizes and Startup Times

ご覧のように、JDKフルサイズではなくカスタムJavaランタイムイメージを追加すると、コンテナイメージのサイズが大幅に小さくなります。特に、RHEL UBI 8.3イメージのOpenJDK 11パッケージよりもかなり大きいFedora 33のOpenJDKパッケージと比較すると、その差は顕著です。

起動時間はQuarkusで表示されている通りで、5回の実行で平均したものです。OpenJDK 11から15に移行することで数値は約10%改善されましたが、これはこの分野での複数の改善、特にJDK 12(JEP 341)でのJDKクラスのデフォルトCDSアーカイブの導入によるものです。

JEP 341: Default CDS Archives
http://openjdk.java.net/jeps/341

カスタムランタイムイメージを使用すること自体は、起動時間に測定可能な影響は与えていません。AppCDSアーカイブは起動時間を54%も改善しました。純粋なイメージサイズが重要な要素でない限り(その場合は、いずれにしても別のアプローチを探すべきで、上記のEven Smaller Container Imagesを参照してください)、AppCDS アーカイブのための追加の 40 MB は、それだけの価値があると言えるでしょう。特に、結果として得られるコンテナイメージは、FedoraベースイメージやRHEL UBIであっても、フルJDK を追加した場合よりもはるかに小さいです。

これらの数字に基づいて、jlink を使って作成したカスタムJavaランタイムイメージとAppCDSアーカイブの組み合わせは、コンテナ化されたJavaアプリケーションのための素晴らしい基盤になると言っても良いと思います。アプリケーションが実際に必要とするJDKモジュールのみを含むカスタムランタイムイメージを追加することで、イメージサイズを大幅に削減できます。節約したスペースの一部はAppCDSアーカイブの追加に割けるので、最終的には、より小さく、より速く起動するコンテナイメージが得られます。つまり、ケーキを食べても減らない、ということです(訳注:本来は “you can’t have your cake and eat it too.” つまり、ケーキは食べたらなくなる、両方一度にはできない、という形で使う表現ですが、どっちも実現できるために、”you can have this cake, and eat it, too!” と表現しています)。

1つの欠点は、ランタイムイメージとAppCDSアーカイブを生成するためのビルドプロセスが複雑になることです。これはスクリプトや自動化によって管理可能ですが、Quarkus Mavenプラグインのようなツールやその他のツールがこの点をさらに改善してくれることを期待しています。厄介な点として、必要なJDKモジュールのセットに影響を与える依存関係をアプリケーションに追加した場合、カスタムランタイムイメージを再構築することを忘れてはいけないということです。ランタイムイメージを使って実行しているアプリケーションの自動テストは、この状況を特定するのに役立つはずです。

ご自身で試してみたい方や、ご自身のハードウェア上での異なるデプロイ方法の数値を取得したい方は、以下のGitHubリポジトリに必要なコードと情報をすべて掲載しています。

Quarkus with AppCDS
https://github.com/gunnarmorling/quarkus-cds

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Google フォト

Google アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中