ScalaでDockerイメージを作る話は定期的に書いている。以前はsbt-jibを使う方法を書いた。
今回はさらに別の方法として、Cloud Native Buildpack(CNB)でビルドしてみる。CNBはアプリのソースからDockerfileなしでOCIイメージを作る仕組みで、CNCFのプロジェクトになっている。この手法のメリットは、メモリ関連のJVMパラメータを全自動で設定してくれるところだ。
Javaやsbtのプロジェクトを扱うための実装はPaketoが用意していて、その中にpaketo-buildpacks/sbtというsbt専用のbuildpackがある。これがbuild.sbtを見つけてソースからビルドしてくれる。
キモは、このsbt buildpackが既定でsbt universal:packageBinを実行し、target/universal/*.zipから成果物を持っていくという点。つまりsbt-native-packagerのUniversal形式の出力を最初から前提にしているので、sbt-native-packagerを入れておくのが素直だ。
- 環境
- 土台となるプロジェクト
- sbt-native-packagerを用意する
- packでビルドする
- 動かす
- JREのバージョンや種別を指定する
- Correttoを使う
- RedHat UBIを使う(使いたかった)
- jlinkを利用する
- 設定たち
- ファイルを追加する
- レジストリにpushする
- メモ
- Special Thanks
- Disclosure
- これまでに書いた記事
- 追記
環境
現時点で最新のバージョンを使っているので、適宜読み替えてほしい:
- Scala 3.8.2
- sbt 2.0
- sbt-native-packager 1.11.7
- pack (Pack CLI) 0.40.8
- builder:
paketobuildpacks/ubuntu-noble-builder
builderは少し前までpaketobuildpacks/builder-jammy-baseという名前だったが、paketobuildpacks/ubuntu-noble-builderに変わった。古い記事や資料はjammyのままなことがある。
また、今回のサンプルコードは以下に公開してある:
土台となるプロジェクト
まず、以下のような素朴なHTTPサーバを用意した:
// build.sbt val scala3Version = "3.8.4" lazy val root = project .in(file(".")) .settings( name := "scala cnb exercise", version := "0.1.0-SNAPSHOT", scalaVersion := scala3Version, libraryDependencies += "org.scalameta" %% "munit" % "1.3.4" % Test, libraryDependencies += "com.softwaremill.sttp.tapir" %% "tapir-jdkhttp-server" % "1.13.28" )
// src/main/scala/Main.scala import sttp.tapir.* import sttp.tapir.server.jdkhttp.* @main def server(): Unit = val health = endpoint.get.in("health").out(stringBody).handle(_ => Right("OK")) val hello = endpoint.get .in("hello") .in(query[String]("name")) .out(stringBody) .handle(name => Right(s"Hello, $name!")) JdkHttpServer().port(8080).addEndpoint(health).addEndpoint(hello).start() println("listening on :8080")
sbt-native-packagerを用意する
まずproject/plugins.sbtにsbt-native-packagerを足す:
addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.11.7")
com.typesafe.sbtではなくcom.github.sbtになっている点に注意(だいぶ前に移管された)。バージョンは releases で最新を確認して差し替えてほしい: https://github.com/sbt/sbt-native-packager/releases
次にbuild.sbtでJavaAppPackagingを有効にする。これはUniversal形式のzip(起動スクリプトと依存JAR一式)を用意するのに必要だ:
val scala3Version = "3.8.4" lazy val root = project .in(file(".")) .enablePlugins(JavaAppPackaging) .settings( name := "scala cnb exercise", version := "0.1.0-SNAPSHOT", scalaVersion := scala3Version, libraryDependencies += "org.scalameta" %% "munit" % "1.3.4" % Test, libraryDependencies += "com.softwaremill.sttp.tapir" %% "tapir-jdkhttp-server" % "1.13.28" )
main classが1つなら勝手に検出される。複数ある場合は明示しておく:
Compile / mainClass := Some("com.example.Main")
この状態で手元でsbt Universal/packageBinするとtarget/out/jvm/scala-3.8.4/scala-cnb-exercise/universal/cnb-exercise-0.1.0-SNAPSHOT.zipができる。中身はこんな感じで、bin/に起動スクリプト、lib/に依存JARと自分のJARが並ぶ:
scala-cnb-exercise-0.1.0-SNAPSHOT/
├── bin/
│ ├── scala-cnb-exercise
│ └── scala-cnb-exercise.bat
└── lib/
├── ...(依存JAR)...
└── scala-cnb-exercise.cnb-exercise-0.1.0-SNAPSHOT.jar
CNB側でこれを作る操作を代わりにやってくれるので、手元で叩く必要は必ずしもないが、想定どおりのzipができるか一度確認しておくと安心。
packでビルドする
CNBのリファレンス実装であるpack CLIを入れる。macOSならbrewでよい:
$ brew install buildpacks/tap/pack
もちろん、GitHubから直接ダウンロードしてもよい:
sbt 2固有の設定
sbt 2は成果物を集中管理しているので、このままだとビルドコンテキストにsymlinkが載ってしまってうまく動かない。また、成果物のパスもちょっと変化している。そこで、project.tomlをプロジェクトルートに置いて以下のように記述する:
[_] schema-version = "0.2" [io.buildpacks] exclude = [ "target/", ".bloop/", ".bsp/", ".metals/", ] [[io.buildpacks.build.env]] name = "BP_SBT_BUILD_ARGUMENTS" value = "Universal/packageBin" [[io.buildpacks.build.env]] name = "BP_SBT_BUILT_ARTIFACT" value = "target/out/jvm/scala-*/*/universal/*.zip
このへんはそのうち修正されるのではないかな。
あとはプロジェクトのルートでpack buildするだけ。builderにnobleを指定する:
$ pack build scala-cnb-exercise --builder paketobuildpacks/ubuntu-noble-builder
初回はbuilderのイメージを引いてくるので少し待つ。ビルドが始まると、sbt buildpackがJDKを用意し、sbt universal:packageBinを走らせ、できたzipを展開してレイヤーに積み、最後にJREを載せたイメージができあがる。sbtやbuild.sbtという語がログに出てきて、detectでsbt buildpackが選ばれているのが分かるはず。
終わるとscala-cnb-exercise:latestというイメージがローカルにできている。
動かす
普通のイメージなので普通に起動できる:
$ docker run --rm -p 8080:8080 scala-cnb-exercise Calculating JVM memory based on 6088036K available memory For more information on this calculation, see https://paketo.io/docs/reference/java-reference/#memory-calculator Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx5495703K -XX:MaxMetaspaceSize=80332K -XX:ReservedCodeCacheSize=240M -Xss1M (Total Memory: 6088036K, Thread Count: 250, Loaded Class Count: 11769, Headroom: 0%) Enabling Java Native Memory Tracking Adding 121 container CA certificates to JVM truststore Picked up JAVA_TOOL_OPTIONS: -Djava.security.properties=/layers/paketo-buildpacks_amazon-corretto/java-security-properties/java-security.properties -XX:+ExitOnOutOfMemoryError -XX:MaxDirectMemorySize=10M -Xmx5495703K -XX:MaxMetaspaceSize=80332K -XX:ReservedCodeCacheSize=240M -Xss1M -XX:+UnlockDiagnosticVMOptions -XX:NativeMemoryTracking=summary -XX:+PrintNMTStatistics listening on :8080
見ての通り、メモリ関連の設定を勝手にやってくれるのが凄いポイント。ECSやCloud Runといったコンテナオーケストレータの下で起動したときも、メモリなどの様子を読み込んで適切なメモリ設定を適用してくれる。このへんの設定は人間がするとキリがないので、gofmtのように機械にやらせて95点を取らせたほうがいい。
起動コマンドはbuildpackがbin/の起動スクリプトから拾って設定してくれるので、こちらでENTRYPOINTを書く必要はない。Dockerfileを一行も書いていないのにイメージができているのが面白い。
見様によってはDockerfileの中身を知ることができないということでもあるが、このへんは考え方次第かなと思う。これまでは人間がわざわざ丁寧にDockerfileを書いていたが、もう別に人間が書くフェーズは終わって、適切な設定をもとに勝手にビルドしてもらったほうがいいのかもしれない。人間がやるべきことはDockerfileの追い込みではなくて、コンテナイメージをとにかく入手してアウトカムを出すことだから。
JREのバージョンや種別を指定する
既定ではPaketoが選んだJava(現時点だと21が既定)でJREが載る。自分で決めたい場合は環境変数を渡す:
$ pack build cnb-exercise \
--builder paketobuildpacks/ubuntu-noble-builder \
--env BP_JVM_VERSION=21 \
--env BP_JVM_TYPE=JRE
BP_JVM_VERSIONがJavaのメジャーバージョン、BP_JVM_TYPEがJREかJDKか(既定はJRE)。実行時にjcmdなどのJDKツールを使いたいのでなければJREのままでよい。このBP_JVM_VERSIONはビルド時にsbtを走らせるJDKにも使われるので、使っているScalaと互換のあるものを選ぶこと。Scala 3.8からはJava 17が必須になっているので注意しよう。
ベンダーを既定のBellSoft Liberica以外(Amazon Correttoなど)にしたい場合は、--buildpackで代替JVM buildpackとJava buildpackをこの順で2つ渡す方法がある。これから説明する。
Correttoを使う
$ pack build scala-cnb-exercise \
--builder paketobuildpacks/ubuntu-noble-builder \
--buildpack docker.io/paketobuildpacks/amazon-corretto \ # javaの先に書く
--buildpack paketo-buildpacks/java \
--env BP_JVM_VERSION=21
上掲のように、javaのbuildpackよりも先にJVM buildpackを先に指定することで、JVMを差し替えられる。ただしCorrettoはJREがなくてJDKしか提供されていないので、サイズがふくらみがち。
RedHat UBIを使う(使いたかった)
ところでubuntu-noble-builderはUbuntuがベースイメージになる。これとは別にUBI(よーするにRHELのサブセット)を利用することもできる。
UBIを使うには、builderの部分を削って数行追加する:
$ pack build scala-cnb-exercise \
--builder paketobuildpacks/builder-ubi8-buildpackless-base \
--extension docker.io/paketocommunity/ubi-java-extension \
--buildpack paketo-buildpacks/java \
--env BP_JVM_VERSION=21
・・・はずなのだが、UBI 8のglibcは古くてsbtnがうまく動かない。sbtnを無効化するか、UBI 9のJava対応を待つ必要がありそうだ(UBI 9 の builderはまだNodeしか対応していない)。
[extender (build)] Compiled Application: Contributing to layer [extender (build)] Executing sbt Universal/packageBin [extender (build)] /layers/paketo-buildpacks_sbt/sbt/bin/sbtn-x86_64-pc-linux: /lib64/libc.so.6: version `GLIBC_2.32' not found (required by /layers/paketo-buildpacks_sbt/sbt/bin/sbtn-x86_64-pc-linux) [extender (build)] /layers/paketo-buildpacks_sbt/sbt/bin/sbtn-x86_64-pc-linux: /lib64/libc.so.6: version `GLIBC_2.34' not found (required by /layers/paketo-buildpacks_sbt/sbt/bin/sbtn-x86_64-pc-linux) [extender (build)] unable to invoke layer creator [extender (build)] unable to contribute application layer [extender (build)] error running build [extender (build)] exit status 1 [extender (build)] ERROR: failed to build: exit status 1
sbtnを無効化するには、project.tomlに以下のように追記し、sbtの引数を上書きする:
[[io.buildpacks.build.env]] name = "BP_SBT_BUILD_ARGUMENTS" value = "--jvm-client Universal/packageBin"
jlinkを利用する
jlinkはJARのサイズを削ってくれるツール。
--env BP_JVM_JLINK_ENABLED=trueをつけておくと、jlinkを利用していい感じにしてくれるが、うまくコンテナが起動しないこともあるので注意。
設定たち
もろもろの設定は各buildpackが提供している。設定はproject.toml経由で注入する。
ファイルを追加する
追加のファイルはsrc/universalに置いておくことで、実行時は/workspace/scala-cnb-exercise-0.1.0-SNAPSHOT/に配置される。/workspaceはpwdになっているので、実際は./scala-cnb-exercise-0.1.0-SNAPSHOT/だけ指定すればよい。
このへんの値はsbt-buildinfoで得られる:
// project/plugins.sbt addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.11.7") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.13.1")
// build.sbt val scala3Version = "3.8.4" lazy val root = project .in(file(".")) .enablePlugins(JavaAppPackaging) // 追加 .enablePlugins(BuildInfoPlugin) .settings( name := "scala cnb exercise", version := "0.1.0-SNAPSHOT", scalaVersion := scala3Version, libraryDependencies += "org.scalameta" %% "munit" % "1.3.4" % Test, libraryDependencies += "com.softwaremill.sttp.tapir" %% "tapir-jdkhttp-server" % "1.13.28", // 追加2行 buildInfoKeys := Seq[BuildInfoKey](name, version), buildInfoPackage := "buildinfo" )
これを利用してパスを得ることができる:
val universalContainerPath = s"""${buildinfo.BuildInfo.name.replaceAll(" ", "-")}-${buildinfo.BuildInfo.version}"""
レジストリにpushする
イメージ名をレジストリのパスにしておき、docker pushするか、pack build --publishで直接publishできる:
$ pack build ghcr.io/windymelt/scala-cnb-exercise --builder paketobuildpacks/ubuntu-noble-builder --publish
CIに載せる場合も、runnerでpackを入れてこのコマンドを叩くだけなので、Dockerfileの管理から解放されて楽できる。たいていのユースケースではとにかくコンテナイメージが欲しいだけであって、別にDockerfileを書きたいわけではないと思うので、これでいいと思う。
メモ
sbt-packやsbt-assemblyを使っている場合は、そのままでは既定のuniversal:packageBinと噛み合わない。BP_SBT_BUILD_ARGUMENTSとBP_SBT_BUILT_ARTIFACTで実行タスクと成果物パスを差し替えれば別の出力にも対応できるが、今回のように素直に乗せたいならsbt-native-packagerが一番手っ取り早い。- 参考: Paketo sbt buildpack / Paketo Java howto
JAVA_OPTSはそのままコンテナに環境変数として渡すだけで読み込まれる。がスレッド数などはメモリ調整まわりと干渉しないようにしたほうがよい。
Special Thanks
そんなお悩みにJava Memory Calculatorと言うツールがありまして…
— Ryo Shindo (@shindo_ryo) 2026年7月23日
Cloud Native Buildpacksでビルドしたイメージであれば、コンテナに割り当てられたメモリサイズから自動的にHeap, Native各領域のサイズを設定してくれます。https://t.co/hS4P6viQW6
Disclosure
一定量の下書きや調査にLLMを利用しました。
これまでに書いた記事
追記
環境変数を実行時に指定することでJMXも利用できる。デフォルトポートは5000。
$ docker run --rm -p 8080:8080 -p 5000:5000 -e 'BPL_JMX_ENABLED=true' scala-cnb-exercise
これによりjconsoleによるメモリの使用状況のトラックも可能になる。すごいぞ。
