SpringフレームワークのアプリケーションをQuarkusで包む その3

前回の記事で、Springフレームワークベースのアプリケーションコアライブラリ(以下、コアライブラリ)をQuakrusでラップして、JVMモードによって起動するまでのステップをご紹介しました。
今回はその続きとなり、ネイティブビルドをしてコンテナから起動するところまでをゴールとしたいと思います。

目次

現時点でビルドするとどうなるか?

step4では、EnvironmentをQuakrusで注入するという変更を行ってBeanの依存関係を解決することで、JVMモードで起動させるところまで、なんとかこぎ着けました。
これでネイティブビルドができるかと思うとそれほど甘くなく、ビルド時に失敗してしまいます。
試してみましょう。


$ ./gradlew quarkusAppPartsBuild

> Task :quarkusAppPartsBuild FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':quarkusAppPartsBuild'.
> There was a failure while executing work items
   > A failure occurred while executing io.quarkus.gradle.tasks.worker.BuildWorker
      > io.quarkus.builder.BuildException: Build failure: Build failed due to errors
                [error]: Build step io.quarkus.arc.deployment.ArcProcessor#registerBeans threw an exception: java.lang.IllegalArgumentException: Producer method return type not found in index: org.springframework.core.env.StandardEnvironment
                at io.quarkus.arc.processor.Types.getProducerMethodTypeClosure(Types.java:433)
                at io.quarkus.arc.processor.BeanDeployment.findBeans(BeanDeployment.java:1209)
                at io.quarkus.arc.processor.BeanDeployment.registerBeans(BeanDeployment.java:272)
                at io.quarkus.arc.processor.BeanProcessor.registerBeans(BeanProcessor.java:144)
                at io.quarkus.arc.deployment.ArcProcessor.registerBeans(ArcProcessor.java:419)
                at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
                at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
                at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
                at java.base/java.lang.reflect.Method.invoke(Method.java:568)
                at io.quarkus.deployment.ExtensionLoader$3.execute(ExtensionLoader.java:858)
                at io.quarkus.builder.BuildContext.run(BuildContext.java:282)
                at org.jboss.threads.ContextHandler$1.runWith(ContextHandler.java:18)
                at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2513)
                at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1538)
                at java.base/java.lang.Thread.run(Thread.java:833)
                at org.jboss.threads.JBossThread.run(JBossThread.java:501)

Quakrus側のbuild.gradleには、しっかりとspring-coreライブラリの依存関係を書いていますが、上記のエラーのように、Environmentはインデックスに見つからないと怒られてしまいます。
application.propertiesのquarkus.index-dependency.xxxx.group-idやquarkus.index-dependency.xxxx.artifact-idを使って、明示的にインデックスさせようと試みてもうまく行きません。

原因はspringのspring-coreではなく、Quakrus専用のspring-coreだから

ちょっと何言っているかよくわからない、、ということでspring-diのPOMを読んでみましょう。

実は、spring-diが依存しているアーティファクトは、すべて「io.quarkus」のものです。
私も最初は戸惑いましたが、Environmentクラスは、確かに io.quarkus:quarkus-spring-core-apiに存在しません。
前回も書きましたが、環境依存変数の設定の仕方、読み込みの方法が両者のフレームワークで異なりますので、Environmentクラスが存在せず、サポートされないというのは頷けます。

step5

コアライブラリ側

step4からstep5への差分はこちらです。

ネイティブビルドができない以上、Environmentクラスに依存したコードは書けなくなります。
幸い、使っているEnvironmentクラスのメソッドは少なく、アクティブプロファイルが設定できて、システムプロパティの値を読むだけであれば、Environmentクラスの代替コンポーネントを用意できそうです。
今回はインターフェースを合わせて、最低限の機能だけを提供する以下のようなクラスを作り、これをConfigクラスに注入することにしました。

そして、Configクラスはsprint-coreのEnvironmentクラスの依存から、作成したCustomEnvironmentクラスへ依存するように変更しました。

あとは、CustomEnviromentにQuarkus側の設定値をjava.util.Propertiesのインスタンスとして注入できれば良さそうです。

テストに関しては、@ActiveProfileを廃止して、テスト用にjava.util.Propertiesでプロファイルを注入する「TestConfigurer」クラスを導入しました。

Quarkus側

step4からstep5への差分はこちらです。

アプリケーションの動作用にapplication.propertiesに定義した値をjava.uti.Propertiesに設定して返すプロデューサーメソッドがあれば良さそうです。
今回は以下のようなクラスを用意しました。(step4にもこのクラスは存在しますが、差分を追いやすくするために、プロデューサーメソッドを変更して、そのまま利用しています)

ネイティブビルドの実行と動作確認

step5以降のブランチでは、./src/main/docker/Dockerfileを使用して、マルチステージビルドでネイティブビルドを行い、結果の実行ファイルを取り出して、実行用のコンテナイメージにデプロイしています。
コンテナイメージのビルド方法は1行目に、コンテナの実行方法は最終行に、それぞれコメントで記載しています。(Dockerfileの説明は割愛しますが、ネイティブビルドを行う際のGraalVMはgraalvm-community-openjdk-21+35.1を利用しています)


