原文はこちら。
The original article was written by Andy Muir.
https://muirandy.wordpress.com/2019/07/08/kafka-streaming-on-graalvm/
tl;dr
GraalVMを使って、Kafka Streamsを実行する小さなJavaマイクロサービスを、実行時に必要なJVMを使わなくてすむネイティブアプリケーションにすることができました。Dockerイメージサイズは1/3以下にできました。アプリケーションのメモリ利用量はおよそ1/9、CPU利用率は1/4ほどに削減できました。しかしながら、いくつかの事柄はサポート外で、ソースコードを変更して問題の回避が必要な場合があります。また、3rdパーティー製のライブラリには互換性がない場合があるのでご注意ください。
GraalVM
https://graalvm.org
Native Image Compatibility and Optimization Guide
https://www.graalvm.org/reference-manual/native-image/Limitations/
https://github.com/oracle/graal/blob/master/substratevm/Limitations.md
History
JavaとJVMが導入されたとき、コンパイル済みコードをを異なるハードウェア上で実行できるという問題を解決しました。JVMは、ハードウェアの問題を抽象化しました。今日、私たちはその当時想像していた以上に多くの方法でJVMを使用しています。しかし、今ではそれほど移植性を必要としていません。なぜなら、その問題はコンテナ化やDockerで解決されているからです。マイクロサービスを使っている場合、ランタイムはDocker on Linux、おそらくKubernetesと共に使っているでしょう。
JVMの”run anywhere”で表現される柔軟性は、JVMをインストールした環境にアプリケーションをバンドルする必要があるため、欠点になることが多々あります。これは「Hello World」プログラムのために膨大なソフトウェアをインストールする必要があることを意味します。JVM以外の言語にはこのような欠点はなく、単純なプログラムはそれなりに小さいサイズです。
Stage Right
OracleからGraalVMが登場しました。Community EditionまたはEnterprise Editionで提供されています。Linux、macOS、Windowsで動作します。
GraalVM Community Edition
https://github.com/oracle/graal
GraalVM Enterprise Edition
https://www.oracle.com/technetwork/graalvm/downloads/index.html
興味深いpolyglot機能に加えて、このコンパイラは、既存のJavaプログラム(例えばfat Jar)を取り込み、Javaバイトコードから、実行にあたりJVMが不要なネイティブ実行ファイルにコンパイルできます。これは新しいもので(最初の公式なproductionリリースは2019年5月)、かなりクールなものです。これは、Dockerイメージのサイズを大幅に削減できることを意味します。その上、アプリケーションのメモリフットプリントは劇的に小さくなるようで、必要なCPUリソースは削減され、アプリの起動時間は桁違いに良くなります。
For Building Programs That Run Faster Anywhere: Oracle GraalVM Enterprise Edition
https://blogs.oracle.com/graalvm/announcement
Versions
このエントリを記載している間に、GraalVM 19.1.0がリリースされました(訳注:2020/12の時点では20.3.0が最新)。19.0.0と19.1.0の両方を例に挙げて使ってみました。GraalVM EEではmacOSネイティブイメージ、GraalVM CEではDockerネイティブイメージを使ってみました。
Moving in
先日のエントリで試したように、簡単なJavaマイクロサービスを作成しました。入力されたKafkaトピックから任意のフォーマットのXMLを取り出し、JSONに変換して出力Kafkaトピックに書き出します。非常にシンプルで、場所を取り過ぎることもないものなので、実験にはもってこいです。
Running IntelliJ on GraalVM
https://muirandy.wordpress.com/2019/05/20/running-intellij-on-graalvm/
muirandy/graal-kafka-xml-json-converter
https://github.com/muirandy/graal-kafka-xml-json-converter/
アプリケーションをネイティブイメージにコンパイルしようとしたとき、すぐにいくつかの問題にぶつかりました。GraalVMが処理できない問題に遭遇した場合、フォールバックイメージを作成します。このイメージはネイティブの実行ファイルのように見えますが、実行にはJVMを使用します。 これは求めていたものではないため、GraalVMコンパイラに指定できるビルドオプションを弄ってみたところ、スタンドアロンネイティブイメージをビルドすることができました。
しかしながら実行すると以下のような例外を吐いて失敗しました。
Exception in thread "main" java.lang.ExceptionInInitializerError
at com.oracle.svm.core.hub.ClassInitializationInfo.initialize(ClassInitializationInfo.java:290)
at java.lang.Class.ensureInitialized(DynamicHub.java:451)
at org.apache.kafka.streams.KafkaStreams.<init>(KafkaStreams.java:544)
at com.aimyourtechnology.xmljson.converter.ConverterStream.runTopology(ConverterStream.java:58)
at com.aimyourtechnology.xmljson.converter.ConverterApp.main(ConverterApp.java:36)
Caused by: org.apache.kafka.common.config.ConfigException: Invalid value org.apache.kafka.streams.errors.LogAndFailExceptionHandler for configuration default.deserialization.exception.handler: Class org.apache.kafka.streams.errors.LogAndFailExceptionHandler could not be found.
at org.apache.kafka.common.config.ConfigDef.parseType(ConfigDef.java:720)
at org.apache.kafka.common.config.ConfigDef$ConfigKey.<init>(ConfigDef.java:1091)
at org.apache.kafka.common.config.ConfigDef.define(ConfigDef.java:150)
at org.apache.kafka.common.config.ConfigDef.define(ConfigDef.java:170)
at org.apache.kafka.common.config.ConfigDef.define(ConfigDef.java:209)
at org.apache.kafka.common.config.ConfigDef.define(ConfigDef.java:371)
at org.apache.kafka.common.config.ConfigDef.define(ConfigDef.java:384)
at org.apache.kafka.streams.StreamsConfig.<clinit>(StreamsConfig.java:514)
at com.oracle.svm.core.hub.ClassInitializationInfo.invokeClassInitializer(ClassInitializationInfo.java:350)
at com.oracle.svm.core.hub.ClassInitializationInfo.initialize(ClassInitializationInfo.java:270)
問題のクラスは確かにfat jarに存在していました。assisted buildのコンパイルオプションを使い、生成されたconfigファイルをプロジェクト内の“graalOutput”の下に配置してみました。
graal-kafka-xml-json-converter/graalOutput/
https://github.com/muirandy/graal-kafka-xml-json-converter/tree/master/graalOutput
assisted buildの詳細は以下を参照してください。
Assisted Configuration of Native Image Builds
https://www.graalvm.org/reference-manual/native-image/BuildConfiguration/#assisted-configuration-of-native-image-builds
Introducing the Tracing Agent: Simplifying GraalVM Native Image Configuration
https://medium.com/graalvm/introducing-the-tracing-agent-simplifying-graalvm-native-image-configuration-c3b56c486271
https://logico-jp.io/2019/06/07/introducing-the-tracing-agent-simplifying-graalvm-native-image-configuration/
これらの設定ファイルを手に入れるために、私のビルドコマンドは以下のように進化しました。
native-image -O0 -H:+ReportExceptionStackTraces -H:ConfigurationFileDirectories=./graalOutput --initialize-at-build-time -jar ./target/xmlJsonConverter-1.0-SNAPSHOT-jar-with-dependencies.jar ./target/macXmlToJsonConverter
ですが、これでは十分ではありませんでした。今度は以下のようなGraalVMネイティブコンパイラのエラーが出ました。
Error: com.oracle.graal.pointsto.constraints.UnsupportedFeatureException: Invoke with MethodHandle argument could not be reduced to at most a single call: java.lang.invoke.LambdaForm$MH.1921375740.invoke_MT(Object, Object, Object)
Trace:
at parsing org.apache.kafka.common.record.CompressionType$5.wrapForInput(CompressionType.java:131)
GraalVMの問題点のうち、リフレクションを使用した一部のコードをGraalVMが嫌がって、Kafka Streamsライブラリとの互換性がないことが判明しました。Kafkaのコードベースをチェックアウトし、以前のJiraチケットの診断に従って、コードを修正しました。これでこの問題は無事解決し、(少なくともmacOS用の)スタンドアロンのネイティブイメージができました。macOS用のネイティブビルドコマンドは以下のようになりました。
[native-image] failed to compile simple kafka consumer app to native image, reflection issue #532
https://github.com/oracle/graal/issues/532
native-image -H:+ReportExceptionStackTraces -H:ConfigurationFileDirectories=./graalOutput --no-fallback -jar ./target/xmlJsonConverter-1.0-SNAPSHOT-jar-with-dependencies.jar ./target/macXmlToJsonConverter
すると次の問題にぶちあたりました。
[main] ERROR org.apache.kafka.common.metrics.Metrics - Error when registering metric on org.apache.kafka.common.metrics.JmxReporter
java.lang.NullPointerException
at org.apache.kafka.common.metrics.JmxReporter.unregister(JmxReporter.java:157)
at org.apache.kafka.common.metrics.JmxReporter.reregister(JmxReporter.java:165)
at org.apache.kafka.common.metrics.JmxReporter.metricChange(JmxReporter.java:85)
at org.apache.kafka.common.metrics.Metrics.registerMetric(Metrics.java:568)
at org.apache.kafka.common.metrics.Sensor.add(Sensor.java:246)
at org.apache.kafka.common.metrics.Sensor.add(Sensor.java:227)
at org.apache.kafka.common.network.Selector$SelectorMetrics.<init>(Selector.java:1132)
at org.apache.kafka.common.network.Selector.<init>(Selector.java:178)
at org.apache.kafka.common.network.Selector.<init>(Selector.java:214)
at org.apache.kafka.common.network.Selector.<init>(Selector.java:227)
at org.apache.kafka.common.network.Selector.<init>(Selector.java:231)
at org.apache.kafka.clients.admin.KafkaAdminClient.createInternal(KafkaAdminClient.java:385)
at org.apache.kafka.clients.admin.AdminClient.create(AdminClient.java:67)
at org.apache.kafka.streams.processor.internals.DefaultKafkaClientSupplier.getAdminClient(DefaultKafkaClientSupplier.java:34)
at org.apache.kafka.streams.KafkaStreams.<init>(KafkaStreams.java:713)
at org.apache.kafka.streams.KafkaStreams.<init>(KafkaStreams.java:634)
at org.apache.kafka.streams.KafkaStreams.<init>(KafkaStreams.java:544)
at com.aimyourtechnology.xmljson.converter.ConverterStream.runTopology(ConverterStream.java:58)
at com.aimyourtechnology.xmljson.converter.ConverterApp.main(ConverterApp.java:36)
ここでは、GraalVMのもう一つの制限、JMXをサポートしない、ということです。JMXはJavaバイトコードへのアクセスを必要とするのですが、これが制限だと言うのです。私たちは、その制限を完全に取り除き、ネイティブの実行ファイルを構築したのです。Kafkaのソースコードに別の修正を加え、関連するJMXコードをコメントアウトしました。
Result
Kafkaのコードを修正して、DockerでmacOSとLinuxの両方で正常に動作するネイティブイメージを構築できました。ミッションを達成しました。次のステップは、リソースの使用量を調べることでした。
Memory Leak?
Mac上でネイティブイメージの処理を見ていると、処理のためにKafka経由でメッセージを送信していないにもかかわらず、メモリの消費量が増え続けていることがわかりました。さらに調査してみると、通常のJVM上でアプリを実行しても、最初は同じような症状が出ていましたが、その後は安定しました。このことから、ガベージコレクションとヒープサイズの設定を検討することになりました。ヒープサイズを制限する -Xmx
オプションを追加することで、ガベージコレクションの実行頻度を上げることができました。これをアプリケーションの実行方法ごとに適用すると、メモリ使用量はすぐに安定しました。ヒープサイズを変えて実験してみましたが、最終的には -Xmx48M
に落ち着きました。恩恵が大きければもっと減らせたかもしれませんが。
The Stats
Arguments | Memory Usage | Physical Footprint | CPU Usage | |
---|---|---|---|---|
JVM | -Xmx48m | Real: 370MB Private: 337MB Shared: 25MB | 343M | 0.6%->3.7% |
GraalVM Native Image | –Xmx48m | Real: 22MB Private: 8MB Shared: 1MB | 10M | 0.4% |
Arguments | Docker Image Size | Memory Usage | CPU Usage | |
---|---|---|---|---|
JVM | -Xmx48m | 114MB | 73MiB | 1.5%->6.8% |
GraalVM Native Image | -Xmx48m | 32.5MB | 8MiB | 1.5% |
Kafka change?
Apache KafkaのJIRAチケットを作成し、コードベースをフォークしました。
Kafka Streams Apps to support small native images through GraalVM
https://issues.apache.org/jira/browse/KAFKA-8629
フォークしたApache Kafkaコードリポジトリ
https://github.com/muirandy/kafka/tree/graal-native-image-support
この記事を書いている時点では、まずいくつかのフィードバックを探しているので、Pullリクエストは出していません。もし将来性があるようであれば、JVMユーザーがJMX機能を失うことがないように、feature flagの後ろに変更点を移動させておくつもりです。進捗状況を確認できますので、投票をお願いします。
And onto Quarkus
パート2では、Quarkus(GraalVM & OpenJDK HotSpotに合わせたKubernetesネイティブJavaスタック)について詳しく見ていきます。同じアプリケーションを使って、Kafka StreamsではなくQuarkusフレームワークを使って構築してみます。ご期待ください。
Quarkus – Supersonic subatomic Java
https://quarkus.io/