ScalaをネイティブコンパイルしてJVM無しで実行ファイルとして動かせるようにする仕組みであるScala Nativeが、いつのまにかシングルバイナリを出力できるようになっていた。これにより、Goと同じように1つのバイナリさえコピーすればScalaを動かせる環境が整ったことになる。
シングルバイナリ
シングルバイナリとは、実行に必要なライブラリが静的にリンクされ、単体のバイナリとして完結した実行ファイルの通称*1である。より正確な表現として、静的リンクされた実行ファイルとか、静的実行ファイル(statically linked executable, static executable)というのがストレートだろう。この意味におけるシングルバイナリの対義語は動的リンクされた実行ファイル、動的実行ファイルである。英語だとdynamic executableだ。静的リンクされた実行ファイルは必要なライブラリが最初から1つのファイルに収められているのに対し、動的リンクされた実行ファイルは、実行されたタイミングで動的にマシンから必要なライブラリを探し出してメモリに読み込む。
ちなみに命令セットの違いも吸収して1つのバイナリにしなくてもシングルバイナリを名乗れるようだ。
シングルバイナリかどうかは、Linuxであればldd
コマンドで確認可能だ:
# シングルバイナリではない例: 動的に要求するライブラリが表示される $ ldd /bin/sh linux-vdso.so.1 (0x00007fff3a332000) libreadline.so.8 => /lib64/libreadline.so.8 (0x00007f6f42b78000) libc.so.6 => /lib64/libc.so.6 (0x00007f6f4297d000) libtinfo.so.6 => /lib64/libtinfo.so.6 (0x00007f6f42948000) /lib64/ld-linux-x86-64.so.2 (0x00007f6f42cd8000)
# シングルバイナリの例: 動的に要求するライブラリはない $ ldd ./target/scala-3.3.0/scala-native-static-linking-exercise-out not a dynamic executable
ところで、Bingに「シングルバイナリってなに」と尋ねると、「bit」と言われてしまった。
そういうことじゃないんだよ pic.twitter.com/ShyI94PFM5
— Windymelt💀(めるくん)🚀❤️🔥 (@windymelt) 2023年6月14日
Scala Native
Scala Nativeを使うと、機械語のバイナリを出力できる。ScalaはJVM言語なので、マシンにインストールされたJVMがないと普通は動作しないのだが、Scala NativeはLLVM技術を使うことで(自分はこのへんの分野に詳しくないので)とにかくあたかもC言語みたいに機械語の実行ファイルができあがる。
Scala Nativeの基本的な使い方は↑のドキュメントに書いてあるのでいったん割愛。以下の記事がよくまとまっている。
機械語なのでJVMを使って起動するよりも圧倒的に立ち上がりが高速であるため、CLIで動作するユーティリティ用途の道がScalaに開かれたのがとても面白いポイント。あと単純に配布しやすくなる。配布しやすいことがどれだけ重要かはGoが流行っている様子を見ればすぐ分かると思う。
ちなみに、Scala Nativeは標準設定では動的リンクされた実行ファイルを生成する。
$ sbt nativeLink ... $ ldd ./target/scala-3.3.0/foo-out linux-vdso.so.1 (0x00007fff22926000) libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007fafb4c00000) libm.so.6 => /lib64/libm.so.6 (0x00007fafb5857000) libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fafb5832000) libc.so.6 => /lib64/libc.so.6 (0x00007fafb4a05000) /lib64/ld-linux-x86-64.so.2 (0x00007fafb595c000)
Scala Nativeでシングルバイナリを出力する
さて、Scala NativeはCプログラム同様にリンカなどを駆使して実行可能ファイルを生成する。そして、その設定に少し手間をかけるだけで簡単にシングルバイナリを生成できる。
実際のコードはこちら。
build.sbt
のScala Nativeの設定にリンカのオプション-static
を追加する:
import scala.scalanative.build._ // ... enablePlugins(ScalaNativePlugin) // ... nativeConfig ~= { c => c.withLinkingOptions(Seq("-static")) // ここが重要 }
これにより、リンカはライブラリを静的リンクするようになる。この結果、Scala Nativeが出力する実行可能ファイルは静的実行ファイルになる:
$ sbt nativeLink $ ldd ./target/scala-3.3.0/scala-native-static-linking-exercise-out not a dynamic executable
このバイナリに、実行に必要な全てが収まっている。
CatsとCirceを使った今回のサンプルの場合、ファイルサイズは15MiB程度になった。動的リンクを使っていた元々のサイズが10MiB弱であったため、シングルバイナリ化によるオーバーヘッドは5MiB程度というところだろう。実際にこのバイナリを別のマシンのFreeBSDにコピーしたところ、何事もなく動作した(最近のFreeBSDにはLinuxのエミュレーションレイヤーがあるのでELF実行形式のファイルがそのまま動作できる)。すごい。また、strip
コマンドで不要なシンボルを削除したところバイナリサイズは7MiB程度にまで縮んだ。これならGoと互角に戦える。upx
を使えばもっと小さくできるだろう。
シングルバイナリ化したことにより、ScalaもGoと並んでバイナリをそのまま配布する方法を取りやすくなった。今までScala Nativeを使っていた人は、ぜひシングルバイナリ化を試してみてほしい。そして今までScala Nativeを使ったことがない人は、バイナリ配布という新たなScalaの形態をぜひ体験してみてほしい。
あわせて読みたい
*1:厳密な定義はないと思う