原文はこちら。
The original article was written by Oleg Šelajev (Developer advocate for GraalVM at OracleLabs).
https://medium.com/graalvm/cli-applications-with-graalvm-native-image-d629a40aa0be
GraalVM Native Image テクノロジーを使うと、アプリケーションをコンパイルしてネイティブ実行形式にできます。これには以下のようなメリットがあります。
- 小さなスタンドアロン配布形態で、JDKを必要としない
- 起動が高速
- より小さなメモリフットプリント
ネイティブイメージは、上記のような便益がアプリケーション実行の実際のコストを決めるクラウド環境に対し、アプリケーションをデプロイする非常にエキサイティングな方法です。
しかし、上記のような特色から便益を受ける別のアプリケーションがあります。それはコマンドラインアプリケーションです。
確かに、よいコマンドラインアプリケーションはまさにスタンドアロンであり、便利にパッケージングされたインフラストラクチャやアプリケーションの依存関係をダウンロードを含むdocker run some-image
とは異なるタイプのスタンドアロンです。これは以前から可能でした。@Grab
アノテーションをGroovyコードにつけて、JDKをダウンロードするラッパーを提供すれば、それで終わりです。ネイティブイメージを使えば、wgetで取得すればそのままで利用できるバイナリを作成できます。
よいCLIアプリは高速に起動します。そしてやることがなければすぐに終了します。
GraalVM Native ImageはJavaアプリケーションをプリコンパイルしてネイティブイメージにします。このネイティブイメージは、例えばクラスファイルのロードや検証といった起動時の初期化を必要としません。起動時間は、Go、C/C++などの他のネイティブコンパイル言語で書かれたアプリと同等です。
ネイティブ実行形式ではJITコンパイラがないため、JITコンパイラのために初期化やメモリ割り当てなども不要です。すべてビルド時にコンパイルされます。JITコンパイラがない、とはつまり、コードキャッシュやプロファイルデータキャッシュもない、ということで、その結果JVMで動作する同じアプリケーションよりもメモリ要件が小さくなります。当然ながら、自身のデータのためにメモリを要します。処理するデータが多い場合、メモリ使用量は必要に応じて増加しますが、通常の -Xmx
などのコマンドラインオプションで構成できます。
全てにおいて、良いCLIアプリがどのようにあるべきか、という点で、GraalVM Native Imageは多くのチェックボックスをチェックしていますが、この記事では、GraalVM Native Imageのもう一つのクールな機能、つまり、イメージのビルド時に必要なデフォルトのメモリを指定することについて探ってみたいと思います。
通常、JVM上で動作するJavaアプリケーションの場合、実行時に利用可能なメモリ使用量を指定する必要があります。 java -Xmx4G -jar myApp.jar
のようなコマンドラインをつけて実行すると、このアプリケーションは4GBがヒープサイズの上限であることがわかります。Without the -Xmx
オプションをつけない場合、ヒープサイズは通常、利用可能なメモリの総量を基にしてヒューリスティックに決まります。
GraalVM Native Imageを使ってアプリケーションをコンパイルすると、ヒープサイズをビルド時に事前設定できます。
サンプルのCLIアプリケーションをMicronaultとPicocliを使って作成し、ネイティブ実行メモリの設定方法を見てみましょう。
Micronaut Framework
http://micronaut.io/
picocli – a mighty tiny command line interface
https://picocli.info/
Micronaut Launchで必要なコンポーネントを指定して作成、もしくはコマンドラインユーティリティのmn
を使って作成します。mn
の興味深いところは、それ自身がGraalVM Native Imageで生成されたMicronautコマンドラインアプリケーションというところです。Java CLIアプリケーションとして完全な例です。
Micronaut Launch
https://micronaut.io/launch/
今回はMicronaut 2.1.2を使っています。
mn create-cli-app primes; cd primes
アプリケーションの準備ができました。これは--help
のような基本的なコマンドに応答できるCLIアプリのテンプレートです。素数を計算するという非常に重要なタスクのビジネスロジックを作成してみましょう。お気に入りのIDEを使ってprimes
パッケージでPrimesComputer.java
を作成します。
package primes; | |
import javax.inject.Singleton; | |
import java.util.stream.*; | |
import java.util.*; | |
@Singleton | |
public class PrimesComputer { | |
private Random r = new Random(41); | |
public List<Long> random(int upperbound) { | |
int to = 2 + r.nextInt(upperbound - 2); | |
int from = 1 + r.nextInt(to - 1); | |
return primeSequence(from, to); | |
} | |
public static List<Long> primeSequence(long min, long max) { | |
return LongStream.range(min, max) | |
.filter(PrimesComputer::isPrime) | |
.boxed() | |
.collect(Collectors.toList()); | |
} | |
public static boolean isPrime(long n) { | |
return LongStream.rangeClosed(2, (long) Math.sqrt(n)) | |
.allMatch(i -> n % i != 0); | |
} | |
} |
非常にシンプルなコンポーネントです。これはupperbound
までの2個の乱数をとり、両者間に存在する素数のリストを返します。
素数の計算はStreams APIを使って実施します。これはあまり効率的ではありませんが、この演習には関係ありません。これは実際のところ、サンプルアプリにとって非常に都合がよいです。なぜなら、すべての計算は、その後に使用されないデータ、つまりガベージを少々生成するからです。
メインの PrimesCommand.java
ファイルで使ってみましょう。
package primes; | |
import io.micronaut.configuration.picocli.PicocliRunner; | |
import io.micronaut.context.ApplicationContext; | |
import picocli.CommandLine; | |
import picocli.CommandLine.Command; | |
import picocli.CommandLine.Option; | |
import picocli.CommandLine.Parameters; | |
import javax.inject.*; | |
import java.util.*; | |
@Command(name = "primes", description = "...", | |
mixinStandardHelpOptions = true) | |
public class PrimesCommand implements Runnable { | |
@Option(names = {"-n", "--n-iterations"}, description = "How many iterations to run") | |
int n; | |
@Option(names = {"-l", "--limit"}, description = "Upper limit for the sequence") | |
int l; | |
@Inject | |
PrimesComputer primesComputer; | |
public static void main(String[] args) throws Exception { | |
PicocliRunner.run(PrimesCommand.class, args); | |
} | |
public void run() { | |
for(int i =0; i < n; i++) { | |
List<Long> result = primesComputer.random(l); | |
System.out.println(result); | |
} | |
} | |
} |
CLIユーティリティが受け入れる2つの整数オプション、実行する計算の反復回数と、考慮する数値の上限を宣言しています。
これでアプリを試して、動作を確認することができるようになりました。便利のために、テンプレートから残っているテストクラスを削除してください(もちろん機能に合わせて編集することもできますが、削除する方が簡単です)。
rm src/test/java/primes/PrimesCommandTest.java
プロジェクトをビルドし、生成されたjarファイルを実行します。
./gradlew build
...
java -jar build/libs/primes-0.1-all.jar
00:51:30.519 [main] INFO i.m.context.env.DefaultEnvironment - Established active environments: [oraclecloud, cloud, cli]
Micronautアプリケーションの起動時間が非常に速いことがわかると思います。1秒足らずです。
引数に値を指定して実行することもできるので、出力も確認できます。
java -jar build/libs/primes-0.1-all.jar -n 1 -l 100
[53, 59, 61, 67, 71, 73]
記事の冒頭で、CLIツールは高速に起動し、まあまあ少量のメモリを使用すると述べたことを覚えていらっしゃるかと思います。これらのメトリクスのベースラインのために、Javaバージョンをテストしてみましょう。
/usr/bin/time -v java -jar build/libs/primes-0.1-all.jar -n 1 -l 100
16:34:02.776 [main] INFO i.m.context.env.DefaultEnvironment - Established active environments: [oraclecloud, cloud, cli]
[53, 59, 61, 67, 71, 73]
Command being timed: "java -jar build/libs/primes-0.1-all.jar -n 1 -l 100"
User time (seconds): 2.73
System time (seconds): 0.43
Percent of CPU this job got: 309%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:01.02
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 354996
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 54451
Voluntary context switches: 9774
Involuntary context switches: 53
Swaps: 0
File system inputs: 0
File system outputs: 64
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0
Linuxのtime
ユーティリティ(macOSならgtime
)を使うと、verboseモードでいくつか興味深いメトリクスが出てきます。
- 最大メモリ利用量 (max RSS, Resident Set Size): 355m
- wall clock time (プログラムの開始から終了までの時間): 1.02s
- CPU 利用率: 309%
なお、テストに利用しているマシンはまともなクラウドVMで、少々のCPUといくらかのRAMが載っています。私のリモート開発者マシンとしてこれをよく利用しています。

