みなさんごきげんよう。何度目かのPlay on GraalVM Native Imageの記事です。今回は、前回からのアップデートとして各種ライライブラリのバージョンアップと、トレースエージェントの起動用Dockerfileを用意しました。それでは、どうぞ。
ゴール
JVMがない環境でPlayFramework製Webアプリケーションを動かします。具体的にはビルドしたWebアプリケーションを次のDockerfileで起動できるようにします。
FROM almalinux:latest
WORKDIR /app
COPY (省略)
ENTRYPOINT ["./play-scala-seed"]
開発時はsbt runで通常通り実行できるものとします。
0. 準備
プロジェクトを準備します。
~ % sbt new
Welcome to sbt new!
Here are some templates to get started:
a) scala/toolkit.local - Scala Toolkit (beta) by Scala Center and VirtusLab
b) typelevel/toolkit.local - Toolkit to start building Typelevel apps
c) sbt/cross-platform.local - A cross-JVM/JS/Native project
d) scala/scala3.g8 - Scala 3 seed template
e) scala/scala-seed.g8 - Scala 2 seed template
f) playframework/play-scala-seed.g8 - A Play project in Scala
g) playframework/play-java-seed.g8 - A Play project in Java
i) softwaremill/tapir.g8 - A tapir project using Netty
m) scala-js/vite.g8 - A Scala.JS + Vite project
n) holdenk/sparkProjectTemplate.g8 - A Scala Spark project
o) spotify/scio.g8 - A Scio project
p) disneystreaming/smithy4s.g8 - A Smithy4s project
q) quit
Select a template: f
[info] resolving Giter8 0.18.0...
This template generates a Play Scala project
name [play-scala-seed]:
organization [com.example]:
Template applied in ~/play-scala-seed
静的解析をしやすくするためにプロジェクトをguiceからcompile time diに変更します。
+ import play.api.Application
+ import play.api.ApplicationLoader
+ import play.api.BuiltInComponentsFromContext
+ import play.api.LoggerConfigurator
+ import router.Routes
+
+ class AppLoader extends ApplicationLoader {
+ def load(context: ApplicationLoader.Context): Application = {
+ LoggerConfigurator(context.environment.classLoader)
+ .foreach(_.configure(context.environment))
+ new MyComponents(context).application
+ }
+ }
+
+ final class MyComponents(context: ApplicationLoader.Context)
+ extends BuiltInComponentsFromContext(context)
+ with controllers.AssetsComponents {
+ lazy val httpFilters = Nil
+
+ val homeController = new controllers.HomeController(controllerComponents)
+
+ lazy val router = new Routes(
+ httpErrorHandler,
+ homeController,
+ assets
+ )
+ }
# https://www.playframework.com/documentation/latest/Configuration
+ play.application.loader = "AppLoader"
今回は説明のために差分を最小に提示しました。必要であればguiceやjavax.inject関連のコードを削除してください。使わなければ、存在する分には悪さはしないです。
scalaのバージョンはビルドに使うScalaのバージョンと揃えておくと不具合が少ないです。今回は sbtscala/scala-sbt:graalvm-community-25.0.1_1.12.6_3.8.2 を使うため、scalaのバージョンを 3.8.2 にしておきます。
name := """play-scala-seed"""
ThisBuild / organization := "com.example"
version := "1.0-SNAPSHOT"
lazy val root = (project in file(".")).enablePlugins(PlayScala)
- scalaVersion := "2.13.18"
+ scalaVersion := "3.8.2"
libraryDependencies += guice
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test
// Adds additional packages into Twirl
//TwirlKeys.templateImports += "com.example.controllers._"
// Adds additional packages into conf/routes
// play.sbt.routes.RoutesKeys.routesImport += "com.example.binders._"
ここで、実行確認しておきましょう。
% sbt run
[info] welcome to sbt 1.12.6 (Eclipse Adoptium Java 25.0.1)
[info] loading global plugins from /Users/kizuki.yasue/.sbt/1.0/plugins
[info] loading settings for project play-scala-seed-build from plugins.sbt...
[info] loading project definition from /Users/kizuki.yasue/play-scala-seed/project
[info] loading settings for project root from build.sbt...
[info] __ __
[info] \ \ ____ / /____ _ __ __
[info] \ \ / __ \ / // __ `// / / /
[info] / / / /_/ // // /_/ // /_/ /
[info] /_/ / .___//_/ \__,_/ \__, /
[info] /_/ /____/
[info]
[info] Version 3.0.10 running Java 25.0.1
[info]
[info] Play is run entirely by the community. Please consider contributing and/or donating:
[info] https://www.playframework.com/sponsors
[info]
--- (Running the application, auto-reloading is enabled) ---
INFO p.c.s.PekkoHttpServer - Listening for HTTP on /[0:0:0:0:0:0:0:0]:9000
(Server started, use Enter to stop and go back to the console...)
~ % open http://localhost:9000
ブラウザで Welcom to Play! が表示されていればOKです。
1. build.sbtにGraalVM Native Imageビルド向け設定を追加
name := """play-scala-seed"""
organization := "com.example"
version := "1.0-SNAPSHOT"
lazy val root = (project in file(".")).enablePlugins(PlayScala)
scalaVersion := "3.8.2"
libraryDependencies += guice
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test
// Adds additional packages into Twirl
//TwirlKeys.templateImports += "com.example.controllers._"
// Adds additional packages into conf/routes
// play.sbt.routes.RoutesKeys.routesImport += "com.example.binders._"
+ enablePlugins(GraalVMNativeImagePlugin)
+ PlayKeys.externalizeResources := false
+ graalVMNativeImageOptions ++= Seq(
+ "-J-Xmx12G",
+ "--allow-incomplete-classpath",
+ "--enable-http",
+ "--install-exit-handlers",
+ "--no-fallback"
+ )
| 追記した行 | 説明 |
|---|---|
enablePlugins(GraalVMNativeImagePlugin) |
sbt-native-packagerのGraalVMNativeImagePluginを有効化します。sbt-native-packagerはplayに組み込まれているものでOKです。 |
PlayKeys.externalizeResources := false |
confフォルダをjarに含めないかどうかを指定します。 |
graalVMNativeImageOptions ++= Seq( |
native-imageコンパイラに設定するオプションです。 |
"-J-Xmx12G", |
コンパイラに12GBのメモリを割り当てます。ネイティブビルドがメモリ不足でクラッシュした場合はこの数字を増やします。 |
"--allow-incomplete-classpath", |
型エラーを実行時エラーにします。 |
"--enable-http", |
http通信できるようにします |
"--install-exit-handlers", |
SIGKILLを受け取った時に正常終了できるようにします。 |
"--no-fallback" |
到達不可能オブジェクトが存在した場合、コンパイルエラーにします。 |
最低限必要なオプションはこんなもんですが、プロジェクトによっては他にも必要なオプションがあるかもしれません。また、native-imageは結構頻繁にこのオプションを変えてくるので、定期的にドキュメントを参照しておきましょう。非推奨オプションなどはコンパイラが警告してくれるので、それきっかけで確認するでも良いです。
2. ビルド用のDockerfileを用意する
native imageのビルド用Dockerfileを用意します。これはゴールとしてalmalinuxで動かすために、ビルド自体もlinux環境下で行う必要があるためです。
+ FROM sbtscala/scala-sbt:graalvm-community-25.0.1_1.12.6_3.8.2 AS build
+ COPY ./ /app/
+ WORKDIR /app
+
+ FROM build AS agent
+ RUN sbt Universal/stage
+ ENV JAVA_OPTS=-agentlib:native-image-agent=config-output-dir=metadata
+ ENTRYPOINT ["target/universal/stage/bin/play-scala-seed"]
+
+ FROM build AS native-build
+ RUN sbt GraalVMNativeImage/packageBin
+
+ FROM almalinux:latest AS app
+ WORKDIR /app
+ COPY --from=native-build /app/target/graalvm-native-image/* .
+ ENTRYPOINT ["./play-scala-seed"]
コンテナ上でビルドするため、適当な.dockerignoreも用意しておきましょう。
+ .bsp/
+ .g8/
+ .idea/
+ logs/
+ project/project/
+ target/
2-1. compose.yamlを用意する。
dockerコマンドでもいいのですが、後にトレースエージェントを起動する都合上、結構何度もdockerコマンドを実行することになります。オプションの指定を省略するためにcompose.yamlを用意しておくと便利です。
+ services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: app
+ ports:
+ - "${PORT:-9000}:9000"
+ environment:
+ PLAY_HTTP_SECRET_KEY: "${PLAY_HTTP_SECRET_KEY}"
+ services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: agent
+ ports:
+ - "${PORT:-9000}:9000"
+ environment:
+ PLAY_HTTP_SECRET_KEY: "${PLAY_HTTP_SECRET_KEY}"
+ volumes:
+ - ./conf/META-INF/native-image/com.example/play-scala-seed:/app/metadata
prodモードで動かすために、HTTPシークレットキーを設定する必要があります。
# https://www.playframework.com/documentation/latest/Configuration
play.application.loader = "AppLoader"
+ play.http.secret.key = ${?PLAY_HTTP_SECRET_KEY}
+ PLAY_HTTP_SECRET_KEY=QCY?tAnfk?aZ?iwrNwnxIlR6CTf:G3gf:90Latabg@5241AB`R5W:1uDFN];Ik@n
3. トレースエージェントを起動し、reachability-metadata.jsonを生成する
GraalVM Native Imageの制限の1つに、リフレクションやリソースをコンパイラに渡す必要があります。このファイルが存在しないとコンパイルできなかったり、リソースを参照できずに起動時に例外を出したりします。
先に作ったDockerfile, compose.agent.yamlを使うことで、トレースエージェント付きでWebアプリが起動します。
% docker compose -f compose.agent.yaml up --build
[+] Building 43.6s (11/11) FINISHED
=> [internal] load local bake definitions 0.0s
=> => reading from stdin 551B 0.0s
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 514B 0.0s
=> [internal] load metadata for docker.io/sbtscala/scala-sbt:graalvm-community-25.0.1_1.12.6_3.8.2 1.6s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 89B 0.0s
=> [internal] load build context 0.2s
=> => transferring context: 978.44kB 0.1s
=> [build 1/3] FROM docker.io/sbtscala/scala-sbt:graalvm-community-25.0.1_1.12.6_3.8.2@sha256:cc24544b8a303a069eaf84f818a0fb98cd96ba88704fbd9a5c60a20a06af35cc 15.6s
=> => resolve docker.io/sbtscala/scala-sbt:graalvm-community-25.0.1_1.12.6_3.8.2@sha256:cc24544b8a303a069eaf84f818a0fb98cd96ba88704fbd9a5c60a20a06af35cc 0.0s
=> => sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1 32B / 32B 0.2s
=> => sha256:13e3e77667c0e05c6accfcc6676a75bf1dec2a2c2caedb138d2fb75bcc92037f 232B / 232B 0.4s
=> => sha256:d95cb211ffab2f9a8b4820383153fbed3583f8edfc5b804309ce259fa9684c2b 168.79MB / 168.79MB 5.3s
=> => sha256:d3c3b0babe93f808584b4cca8d4dcce991a6d3eca941086513efc4c25df6a0de 250.94MB / 250.94MB 6.2s
=> => sha256:0ebb6f25fa52f5191381c2e7b0bc5d95c61e48456dee630894dd91eed7e67a35 1.67kB / 1.67kB 0.4s
=> => sha256:61c1ac894690ec00bce0dcd98afadcc6fcb681041ed89749f24b2e3e8464a184 58.87MB / 58.87MB 2.5s
=> => sha256:4363c46e5b34217800711cdeb58f7580278cfaa677c513030e114a86cbed71b4 315.89MB / 315.89MB 9.6s
=> => sha256:2addf3f8644a6e543379817d5d8098629d7e09ebd794fd00a4de7ec3488ea91e 51.06MB / 51.06MB 1.9s
=> => sha256:89f7d15b7840b98a8f22480e2d73f63780a16160cb0dd3bb70edf7585f87361b 92B / 92B 0.2s
=> => sha256:8c80c7940e75deeced3c3738823ed9b632b9c298e01a6d58e94125df4d4921f1 3.08kB / 3.08kB 0.3s
=> => sha256:0af0ea4b31d1867da9218cedcbce358b405eb24c8edeec20fccc9c589e48eefc 156.82MB / 156.82MB 3.0s
=> => sha256:667d06e2a8c453433887999ab044d6e722a2c05862c472b612b4e6111771b071 41.62MB / 41.62MB 1.3s
=> => extracting sha256:667d06e2a8c453433887999ab044d6e722a2c05862c472b612b4e6111771b071 0.7s
=> => extracting sha256:0af0ea4b31d1867da9218cedcbce358b405eb24c8edeec20fccc9c589e48eefc 2.0s
=> => extracting sha256:8c80c7940e75deeced3c3738823ed9b632b9c298e01a6d58e94125df4d4921f1 0.0s
=> => extracting sha256:89f7d15b7840b98a8f22480e2d73f63780a16160cb0dd3bb70edf7585f87361b 0.0s
=> => extracting sha256:4363c46e5b34217800711cdeb58f7580278cfaa677c513030e114a86cbed71b4 1.9s
=> => extracting sha256:2addf3f8644a6e543379817d5d8098629d7e09ebd794fd00a4de7ec3488ea91e 0.9s
=> => extracting sha256:61c1ac894690ec00bce0dcd98afadcc6fcb681041ed89749f24b2e3e8464a184 0.8s
=> => extracting sha256:d95cb211ffab2f9a8b4820383153fbed3583f8edfc5b804309ce259fa9684c2b 0.6s
=> => extracting sha256:0ebb6f25fa52f5191381c2e7b0bc5d95c61e48456dee630894dd91eed7e67a35 0.0s
=> => extracting sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1 0.0s
=> => extracting sha256:d3c3b0babe93f808584b4cca8d4dcce991a6d3eca941086513efc4c25df6a0de 1.2s
=> => extracting sha256:13e3e77667c0e05c6accfcc6676a75bf1dec2a2c2caedb138d2fb75bcc92037f 0.0s
=> [build 2/3] COPY ./ /app/ 0.1s
=> [build 3/3] WORKDIR /app 0.0s
=> [agent 1/1] RUN sbt Universal/stage 19.4s
=> exporting to image 6.4s
=> => exporting layers 5.2s
=> => exporting manifest sha256:0c64a841adaae8c69b724efbf61f88948d7c99cdaa7c065be12fdb16ec162ef5 0.0s
=> => exporting config sha256:0ef85436b58a167f00932de717097cc144f5c4b3058da9a4f14a90656cd49a6d 0.0s
=> => exporting attestation manifest sha256:185a1279abfa47b5d0e09a2d59b7bf065ad5c048bcfd8b228014b99e5d5f7d47 0.0s
=> => exporting manifest list sha256:b5a29d083ba519c7642b596298c2653621c745d5771ce413decb79580f25c465 0.0s
=> => naming to docker.io/library/play-scala-seed-app:latest 0.0s
=> => unpacking to docker.io/library/play-scala-seed-app:latest 1.2s
=> resolving provenance for metadata file 0.0s
[+] up 3/3
✔ Image play-scala-seed-app Built 43.7s
✔ Network play-scala-seed_default Created 0.0s
✔ Container play-scala-seed-app-1 Created 0.4s
Attaching to app-1
app-1 | WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
app-1 | WARNING: sun.misc.Unsafe::objectFieldOffset has been called by scala.runtime.LazyVals$ (file:/app/target/universal/stage/lib/org.scala-lang.scala-library-3.8.2.jar)
app-1 | WARNING: Please consider reporting this to the maintainers of class scala.runtime.LazyVals$
app-1 | WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
app-1 | 2026-03-26 12:42:49 INFO play.api.Play Application started (Prod) (no global state)
app-1 | 2026-03-26 12:42:51 INFO play.core.server.PekkoHttpServer Listening for HTTP on /[0:0:0:0:0:0:0:0]:9000
アプリが起動するので、ある程度コードカバレッジが満たされるように実行します。実行が終わったらCtrl+Cでアプリを終了します。するとconf/META-INF/native-image/com.example/play-scala-seed/reachability-metadata.jsonが生成されます。
実際のreachability-metadata.jsonの運用ですが、リフレクション呼び出しやリソースが増えない限り、更新する必要はありません。またリソースはglobの書き方ができるので、ダルかったら「フォルダ配下全部」みたいに楽することもできます。
4. native-imageを使ってビルドする。
% docker compose -f compose.build.yaml up --build
[+] Building 113.8s (15/15) FINISHED
=> [internal] load local bake definitions 0.0s
=> => reading from stdin 549B 0.0s
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 514B 0.0s
=> [internal] load metadata for docker.io/library/almalinux:latest 0.8s
=> [internal] load metadata for docker.io/sbtscala/scala-sbt:graalvm-community-25.0.1_1.12.6_3.8.2 0.8s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 89B 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 57.52kB 0.0s
=> [app 1/3] FROM docker.io/library/almalinux:latest@sha256:3ea6bed76e47c1a816ed7e1ed7be8661efcf6984bec90bcad5ec73b66b6754ce 0.0s
=> => resolve docker.io/library/almalinux:latest@sha256:3ea6bed76e47c1a816ed7e1ed7be8661efcf6984bec90bcad5ec73b66b6754ce 0.0s
=> CACHED [build 1/3] FROM docker.io/sbtscala/scala-sbt:graalvm-community-25.0.1_1.12.6_3.8.2@sha256:cc24544b8a303a069eaf84f818a0fb98cd96ba88704fbd9a5c60a20a06af35cc 0.0s
=> => resolve docker.io/sbtscala/scala-sbt:graalvm-community-25.0.1_1.12.6_3.8.2@sha256:cc24544b8a303a069eaf84f818a0fb98cd96ba88704fbd9a5c60a20a06af35cc 0.0s
=> [build 2/3] COPY ./ /app/ 0.0s
=> [build 3/3] WORKDIR /app 0.0s
=> [native-build 1/1] RUN sbt GraalVMNativeImage/packageBin 110.1s
=> CACHED [app 2/3] WORKDIR /app 0.0s
=> [app 3/3] COPY --from=native-build /app/target/graalvm-native-image/* . 0.1s
=> exporting to image 1.9s
=> => exporting layers 1.7s
=> => exporting manifest sha256:d77560ea5329644101defac376ffba5d5772b0fe266f4281406b21e361acb76f 0.0s
=> => exporting config sha256:49b93e5f231c700db0aeb18cdbea85f79c6564e6e91f99bda0165bd3dc0a133d 0.0s
=> => exporting attestation manifest sha256:fad99a1030834be91c2d30bb73f4ce2e98f44964127e79904853e339394fd6ec 0.0s
=> => exporting manifest list sha256:c8771bec4497bf5ba919fbce1cefe995673d086fa879dc35c0dbd354cd28e7e8 0.0s
=> => naming to docker.io/library/play-scala-seed-app:latest 0.0s
=> => unpacking to docker.io/library/play-scala-seed-app:latest 0.2s
=> resolving provenance for metadata file 0.0s
[+] up 2/2
✔ Image play-scala-seed-app Built 113.9s
✔ Container play-scala-seed-app-1 Recreated 0.3s
Attaching to app-1
app-1 | WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
app-1 | WARNING: sun.misc.Unsafe::objectFieldOffset has been called by scala.runtime.LazyVals$ (file:/app/play-scala-seed)
app-1 | WARNING: Please consider reporting this to the maintainers of class scala.runtime.LazyVals$
app-1 | WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
app-1 | 2026-03-26 12:45:41 INFO play.api.Play Application started (Prod) (no global state)
app-1 | 2026-03-26 12:45:41 INFO play.core.server.PekkoHttpServer Listening for HTTP on /[0:0:0:0:0:0:0:0]:9000
% open http://localhost:9000
ブラウザで Welcom to Play! が表示されていればOKです。
まとめ
どうでしょうか?思ったより少ないコード量でネイティブ化できたのではないかなと思います。トレースエージェントを起動する手間もあるので、既にcompile time di実装になっている小規模なplay webサービスの場合はトライしてみるのもありかもしれません。
それではみなさん、良いScala and Playライフを!
