Rustを使ってRubyスクリプトをパックしてみる
先日唐突にRubyスクリプトを実行可能な1ファイルにパックして単一実行ファイルにしたくなったので、色々調べて実装してみました。
なおRustとありますが主な実装はRustのRubyバインディングであるrutieを使っています。
まずパックするにあたってRubyにはRubyVM::InstructionSequence
という存在があることを思い出しました。
RubyVM::InstructionSequence
Ruby の Virtual Machine のコンパイル済みの命令シーケンスを表すクラスです。
とありますが、具体的にはどんなものでしょうか。
p 'hello world!!'
これを以下のようなスクリプトでRubyVM::InstructionSequence
に変換してみます。
iseq = RubyVM::InstructionSequence.compile_file("#{ARGV[0]}")
puts iseq.disasm
ruby iseq.rb ./hello.rb
のように実行します。すると
== disasm: #<ISeq:<main>@./hello.rb:1 (1,0)-(11,17)> (catch: FALSE)
0000 putself ( 11)[Li]
0001 putstring "hello world!!"
0003 opt_send_without_block <callinfo!mid:p, argc:1, FCALL|ARGS_SIMPLE>, <callcache>
0006 leave
何やら色々並んでいますね。これがRubyVMが実際に実行している命令になります。
Rubyが実行されるとき、具体的にはruby hello.rb
としたとき、hello.rb
のファイルに書かれたスクリプトをこの命令にコンパイルします。その後RubyVMがこの命令を実行するという流れになっています。
さらにRubyVM::InstructionSequence#to_binary
を実行することでこの命令列をバイナリ形式にすることができます。これをFile.binwrite
使うことでバイナリファイルにすることができます。
バイナリファイルにすることで以下のように実行することができます。
bin_iseq = File.binread('./hello.rbin')
iseq = RubyVM::InstructionSequence.load_from_binary(bin_iseq)
iseq.eval
あらかじめFile.binwrite
を使ってhello.rbin
という名前でバイナリファイルにしておきます。これをruby exec.rb
を実行することでhello world!!
が出力されます。
RubyがRubyスクリプトを実行するときには必ずRubyVM::InstructionSequence
に変換しなければなりませんが、あらかじめこの形式に変換しておくことで変換の手間をなくせます。
Railsアプリの高速化gemであるBootsnap
も同じ手法を用いてあらかじめソースをコンパイルすることで実行を高速化しています。
RubyVM::InstructionSequence
を実行する
rutieを使いではRubyVM::InstructionSequence
に変換したRubyスクリプトをRustから実行してみようと思います。
use rutie::{Class, Encoding, Object, RString, VM};
fn main() {
unsafe {
VM::init();
VM::init_loadpath();
let iseq_class = Class::from_existing("RubyVM").get_nested_class("InstructionSequence");
let file = include_bytes!("../hello.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", &[]);
}
}
rutieにはRubyVM::InstructionSequence
を直接扱うAPIがないので無理やり取得して使います。
パックするためにinclude_bytes
マクロを使ってRubyVM::InstructionSequence
のバイナリをプログラムに埋め込んでいます。これをRubyの文字列形式に変換してload_from_binary
に渡しています。先程のRubyスクリプトのコードをそのままrutieで実現した形です。
このコードを実行すると先ほどと同じように実行できると思います。
スクリプトの結合
今回は以下のスクリプトをパックしてみます
require 'csv'
p 'hello world!!'
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"]]
後半のCSVを使うコードはリファレンスのコードのままです。
ここでrequire 'csv'
としています。一つのバイナリファイルにするためにはrequire
で読み込まれているスクリプト全てを一つのスクリプトに結合してからバイナリファイルにする必要があります。
そこでこんなコードを書いてみます。
$path = []
iseq_patch = Module.new do
def load_iseq(fname)
$path << fname
nil
end
end
RubyVM::InstructionSequence.singleton_class.prepend(iseq_patch)
require ARGV[0]
paths = $" & $path
File.open("#{ARGV[0]}.cat.rb", 'w') do |file|
paths.each do |path|
next if path =~ /.*\.so$/
next if path =~ /thread.rb/
prog = File.open(path).read
prog = prog.gsub(/(\s*require\s*('|").*('|")|require_relative).*/, '')
file.print prog
end
end
前半のコードはRubyVM::InstructionSequence
にload_iseq
というメソッドを生やしています。このメソッドがあるとrequire
が実行されるたびにこのメソッドの内容を実行し、その結果をrequire
の結果とします。今回は$path
というグローバル変数に読み込まれたファイルを追加していきます。
paths = $".grep(/.*\.bundle/) + ($" & $path)
では読み込んだスクリプトとライブラリのパスをもう一度配列に詰め直しています。
$"
はRubyの特殊変数の一つで読み込まれた全てのRubyスクリプト、ライブラリのパスを配列で返します。ここには直接使われていないスクリプト(例えばthread.rb
やenumerator.so
)も含まれています。
この配列とrequire
で呼ばれたファイルの配列と論理積を取って$"
から読み込まれたファイルを抜き出しています。
ところでrequire
による読み込みは
- ファイルAがファイルBを読み込む
- ファイルBがファイルCとDを読み込む
- ファイルCが...
というような形となり多分木構造となります。
依存関係を正しく解決するためには末端から幅優先で読み込む必要があります。
$path
の配列では[A, B, C, D, E, F, G]
と並んでいるためこのままでは依存関係を正しく解決できません。そのため$"
から抜き出す必要があります。
後半のコードで実際に一つのスクリプトに結合しています。今回のコードではthread.rb
や*.so
はVM::init_loadpath()
で読み込まれるので除外しています。
またスクリプト中に含まれているrequire
は必要ないので一括で削除します。
ひとまずこれで一つのスクリプトに結合できました。
早速RubyVM::InstructionSequence
のバイナリファイルにしてRustで実行してみます。
cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/ruby_pack`
fish: Job 1, 'cargo run' terminated by signal SIGSEGV (Address boundary error)
エラーが出て失敗してますね……。
SEGVが出ているため、何か致命的なエラーが起きていることはわかりますが具体的なことが何もわかりません。というわけでデバッグツールを使って少し調べてみます。
lldb
Rustのデバッグツールといえばlldb
です。これを使って先程実行したプログラムをデバッグしてみます。
lldb target/debug/ruby_pack
(lldb) target create "target/debug/ruby_pack"
Current executable set to '/Users/ahogappa/project/ruby_pack/target/debug/ruby_pack' (arm64).
(lldb)
この状態でrun
と入力してプログラムを実行します。
Process 27168 launched: '/Users/ahogappa/project/ruby_pack/target/debug/ruby_pack' (arm64)
Process 27168 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0xe0)
frame #0: 0x00000001c434abf4 libruby.2.6.dylib`___lldb_unnamed_symbol3626$$libruby.2.6.dylib + 16
libruby.2.6.dylib`___lldb_unnamed_symbol3626$$libruby.2.6.dylib:
-> 0x1c434abf4 <+16>: str w1, [x8, #0xe0]
0x1c434abf8 <+20>: add x0, x8, #0x10 ; =0x10
0x1c434abfc <+24>: mov w1, #0x1
0x1c434ac00 <+28>: bl 0x1c4373ab0 ; symbol stub for: siglongjmp
Target 0: (ruby_pack) stopped.
こんな感じで実行が停止すると思います。この箇所で問題が起こってしまっているようです。ここはどの処理から呼ばれているのか調べてみます。その場合はup
とすることでスタックをたぐれます。
Target 0: (ruby_pack) stopped.
(lldb) up
frame #1: 0x00000001c435b54c libruby.2.6.dylib`rb_vm_exec + 1516
libruby.2.6.dylib`rb_vm_exec:
-> 0x1c435b54c <+1516>: mov w8, #0x8
0x1c435b550 <+1520>: str x8, [x19, #0x78]
0x1c435b554 <+1524>: add x8, x21, #0x38 ; =0x38
0x1c435b558 <+1528>: str x8, [x20, #0x18]
(lldb)
frame #2: 0x00000001c43547e4 libruby.2.6.dylib`___lldb_unnamed_symbol3671$$libruby.2.6.dylib + 420
libruby.2.6.dylib`___lldb_unnamed_symbol3671$$libruby.2.6.dylib:
-> 0x1c43547e4 <+420>: mov x20, x0
0x1c43547e8 <+424>: ldr x8, [x19, #0x10]
0x1c43547ec <+428>: add x8, x8, #0x38 ; =0x38
0x1c43547f0 <+432>: cmp x8, x28
(lldb)
frame #3: 0x00000001c435309c libruby.2.6.dylib`rb_funcallv + 360
libruby.2.6.dylib`rb_funcallv:
-> 0x1c435309c <+360>: b 0x1c4353030 ; <+252>
0x1c43530a0 <+364>: cmp x23, #0x8 ; =0x8
0x1c43530a4 <+368>: b.eq 0x1c43530d0 ; <+412>
0x1c43530a8 <+372>: cbnz x23, 0x1c4352f94 ; <+96>
(lldb)
frame #4: 0x000000010000537c ruby_pack`rutie::binding::vm::call_method::h056c3d065f1cc95b(receiver=(value = 4320197680), method=(data_ptr = "eval", length = 4), arguments=&[rutie::rubysys::value::Value] @ 0x000000016fdff1c0) at vm.rs:57:14
54 let method_id = internal_id(method);
55
56 // TODO: Update the signature of `rb_funcallv` in ruby-sys to receive an `Option`
-> 57 unsafe { vm::rb_funcallv(receiver, method_id, argc, argv) }
58 }
59
60 pub fn call_public_method(receiver: Value, method: &str, arguments: &[Value]) -> Value {
(lldb)
ある程度たぐるとrutieのコードにたどり着きます。どうやらiseq.send("eval", &[]);
としているところで問題が起きているようです。つまり実行しているRubyVM::InstructionSequence
のバイナリファイル自体に何か問題ありそうです。
またrb_vm_exec
と出ているのはRuby本体の関数名です。これからもソースから追っていくとRubyVM::InstructionSequence
を実行していそうなこともわかります。
RubyVM::InstructionSequence
のバイナリファイルが何かおかしいみたいなのでRubyで実行してみます。
Traceback (most recent call last):
4: from exec.rb:13:in `<main>'
3: from exec.rb:13:in `eval'
2: from ./csv.rb:1480:in `<main>'
1: from ./csv.rb:1481:in `<class:CSV>'
./csv.rb:1485:in `<class:Parser>': uninitialized constant CSV::Parser::StringScanner (NameError)
Did you mean? StringIO
どうやら結合したファイル中に読み込まれていないファイルがあるようです。
実はRubyのCSVライブラリの中にはRubyスクリプト以外に共有ライブラリ
を読み込んでいる箇所があります。その場合load_iseq
メソッドで$path
に配列に詰め込まれないため、読み込み対象とならずに実行が失敗していました。
ということで読み込まれている共有ライブラリも読み込むようにする必要があります。
Macの場合この共有ライブラリはbundle
ファイルであることが多いです。なので $".grep(/.*\.bundle/)
とすることで読み込んでいるライブラリを取得することができます。
["/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/enc/encdb.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/enc/trans/transdb.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/stringio.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/date_core.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/strscan.bundle"]
あとはこの必要なライブラリをrutieのVM::require()
を使えば読み込めますが、少し芸がないので別の方法で読み込んでみます。
とは言ってもRubyのrequire
が行なっている処理をRustで書いてみるというだけです。
require
Rustでの実装の前にまずrequire
の実装についてみてみます。
Rubyのrequire
は対象のファイルの種類により挙動が異なります
-
*.rbファイルの場合
...対象ファイルをRubyVM::InstructionSequence
にし、読み込む。 -
*.soや*.bundleなどの共有ライブラリの場合
...動的ロード
の仕組みを使い展開し、読み込む。
Rubyスクリプトの場合はRubyVM::InstructionSequence
にして実行することで読み込んでいます。
一方共有ライブラリの場合は動的ロードによってRubyVMで扱えるようにプログラム上に展開しています。ここで動的ロードというワードが出てきました。
動的ロード
以下のサイトが詳しいので詳細を省きますが、
ライブラリのリンク方法には以下の3つがあります- 静的リンク
- 動的リンク
- 動的ロード
このうち静的リンクと動的リンクは実行前にリンクすべきライブラリとパスはわかっていないと実行ができませんが、動的ロードの場合実際に使われる(ロードされる)タイミングまでライブラリを必要としません。
また動的ロードは処理系に依存した処理であり、Macであればdlopen()
を使ってライブラリをロードします。
実際にRubyのソースコードを見てみます。
ここがrequire
の実体となります。ここからさらに処理を追っていくと
ここのdln_load()
にたどり着きます。
ここがrequire
しているファイルがライブラリだった場合の処理です。マクロでつらつらと処理系ごとに定義を変えてますが肝心なのはここの部分です。
void*
dln_load(const char *file)
{
void *handle;
void (*init_fct)(void);
/* Load file */
if ((handle = (void*)dlopen(file, RTLD_LAZY|RTLD_GLOBAL)) == NULL){
...
}
...
init_fct = (void(*)(void))(VALUE)dlsym(handle, buf);
...
/* Call the init code */
(*init_fct)();
return handle;
}
dlopen()
を使ってファイルを読み込み読み込んだライブラリのハンドルを取得しています。
そのあとdlsym()
を使いライブラリにあるbuf
変数の名前の関数を取得し、最後に実行しています。ではbuf
にはどんな名前が入っているかというと以下のような記述があります。
#define FUNCNAME_PREFIX EXTERNAL_PREFIX"Init_"
static size_t
init_funcname_len(const char **file)
{
const char *p = *file, *base, *dot = NULL;
/* Load the file as an object one */
for (base = p; *p; p++) { /* Find position of last '/' */
if (*p == '.' && !dot) dot = p;
if (isdirsep(*p)) base = p+1, dot = NULL;
}
*file = base;
/* Delete suffix if it exists */
return (dot ? dot : p) - base;
}
static const char funcname_prefix[sizeof(FUNCNAME_PREFIX) - 1] = FUNCNAME_PREFIX;
#define init_funcname(buf, file) do {\
const char *base = (file);\
const size_t flen = init_funcname_len(&base);\
const size_t plen = sizeof(funcname_prefix);\
char *const tmp = ALLOCA_N(char, plen+flen+1);\
if (!tmp) {\
dln_memerror();\
}\
memcpy(tmp, funcname_prefix, plen);\
memcpy(tmp+plen, base, flen);\
tmp[plen+flen] = '\0';\
*(buf) = tmp;\
} while (0)
...
void*
dln_load(const char *file)
{
/* Load the file as an object one */
init_funcname(&buf, file);
...
init_funcname()
でInit_ + 読み込んだファイル名
を取得し、buf
に入れています。
実際にstringio
をみてみると
Init_stringio()
という関数があることがわかります。
以上から
-
dlopen()
を使ってライブラリを読み込む -
Init_ + ライブラリのファイル名
の関数をdlsym()
を使い取得する - 取得した関数を呼び出して初期化
という手順を踏めば良さそうです。
Rustでrequireを実装する
Rustでdlopen()
を使うにはdlopen crate
を使うといいみたいです。
exampleではstructを使って読みこむ例が紹介されてますが、スクリプトの内容によって読み込むライブラリが変わるのであらかじめstructを用意することができません。なので今回はLibrary::open()
を使います。ちょうどdlopen()
相当の関数みたいです。
let loaded_lib_names = vec!["/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/enc/encdb.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/enc/trans/transdb.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/stringio.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/cgi/escape.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/strscan.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/date_core.bundle"];
let mut libs: Vec<Library> = vec![];
for name in loaded_lib_names {
let lib = Library::open(name).unwrap();
let path = Path::new(name);
let file_name = path.file_stem().unwrap();
let init_func_name = format!("{}{}", "Init_", file_name.to_str().unwrap());
let func: unsafe extern "C" fn() = lib.symbol(&init_func_name).unwrap();
libs.push(lib);
func();
}
読み込むライブラリをVec
に詰め込み、順番に読み込みます。
読み込んだファイルはPath
を使ってファイル名を取得し、Init_
と結合してdlsym()
相当の関数であるsymbol()
で関数を取り出しています。その後関数を呼び出し初期化しています。require
の処理のままですね。
なおlib
をVec
に詰め込んでいるのはfor
のスコープでlib
がdropされてしまうの防ぐためです。
これでライブラリを読み込めるようになりました。cargo run
で実行してみます。
"hello world!!"
["Ruby", "1995"]
["Rust", "2010"]
[["Ruby", "1995"], ["Rust", "2010"]]
["Ruby", "1995"]
["Rust", "2010"]
[["Ruby", "1995"], ["Rust", "2010"]]
ちゃんとRubyスクリプトが実行できてます!
さらに*.bundle
ファイルもRubyVM::InstructionSequence
のバイナリファイルと同じようにプログラムに埋め込めます。
use once_cell::sync::Lazy;
use rutie::{Class, Encoding, Object, RString, VM};
extern crate dlopen;
use dlopen::raw::Library;
use std::{io::Write, path::Path};
use tempfile::NamedTempFile;
static mut FILES: Lazy<Vec<&[u8]>> = Lazy::new(|| {
vec![include_bytes!("/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/enc/encdb.bundle"),include_bytes!("/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/enc/trans/transdb.bundle"),include_bytes!("/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/stringio.bundle"),include_bytes!("/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/cgi/escape.bundle"),include_bytes!("/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/strscan.bundle"),include_bytes!("/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/date_core.bundle"),]
});
fn main() {
unsafe {
VM::init();
VM::init_loadpath();
let loaded_lib_names = vec!["/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/enc/encdb.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/enc/trans/transdb.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/stringio.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/cgi/escape.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/strscan.bundle", "/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/universal-darwin20/date_core.bundle"];
let mut libs: Vec<Library> = vec![];
for (i, lib_file) in FILES.iter().enumerate() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(*lib_file).unwrap();
let lib = Library::open(file.path()).unwrap();
let path = Path::new(loaded_lib_names[i]);
let file_name = path.file_stem().unwrap();
let init_func_name = format!("{}{}", "Init_", file_name.to_str().unwrap());
let func: unsafe extern "C" fn() = lib.symbol(&init_func_name).unwrap();
libs.push(lib);
func();
file.close().unwrap();
}
let iseq_class = Class::from_existing("RubyVM").get_nested_class("InstructionSequence");
let file = include_bytes!("../csv.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", &[]);
};
}
少々強引ですが、*.bundle
ファイルをバイナリとして読み込んでおき、Library::open()
する前にテンポラリファイルに書き出しそれを読み込ませています。
これでも先程と同じように実行できます。
これでパックできた……かと思いましたが、実はまだできていません。Ruby本体を詰め込めていないのです。rutieで使っているRuby本体はプログラムをコンパイルする際に動的リンク
でリンクしています。つまり実行前にRubyが必要になるため、実行環境にRubyがインストールされている必要があり、またバイナリにして詰め込むという方法も使えません。
やるならRuby本体のソースコードを入手し、同時にコンパイルするなどが必要だと思います。
まとめ
というわけで完全にパックはできませんでしたが、デバッグツールや動的ロード、Rubyの実装など色々学べることが多かったのでよしとしました。
追記
後半記事できました。
Discussion