フレクトのクラウドblog re:newal

http://blog.flect.co.jp/cloud/からさらに引っ越しています

Spring Boot Web JPA は GraalVM でメモリ節約

こんにちは。エンジニアの佐藤です。今回はGraalVMのお話をさせていただきます。

きっかけは相談から

先日他プロジェクトの方から「Webサーバーのリクエストが不思議なタイムアウトをしているが、どうしてだろうか?」と相談を受けました。話を聞いていると、メモリ(主記憶)利用超過が疑わしいと思いました。メモリは古来からある問題で、瞬間的にでも超過すると、サーバーはOS(通常はLinux)によって実行停止措置となってしまいます。今回の場合、そのリミットは1GB。仮想メモリはありませんので、この制限を踏み越えればアウトです。

Spring Boot Data JPAの凄まじいメモリ使用量

インタビューによると、利用しているフレームワークはSpring Boot。長い歴史を持つJava言語のポピュラーなフレームワークです。しかし筆者はこのとき「どうしてNodeJSとかにしなかったのだろうか」と思っていましました。なぜならばこのSpring Boot、「Data JPA」を使うと、起動がものすごく重くなるからです。数年前に何とか速くならないものかと頑張ったこともあったのですが、どうしようもない降参だと思っていました。メモリ利用量も多く、ごく単純なアプリケーションでも300MB以上のメモリを消費するとされています。今回のリミットの1GB(=1000MB)と比較すると、何とも心配な消費量です。

そう言えばGraalVMがあった

そう言えば。。Java界隈での最近の話題と言えば、GraalVMです。これ自体は以前からあったのですが(2011~)、今年初夏にOracleが無償利用条件を緩和し、再び注目を集めています。Spring FrameworkでもSpring NativeとしてSpring Boot v3からサポートされています。GraalVMのホームページによると、起動時間の短縮とメモリ利用量 節約に効果があるとされています。今回のお悩みにミートしそうです。

しかし、(古い話ですが)Java言語は何といっても動的コンパイルの元祖です。四半世紀も続いたパラダイムを事前コンパイルにシフトするなど、可能なものでしょうか?そこはもちろんJavaの本家Oracleが頑張っているので大丈夫と信じたいですが、Javaの歴史は古いので、古いライブラリがサポートされていない可能性はどこまでもあります。一方、Javaと多くのアイディアを共有するMicrosoft .NETはネイティブイメージコンパイラNGenを10年以上前からサポートし、Microsoft製品のインストール経過で利用されているのを目にしていました。

この機会に、試してみることにしましょう。

ミニマムプログラムを観察する

最初に極小プログラムで試してみましょう。GraalVMサイトの以下のような内容です。なお、今回のテストはOracle Cloud A1インスタンス上のUbuntu 22.04 (arm64)で行いました。

public class HelloWorld {
  public static void main(String[] args) {
    System.out.println("Hello, World!");
  }
}

ネイティブイメージをビルドしてみると。。Javaクラスファイルに比べて非常に大きいのがわかります。(1000倍以上!)さらに、ビルドは非常に重い。4コアインスタンスでメモリを1GB近く消費して1分ほどかかりました。(たった4行のソースコードにです!)

ファイル サイズ
HelloWorld.class 427 bytes
helloworld(ネイティブ) 6781368 bytes (6MB!)

実行時のメモリ消費量はどうでしょうか?このページに書かれているtimeコマンドで最大利用メモリ(Maximum resident set size)を調べてみると、以下のようになりました。

ファイル Maximum resident set size (kbytes)
HelloWorld.class 36044 (36MB)
helloworld(ネイティブ) 1220 (1MB!)

利用メモリの劇的節約です!これは期待できそうです。

ミニマムSpring Boot Web + Data JPAで試す

次に本丸のSpring Boot Web + DBアプリケーションで試してみましょう。Spring BootのGithubから以下のサンプルを選びました。

https://github.com/spring-guides/gs-accessing-data-mysql/tree/main/complete

以下のコマンドでネイティブイメージをビルドします。

mvn -Pnative native:compile

コンパイルは多量のリソースを必要とします。4コアインスタンスで10分ほどで、利用メモリは8GBに達しました。

すぐには成功しませんでしたが、何点か調整すると、成功しました。

遭遇したエラーと対処方法は以下のようなものです。

エラー 対処方法
MySQLドライバがロードできない pom.xml依存関係にmysql-connector-jを追加。
実行時にクラス初期化エラー pom.xmlのparentをspring-boot-starter-parent v3.1.4に最新化。Reachability Metadataサポート追加

出来上がったネイティブイメージのファイルサイズは非常に大きいです。

ファイル サイズ
accessing-data-mysql-complete-0.0.1-SNAPSHOT.jar 44MB
accessing-data-mysql-complete(ネイティブ) 157MB

メモリ消費はどうでしょうか?サーバーが起動してから、最も単純なDB参照を伴うREST APIに1回レスポンスを返すまでの動作について、同様にtimeコマンドでメモリ消費量を比較してみると。。減りました!45%節約です!

ファイル Maximum resident set size (kbytes)
Jarで実行 320568 (321MB)
ネイティブで実行 175344 (175MB)

今後に期待

今回の実験はここまでですが、これは期待できると思います。ただし、今回調査していて感じたことですが、開発コードを正しく動かす道のりは簡単ではない場合がありそうです。JVMから事前コンパイルへの方式変更の実行環境へのインパクトは大きく、ソリューションの品質保証のためには非互換の開発モジュールをどうやって動かすか?などの難局を乗り切る知識がきっと必要になるでしょう。

今回も最後までお読みいただきありがとうございました。