JavaやScalaといったJVM言語のDockerイメージは、JVMを同梱しなければならない都合で肥大化しがちである。特に何もしなくても、例えば一般的なamazoncorretto:21
のイメージサイズは217.7 MBもある。
これにさらにビルド済みのJARファイルが載ってくるので、結構大きくなってしまうのだ。
そこで、Scalaのコンテナイメージのサイズをなんとか小さくできないかと、考えた。すると、JVMを使ったまま70 MiBくらいに縮めることができた。
コンテナイメージのサイズを小さくするために、何をしたかを書いていく。ちなみに題材としたアプリケーションはちょっとしたHello, Worldをするだけのもので、ライブラリはCatsに依存させた。
JVM使う編
マルチステージビルドを行う
コンパクトなDockerイメージのためには、マルチステージビルドはもはや必須の工程となりつつある。マルチステージビルドを行うことで、実際に動作する成果物を作るために必要なファイルを、最終的なコンテナイメージから切り分けることができるため、イメージサイズを小さくできるのだ。
要するにロケットが下段を捨てていくのと同じです。不要な部分を捨てて必要な部分だけをお届けするわけ。
Alpineなどの軽量ランナーイメージを使う
軽量なコンテナにするためのベースイメージといえば、Distrolessが有名だ。しかしDistrolessは動的ライブラリを殆んど含んでおらず、当然Javaバイナリも無いので厳しいものがある。(javaが入ったdistrolessもあるが、これはこれで結構大きくてぜんぜん小さくならない)
そこで今回はAlpineを使うことにした。Alpineといえば、安易な利用について警鐘が鳴らされがちで、すっかり最近では嫌われ者だ。
たぶんこれが発端なのだが、よくわからずに同調しているだけの人間もいるようだ。
今回はAlpineの代表的な地雷、すなわちmuslが使われていることをうまく回避できるので問題ない。というのも、JVM自体がmuslでビルドされていれば後はVM上でJavaバイトコードが動くだけなので何も問題がないのである。
そういうわけで、今回はランナーイメージとしてalpine:latest
を利用している(本当は固定したほうがよい)。
jlink
を利用する
JVMのバイナリやら付属品やらがデカすぎるという問題をなんとかするためにあるツールがjlink
である。これはJDKに付属するツールで、アプリケーションから呼ばれないモジュールを削除したJVMのサブセットを生成することで、小さなJVMを作ってくれるやつ。
例えばjava.base
以外のモジュールが呼ばれていないことがわかったとき、jlink
はjava.base
以外のモジュールを取り払ったバージョンのJVMをディレクトリに出力してくれる。後はこれをDockerイメージに入れて、JVMとして使えばよいのだ。
jlink
に関しては以下の記事が詳しい:
今回は、Scalaのビルドツールであるsbtからjlink
を呼び出すプラグインとしてsbt-native-packager
を利用した。このプラグインにはjlink
を呼び出すための設定が附属するのだ。
まずproject/plugins.sbt
に以下のように記述する:
addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16")
次にbuild.sbt
に以下のように記述する:
val scala3Version = "3.3.3" enablePlugins(JlinkPlugin) lazy val root = project .in(file(".")) .settings( // ... jlinkIgnoreMissingDependency := JlinkIgnore.everything, // you should specify more preciously in production jlinkOptions += "--compress=2" )
jlinkIgnoreMissingDependency
は、もしjlink
が依存性を解決できなかったらどうするか、を指定するものだ。実際に使ったところ誤検知だったので、ここでは全部無視させている。
これで以下のように入力すると、target/universal/stage
以下にカスタムされたJVMなどが出力される:
% sbt stage
これをDockerコンテナに詰め込めばよい。
Dockerfile
全体
Dockerfile
の全体を見ると以下のようになっている:
FROM sbtscala/scala-sbt:eclipse-temurin-alpine-21.0.2_13_1.9.9_3.4.1 AS builder WORKDIR /app COPY build.sbt . COPY src ./src COPY project ./project RUN sbt stage FROM alpine:latest AS runner RUN apk add bash COPY --from=builder /app/target/universal/stage /app/stage WORKDIR /app/stage CMD /app/stage/bin/thin-scala-container
bash
をわざわざ入れているのは、stageされて出力されたランチャーがbash前提で設計されているためだ。
ビルドして実行する
コンテナをビルドする。
% docker build -t thinscala . % docker image inspect thinscala | jq '.[0].Size' 70372447
70 MiBちょっとになった。
実行する。
% % docker run --rm -it thinscala Hello world! I was compiled by Scala 3. :) List(1, 2, 3, 4, 5, 6, 7, 8, 9, 2 ...
あたりまえだが普通に動く。
Scala Nativeを使う
当たり前だが、Scala Nativeを使えばJVMを使わなくてもいいので、もっとイメージサイズを小さくできる。
// project/plugins.sbt addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.17")
// build.sbt val scala3Version = "3.3.3" import scala.scalanative.build._ enablePlugins(ScalaNativePlugin) lazy val root = project .in(file(".")) .settings( name := "thin-scala-container", version := "0.1.0-SNAPSHOT", scalaVersion := scala3Version, libraryDependencies += "org.typelevel" %%% "cats-core" % "2.10.0", nativeConfig ~= { c => c.withLinkingOptions(Seq("-static")) }, nativeLTO := "thin", nativeMode := "release-fast" )
ここでは、シングルバイナリにすることで依存性の問題を回避している。実はScalaもシングルバイナリを作れるのだ。
ビルドするにはnativeLink
を呼べばよい。
% sbt nativeLink
Dockerfile-native
を作って、Dockerでも動かせるようにする。シングルバイナリにできているので、ランナーイメージにはDistrolessを利用できる。
FROM sbtscala/scala-sbt:eclipse-temurin-jammy-21.0.2_13_1.9.9_3.4.1 AS builder RUN apt update -y && apt -y install clang WORKDIR /app COPY build.sbt . COPY src ./src COPY project ./project RUN sbt nativeLink FROM gcr.io/distroless/static-debian12:latest AS runner COPY --from=builder /app/target/scala-3.3.3/thin-scala-container-out /app/main WORKDIR /app CMD [ "/app/main" ]
こうしてできたイメージは6 MiBくらいになる。軽い。
% docker image inspect thinscala | jq '.[0].Size' 5930229
ここまで小さくできると、デプロイフローも改善できることだろう。ただ、Scala Nativeにまだ対応していないライブラリもあるので、ライブラリに少し注意が必要だ。