3
2

More than 1 year has passed since last update.

PlayをGraalVMで動かす

Last updated at Posted at 2022-04-27

ごきげんよう。みなさんScala書いてますか?ところで、今日はPlayGraalVMでビルドしてみたので、その紹介をしたいと思います。サンプルプログラムも貼りますので、気になる方は試してみてください。

背景

「Play GraalVM」とかでGoogle検索かけると、過去にそれっぽい健闘をした記事が色々と出てきますが、どれも何がダメだったとか、具体的なコードとかが出てこなかったので、試しに作ってみることにしました。

結果

とりあえず、Hello worldとして紹介されているplay-scala-seed.g8はGraalVMでビルドできました。4点ほど注意点があったので紹介します。

confフォルダ内のファイルを参照できるようにする

まず PlayKeys.externalizeResources := false とします。これによりconfディレクトリの中身がjarファイルに含まれるようになり、デフォルトのクラスパス上からapplication.confとかlogback.xmlなどのファイルが参照できるようになります。

次に resource-config.json にて動的にロードが必要なファイルを明示します。ただし、独自に resource-config.json を書くのは辛いので、後述するagentの出力結果をコピーしましょう。

1点注意すべきところがあります。resource-config.json を指定する -H:ResourceConfigurationFiles には 絶対パス を渡すようにしてください。sbtの内部でカレントディレクトリを移動してビルドするため、build.sbtからの相対パスだと指定に失敗します。

動的に読み込まれるクラスを参照できるようにする

ここが一番のキモです。GraalVMではリフレクションで使用するクラスは明示的に宣言しておく必要があります。Playはguiceを使っており、guiceが動的ロードを使っているので、guiceでロードされるクラスを全て明らかにする必要があります。これを手動でやるのは億劫なので、動的ロードされたものを記録するagentをアプリに仕込んで、アプリを一通り動かして、その結果を利用するのが一般的です。

サンプルプロジェクトだとこんな感じで結果を取得します:

  1. Dockerfile の 9行目以降をコメントアウトします。
    Dockerfile
    WORKDIR /app
    
    #RUN sbt graalvm-native-image:packageBin
    ...
    
  2. ビルドします
    $ docker build .
    
  3. コンテナに入ってアプリを起動します。
    $ docker run -p 9000:9000 -it --rm --entrypoint /bin/bash <<ビルドしたimageのsha256>>
    # sbt universal:packageZipTarball
    # cd target/universal/
    # tar -xzvf play-scala-seed-1.0-SNAPSHOT.tgz 
    # cd play-scala-seed-1.0-SNAPSHOT
    # JAVA_OPTS=-agentlib:native-image-agent=config-output-dir=. bin/play-scala-seed
    
  4. ホスト側から http://localhost:9000 を叩きます。既存アプリの場合はroutesにあるパスを一通り実行しておくと良いです。
  5. コンテナ側で起動したアプリを閉じて、作成されたjsonをプロジェクトにコピーします。
    # cat resource-config.json
    # cat reflect-config.json
    

通常はこれで終わりですが、guiceの場合、追加の修正があります。GUICE$INVOKERSに対応するクラスのqueryAllDeclaredConstructorsallDeclaredConstructorsに変更する必要があります。

reflect-config.json
  {
    "name":"controllers.Assets",
    "allDeclaredFields":true,
    "queryAllDeclaredMethods":true,
    "queryAllDeclaredConstructors":true}  <- 2. これを allDeclaredConstructors にする
,
  {
    "name":"controllers.Assets$$FastClassByGuice$$17001601",
    "fields":[{"name":"GUICE$INVOKERS"}]}  <- 1. GUICE$INVOKERS があるクラスの
,

これが80個近くあります。気合いで直しましょう(もしくはいい感じのjqを書くと良いのかもしれない)。

ビルドするマシンのメモリを確保する

GraalVMはビルド時に大量のメモリを消費します。もしメモリが足りなかったらエラーコード137を出してビルドが終わってしまいます。サンプルプログラムの場合はとりあえず10GB確保すると安定してビルドできるようになりました。

resourceスキームに対応する

これが多少厄介で、PlayFrameworkの修正が必要になります(あとでIssuePullRequest出しておこうと思います)。GraalVMは(アプリ内にバンドルされた)外部リソースをresource://~というURLで参照するようになります。このresourceスキームにPlayは対応していないため、(Playにとって)外部リソースを取得するタイミングでエラーが発生します。

修正自体は既存のbundleスキームと同じ処理をしてあげれば良いです。

余談:修正したPlayのバンドル方法について

この対応していて一番感動したのはここで、Playくらい大きなフレームワークでも、sbtの基本コマンドに忠実に則っているので、かなり楽でした。まずはPlayをcloneしてきます。サンプルプロジェクトだとサブモジュールを張っているので下記コマンドで取得します。

$ git submodule update

次に、コードを修正した後 sbt publishLocal します。

$ cd playframework
$ SBT_OPTS=-Dsbt.ivy.home=../ivy2 sbt publishLocal 

すると 2.8.15+3-1e1d609d-SNAPSHOT みたいなバージョンで ivy2/local にパブリッシュされます。Dockerコンテナでは標準ivyキャッシュディレクトリにコピーします。

Dockerfile
COPY ivy2/local/ /root/.ivy2/local/

最後に、sbt-pluginのバージョンを、パブリッシュされたバージョンに更新します

project/plugins.sbt
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.15+3-1e1d609d-SNAPSHOT")

感想

出来上がったファイルは 83MB くらいでした。アプリは 40MB くらいなので、およそ半分がJVMとScalaが占めているんでしょうか?あと起動が早い。レスポンスもJVM版に比べるとかなり良いです(後述)。ネイティブ化によってJVMに依存しなくなったので、簡単にクラウド環境に持っていけるかもなぁとか思いました。

既にSelenium等で全パスを実行するようなテストが整っていると、relect-config.jsonの生成は多少楽になるかな?とか思いました。

追記

パフォーマンス比較を行いました。サンプルプロジェクトなのであくまで参考値ですが、レスポンスは半分以下、秒間リクエスト数は約1.4倍に向上しました。

環境 平均レスポンス(ms) 平均秒間リクエスト数
GraalVM 8.47 6.21k
JVM 19.03 4.50k

参考

こちらがとても参考になりました

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2