Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

ScalaをDocker Imageにビルドする2026夏 / Cloud Native BuildpackでScalaプロジェクトをビルドしてメモリ関連の設定を楽しよう

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を入れておくのが素直だ。

環境

現時点で最新のバージョンを使っているので、適宜読み替えてほしい:

  • 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のままなことがある。

また、今回のサンプルコードは以下に公開してある:

github.com

土台となるプロジェクト

まず、以下のような素朴な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.sbtJavaAppPackagingを有効にする。これは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から直接ダウンロードしてもよい:

github.com

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を載せたイメージができあがる。sbtbuild.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経由で注入する。

github.com

github.com

github.com

ファイルを追加する

追加のファイルは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"
  )

github.com

これを利用してパスを得ることができる:

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-packsbt-assemblyを使っている場合は、そのままでは既定のuniversal:packageBinと噛み合わない。BP_SBT_BUILD_ARGUMENTSBP_SBT_BUILT_ARTIFACTで実行タスクと成果物パスを差し替えれば別の出力にも対応できるが、今回のように素直に乗せたいならsbt-native-packagerが一番手っ取り早い。
  • 参考: Paketo sbt buildpack / Paketo Java howto
  • JAVA_OPTSはそのままコンテナに環境変数として渡すだけで読み込まれる。がスレッド数などはメモリ調整まわりと干渉しないようにしたほうがよい。

Special Thanks

Disclosure

一定量の下書きや調査にLLMを利用しました。

これまでに書いた記事

blog.3qe.us

blog.3qe.us

blog.3qe.us

blog.3qe.us

blog.3qe.us

追記

環境変数を実行時に指定することでJMXも利用できる。デフォルトポートは5000。

$ docker run --rm -p 8080:8080 -p 5000:5000 -e 'BPL_JMX_ENABLED=true' scala-cnb-exercise

paketo.io

これによりjconsoleによるメモリの使用状況のトラックも可能になる。すごいぞ。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?