# ビルド
$ docker build -t "mukeisoftllc/spring-di-quarkus-example" -f ./src/main/docker/Dockerfile .

# 実行
$ docker run -p 9000:9000 -it mukeisoftllc/spring-di-quarkus-example /application -Dquarkus.http.host-enabled=false -Dpath.for.texts=/tmp -Dpath.for.images=/tmp

 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2023-11-22 03:05:25,389 WARN  [io.qua.grp.run.GrpcServerRecorder] (main) Using legacy gRPC support, with separate new HTTP server instance. Switch to single HTTP server instance usage with quarkus.grpc.server.use-separate-server=false property
2023-11-22 03:05:25,395 INFO  [io.qua.grp.run.GrpcServerRecorder] (vert.x-eventloop-thread-1) Started gRPC server on 0.0.0.0:9000 [TLS enabled: false]
2023-11-22 03:05:25,396 INFO  [io.quarkus] (main) spring-di-quarkus-example 1.0-SNAPSHOT native (powered by Quarkus 3.4.1) started in 0.051s. Listening on:
2023-11-22 03:05:25,398 INFO  [io.quarkus] (main) Profile prod activated.
2023-11-22 03:05:25,398 INFO  [io.quarkus] (main) Installed features: [cdi, grpc-server, smallrye-context-propagation, spring-di, vertx]

うまくコンテナが起動できたら、動作確認をします。


gradlew test --tests "FileGrpcServiceTest"

ちゃんと動いていますね。docker execで実際にファイルが作成されているか/tmpを見てみましょう。(/tmpは、コンテナ起動時の引数で「path.for.texts」「path.for.images」という二つのシステムプロパティで指定しています。cbca39d2288dはコンテナIDですので、環境によって変わります )

テストの結果どおり、testFile_{ランダム整数}という名前のファイルが作成されていますね。


$ docker exec -it cbca39d2288d ls -ltr /tmp
total 28
-rwx------ 1 root root  701 Oct 18 11:53 ks-script-smjwo3kk
-rwx------ 1 root root  291 Oct 18 11:53 ks-script-ns3etfqh
drwxrwxrwx 3 root root 4096 Nov 22 03:08 vertx-cache
-rw-r--r-- 1 root root   11 Nov 22 03:09 testFile_689214249
-rw-r--r-- 1 root root   21 Nov 22 03:09 testFile_858068663

step6

さて、ネイティブ化も終わり、動作確認をしてみたものの、ログが出ていないようです。
もし、既存のアプリケーションをQuakrusに置き換えたとして、ログが出ないのは後方互換性上の深刻な問題ですね。

コアライブラリ側

step5からstep6への差分はこちらです。

Quarkusのドキュメントによると、SLF4J(+Logback)はサポートされていますので、特に変更の必要はありません。
以下の「FileService」クラスのログが正しく出力されるかチェックしていきます。

Quarkus側

step5からstep6への差分はこちらです。

以下のように、application.propertiesにロギング設定を追加します。
幸いのことに、多くの場合、利用できる書式文字列(フォーマット)やローテーションの設定に互換性はありそうですね。

動作確認


$ docker build -t "mukeisoftllc/spring-di-quarkus-example" -f ./src/main/docker/Dockerfile .
$ docker run -p 9000:9000 -it mukeisoftllc/spring-di-quarkus-example /application -Dquarkus.http.host-enabled=false -Dpath.for.texts=/tmp -Dpath.for.images=/tmp -Dlog_dir=/var/log

それでは例の「FileGrpcServiceTest」を実行して、コンテナ内部のログが出力されているかどうか調べてみます。


$ docker exec -it bead6c2ccbf1 cat /var/log/test.log
2023/11/22 03:34:23 [main] WARN  io.quarkus.grpc.runtime.GrpcServerRecorder - Using legacy gRPC support, with separate new HTTP server instance. Switch to single HTTP server instance usage with quarkus.grpc.server.use-separate-server=false property
2023/11/22 03:34:23 [vert.x-eventloop-thread-1] INFO  io.quarkus.grpc.runtime.GrpcServerRecorder - Started gRPC server on 0.0.0.0:9000 [TLS enabled: false]
2023/11/22 03:34:23 [main] INFO  io.quarkus.bootstrap.runner.Timing - spring-di-quarkus-example 1.0-SNAPSHOT native (powered by Quarkus 3.4.1) started in 0.263s. Listening on:
2023/11/22 03:34:23 [main] INFO  io.quarkus.bootstrap.runner.Timing - Profile prod activated.
2023/11/22 03:34:23 [main] INFO  io.quarkus.bootstrap.runner.Timing - Installed features: [cdi, grpc-server, smallrye-context-propagation, spring-di, vertx]
2023/11/22 03:34:52 [vert.x-eventloop-thread-0] INFO  jp.co.mukeisoftllc.ex.spring.world.services.FileService - writing /tmp testFile_828601927 11 bytes.
2023/11/22 03:34:52 [vert.x-eventloop-thread-0] INFO  jp.co.mukeisoftllc.ex.spring.world.services.FileService - reading /tmp testFile_828601927
2023/11/22 03:34:52 [vert.x-eventloop-thread-0] INFO  jp.co.mukeisoftllc.ex.spring.world.services.FileService - writing /tmp testFile_695860993 21 bytes.
2023/11/22 03:34:52 [vert.x-eventloop-thread-0] INFO  jp.co.mukeisoftllc.ex.spring.world.services.FileService - reading /tmp testFile_695860993

