Lambdaカクテル

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

Invite link for Scalaわいわいランド

ちっちゃなScalaコンテナを作つコツ(6 MiBだぞ)

おなじみの画像

JavaやScalaといったJVM言語のDockerイメージは、JVMを同梱しなければならない都合で肥大化しがちである。特に何もしなくても、例えば一般的なamazoncorretto:21のイメージサイズは217.7 MBもある。

hub.docker.com

これにさらにビルド済みのJARファイルが載ってくるので、結構大きくなってしまうのだ。

そこで、Scalaのコンテナイメージのサイズをなんとか小さくできないかと、考えた。すると、JVMを使ったまま70 MiBくらいに縮めることができた。

github.com

コンテナイメージのサイズを小さくするために、何をしたかを書いていく。ちなみに題材としたアプリケーションはちょっとしたHello, Worldをするだけのもので、ライブラリはCatsに依存させた。

JVM使う編

マルチステージビルドを行う

コンパクトなDockerイメージのためには、マルチステージビルドはもはや必須の工程となりつつある。マルチステージビルドを行うことで、実際に動作する成果物を作るために必要なファイルを、最終的なコンテナイメージから切り分けることができるため、イメージサイズを小さくできるのだ。

docs.docker.jp

要するにロケットが下段を捨てていくのと同じです。不要な部分を捨てて必要な部分だけをお届けするわけ。

Alpineなどの軽量ランナーイメージを使う

軽量なコンテナにするためのベースイメージといえば、Distrolessが有名だ。しかしDistrolessは動的ライブラリを殆んど含んでおらず、当然Javaバイナリも無いので厳しいものがある。(javaが入ったdistrolessもあるが、これはこれで結構大きくてぜんぜん小さくならない)

そこで今回はAlpineを使うことにした。Alpineといえば、安易な利用について警鐘が鳴らされがちで、すっかり最近では嫌われ者だ。

blog.inductor.me

たぶんこれが発端なのだが、よくわからずに同調しているだけの人間もいるようだ。

今回はAlpineの代表的な地雷、すなわちmuslが使われていることをうまく回避できるので問題ない。というのも、JVM自体がmuslでビルドされていれば後はVM上でJavaバイトコードが動くだけなので何も問題がないのである。

そういうわけで、今回はランナーイメージとしてalpine:latestを利用している(本当は固定したほうがよい)。

jlinkを利用する

JVMのバイナリやら付属品やらがデカすぎるという問題をなんとかするためにあるツールがjlinkである。これはJDKに付属するツールで、アプリケーションから呼ばれないモジュールを削除したJVMのサブセットを生成することで、小さなJVMを作ってくれるやつ。

例えばjava.base以外のモジュールが呼ばれていないことがわかったとき、jlinkはjava.base以外のモジュールを取り払ったバージョンのJVMをディレクトリに出力してくれる。後はこれをDockerイメージに入れて、JVMとして使えばよいのだ。

jlinkに関しては以下の記事が詳しい:

blog1.mammb.com

今回は、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もシングルバイナリを作れるのだ。

blog.3qe.us

blog.3qe.us

ビルドするには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にまだ対応していないライブラリもあるので、ライブラリに少し注意が必要だ。

www.youtube.com

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