🔬

Rubyにもポータビリティを! シングルバイナリを作る3つの方法

2020/12/17に公開

この記事はRuby Advent Calendar 2020の17日目です。

TL;DR

  • GraalVM: 問題無く動作するがgemとか考えるとめんどそう。JavaのエコシステムやJS/Pythonなど別言語と組み合わせたい時には便利。
  • RubyPacker: gem/bunlder含めて特に問題なく動く。フルスペックRubyが必要なら現状でベスト
  • mruby-cli: ビルド速度含めて最軽量。使えるならこれがベストシナリオ。エコシステムが弱いのが欠点

はじめに

皆さんはRubyをなんに使っていますか? 多くの方は 「Railsを使ったWebアプリケーション」 と回答されるのではないでしょうか?
私の場合は少し違ってCLIのコマンドを作るのによく使います。Bashよりちょっと凝ったことをするのに自分にはちょうど良いんですよね。手に馴染んでるので。

ただ、今この手の用途に一番利用されるのはGo言語でしょう。なぜならシングルバイナリが作れるので単純にバイナリを置くだけでインストールが完了するからです。これは便利。

Rubyではこうはいきません。そもそもRubyが入ってない環境も多い ですし、仮に入っていてもライブラリなどの依存をインストールしないといけないのでbundler等を使うにしても導入はかなり不便です。最近は JavaScriptもdenoを使ってシングルバイナリ化が出来る のでRubyだってやりたいですよね?

と言うわけで 「Rubyでもシングルバイナリを使いたい!」 と常々思っており、去年も似たような記事を書いたのですが今一つな結果だったので、今年はリベンジとなります。

GraalVM

私のはJavaのエンジニアでもあるのでJRubyを使いFat-jarとしてRubyスクリプトをシングルバイナリで配布するという事を前からやっていました。しかし、これはJavaに依存しているためスタンドアローンバイナリというわけではありません。
仕事的にPC環境は問題無いのですが、サーバ環境だと必ずしもJavaが入ってるとは言えず、しかも起動が遅いのでCLIコマンドとしては不満が大きかったのも事実。

と言うわけで、JavaをネイティブイメージにコンパイルするGaalVMを使って何とかできないか? とチャレンジしてみました。

まあ、これは去年の結果を再確認ただけでもあります。1年間でどのくらい変わったかが気になるところ。
https://qiita.com/koduki/items/4096e37f4323e727558c

インストール

まずはGraalVMのRuby環境のインストールをします。GraalVMはこちらからダウンロードできますが、今回はsdkmanを使ってインストールしています。

$ sdk use java 20.3.0.r11-grl
$ java -version
openjdk version "16-ea" 2021-03-16
OpenJDK Runtime Environment (build 16-ea+28-2065)
OpenJDK 64-Bit Server VM (build 16-ea+28-2065, mixed mode, sharing)

gu install ruby
gu install native-image

ソースコード

プログラムは以下のようなコードを作成。GraalVMのpolyglotを使って動かす形ですね。ここでポイントなのはこの書き方では Rubyそのものはネイティブコードに変換されない という事です。現状、TruffleRubyをVMをネイティブコード化は出来てもRubyをネイティブコード化することは出来ません。そのため、Rubyコードをリソースとして含めてリソース内のRubyを毎回インタプリタで動的に実行 という形で動かしています。

main.rb

puts "Hello, Single Binary of Ruby!"

RubyNative.java

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;

import org.graalvm.polyglot.*;

public class RubyNative {
    public static void main(String[] args) throws PolyglotException, IOException {
        var text = Files.readAllLines(Path.of("main.rb"), Charset.defaultCharset());
        var src = String.join("\n", text);
        System.out.println(src);

        try (var context = Context.newBuilder().allowAllAccess(true).build()) {
            context.eval("ruby", src);
        }
    }
}

main.rbが本体のコードで、RubyNative.javaがブートストラップとなります。

ビルド & 実行

以下のように一度クラスファイルに変換してからnative-imageに変換します。IncludeResourcesでソースコードを取り込んでいる形ですね。

$ ruby main.rb
$ javac RubyNative.java 
$ native-image --language:ruby -H:IncludeResources='.*.rb$' RubyNative
$ ls -lh ./rubynative
-rwxr-xr-x 1 koduki koduki 144M Dec 17 01:19 ./rubynative

たった1行のRubyコードですがビルドには200秒近く私の環境ではかかります。また容量も144MBとかなり大きいです。ただ、去年の結果と比較するとかなり改善しているのでこれはGraalVMのnative-imageが順調に進化しているといえるでしょう。

