GaalVM + Kotlin + Spring Boot + jOOQ(Postgres)で現実的にバッチアプリケーション作るためのPoC
はじめに
アドベントカレンダーから次のBlogまでの間隔が短く、ひいひい言っているゲインです。
今回は冬休みの自由研究でGraalVMで遊んでみました。
モチベーション
ログラスではKotlin + Spring Bootを利用しています。
いわゆるJVM言語ではJavaバイトコードと呼ばれる中間コードに変換したうえで、実行時に各CPU各環境に最適なコンパイルを行います。(JITコンパイル)
このJITの最適化にはある程度時間がかかり、最適化が効いた後は高速に動作してくれます。
しかしクラウドネイティブな世界では、アプリケーションは頻繁に起動と停止を繰り返すためJITの最適化を効かせる前にアプリケーションを停止してしまうという課題が広く知られています。
ログラスでは、本番の動作環境としてAWSのECS Fargateを利用し、コンテナ上で動作しています。長時間実行するAPIサーバーなどではJITのメリットを享受できますが、1ジョブ1コンテナで立ち上げるバッチ処理を行うアプリケーションではJITの恩恵を得られず、むしろJVMの立ち上げの重さがネックになってしまいます。
そこで上記の問題を解決するためにバッチ用途のアプリケーションをGraalVMを使ってNative imageに変換し現実的に実行できるかをPoCしてみました。
実際にログラスで使用しているライブラリ群と近いライブラリ[1] を利用したいので、 Spring BootとjOOQを利用してみることにします。
GraalVMとは
以下は、Oracleのウェブサイトに記載されているGraalVMの定義を引用したものです。
Oracle GraalVMは、高パフォーマンスのJDKで、代替の実行時(JIT)コンパイラを使用することで、JavaおよびJVMベースのアプリケーションのパフォーマンスを高速化できます。GraalVM Enterpriseは、アプリケーションの遅延を低減し、ガベージコレクション時間を短縮してピークスループットを改善できるだけでなく、24時間365日のOracleサポートも付属しています。
また、ネイティブ・イメージ・ユーティリティも用意されているため、Javaバイトコードを事前(AOT)にコンパイルし、一部のアプリケーションについて、ほぼ瞬時に起動し、ほんの少しのメモリリソースしか消費しないネイティブ実行可能ファイルを生成することができます。
GraalVMといえば、AOTでネイティブコンパイルを行うためのものだと個人的に認識していました。しかし実際には、AOTコンパイルのためのユーティリティが含まれたJDKとなっています。
そのため一般的なHotspotVMの上で動くJITモードと、ネイティブ実行ファイルに事前にコンパイルするAOTモードを選択することが出来ます。
またTruffle言語実装フレームワークにより、RubyやPythonなどの言語も動かすことが可能となっているそうです。
PoC
環境構築をする
- Language: java 21
- JDK: 21.0.5-graal
- CPU: Apple M3 Pro
Javaのバージョンが21なのは、後述のSpringプロジェクトの準備でデフォルトが21だったためで、特にこだわりはありません。
以下の手順に沿って JDKをinstallします。
Getting Started with Oracle GraalVM
今回は sdkmanを使ってinstallしてみました。
Home | SDKMAN! the Software Development Kit Manager
curl -s "https://get.sdkman.io" | bash
sdk install java 21.0.5-graal
sdk use java 21.0.5-graal
java -version
java version "21.0.5" 2024-10-15 LTS
Java(TM) SE Runtime Environment Oracle GraalVM 21.0.5+9.1 (build 21.0.5+9-LTS-jvmci-23.1-b48)
Java HotSpot(TM) 64-Bit Server VM Oracle GraalVM 21.0.5+9.1 (build 21.0.5+9-LTS-jvmci-23.1-b48, mixed mode, sharing)
GraalのJITコンパイラで動くWebアプリケーションを作る
まずは普通のWebアプリケーションを作ってみようと思います。
SpringのProjectを準備する
以下のサイトを使ってSpringのProjectを作成します。
今回は以下の条件で生成してみました。
- Project: Gracle - Kotlin
- Language: Kotlin
- Java: 21
ここはご自身の好きな環境に合わせて変更してください。
Dependenciesは後ほど手動で追加しようと思うのでそのまま GENERATE
をクリックして、適当な箇所に解凍を行ってください。
PostgreSQLを用意する
PostgreSQLを使いたいので、適当にPostgreSQLの環境を用意します。
プロジェクト配下に compose.yml を作成してください。
services:
db:
image: postgres:17.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: demo
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
ファイルを保存したら docker compose up -d
で立ち上げておいてください。
またサンプルのテーブルとして以下を作成しておきます。
docker compose exec -it db psql -h localhost -U postgres -d demo
demo=# CREATE TABLE IF NOT EXISTS users
(
id serial
primary key,
name varchar(100) not null
);
jOOQの用意をする
jOOQでコード生成を行うための準備をします。
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
id("org.springframework.boot") version "3.4.1"
id("io.spring.dependency-management") version "1.1.7"
+ id("org.jooq.jooq-codegen-gradle") version "3.19.11"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
- implementation("org.springframework.boot:spring-boot-starter")
+ implementation("org.springframework.boot:spring-boot-starter-jooq")
+ implementation("org.springframework.boot:spring-boot-starter-web")
+ runtimeOnly("org.postgresql:postgresql")
+ implementation("org.jooq:jooq:3.19.11")
+ implementation("org.jooq:jooq-meta:3.19.11")
+ implementation("org.jooq:jooq-codegen:3.19.11")
+ implementation("org.jooq:jooq-postgres-extensions:3.19.11")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ jooqCodegen("org.postgresql:postgresql:42.7.4")
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
+jooq {
+ configuration {
+ jdbc {
+ driver = "org.postgresql.Driver"
+ url = "jdbc:postgresql://localhost:5432/demo"
+ user = "postgres"
+ password = "password"
+ }
+ generator {
+ database {
+ name = "org.jooq.meta.postgres.PostgresDatabase"
+ inputSchema = "public"
+ includes = ".*"
+ }
+
+ target {
+ packageName = "com.example.demo.infra.jooq"
+ }
+ }
+ }
+}
追記を行った後、以下のコマンドを実行しファイルを生成します。
./gradlew jooqCodegen
コードを用意する
それっぽいコードを用意します。
今回はオニオンアーキテクチャっぽい形でパッケージを作ってみることにします。
ユーザー登録とユーザー表示を行うアプリケーションを作ってみることにします。
それぞれディレクトリを作成し、ファイルを配置してください。
domain
UserRepositoroyのInterfaceとUser Domainを追加します。
package com.example.demo.domain
data class User(val id: Int, val name: String)
interface UserRepository {
fun findById(id: Int): User?
fun save(user: User)
}
infra
UserRepositoroyの実装を追加します。
package com.example.demo.infra
import com.example.demo.domain.User
import com.example.demo.domain.UserRepository
import com.example.demo.infra.jooq.tables.Users.USERS
import org.jooq.DSLContext
import org.springframework.stereotype.Repository
@Repository
class UserRepository(private val dsl: DSLContext): UserRepository {
override fun findById(id: Int): User? {
val record = dsl.selectFrom(USERS)
.where(USERS.ID.eq(id))
.fetchOne()
return record?.let {
User(it.id, it.name)
}
}
override fun save(user: User) {
val result = dsl.insertInto(USERS)
.set(USERS.ID, user.id)
.set(USERS.NAME, user.name)
.execute()
}
}
application.propertiesに以下を追加します。
spring.application.name=demo
spring.datasource.url=jdbc:postgresql://localhost:5432/demo
spring.datasource.username=postgres
spring.datasource.password=password
spring.datasource.driver-class-name=org.postgresql.Driver
usecase
package com.example.demo.usecase
import com.example.demo.domain.User
import com.example.demo.infra.UserRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class UserService(private val userRepository: UserRepository) {
fun getUserById(id: Int): User? {
println(id)
return userRepository.findById(id)
}
@Transactional
fun createUser(user: User) {
println(user)
userRepository.save(user)
}
}
SpringBootApplication
最後にSpringBootApplicationのEntrypointとして、 DemoApplication.kt
に修正を加えます。
package com.example.demo
import com.example.demo.domain.User
import com.example.demo.usecase.UserService
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
@SpringBootApplication
@RestController
class DemoApplication(private val userService: UserService) {
@GetMapping("/user/{id}")
fun getUser(@PathVariable id: Int): User? {
return userService.getUserById(id)
}
@PostMapping("/user")
fun createUser(@RequestBody user: User) {
userService.createUser(user)
}
}
fun main(args: Array<String>) {
runApplication<DemoApplication>(*args)
}
ではこれを実行してみましょう。
./gradlew bootRun
別のターミナルを開いて以下を試します。
curl -X POST http://localhost:8080/user -H "Content-Type: application/json" -d '{"id": 1, "name": "loglass taro"}'
curl -X GET http://localhost:8080/user/1
{"id":1,"name":"loglass taro"}
無事に動きました。
この時点では特にNative compileされているわけでもなく、互換性のあるJITモードで実行されるためおかしい点は無いでしょう。
WebアプリケーションをNative buildして動かす
では次にGraalコンパイラのAOTモードを利用してNative imageを生成してみます。
build.gradle.ktsに一行追加します。
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
id("org.springframework.boot") version "3.4.1"
id("io.spring.dependency-management") version "1.1.7"
id("org.jooq.jooq-codegen-gradle") version "3.19.11"
+ id("org.graalvm.buildtools.native") version "0.10.4"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-jooq")
implementation("org.springframework.boot:spring-boot-starter-web")
runtimeOnly("org.postgresql:postgresql")
implementation("org.jooq:jooq:3.19.11")
implementation("org.jooq:jooq-meta:3.19.11")
implementation("org.jooq:jooq-codegen:3.19.11")
implementation("org.jooq:jooq-postgres-extensions:3.19.11")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
jooqCodegen("org.postgresql:postgresql:42.7.4")
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
sourceSets.main {
java.srcDirs("build/generated-sources/jooq")
}
jooq {
configuration {
jdbc {
driver = "org.postgresql.Driver"
url = "jdbc:postgresql://localhost:5432/demo"
user = "postgres"
password = "password"
}
generator {
database {
name = "org.jooq.meta.postgres.PostgresDatabase"
inputSchema = "public"
includes = ".*"
}
target {
packageName = "com.example.demo.infra.jooq"
}
}
}
}
この状態で以下のコマンドを実行します。
./gradlew nativeCompile
無事に生成できたら以下を実行します。
./build/native/nativeCompile/demo
これで同様にアプリケーションが起動するので動作検証を行ってみます。
curl -X GET http://localhost:8080/user/1
{"timestamp":"2025-01-15T09:09:27.150+00:00","status":500,"error":"Internal Server Error","path":"/user/1"}
curl -X POST http://localhost:8080/user -H "Content-Type: application/json" -d '{"id": 1, "name": "loglass taro"}'
{"timestamp":"2025-01-15T09:09:48.745+00:00","status":500,"error":"Internal Server Error","path":"/user"}
おや?どうやらInternal Server Errorが発生してしまいました。
スタックトレースを確認してみます。
java.lang.NoSuchMethodException: com.example.demo.infra.jooq.tables.records.UsersRecord.<init>()
at java.base@21.0.5/java.lang.Class.checkMethod(DynamicHub.java:1078) ~[demo:na]
at java.base@21.0.5/java.lang.Class.getConstructor0(DynamicHub.java:1241) ~[demo:na]
at java.base@21.0.5/java.lang.Class.getDeclaredConstructor(DynamicHub.java:2930) ~[demo:na]
at org.jooq.impl.TableImpl.getRecordConstructor(TableImpl.java:571) ~[demo:na]
...
どうやら jOOQが生成したコードで NoSuchMethodException
が発生しているようです。
これはNative imageを作成する際に静的分析によって到達しなかったため起こるようです。
そのため到達可能性メタデータと呼ばれるメタデータをbuild時に渡して上げる必要があります。
これはトレース・エージェントを利用して、自動で生成することが可能となっています。
実際にトレースエージェントを利用した際には、対象のコードを実行する必要があるためテストで網羅したり実際にリクエストを投げたりする必要があります。
では実際に試してみましょう。
# jarを生成
./gradlew assemble
#aotを有効にして、トレースエージェントを読み込みつつ起動
java -Dspring.aot.enabled=true -agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image/auto/ -jar build/libs/demo-0.0.1-SNAPSHOT.jar
#適当にリクエストを投げて実行させる
curl -X GET http://localhost:8080/user/1
{"timestamp":"2025-01-15T09:09:27.150+00:00","status":500,"error":"Internal Server Error","path":"/user/1"
}
curl -X POST http://localhost:8080/user -H "Content-Type: application/json" -d '{"id": 3, "name": "loglass taro"}'
{"timestamp":"2025-01-15T09:09:48.745+00:00","status":500,"error":"Internal Server Error","path":"/user"}
これによって build/resources/aot/META-INF/native-image/com.example/demo/
にあるメタデータが更新されます。
この状態で以下のコマンドを実行します。
./gradlew nativeCompile
無事に生成できたら以下を実行します。
./build/native/nativeCompile/demo
この状態でNative Compileしたアプリケーションに対してリクエストを投げてみると無事にリクエストが通ることが確認できると思います。
curl -X GET http://localhost:8080/user/1
{"id":1,"name":"loglass taro"}
curl -X POST http://localhost:8080/user -H "Content-Type: application/json" -d '{"id": 5, "name": "loglass taro"}'
curl -X GET http://localhost:8080/user/5
{"id":5,"name":"loglass taro"}
バッチアプリケーションに変換して Native buildして動かす
さて、簡単なAPIは作成してみたので次にバッチアプリを作ってみたいと思います。
とはいえSpringの仕組みを使うため大きく変わることろはありません。
package com.example.demo
import com.example.demo.domain.User
import com.example.demo.usecase.UserService
import org.springframework.boot.CommandLineRunner
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.context.ConfigurableApplicationContext
@SpringBootApplication
class DemoApplication(private val userService: UserService) : CommandLineRunner {
override fun run(vararg args: String?) {
if (args.size < 2) {
println("Usage: <UserID> <Name>")
return
}
val userId = args[0]?.toIntOrNull()
val userName = args[1]
if (userId == null || userName.isNullOrEmpty()) {
return
}
// ユーザー作成
val user = User(id = userId, name = userName)
userService.createUser(user)
val savedUser = userService.getUserById(userId)
println("saved user: $savedUser")
return
}
}
fun main(args: Array<String>) {
val context: ConfigurableApplicationContext = SpringApplication.run(DemoApplication::class.java, *args)
SpringApplication.exit(context)
}
こいつをcompile
./gradlew nativeCompile
そして実行してみます。
./build/native/nativeCompile/demo 1234657 'loglass taro'
...
saved user: User(id=1234657, name=loglass taro)
...
となり無事に動いていることが確認できました。
この生成したバイナリをDockerで固めてあげれば無事クラウドネイティブなユーザー登録バッチをクラウド上で実行することが可能なわけですね!
バッチの方は到達可能性メタデータを生成しなくても動いたのですが、なぜこの差異があるのかは正直良く分かっておらず今後理解していきたいと思っています。
では我々は現実Native imageすぐ使い始めることができるのか?
PoCとしていろいろ試してみましたが、現状では難しいと判断しました。
理由としては、現状ではDIの仕組みなどをSpring BootのBeanの仕組みに依存しているのでバッチアプリケーションで使わないコードもDIする必要がありました。
PoCの段階では、DIする対象の数も多くなく、簡単にコンパイルすることができました。しかし、実プロジェクトでは対象の数がどうしても多くなり、バッチアプリケーションをネイティブイメージにした際に、挙動に必要のないコードでコンパイルエラーや実行時エラーが多発し最終的に動作させることは叶いませんでした。
上記を通常のAPIの開発を行いながら考慮するのはあまり現実的では無いなと感じています。
これを解消するためにKoinなどの軽量なDIフレームワークなどに乗り換え、明示的に各アプリケーションごとでDIを行うことで実際に稼働させること自体は検証できました。
またgradleのマルチプロジェクトで細かくプロジェクトを切るなども考えられるかもしれません。
しかしこれらも他の開発も進めながら解消するのは中々根性が必要だなというのが所感です。
また1回Buildするのに6分ぐらい必要で、Buildした後にきちんと到達可能性メタデータが有効になっているかを全て動作確認をしなければならない点は開発サイドとしてはなかなかハードだなと感じています。
そもそもKotlinに固執せずにコンテナに相性の良い言語を選択するというのも、もしかしたらベターな手かもしれません。
まとめ
GraalVMを使い、Webアプリケーションとバッチアプリケーションをネイティブイメージに変換して実行してみました。
Kotlin自体は非常に良い言語だと個人的に思っているため、クラウドネイティブ環境との相性をNative Imageによって解消できれば、選択肢の幅が広がり嬉しいと感じています。
今後のエコシステムの発展に期待したいと思います。
Discussion