ファイルの読み書きの際に仕込んでおいたログは出ていますね。
ただ、ログ自体は出力されるようになったものの、FileService#postConstructや、FileService#preDestroyメソッドで出力しているログは見当たりません。
これは呼ばれていないためです。

step7

コアライブラリ側

step6からstep7への差分はこちらです。

特に変更はありません。Quarkus側がBeanのライフサイクルメソッドを正しく呼び出してくれることを待っている状態です。

Quarkus側

step6からstep7への差分はこちらです。

コアライブラリのBeanの中でライフサイクルメソッドの呼び出しが必要なものを把握し、Quakrusのライフサイクルイベントハンドラーの中で、適宜呼び出します。
今回は「LifecycleController」というクラスを作成し、アプリケーションの起動と停止のタイミングで「FileService」の「postConstruct」「preDestroy」メソッドを呼び出しています。

動作確認

コンテナイメージを作成するコマンドは同じですが、アプリケーションの停止時のログが出力されているかどうか確認するために、コンテナ起動時にローカルのカレントディレクトリをマウントして、そこにログが出力されるようにします。


$ docker build -t "mukeisoftllc/spring-di-quarkus-example" -f ./src/main/docker/Dockerfile .
$ docker run -v ./:/var/log -p 9000:9000 -it mukeisoftllc/spring-di-quarkus-example /application -Dquarkus.http.host-enabled=false -Dpath.for.texts=/tmp -Dpath.for.images=/tmp -Dlog_dir=/var/log

例の「FileGrpcServiceTest」を実行後、Ctrl+Cでコンテナを停止して、カレントディレクトリのtest.logをcatしてみます。


$ cat ./test.log
2023/11/22 07:05:41 [main] WARN  io.quarkus.grpc.runtime.GrpcServerRecorder - Using legacy gRPC support, with separate new HTTP server instance. Switch to single HTTP server instance usage with quarkus.grpc.server.use-separate-server=false property
2023/11/22 07:05:41 [vert.x-eventloop-thread-1] INFO  io.quarkus.grpc.runtime.GrpcServerRecorder - Started gRPC server on 0.0.0.0:9000 [TLS enabled: false]
2023/11/22 07:05:41 [main] INFO  jp.co.mukeisoftllc.ex.spring.world.services.FileService - FileService now initializing...
2023/11/22 07:05:41 [main] INFO  io.quarkus.bootstrap.runner.Timing - spring-di-quarkus-example 1.0-SNAPSHOT native (powered by Quarkus 3.4.1) started in 0.043s. Listening on:
2023/11/22 07:05:41 [main] INFO  io.quarkus.bootstrap.runner.Timing - Profile prod activated.
2023/11/22 07:05:41 [main] INFO  io.quarkus.bootstrap.runner.Timing - Installed features: [cdi, grpc-server, smallrye-context-propagation, spring-di, vertx]
2023/11/22 07:06:58 [vert.x-eventloop-thread-0] INFO  jp.co.mukeisoftllc.ex.spring.world.services.FileService - writing /tmp testFile_2065580279 11 bytes.
2023/11/22 07:06:58 [vert.x-eventloop-thread-0] INFO  jp.co.mukeisoftllc.ex.spring.world.services.FileService - reading /tmp testFile_2065580279
2023/11/22 07:06:58 [vert.x-eventloop-thread-0] INFO  jp.co.mukeisoftllc.ex.spring.world.services.FileService - writing /tmp testFile_-1732569046 21 bytes.
2023/11/22 07:06:58 [vert.x-eventloop-thread-0] INFO  jp.co.mukeisoftllc.ex.spring.world.services.FileService - reading /tmp testFile_-1732569046
2023/11/22 07:07:25 [Shutdown thread] INFO  jp.co.mukeisoftllc.ex.spring.world.services.FileService - FileService now destroying...
  • FileService now initializing…
  • FileService now destroying…

という二つのログが出力されるようになり、コアライブラリのBeanのライフサイクルも正しく管理できるようになりました。

まとめ

今回のサンプルアプリケーションでは、Springフレームワークに依存するコードが少なく、Quakrusへのマイグレーションが比較的スムーズに行えました。
「Mujo」の場合は、内部で使っているライブラリの都合上、記載した手順に加え、独自のエクステンションを作成しなければならなかったのですが、Quakrusも多くの情報がインターネット上から拾えるようになってきましたので、払うコストより得られるメリットが大きいと感じれば、ネイティブ化にチャレンジしてみるのもアリかもしれません。

それでは!