また、UPXを使って圧縮しさらにバイナリを小さくしてみます。

$ sudo apt-get install -y upx
$ cp rubynative rubynative-upx
$ upx rubynative-upx
$ ls -lh -rwxr-xr-x 1 koduki koduki 43M Dec 17 01:25 ./rubynative-upx

43MBと非常に小さくなりました! では、こちらをそれぞれ実行した場合の起動時間を計測します。

time ruby main.rb
Hello, Single Binary of Ruby!
________________________________________________________
Executed in   59.59 millis    fish           external
   usr time   47.99 millis  209.00 micros   47.78 millis
   sys time   12.75 millis  427.00 micros   12.33 millis

time java RubyNative
puts "Hello, Single Binary of Ruby!"
Hello, Single Binary of Ruby!
________________________________________________________
Executed in    1.97 secs   fish           external
   usr time    5.63 secs  203.00 micros    5.63 secs
   sys time    0.39 secs  424.00 micros    0.39 secs

time ./rubynative
puts "Hello, Single Binary of Ruby!"
Hello, Single Binary of Ruby!
________________________________________________________
Executed in   52.99 millis    fish           external
   usr time   41.33 millis  209.00 micros   41.12 millis
   sys time   10.71 millis  427.00 micros   10.28 millis

time ./rubynative-upx
Hello, Single Binary of Ruby!
________________________________________________________
Executed in  502.88 millis    fish           external
   usr time  431.28 millis    0.00 micros  431.28 millis
   sys time   70.74 millis  534.00 micros   70.21 millis

GraalVMのネイティブイメージがCRubyよりも気持ち速い52msで実行できていますね。プログラムが十分に小さいのでこれはほぼ起動時間と考えて良いと思います。JVMで起動すると2秒近くかかっているのでこれは速度面ではかなり実用的になりました。インタプリタ実行とはいえ50msなら通常は特に気にならないですしね。

一方で、UPX圧縮した場合はファイルサイズは小さくなるのですが実行時間が500msを超えています。これは単純に解凍の時間がプラスされたためですが、500msだと起動に少しもたつきを感じるのでコマンドとしては不満がある速度です。このあたりはもしかしたら圧縮オプションで調整可能かもしれません。

まとめ

とりあえず、十分実用的な速度で実行させることが出来ました。ただ、gem/bundlerを使った場合はそれをリソースに組み込んでやるためにひと手間また必要でありビルド周りの仕組みを作りこまないとちょっと面倒が多そうです。

なので標準ライブラリで解決するコードやJavaあるいはJavaScriptやPythonなどとコードを相互連携させたPolyglotなプログラムを作りたい時に選ぶ選択肢だと思います。

Ruby Packer

続いてRuby Packerです。こちらはCRubyをベースにシングルバイナリを作成するという優れものです。CRubyなので互換性の観点で懸念が無いのが良いですね。LinuxのみならずWindowsやMac向けにもバイナリが作れますが試した限りではクロスビルドする事は出来なさそうなので配布向けのバイナリを作るためにはそれぞれの環境が必要になるかもです。
https://github.com/pmq20/ruby-packer

日本語だとこちらの記事に詳細が解説されています。
https://techracho.bpsinc.jp/hachi8833/2020_06_04/92808

インストール

curlコマンドを使って簡単にインストールが出来ます。注意点としてはstable版ではなくnightly版を落とさないとCRubyのバージョンが古かったりBundler2が動かなかったりするので下記のようにバージョンを指定しないUnstable版がお勧めです。

sudo apt install squashfs-tools  bisonsh
curl -L https://github.com/pmq20/ruby-packer/releases/download/linux-x64/rubyc > rubyc
chmod a+x ./rubyc

ソースコード

Ruby Packerの良いところはBundlerと連携してgemを自動で取り込んでくれるところです。そのため、thorなど高機能なライブラリを問題無く利用できますしRubyのエコシステムを最大限活用した形でシングルバイナリが作成できます。

Gemfile

source "http://rubygems.org"

gem "thor"

cli.rb

require "thor"

class MyCLI < Thor
    desc "hello NAME", "say hello to NAME"
    def hello(name)
      puts "Hello #{name}"
    end
end

MyCLI.start(ARGV)

ビルド & 実行

下記のコマンドでビルドをします。こちらもプログラムサイズに関わらずビルド時間は464秒と結構かかります。GraalVMよりもさらに長いですね。

