CLI applications with GraalVM Native Image

原文はこちら。
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);
}
}
view raw PrimesComputer.java hosted with ❤ by GitHub

非常にシンプルなコンポーネントです。これは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);
}
}
}
view raw PrimesCommand.java hosted with ❤ by GitHub

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が載っています。私のリモート開発者マシンとしてこれをよく利用しています。

Image for post

このマシンにはかなりメモリを積んでいて、開発には最適ですが、ヒューリスティックベースの制限設定には少し不利です。それゆえに、ユーティリティの簡単な呼び出しによって消費される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が抑えが効かずに増加することはないことがわかります。

Image for post
ネイティブイメージビルド時に事前構成されたヒープサイズを使うネイティブ実行ファイルの計測状況

筆者のテストマシンでは、以下の結果でした。

  • 60M RSS
  • 2.02 total time
  • 77% CPU

このようにビルドされたネイティブ実行ファイルも、実行時に-Xmx-Xmn の設定を尊重しますので、必要に応じて増やすことができ、事前に設定した制限を使う分には不都合な面はありません。

Conclusion

GraalVM Native Image テクノロジーはコマンドラインアプリケーションに最適です。生成されるスタンドアロンバイナリは、JVMに依存せず、素早く起動し、多くのメモリを使わず、デフォルトで期待されるワークロードのための合理的なヒープ設定値を事前に設定できます。この記事では、MicronautとPicoCLIで作ったサンプルコマンドラインアプリケーションを紹介しましたが、MicronautとPicoCLIは本当にうまく機能する素晴らしい組み合わせで、ネイティブイメージとの相性が良いです。

GraalVM Native ImageをCLIアプリケーションに使っているプロジェクトは数多くあります。

ほかにもたくさんあります。おそらくKubernetesオペレータの作成にも適しているでしょう。CLIアプリに近いですが、おそらくこれは今後のトピックになるでしょう。

コマンドラインアプリケーションのためにGraalVM Native Imageを利用しているのであれば、(原文の)コメント欄やTwitter、Slackなどで、そのコマンドラインアプリケーションについて教えてください。私たちは常にGraalVMのさらなる改善を目指しています。

コメントを残す

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

WordPress.com ロゴ

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

Google フォト

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

Twitter 画像

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

Facebook の写真

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

%s と連携中