このマシンにはかなりメモリを積んでいて、開発には最適ですが、ヒューリスティックベースの制限設定には少し不利です。それゆえに、ユーティリティの簡単な呼び出しによって消費される350MBが非常に大きなものか否かを判断するべきではありません。
たとえCLIアプリが必要以上にメモリを必要としているように見えるとしても、これらのヒューリスティックは、アプリケーションがリソースを単独で消費するサーバー環境では非常に理にかなっていることを覚えておいてください。5個の素数を計算するにあたって、CPU利用率が300%、wall clock timeが1秒を何も言わずに残しておきます。ベースラインとしては、これが良いでしょう。
さて、このアプリケーションを実行するためのより良い方法は、GraalVM Native Imageで生成されたネイティブ実行ファイルとして実行することです。幸いなことに、Micronautアプリケーションのネイティブ実行ファイルを構築するのは非常に簡単で、native-image
ユーティリティの設定と実行を支援するGradleプラグインがあります。
Run ./gradlew nativeImage
と実行して、コンパイルと実行ファイル生成後、デプロイ可能なスタンドアロンバイナリファイルができあがります。
実行ファイルは build/native-image/
ディレクトリにあります。これを便利のためにカレントディレクトリに移動しましょう。
mv build/native-image/application ./primes-defaults
実行して、それがJavaバージョンと同じように動作することを確認することができます。
./primes-defaults -n 1 -l 100
同時に、ネイティブイメージの挙動がCLIアプリケーションにとってより適切かどうかをチェックするためのコマンドを実行しています。
/usr/bin/time -v ./primes-defaults -n 1 -l 100
23:57:47.286 [main] INFO i.m.context.env.DefaultEnvironment - Established active environments: [oraclecloud, cloud, cli]
[53, 59, 61, 67, 71, 73]
Command being timed: "./primes-defaults -n 1 -l 100"
User time (seconds): 0.01
System time (seconds): 0.01
Percent of CPU this job got: 113%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:00.02
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 49980
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 1226
Voluntary context switches: 232
Involuntary context switches: 2
Swaps: 0
File system inputs: 0
File system outputs: 0
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0
ここでハイライトをご紹介します。
- 50M rss
- 20 ms total time
- 113% CPU
当然ながら、これはずっとよい結果ですが、メモリ制限を設定していません。ワークロードを増やした場合、ヒューリスティックを使用し、予想以上に積極的にメモリ利用量を増やすことになります。
幸いにして、パラメータに実行反復回数があるので、非常に簡単に実験ができます。以下は、同じタスクを10万回繰り返し実行したときの出力結果です。
Command being timed: "./primes-defaults -n 100000 -l 100"
User time (seconds): 0.88
System time (seconds): 0.63
Percent of CPU this job got: 72%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:02.07
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 309956
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 65110
Voluntary context switches: 199
Involuntary context switches: 5
Swaps: 0
File system inputs: 0
File system outputs: 0
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0
- 309M RSS
- 2s
- 72% CPU
(念のため、Java JVMバージョンでは、以下の結果が表示されます)
- 517M RSS
- 3.3s
- 171% CPU
それでも、動作の良い CLI ユーティリティが必要とするメモリにしては300Mは多いと思う場合は、以下のように実行時に -Xmx
と -Xmn
オプションを使って制御することができます。
./primes-defaults -Xmx64m -Xmn16m -n 100000 -l 100
もしくは、デフォルトの構成値をネイティブイメージに事前に組み込むこともできます。以下のスニペットをbuild.gradle
ファイルに追加します。
nativeImage {
args("-R:MaxHeapSize=64m")
args("-R:MaxNewSize=16m")
}
ここでは、ビルドされるネイティブイメージの最大ヒープサイズを64m、新生成サイズを16mに指定します。これらのオプションは、ネイティブイメージ実行ファイルのデフォルトになります。テストのために、もう一度ネイティブイメージをビルドします。
./gradlew nativeImage
バイナリも移動して将来のビルドで上書きされないようにします。
mv build/native-image/application ./primes-limits
同じように実行してみます。
./primes-limits -n 1 -l 100
00:08:08.228 [main] INFO i.m.context.env.DefaultEnvironment - Established active environments: [oraclecloud, cloud, cli]
[53, 59, 61, 67, 71, 73]
(10万回の繰り返しテストで)負荷をかけたところ、稼働中のヒープ構成でRSSが抑えが効かずに増加することはないことがわかります。