$ bundle install
$ ./rubyc cli.rb
$ ls -lh ./a.out
-rwxr-xr-x 1 koduki koduki 87M Dec 17 01:56 ./a.out

ファイルサイズは87MBとなかなかコンパクトです。同じくUPXで圧縮してみましょう。

$ cp a.out a.out-upx
$ upx a.out-upx
$ ls -lh a.out-upx
-rwxr-xr-x 1 koduki koduki 81M Dec 17 01:58 a.out-upx

ほとんどサイズが変わりません。おそらくすでにruby-pakcer側で圧縮されていると思われるので速度低下も考えると追加の圧縮はするべきではないでしょう。

実行速度は以下の通り。

$ time ruby cli.rb hello wold
Hello wold
________________________________________________________
Executed in   67.58 millis    fish           external
   usr time   39.92 millis  109.00 micros   39.81 millis
   sys time   28.70 millis  241.00 micros   28.46 millis

$ time ./a.out hello wold
Hello wold
________________________________________________________
Executed in   92.76 millis    fish           external
   usr time   92.61 millis   97.00 micros   92.51 millis
   sys time    0.24 millis  236.00 micros    0.00 millis

体感ではほぼ違いが判りませんでしたが実行速度は微妙にruby-pakcerでシングルバイナリ化された場合が遅いです。
予想ですがこれはUPXを利用したときと同様に解凍にかかっている時間がオーバーヘッドになっているのではないかと思われます。
ただし、繰り返しますが体感ではほぼ変わらないので十分にコマンドとして実用的な速度と言えるでしょう。

まとめ

ruby-pakcerの最大の魅力は何といってもgem/bundlerと組み合わせてRubyのエコシステムをフルスペックで利用できることです。
これにより既存のコードの移植などが非常に容易になります。他の二つの選択肢が新規作成ならば対応可能なのに対して、Ruby Packerは既存ですでに作っているスクリプトをシングルバイナリとして移植出来るのでこれは大きなポイントでしょう。容量に関しても87MB程度であればGo言語で作った時の倍程度だし、現代のストレージサイズならさほど気にしなくて良いと思います。

mruby-cli

最後にmrubyのラッパーであるmruby-cliです。mrubyはCRubyの文法を参考にしつつ組み込み向けに新規に作成された言語及び実装です。組込みシステムで動かすためファイルI/Oの利用など含めて高度にモジュール化されていてgemとは別のmgemという仕組みを使います。そのため 文法も厳密には違いますしgemなどのエコシステムもそのまま利用できません がその代わりに 圧倒的なフットプリントの小ささ を実現しています。

https://github.com/mruby/mruby

mruby-cliの開発自体はGitHubを見ると開発が止まっているように見えますが、本当に薄いレイヤーなので特に問題なく動作します。
https://github.com/hone/mruby-cli

下記のブログが参考になると思います。
https://dojineko.hateblo.jp/entry/2016/02/22/002701
https://blog.agile.esm.co.jp/entry/2016/12/01/231826

インストール

下記のコマンドでmruby-cliをダウンロードして--setupでプロジェクトのテンプレートを作成します。また、ビルドはdocker-composeを使う方法rakeを直接叩く方法があります。前者の場合はdockerのインストールが必要ですし、後者の場合は各種コンパイラが必要になるのでそちらは別途インストールをしてください。

$ curl -L https://github.com/hone/mruby-cli/releases/download/v0.0.4/mruby-cli-0.0.4-x86_64-pc-linux-gnu.tgz | tar xfvz -
$ ./mruby-cli --setup hello
$ cd hello

ソースコード

build_config.rbに必要なmgemを記載していきます。今回はgetopsを取り込んでいます。動的リンクでは無く静的リンクになりますし、このあたりはかなり通常のRubyとは違うところですね。
また、mgemの一覧は下記にあります。
https://github.com/mruby/mgem-list

GitHubに独自に公開されているmgemも問題無く取り込めるとても今風の良い仕組みなのですが、探し方が分からないです。この辺のエコシステムはオリジナルのRubyに比べる各段に弱いのでむしろコントリビュートして行かないといけないなぁ、と思う部分ですね。

build_config.rb

def gem_config(conf)
  #conf.gembox 'default'
  #conf.gem :github => 'iij/mruby-io'

  conf.gembox 'full-core'
  conf.gem :mgem => 'mruby-getopts'

  # be sure to include this gem (the cli app)
  conf.gem File.expand_path(File.dirname(__FILE__))
end
.
.
.

