Kafka Streaming on GraalVM

原文はこちら。
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

ArgumentsMemory UsagePhysical FootprintCPU Usage
JVM-Xmx48mReal: 370MB
Private: 337MB
Shared: 25MB
343M0.6%->3.7%
GraalVM Native ImageXmx48mReal: 22MB
Private: 8MB
Shared: 1MB
10M0.4%
ArgumentsDocker Image SizeMemory UsageCPU Usage
JVM-Xmx48m114MB73MiB1.5%->6.8%
GraalVM Native Image-Xmx48m32.5MB8MiB1.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/

コメントを残す

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

WordPress.com ロゴ

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

Google フォト

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

Twitter 画像

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

Facebook の写真

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

%s と連携中