筆者のテストマシンでは、以下の結果でした。
- 60M RSS
- 2.02 total time
- 77% CPU
このようにビルドされたネイティブ実行ファイルも、実行時に-Xmx
、-Xmn
の設定を尊重しますので、必要に応じて増やすことができ、事前に設定した制限を使う分には不都合な面はありません。
Conclusion
GraalVM Native Image テクノロジーはコマンドラインアプリケーションに最適です。生成されるスタンドアロンバイナリは、JVMに依存せず、素早く起動し、多くのメモリを使わず、デフォルトで期待されるワークロードのための合理的なヒープ設定値を事前に設定できます。この記事では、MicronautとPicoCLIで作ったサンプルコマンドラインアプリケーションを紹介しましたが、MicronautとPicoCLIは本当にうまく機能する素晴らしい組み合わせで、ネイティブイメージとの相性が良いです。
GraalVM Native ImageをCLIアプリケーションに使っているプロジェクトは数多くあります。
- sbtのネイティブthinクライアント
https://www.scala-sbt.org/1.x/docs/sbt-1.4-Release-Notes.html#Native+thin+client - Micronautの
mn
ユーティリティ
https://github.com/micronaut-projects/micronaut-starter - GoogleのJavaScript用Closureコンパイラ
https://www.npmjs.com/package/google-closure-compiler - Maven Daemonプロジェクト
https://github.com/mvndaemon/mvnd
ほかにもたくさんあります。おそらくKubernetesオペレータの作成にも適しているでしょう。CLIアプリに近いですが、おそらくこれは今後のトピックになるでしょう。
コマンドラインアプリケーションのためにGraalVM Native Imageを利用しているのであれば、(原文の)コメント欄やTwitter、Slackなどで、そのコマンドラインアプリケーションについて教えてください。私たちは常にGraalVMのさらなる改善を目指しています。
「CLI applications with GraalVM Native Image」への1件のフィードバック