mrblib/hello.rb

def __main__(argv)
  opt = Getopts.getopts(
    'vh',
    'version',
    'help',
    'path:'
  )

  if opt['v'] || opt['version']
    puts "v#{Hello::VERSION}"
  elsif opt['h'] || opt['help'] || opt['path'] == ''
    hname=`hostname`
    puts "host: #{hname}"
  elsif opt['path']
    puts "path: " + opt['path']
  else
    puts `hostname`
  end
end

ビルド & 実行

ビルドは下記の通り。ビルド時間はプラットフォーム当たりでフルビルドでも15秒程度、差分ビルドだと1秒くらいと一瞬で終わります。他の2つのビルド方式と大きく違うところですね。
ただ、デフォルトではLinux/Windows/Macの全ての環境の32bit/64bit版のビルドを行うのでフルビルドには200秒くらいかかります。

なので、実行時間を短縮させたい場合はbuild_config.rbを修正して不要なビルド対象を削除ないしはコメントアウトしておくと良いと思います。

# docker-compose版
$ docker-compose run compile
# rake版
$ rake
$ ls -lh mruby/build/x86_64-pc-linux-gnu/bin/hello
-rwxr-xr-x 1 koduki koduki 2.1M Dec 16 02:35 mruby/build/x86_64-pc-linux-gnu/bin/hello*

2.1MBと他の方式とは比べ物にならないコンパクトさです。今回はfull-coreにしたりとかなりリッチに設定してるのでその辺をシュリンクすればファイルサイズはどんどん小さくできます。

time mruby/build/x86_64-pc-linux-gnu/bin/hello
Hello World
________________________________________________________
Executed in    1.66 millis    fish           external
   usr time  1540.00 micros   90.00 micros  1450.00 micros
   sys time  225.00 micros  225.00 micros    0.00 micros

実行時間も1.6msと他より何十倍も速いですね!

まとめ

根本的な仕組みの違いがあるのでビルド時間/ファイルサイズ/実行時間(起動時間)のどれをとてもmrubyがベストになります。
一方で、文法面の差異はさておきとしてmgemはgem/bundlerに比べるとコミュニティのエコシステムが弱いので既存の移植にはあまり向いていません。標準ライブラリをベースに新規に作る場合では強力なツールになると思うのでいつでも使える状態にしておくのが重要だと思います。

番外: Docker

今回の評価には入れていませんがDockerを使って配布というのも良くやる手段です。

起動が100ms以上と少し重いのであまり多様はしませんが、Rubyコードだけではなく特定のライブラリやパッケージを含めてスクリプトを作っている場合にはDockerをベースにしてスクリプトファイルでシンプルに動かせる形にラッピングしてやるのがお勧めです。
ただし、スタンドアローンでもありませんしWindowsやMacでは起動速度がさらに遅くなる傾向があるので利用シーンは限られるかもしれないです。

何も考えずにポータブルなRubyを作るには一番手っ取り早いのでプロトタイプ的に作るときにはお勧め。あるいはImageMagickとかローカルライブラリの依存が強いものとか。

下記は実際にDockerをスクリプトで包んでコマンド化してるものです。特にテンプレート化してるわけではないですが良ければ参考に。
https://github.com/koduki/httpd4test/tree/v0.1

総まとめ

ある意味去年に引き続きなのですが今年のアドカレもRubyでのシングルバイナリネタとなりました。
各プラットフォームに関してまとめるとこんな感じ

方式 ビルド時間 バイナリサイズ 実行時間 コメント
GraalVM 200s 144MB 52ms Gem連携には課題。JavaやPythonなど多言語連携が必要な時に最適
Ruby Packer 460s 87MB 68ms Bundlerとの連携も完璧なのでフルスペックなRubyエコシステムを活用したい時に最適
mruby-cli 15s 2.1MB 1.6ms ビルド時間/バイナリサイズ/起動時間ともにベスト。ただしエコシステムが弱いので既存の移植には不向き

個人的にはmruby-cliを主軸に置きつつ、既存の便利ライブラリが使いたい場合はRuby Packerを使うというのがベストなシナリオな気がします。
GraalVMもビルドを作りこめばRuby Packerと同等以上の利便性を発揮できそうなのですが、Ruby Packerあるしそこを無理に頑張らなくても良いかなー、というのが現状。

とりあえず人類の夢であるシングルバイナリなRubyスクリプトは実現できるので、来年もまたゆるりとしたRubyライフを送っていきたいと思います。

それではHappy Hacking!

Discussion