📦

続・Rustを使ってRubyスクリプトをパックしてみる

18 min read

この記事はRust Advent Calendar 2021の15日目の記事です。


はじめに

この記事は前回記事の続きとなります。前回記事をみていただいてる前提の部分もあるので、参照いただければと思います。

また、今回の記事の内容はこのレポジトリで公開しています。ご自由にお使いください。

なおこの記事で実行している環境は以下の通りです

MacBook Air (M1, 2020) Monterey
rustc 1.56.0 (09c42c458 2021-10-18)
rutie 0.8.3

実行ファイルが依存しているライブラリを確認する

前回の方法ではRuby本体が動的ライブラリとして依存しており、完全なシングルバイナリとはなっていませんでした。それを確認するためにotoolコマンドを使います。
otool -L <実行ファイル>とすることでその実行ファイルが依存している動的ライブラリの一覧を確認することができます。

$ otool -L ./target/debug/ruby_pack
./target/debug/ruby_pack:
	/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/libruby.2.6.dylib (compatibility version 2.6.0, current version 2.6.3)
	/usr/lib/libiconv.2.dylib (compatibility version 7.0.0, current version 7.0.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1311.0.0)
	/usr/lib/libresolv.9.dylib (compatibility version 1.0.0, current version 1.0.0)

自分の環境では以上のようになりました。一番上の依存にRubyの動的ライブラリであるlibruby.2.6.dylibが動的リンク(dynamic link)されているのがわかります。
この動的ライブラリはOSに最初からインストールされているRubyのものなので、この実行ファイルを他のMacで実行しても問題になりにくいです。しかしOSのバージョンが違えばインストールされているRubyのバージョンは異なりますし、別のRubyバージョンを使って実行したい時に困ります。


というわけでどうにかしてこの動的ライブラリの依存をなくしたいわけです。今回の目的はRubyのdynamic linkをなくし、Rubyを静的リンク(static link)することです。

rutieを使いstatic linkする

rutieのREADMEにはこう書いてあります。

Static build support is incomplete for now.

どうやら公式では完全にはサポートできていないようです。

しかしソースコードには以下のような記述があります。

