続・Rustを使ってRubyスクリプトをパックしてみる
この記事は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.
どうやら公式では完全にはサポートできていないようです。
しかしソースコードには以下のような記述があります。
....
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のソースを読む
重要そうなコードは次の通りです。
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してバイナリに含めてくれます。stringio
やstrscan
などがこれに当たります。早速確かめてみます。
$ ./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 $"'
これでも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スクリプトです
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スクリプトです。
# 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
を使って出力されたコードです。
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
が必要とする関数などがわかりません。そのため使われていないと判断し、リンクするときに削除されてしまっています。
リンカーが関数を削除しないようにするためにするには二つの方法を思いつきました。
- 使っていることを明示的に示す
- リンカーのオプションで削除しないようにする
使っていることを明示的に示す
削除されてしまうのは、リンクした時に使っていないと判断されてしまっているからでした。
ですので使っている、と明示的に示せば良いのです。いわゆるプロトタイプ宣言
的なことをすれば解決できます。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