📦

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

19 min read

先日唐突にRubyスクリプトを実行可能な1ファイルにパックして単一実行ファイルにしたくなったので、色々調べて実装してみました。

なおRustとありますが主な実装はRustのRubyバインディングであるrutieを使っています。

まずパックするにあたってRubyにはRubyVM::InstructionSequenceという存在があることを思い出しました。

RubyVM::InstructionSequence

https://docs.ruby-lang.org/ja/latest/class/RubyVM=3a=3aInstructionSequence.html

Ruby の Virtual Machine のコンパイル済みの命令シーケンスを表すクラスです。

とありますが、具体的にはどんなものでしょうか。

hello.rb
p 'hello world!!'

これを以下のようなスクリプトでRubyVM::InstructionSequenceに変換してみます。

iseq.rb
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使うことでバイナリファイルにすることができます。
バイナリファイルにすることで以下のように実行することができます。

exec.rb
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も同じ手法を用いてあらかじめソースをコンパイルすることで実行を高速化しています。

rutieを使いRubyVM::InstructionSequenceを実行する

ではRubyVM::InstructionSequenceに変換したRubyスクリプトをRustから実行してみようと思います。

main.rs
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で実現した形です。
このコードを実行すると先ほどと同じように実行できると思います。

スクリプトの結合

今回は以下のスクリプトをパックしてみます

csv.rb
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で読み込まれているスクリプト全てを一つのスクリプトに結合してからバイナリファイルにする必要があります。
そこでこんなコードを書いてみます。

main.rb
$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::InstructionSequenceload_iseqというメソッドを生やしています。このメソッドがあるとrequireが実行されるたびにこのメソッドの内容を実行し、その結果をrequireの結果とします。今回は$pathというグローバル変数に読み込まれたファイルを追加していきます。

なおこのメソッドが呼ばれるのはrequireの対象がRubyスクリプトの場合のみであり、*.so*.bundleなどの共有ライブラリファイルの場合は呼ばれません。

paths = $".grep(/.*\.bundle/) + ($" & $path)では読み込んだスクリプトとライブラリのパスをもう一度配列に詰め直しています。
$"はRubyの特殊変数の一つで読み込まれた全てのRubyスクリプト、ライブラリのパスを配列で返します。ここには直接使われていないスクリプト(例えばthread.rbenumerator.so)も含まれています。
この配列とrequireで呼ばれたファイルの配列と論理積を取って$"から読み込まれたファイルを抜き出しています。

ところでrequireによる読み込みは

  • ファイルAがファイルBを読み込む
  • ファイルBがファイルCとDを読み込む
  • ファイルCが...
    というような形となり多分木構造となります。
graph TB
    A[ファイルA] -->|require| B[ファイルB]
    B -->|require| C[ファイルC]
    B -->|require| D[ファイルD]
    C -->|require| E[ファイルE]
    C -->|require| F[ファイルF]
    C -->|require| G[ファイルG]

依存関係を正しく解決するためには末端から幅優先で読み込む必要があります。
$pathの配列では[A, B, C, D, E, F, G]と並んでいるためこのままでは依存関係を正しく解決できません。そのため$"から抜き出す必要があります。

後半のコードで実際に一つのスクリプトに結合しています。今回のコードではthread.rb*.soVM::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で扱えるようにプログラム上に展開しています。ここで動的ロードというワードが出てきました。

動的ロード

以下のサイトが詳しいので詳細を省きますが、

http://www.rcc.ritsumei.ac.jp/2017/1217_8241/
ライブラリのリンク方法には以下の3つがあります
  • 静的リンク
  • 動的リンク
  • 動的ロード

このうち静的リンクと動的リンクは実行前にリンクすべきライブラリとパスはわかっていないと実行ができませんが、動的ロードの場合実際に使われる(ロードされる)タイミングまでライブラリを必要としません。

また動的ロードは処理系に依存した処理であり、Macであればdlopen()を使ってライブラリをロードします。
実際にRubyのソースコードを見てみます。

https://github.com/ruby/ruby/blob/378e8cdad69e6ba995a024da2957719789f0679e/load.c#L1203
ここがrequireの実体となります。ここからさらに処理を追っていくと
https://github.com/ruby/ruby/blob/550b02e4790c406450008e3bbbf28d8982cc0908/dln.c#L302
ここのdln_load()にたどり着きます。
ここがrequireしているファイルがライブラリだった場合の処理です。マクロでつらつらと処理系ごとに定義を変えてますが肝心なのはここの部分です。
dln.c
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にはどんな名前が入っているかというと以下のような記述があります。

dln.c
#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をみてみると

https://github.com/ruby/stringio/blob/a88c070e0b6b7cc3a9f263f2e92ab5aee6801a2b/ext/stringio/stringio.c#L1753
Init_stringio()という関数があることがわかります。

以上から

  1. dlopen()を使ってライブラリを読み込む
  2. Init_ + ライブラリのファイル名の関数をdlsym()を使い取得する
  3. 取得した関数を呼び出して初期化

という手順を踏めば良さそうです。

Rustでrequireを実装する

Rustでdlopen()を使うにはdlopen crateを使うといいみたいです。

https://docs.rs/dlopen/0.1.8/dlopen/
exampleではstructを使って読みこむ例が紹介されてますが、スクリプトの内容によって読み込むライブラリが変わるのであらかじめstructを用意することができません。なので今回はLibrary::open()を使います。ちょうどdlopen()相当の関数みたいです。
main.rs
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の処理のままですね。
なおlibVecに詰め込んでいるのは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のバイナリファイルと同じようにプログラムに埋め込めます。

main.rs
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

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