build.rs
....
fn main() {
    // Ruby programs calling Rust doesn't need cc linking
    if should_link() {

        // If windows OS do windows stuff
        windows_support();

        if is_static() {
            ci_stderr_log!("RUBY_STATIC is set");
            use_static()
...

コード自体にはstatic linkに対応してそうなものが入っているため、それらを使うことでビルドできそうです。
ですが、(自分が調べた限りでは)static linkする方法はrutieで解説されていなかったためコードを読みながらRubyをstatic linkしてみます。

ruiteのソースを読む

重要そうなコードは次の通りです。

build.rs
fn use_static() {
    if let Some(location) = env::var_os("RUBY_STATIC_PATH").map(|s|s.to_string_lossy().to_string()) {
        println!("cargo:rustc-link-search={}", location);
    }
...
fn static_linker_args() {
    let mut library = Library::new();
    library.parse_libs_cflags(rbconfig("LIBRUBYARG_SHARED").as_bytes(), true);
    library.parse_libs_cflags(format!("-l{}-static", rbconfig("RUBY_SO_NAME")).as_bytes(), true);
    library.parse_libs_cflags(rbconfig("MAINLIBS").as_bytes(), false);
}
...
fn is_static() -> bool {
    env::var_os("RUBY_STATIC").is_some()
}
...

環境変数RUBY_STATICが存在している状態でbuildすることで静的ライブラリを使うようになっています。またライブラリのパスはRUBY_STATIC_PATHで指定することができます。

Rubyの静的ライブラリを用意する

Rubyをstatic linkにするためにはstaticなライブラリが必要です。
Macの場合は*.aとなっているファイルがそれに当たりますが、OSでインストールされていないのでどこからか調達する必要があります。
今回はRubyのレポジトリをcloneし自前でbuildして用意してみます。せっかくなのでRubyのバージョンは今月25日にリリースされるであろう、3.1の開発版を使ってみます。

$ git clone git@github.com:ruby/ruby.git
$ cd ruby
$ ./autogen.sh
$ ./configure
$ sudo make && sudo make install
$ ruby --version
ruby 3.1.0dev (2021-11-27T19:07:53Z master 0e5e2e35f8) [arm64-darwin21]

これらを実行すると/usr/local/lib以下にlibruby.3.1-static.aという静的ライブラリが作成されます。

静的ライブラリのRubyを調べる

さて、Rubyを静的ライブラリにしましたが、このファイルは一体どのような状態になっているのでしょうか。
このような時はファイルに何が書かれているかを調べればどういう状態かわかるかもしれません。
この場合はnmコマンドを使います。自分の環境ではclangを使ってRubyをbuildしたのでllvm-nmを使います。このコマンドを使うとバイナリファイルに書かれている関数名(シンボル名)の一覧を見ることができます。
実行ファイルの場合はmainがエントリポイントとなるのでmainがあるか探してみます。

$ llvm-nm /usr/local/bin/ruby | grep main
0000000100464e40 b _BigDecimal_divremain.rbimpl_id
00000001000087c0 t _BigDecimal_remainder
0000000100272f34 t _check_step_domain
000000010026e8cc t _domain_error
000000010021fb8c t _int_remainder
000000010000459c t _main
0000000100337718 t _main_to_s
...

$ llvm-nm /usr/local/lib/libruby.3.1-static.a | grep main
000000000000ea00 T _rb_big_remainder
000000000008bab8 b _rb_big_remainder.rbimpl_id
                 U _rb_eFloatDomainError
                 U _ruby_single_main_ractor
                 U _ruby_single_main_ractor
                 U _rb_iseq_eval_main
                 U _rb_obj_is_main_ractor
                 U _ruby_single_main_ractor
0000000000015df0 t _remain_size
000000000001a8ff s l___func__.remain_size
0000000000001d7c T _rb_iseq_new_main
0000000000000008 C _rb_eMathDomainError
                 U _ruby_single_main_ractor
                 U _ruby_single_main_ractor
000000000000a1a8 t _int_remainder
00000000000088d0 t _num_remainder
                 U _rb_big_remainder
0000000000000008 C _rb_eFloatDomainError
                 U _rb_eMathDomainError
0000000000000bb4 T _rb_obj_is_main_ractor
0000000000000024 T _rb_ractor_main_alloc
0000000000000b8c T _rb_ractor_main_p_
0000000000000274 T _rb_ractor_main_setup
000000000000102c T _rb_ractor_terminate_interrupt_main_thread
0000000000001250 T _rb_vm_main_ractor_ec
                 U _ruby_single_main_ractor
0000000000003d9c t _domain_error
00000000000039e8 t _check_step_domain
                 U _rb_eFloatDomainError
                 U _rb_iseq_new_main
                 U _rb_ractor_main_p_
                 U _ruby_single_main_ractor
                 U _ruby_single_main_ractor
                 U _ruby_single_main_ractor
0000000000004c44 T _rb_thread_main
0000000000008344 t _rb_thread_s_main
                 U _ruby_single_main_ractor
                 U _ruby_single_main_ractor
                 U _rb_ractor_main_p_
                 U _ruby_single_main_ractor
000000000001b538 t _main_to_s
0000000000018b00 T _rb_iseq_eval_main
0000000000028624 t _rb_iseq_eval_main.cold.1
                 U _rb_ractor_main_alloc
                 U _rb_ractor_main_p_
                 U _rb_ractor_main_setup
0000000000000008 C _ruby_single_main_ractor
0000000000131510 b _vm_set_main_stack.rbimpl_id
000000000002a12a s l___func__.vm_set_main_stack
                 U _ruby_single_main_ractor
                 U _ruby_single_main_ractor
                 U _rb_vm_main_ractor_ec
                 U _ruby_single_main_ractor

mainとあるシンボルは実行ファイルの方にはあるように見えますが、静的ライブラリの方には当然ながら存在しません。また実行ファイルの方にある他のシンボルは静的ライブラリには含まれていることがわかります。
というわけで、静的ライブラリになったRubyは実行するためのエントリポイントがないRubyというような形でしょうか。

--with-static-linked-ext オプション

もう一つ確認したいポイントがあります。実はRubyをビルドするときに--with-static-linked-extというオプションを使うことができます。このオプションを使うことでRubyの外部ライブラリをstatic linkしてバイナリに含めてくれます。stringiostrscanなどがこれに当たります。早速確かめてみます。

$ ./autogen.sh && sudo make clean && sudo ./configure --with-static-linked-ext && sudo make && sudo make install

このオプションは./configureを実行する時に付与します。他にも色々オプションがあるので--helpで確認してみてください。


buildできたらnmコマンドをつかって中身を確かめてみます。外部ライブラリをstatic linkすると書きましたが、どのような状態になっているのでしょうか。
前回の記事で調査したところ、ライブラリを読み込んだ時Init_から始まる関数をエントリポイントとして実行し、初期化しているのでした。なので、Init_から始まるシンボルでどのようなものがあるか調べてみます。

$ llvm-nm /usr/local/bin/ruby | grep Init_
...
00000001006850b1 s _Init_signal.failed.9
00000001000a336c T _Init_sizeof
00000001000d6a14 T _Init_socket
00000001000e52d0 T _Init_stringio
00000001000e94fc T _Init_strscan
00000001002eba88 t _Init_sym
0000000100199840 t _Init_syserr
00000001000ebfb0 T _Init_syslog
0000000100342360 t _Init_top_self
...

確かにRubyの実行ファイルの方にはInit_stringioが埋め込まれており、static linkされていることがわかります。一方静的ライブラリのRubyにはこれがありません。ですので静的ライブラリを使う時にはこれまでと同様stringio.bundleを別途読み込む必要があります。

Ruby実行時に読み込みライブラリ

少し本筋からずれますが、この状態のRubyにはInit_stringioが埋め込まれています。では、requireをしなくてもstringioを使えるのでしょうか。確認してます。

$ irb
irb(main):001:0> RUBY_VERSION
=> "3.1.0"
irb(main):002:0> StringIO
(irb):2:in `<main>': uninitialized constant StringIO (NameError)
	from /usr/local/lib/ruby/gems/3.1.0/gems/irb-1.3.8.pre.11/exe/irb:11:in `<top (required)>'
	from /usr/local/bin/irb:25:in `load'
	from /usr/local/bin/irb:25:in `<main>'
irb(main):003:0> require 'stringio'
=> true
irb(main):004:0> StringIO
=> StringIO

どうやら外部ライブラリをstatic linkしたRubyで実行するときでもrequireは必要なようです。実行ファイルに埋め込まれているとは言っても、requireで明示的に初期化してあげる必要があるようです。
ところで、$"にはRubyが読み込んだライブラリなどが格納されているのでした。

$ ruby -e 'p $"'

irbを使うとirbで使われるライブラリが読み込まれてしまうため、コマンドで確認します。

これでもstringioが読み込まれていないのがわかります。ではsringioを読み込んだときどのようなパスが格納されるのでしょうか。

$ ruby -e 'require "stringio";p $"'
["enumerator.so", "thread.rb",
... 
"/usr/local/lib/ruby/3.1.0/did_you_mean.rb", "stringio.so"]

どうやらstringio.bundleではなくstringio.soとして読み込まれているようです。
これは--with-static-linked-extオプションを外したRubyでも同様で、おそらくbuildオプションの違いだと思いますが、OS標準のRubyでは*.bundleが読み込まれ(buildされ)、buildしたRubyでは*.soが使われるようです。(ここの違いについてはよくわかりませんでした……。)

続・Rubyスクリプトをパックする

ここまでの調査結果と前回の調査結果から

  • 静的ライブラリのRubyを用意する
  • rutieが静的ライブラリを使うように環境変数を設定する
  • Rubyスクリプトを一枚のファイルにする
  • RubyVM::InstructionSequenceに変換
  • Rustでinclude_bytes!を使いライブラリとRubyスクリプトを読み込む
    • 読み込んだライブラリはrequireをする必要がある
    • require相当の処理はInit_<ライブラリ名>という関数を実行する

をすればRubyスクリプトをパックできそうです。

まずはパックするRubyスクリプトです

csv.rb
require 'csv'

p 'hello world!!'

p RUBY_VERSION

csv_text = <<~CSV_TEXT
  Ruby,1995
  Rust,2010
CSV_TEXT

IO.write "sample.csv", csv_text

# ファイルから一行ずつ
CSV.foreach("sample.csv") do |row|
  p row
end
# => ["Ruby", "1995"]
#    ["Rust", "2010"]

# ファイルから一度に
p CSV.read("sample.csv")
# => [["Ruby", "1995"], ["Rust", "2010"]]

# 文字列から一行ずつ
CSV.parse(csv_text) do |row|
  p row
end
# => ["Ruby", "1995"]
#    ["Rust", "2010"]

# 文字列から一度に
p CSV.parse(csv_text)
# => [["Ruby", "1995"], ["Rust", "2010"]]

前回からほとんど変化はありませんが、Rubyのバージョンを表示させてみました。
次に一枚のRubyスクリプトに変換し、Rustのコードを生成するRubyスクリプトです。

main.rb
# ruby実行時からすでに読み込まれているファイルを記憶しておく
already_libs = $".dup
require ARGV[0]

# .soで読み込まれているので.bundleに変更する
lib_files = ($" - already_libs).grep(/.*\.so$/).map { _1.gsub(/\.so/, '.bundle') }
ruby_files = ($" - already_libs).grep(/.*\.rb$/)

# 読み込まれているrubyスクリプトを読み込んで一枚のスクリプトファイルにする
# 一部読み込まなくて良いものがあるのではじく
# 一枚のスクリプトファイルにするのでrequireは削除しておく
File.open("#{ARGV[0]}.cat.rb", 'w') do |file|
  file.print "# frozen_string_literal: true\n"
  ruby_files.each do
    next unless _1 =~ %r{^/.*\.rb$}

    prog = File.open(_1).read
    prog = prog.gsub(/^(\s*(require|require_relative)\s+('|").*('|"))$/, '')
    file.print "#{prog}\n"
  end
end

# 結合したrubyスクリプトをコンパイルしてファイルに書き出す
# 拡張子は適当
iseq = RubyVM::InstructionSequence.compile_file("#{ARGV[0]}.cat.rb")
bin_iseq = iseq.to_binary
File.binwrite("#{ARGV[0]}.cat.rbin", bin_iseq)

# erbを使ってrustのコードを生成する
require 'erb'
include_bytes_paths = lib_files.map { "include_bytes!(\"#{RbConfig::CONFIG['rubyarchdir']}/#{_1}\")" }.join(', ')
lib_names = lib_files.map { "\"#{_1}\"" }.map { _1.gsub(/\.bundle/, '') }.join(', ')
rbin_path = "#{ARGV[0]}.cat.rbin"

File.open('./src/main.rs', 'w') { _1.print(ERB.new(File.open('./main.rs.erb', 'r').read).result(binding)) }

前回よりもコードをスッキリさせました。これをruby ./main.rb ./csv.rbのように実行します。実行すると一枚のファイルになったcsv.rb.cat.rbとそれをRubyVM::InstructionSequenceに変換したcsv.rb.cat.rbinというファイルができると思います。


Rustのコードはerbを使いテンプレートから生成します。以下はerbを使って出力されたコードです。

main.rs
use dlopen::raw::Library;
use once_cell::sync::Lazy;
use rutie::{Class, Encoding, Object, RString, VM};
use std::io::Write;
use tempfile::NamedTempFile;

static FILES: Lazy<Vec<&[u8]>> = Lazy::new(|| {
    vec![
        include_bytes!("/usr/local/lib/ruby/3.1.0/arm64-darwin21/date_core.bundle"),
        include_bytes!("/usr/local/lib/ruby/3.1.0/arm64-darwin21/stringio.bundle"),
        include_bytes!("/usr/local/lib/ruby/3.1.0/arm64-darwin21/strscan.bundle"),
    ]
});

fn main() {
    unsafe {
        VM::init();
        VM::init_loadpath();
        let loaded_lib_names = vec!["date_core", "stringio", "strscan"];
        let _libs = FILES
            .iter()
            .enumerate()
            .map(|(i, file)| {
                let mut temp_file = NamedTempFile::new().unwrap();
                temp_file.write_all(file).unwrap();
                let lib = Library::open(temp_file.path()).unwrap();
                let init_func_name = format!("{}{}", "Init_", loaded_lib_names[i]);
                let func: unsafe extern "C" fn() = lib.symbol(&init_func_name).unwrap();
                func();
                temp_file.close().unwrap();
                return lib;
            })
            .collect::<Vec<Library>>();

        let iseq_class = Class::from_existing("RubyVM").get_nested_class("InstructionSequence");
        let file = include_bytes!(".././csv.rb.cat.rbin");
        let src = RString::from_bytes(file, &Encoding::us_ascii());
        let iseq = iseq_class.send("load_from_binary", &[src.to_any_object()]);
        iseq.send("eval", &[]);
    };
}

前回からほとんど変わっていませんが、mapを使ってライブラリのバイナリデータをテンポラリファイルに書き出して読み込んだあと、そのライブラリをベクターに持つようにしました。
ベクターに持つのは前回同様、ライブラリが読み込まれた後に、dropされてしまうのを防ぐためです。

buildする

Rubyの静的ライブラリを用意でき、Rustのコードも準備できたのでbuildしてみます。

$ export RUBY_STATIC=true
$ export RUBY_STATIC_PATH=/usr/local/lib
$ cargo build

環境変数を設定し、先程自前でbuildしたRubyにPATHを通してからbuildします。
なお自分の環境では、library.parse_libs_cflags(rbconfig("LIBRUBYARG_SHARED").as_bytes(), true);の箇所でエラーが発生しうまくbuildすることができなかったため、rutieをcloneしコメントアウトしたところうまくbuildが通りました。


では早速実行してみます

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/ruby_pack`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: OpeningLibraryError(Custom { kind: Other, error: "dlopen(/var/folders/f8/nnwg0vt9493fk594f1lqts6c0000gn/T/.tmpFVhtbI, 0x0005): symbol not found in flat namespace '_rb_ext_ractor_safe'" })', src/main.rs:24:50
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

エラーが起きて実行が停止してしまいます。symbol not found in flat namespace '_rb_ext_ractor_safe'"は実行ファイルに_rb_ext_ractor_safeというシンボルがないというエラーです。

しかし、Rubyはstatic linkにし、必要な外部ライブラリは埋め込んだはずなので見つからないというのがわかりません。もう少し調べる必要があるようです。

実行ファイルを調べる

まずはbuildした実行ファイルをnmコマンドをつかって調べてみます。

$ llvm-nm ./target/debug/ruby_pack | grep _rb_ext_ractor_safe
$

当然ながら見当たりません。では静的ライブラリのRubyはどうでしょうか。

$ llvm-nm /usr/local/lib/libruby.3.1-static.a | grep _rb_ext_ractor_safe
0000000000000e58 T _rb_ext_ractor_safe

おや、こちらには存在しているようです。つまり静的ライブラリをリンクすると存在していた関数が消えてしまうみたいです。

リンク時にコードが削除される

実はrustcを使ってコンパイル、リンクをする際に使われていない関数やコードなどを削除し、バイナリサイズを小さくしています。今回の手法はstringioなどはコンパイル時にリンクではなくバイナリデータとして実行ファイルに埋め込んでいます。つまりリンカーからはstringioが必要とする関数などがわかりません。そのため使われていないと判断し、リンクするときに削除されてしまっています。

リンカーが関数を削除しないようにするためにするには二つの方法を思いつきました。

  1. 使っていることを明示的に示す
  2. リンカーのオプションで削除しないようにする

使っていることを明示的に示す

削除されてしまうのは、リンクした時に使っていないと判断されてしまっているからでした。
ですので使っている、と明示的に示せば良いのです。いわゆるプロトタイプ宣言的なことをすれば解決できます。Rustでは以下のように書きます。

extern "C" {
  fn fn rb_ext_ractor_safe(falg: bool);
  fn onig_region_new() -> *const i32;
}

引数や戻り値の型や数は実際のRubyのソースコードを調べて実装します。
しかし宣言だけしてもbuild時に削除されてしまうので、適当に使ってあげて削除されないようにします。

let hoge = onig_region_new();
println!("{:?}", hoge);
rb_ext_ractor_safe(true);

後続のコードでこんな感じで呼んであげれば、使われていると判断されるため削除されません。
この方法は引数や戻り値がvoidであったり、実装すべき関数が1~2個程度あればすぐにできますが、今回はこれに当てはまらず、自分は4つほど定義したところで諦めました……。

リンカーのオプションで削除しないようにする

そもそもリンカーが削除してしまっていたので、リンカーに削除しないようにオプションをつけて実行してあげれば良いはずです。
Rustでbuildするときにcargo rustc -- -C link-dead-code=onとすると使われていない関数などを削除せずにbuildしてくれます。今回はこちらを採用しました。

ちなみにrustc -C helpここで他のオプションを確認することができます。

実行してみる

リンカーオプションで関数を削除しないようにbuildしてできた成果物を実行してみます。cargo runとして実行してしまうと、オプションなしで再度コンパイルしてしまうので直接実行します。

$ ./target/debug/ruby_pack
"hello world!!"
"3.1.0"
["Ruby", "1995"]
["Rust", "2010"]
[["Ruby", "1995"], ["Rust", "2010"]]
["Ruby", "1995"]
["Rust", "2010"]
[["Ruby", "1995"], ["Rust", "2010"]]

見事実行できました!念の為依存を調べてみます。

$ otool -L ./target/debug/ruby_pack
./target/debug/ruby_pack:
	/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 1853.0.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1311.0.0)
	/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
	/usr/lib/libiconv.2.dylib (compatibility version 7.0.0, current version 7.0.0)
	/usr/lib/libresolv.9.dylib (compatibility version 1.0.0, current version 1.0.0)
	/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 1853.0.0)

依存関係からもRubyの動的ライブラリがなくなっていることがわかります。これでRubyとRubyスクリプトをパックした実行ファイルができました!

まとめ

RustのアドベントカレンダーなのにRustのコードがほとんど出てきませんでしたが、無事、Rubyの動的ライブラリの依存をなくし、Rubyスクリプトをシングルバイナリとして実行できる形にできました。
rustcのオプションやデバッガなどを活用して調査する経験ができました。
実装の面では、テンポラリファイルに書き出して読み込んでいる部分が少し気になるので何か方法があれば改善したいと思います。


ruby buildコマンド出てこないかなあ

Discussion

ログインするとコメントできます