RubyスクリプトをWASMにパックしてみる
はじめに
この記事では、任意のRubyスクリプトをシングルバイナリにパックすることで、Rubyの実行環境がなくても実行できるように、可搬できるバイナリを作ろうという一連の記事の第三弾です。
前回までのあらすじ
前々回、前回でRustのRubyをバインディングするcrateを使って、Rubyスクリプトをシングルバイナリにパックする実験をしていました。
前回手法の問題
前回までの手法は以下の二つの大きな問題があります。
- 拡張ライブラリに対応できない
- 動的requireに対応できない
拡張ライブラリに対応できない
前回までの手法では、スクリプトがrequire
で読み込むものはRubyコードであることを前提としていたため、拡張ライブラリを読み込んでいる場合は、動作することができません。
動的requireに対応できない
ここでの動的require
とは例えば以下のようなコードです。
def hoge
require 'csv'
end
hoge
前回までの手法だと、スクリプトを読み込んだ時にrequire
されているものを連結して一枚のファイルにしていますが、上記のコードのように特定のメソッドを実行したときに読み込まれる場合や、条件付きで読み込まれるような場合はrequire
が実行されない場合があるため、ファイルに含めることができない場合があります。
つまり、あるライブラリの全ファイルが10ファイルあり、単に読み込んだだけだと7ファイルしか読み込まない場合、残りの3ファイルも含めて一枚のファイルにする必要がありますが、この時点では残りのファイル名と格納場所はわかりません。
解決方法
拡張ライブラリを読み込むためには、その内容をバイナリに含めdlopen
で読み込む必要がありますが、require
の対象が拡張ライブラリかどうか判断することができなません。
また、動的require
は例えばライブラリである場合、関連するファイルすべてからrequire
されているものを精査して、最終結果に含める必要があります。
どちらの問題も、Rubyのコードを静的解析なり、正規表現なりで頑張ればできそうですが、もう少しスマートなやり方を考えていました。
そんな中、RubyKaigi 2023に参加し、naruseさんに相談したところ、「ruby.wasmを使ってみたらどうか[1]」というヒントをもらえました。
ruby.wasm
ruby.wasmとはRubyをwasmにコンパイルして動かすプロジェクトで、RubyKaigi 2022でkateinoigakukunさんのキーノート発表で印象に残っている人も多いのではないでしょうか。require
なども使われています。これはどのように実現しているのかというと、wasi-vfs
というものを使ってwasmの中にrequire
されるコードを含めていました。
wasi-vfs
スライド中でも説明がありましたが、wasm化したRubyの中に仮想的なファイルシステム(vfs)を構築して、その中に必要なファイルやディレクトリごとマウントすることで、Rubyのコードからはrequire
やFile.read
[2]で指定するパスがそのまま使える仕組みです。
このwasi-vfsを使い「対象のスクリプトを含めたすべてのファイルをwasi-vfs内に組み込む」ことで、一枚のスクリプトファイルにすることなく、シングルバイナリにできます。
また、動的require
の問題については、「シングルバイナリにしたいRubyコードはbundler
を使ったgem管理をしていること」を前提にすれば、bundler
gemを使いgemのソースコードを全て取得して、wasi-vfs
に取り込ませることができます。
パックする
パックまでの手順は、
- Rubyスクリプトで使われている、拡張ライブラリのソースコードを取得する
- 拡張ライブラリを、wasm向けにbuildする
- buildしたものを
ruby.wasm
にリンクする -
wasi-vfs
を使って、Rubyスクリプトをruby.wasm
にマウントする
以上の手順でパックできそうです。
拡張ライブラリは多くはC言語で記述されていると思いますが、Rustを使った拡張もサポート[3]されるようになりました。
なので今回はC言語の他に、Rustを使った拡張ライブラリを含むスクリプトをパックしてみようと思います。
今回の記事で行ったことはこちらのレポジトリにまとめていますので、説明しきれていない点など参照してみてください。
また、今回のスクリプトで使用している拡張ライブラリは以下のレポジトリにおいています。
また、実行環境は以下のとおりです。
$ uname -a
Darwin ahogappAir.local 23.0.0 Darwin Kernel Version 23.0.0: Tue Aug 1 03:25:17 PDT 2023; root:xnu-10002.0.242.0.6~31/RELEASE_ARM64_T8103 arm64
対象のスクリプト
今回パックするスクリプトの内容は以下のとおりです。
require 'c_lang_gem'
require 'rust_gem'
p CLangGem.hello('ahogappa')
p RustGem.hello('ahogappa')
require
しているc_lang_gem
とrust_gem
はそれぞれC言語とRustを使った拡張ライブラリです。直接require
しているのはRubyスクリプトですが、内部で拡張ライブラリをrequire
している形になっています。
拡張ライブラリ
CLangGemとRustGemの内容は以下のとおりです。
まずはC言語の拡張ライブラリです。
#include "c_lang_gem.h"
VALUE rb_mCLangGem;
VALUE
hello(VALUE self, VALUE subject)
{
Check_Type(subject, T_STRING);
int size = 21 + RSTRING_LEN(subject);
char buf[size];
snprintf(buf, sizeof buf, "Hello from C Lang, %s!", StringValueCStr(subject));
return rb_str_new(buf, size - 1);
}
void Init_c_lang_gem(void)
{
rb_mCLangGem = rb_define_module("CLangGem");
rb_define_singleton_method(rb_mCLangGem, "hello", hello, 1);
}
次にRustの拡張ライブラリです。
use magnus::{define_module, function, prelude::*, Error};
fn hello(subject: String) -> String {
format!("Hello from Rust, {}!", subject)
}
#[magnus::init]
fn init() -> Result<(), Error> {
let module = define_module("RustGem")?;
module.define_singleton_method("hello", function!(hello, 1))?;
Ok(())
}
Rustのコードはbundle gem RustGem --ext=rust
で自動生成された内容のものです。C言語のコードはRustのコードを参考に同じ内容のものを実装しました。どちらもクラスメソッドhello
が生えているので、これに文字列を渡すと標準出力に表示されるだけの簡単なライブラリです。
拡張ライブラリをWASIターゲットでbuildする
今回はruby.wasm
レポジトリをforkしたものに手を加えていきました。
また、Rustの拡張ライブラリのbuildにバージョン指定された、clang
やwasi-sysroot
が必要なので、自前buildするためにwasi-sdk
もforkしておきます。
さらにruby.wasm
では内部でwasi-vfs
をlibwasi-vfs.a
としてbuildしたものリンクしており、通常はプリコンパイルされたものをダウンロードしてリンクしますが、今回はソースコードが欲しいので、これもforkしておきます。
bundler gemを使って拡張ライブラリのソースコードを取得する
Gemfile
に記述されたgemはbundler
gemを使うことでさまざまな情報を取得できます。
require 'bundler'
Bundler.load.specs.reject { |spec| spec.name == "bundler" }.each do |spec|
spec.extensions # => C言語であればextconf.rb, RustであればCargo.tomlのパスがとれれる
spec.full_gem_path # => bundlerでインストールしたgemのディレクトリのパスが取れる
end
これらの情報をもとに拡張ライブラリのソースコードなどをコピーしてきて、buildしていきます。
C言語の拡張ライブラリをbuildする
C言語の拡張ライブラリですが、実はruby.wasm
にはブラウザで動かした時にJavaScript
と連携するために必要なライブラリをC言語を使って実装しており、Rubyのwasmバイナリを生成する際にそのライブラリもbuild、リンクしています。なのでこの流れに乗ってあげれば勝手にリンクまでしてくれます。
bundler
gemを使って拡張ライブラリのソースコードを取得すると、extconf.rb
があります。このファイルを実行することでmakefile
を作成することができます。
このmake
を実行すると、通常は*.bundle
というファイルが出来上がります。今回は*.a
が欲しいのでmake c_lang_gem.a
を実行[4]します。また、extconf.rb
を実行するときにディレクトリにdepend
というファイルがあると、作成するmakefile
の最終行に内容がコピーされます。今回は内容としては以下のようなものを用意します。
link.filelist:
echo $(foreach obj,$(OBJS),$(abspath $(obj))) > $@
c_lang_gem.a: link.filelist
c_lang_gem.a
をターゲットとしてmake
するとlink.filelist
ファイルを生成するように指示します。このファイルはリンク時に使用します。
Rustの拡張ライブラリをbuildする
Rustの拡張ライブラリの方は以下のようなオプションでコンパイルします。
$ RBCONFIG_CPPFLAGS='-D_WASI_EMULATED_SIGNAL -D_WASI_EMULATED_MMAN -D_WASI_EMULATED_GETPID -D_WASI_EMULATED_PROCESS_CLOCKS -I/Users/ahogappa/project/wasi-sdk/build/install/opt/wasi-sdk/share/wasi-sysroot/include' RBCONFIG_rubyarchhdrdir='/Users/ahogappa/project/ruby.wasm/build/wasm32-unknown-wasi/wasm-pack-ruby/.ext/include/wasm32-wasi' RBCONFIG_rubyhdrdir='/Users/ahogappa/project/ruby.wasm/build/arm64-apple-darwin23/baseruby-3_2/opt/include/ruby-3.2.0' cargo rustc --target=wasm32-wasi --release --crate-type=staticlib
今回のRustの拡張ライブラリをwasm向けにbuildするためには、
- Rubyヘッダファイル
- 標準ヘッダファイル
が必要になってきます。そのため前半の環境変数では、Rustの拡張ライブラリで使用しているmagnus
crate[5]で必要なruby.h
、config.h
などの位置を指定しています。また、wasm向けにbuildする場合wasi-sdk
にあるwasi-sysroot
以下のファイルにあるstdio.h
などを使うように指定します。
後半のrustc
でwasm32-wasi
、--crate-type=staticlib
を指定することでwasi-vfs
と同様にwasm向けのアーカイブファイル(librust_gem.a)を成果物としてbuildすることができます。
この成果物を、ruby.wasm
でbuildできるRubyのwasmバイナリに対してリンクをすれば、Rustの拡張ライブラリを含めることができるはずです。
しかし、Rustの拡張ライブラリをbuildしたものをリンクすると以下のようなエラーが発生します。
wasm-ld: error: duplicate symbol: __rdl_oom
>>> defined in /Users/ahogappa/project/wasi-vfs/target/wasm32-wasi/release/libwasi_vfs.a(alloc-a61d2e368214e80a.alloc.5635bdbc6ab855af-cgu.0.rcgu.o)
>>> defined in /Users/ahogappa/project/ruby-pack-wasm/ruby.wasm/build/wasm32-unknown-wasi/wasm-pack-ruby-ext/rust_gem/target/wasm32-wasi/release/librust_gem.a(alloc-6a5e456ae6a6403a.alloc.725ccee3e364d81a-cgu.0.rcgu.o)
wasm-ld: error: duplicate symbol: __rdl_alloc
>>> defined in /Users/ahogappa/project/wasi-vfs/target/wasm32-wasi/release/libwasi_vfs.a(std-fa3586209db28651.std.6a84f8cdcf46d7fd-cgu.0.rcgu.o)
>>> defined in /Users/ahogappa/project/ruby-pack-wasm/ruby.wasm/build/wasm32-unknown-wasi/wasm-pack-ruby-ext/rust_gem/target/wasm32-wasi/release/librust_gem.a(std-b560b09efd464f9b.std.d161650ff73d9ac5-cgu.0.rcgu.o)
wasm-ld: error: duplicate symbol: __rdl_dealloc
>>> defined in /Users/ahogappa/project/wasi-vfs/target/wasm32-wasi/release/libwasi_vfs.a(std-fa3586209db28651.std.6a84f8cdcf46d7fd-cgu.0.rcgu.o)
>>> defined in /Users/ahogappa/project/ruby-pack-wasm/ruby.wasm/build/wasm32-unknown-wasi/wasm-pack-ruby-ext/rust_gem/target/wasm32-wasi/release/librust_gem.a(std-b560b09efd464f9b.std.d161650ff73d9ac5-cgu.0.rcgu.o)
wasm-ld: error: duplicate symbol: __rdl_realloc
>>> defined in /Users/ahogappa/project/wasi-vfs/target/wasm32-wasi/release/libwasi_vfs.a(std-fa3586209db28651.std.6a84f8cdcf46d7fd-cgu.0.rcgu.o)
>>> defined in /Users/ahogappa/project/ruby-pack-wasm/ruby.wasm/build/wasm32-unknown-wasi/wasm-pack-ruby-ext/rust_gem/target/wasm32-wasi/release/librust_gem.a(std-b560b09efd464f9b.std.d161650ff73d9ac5-cgu.0.rcgu.o)
wasm-ld: error: duplicate symbol: __rdl_alloc_zeroed
>>> defined in /Users/ahogappa/project/wasi-vfs/target/wasm32-wasi/release/libwasi_vfs.a(std-fa3586209db28651.std.6a84f8cdcf46d7fd-cgu.0.rcgu.o)
>>> defined in /Users/ahogappa/project/ruby-pack-wasm/ruby.wasm/build/wasm32-unknown-wasi/wasm-pack-ruby-ext/rust_gem/target/wasm32-wasi/release/librust_gem.a(std-b560b09efd464f9b.std.d161650ff73d9ac5-cgu.0.rcgu.o)
wasm-ld: error: duplicate symbol: __rust_drop_panic
>>> defined in /Users/ahogappa/project/wasi-vfs/target/wasm32-wasi/release/libwasi_vfs.a(std-fa3586209db28651.std.6a84f8cdcf46d7fd-cgu.0.rcgu.o)
>>> defined in /Users/ahogappa/project/ruby-pack-wasm/ruby.wasm/build/wasm32-unknown-wasi/wasm-pack-ruby-ext/rust_gem/target/wasm32-wasi/release/librust_gem.a(std-b560b09efd464f9b.std.d161650ff73d9ac5-cgu.0.rcgu.o)
wasm-ld: error: duplicate symbol: __rust_foreign_exception
>>> defined in /Users/ahogappa/project/wasi-vfs/target/wasm32-wasi/release/libwasi_vfs.a(std-fa3586209db28651.std.6a84f8cdcf46d7fd-cgu.0.rcgu.o)
>>> defined in /Users/ahogappa/project/ruby-pack-wasm/ruby.wasm/build/wasm32-unknown-wasi/wasm-pack-ruby-ext/rust_gem/target/wasm32-wasi/release/librust_gem.a(std-b560b09efd464f9b.std.d161650ff73d9ac5-cgu.0.rcgu.o)
wasm-ld: error: duplicate symbol: rust_begin_unwind
>>> defined in /Users/ahogappa/project/wasi-vfs/target/wasm32-wasi/release/libwasi_vfs.a(std-fa3586209db28651.std.6a84f8cdcf46d7fd-cgu.0.rcgu.o)
>>> defined in /Users/ahogappa/project/ruby-pack-wasm/ruby.wasm/build/wasm32-unknown-wasi/wasm-pack-ruby-ext/rust_gem/target/wasm32-wasi/release/librust_gem.a(std-b560b09efd464f9b.std.d161650ff73d9ac5-cgu.0.rcgu.o)
wasm-ld: error: duplicate symbol: rust_panic
>>> defined in /Users/ahogappa/project/wasi-vfs/target/wasm32-wasi/release/libwasi_vfs.a(std-fa3586209db28651.std.6a84f8cdcf46d7fd-cgu.0.rcgu.o)
>>> defined in /Users/ahogappa/project/ruby-pack-wasm/ruby.wasm/build/wasm32-unknown-wasi/wasm-pack-ruby-ext/rust_gem/target/wasm32-wasi/release/librust_gem.a(std-b560b09efd464f9b.std.d161650ff73d9ac5-cgu.0.rcgu.o)
wasm-ld: error: duplicate symbol: __rg_oom
>>> defined in /Users/ahogappa/project/wasi-vfs/target/wasm32-wasi/release/libwasi_vfs.a(std-fa3586209db28651.std.6a84f8cdcf46d7fd-cgu.0.rcgu.o)
>>> defined in /Users/ahogappa/project/ruby-pack-wasm/ruby.wasm/build/wasm32-unknown-wasi/wasm-pack-ruby-ext/rust_gem/target/wasm32-wasi/release/librust_gem.a(std-b560b09efd464f9b.std.d161650ff73d9ac5-cgu.0.rcgu.o)
error: duplicate symbol
はリンク時に同名の関数などが複数存在することになってしまうために、どちらのシンボルを使うかわからないためエラーとなっているということです。defined in
に続くものが被っているオブジェクトファイルですが、libwasi_vfs.a
とlibrust_gem.a
が被ってしまっています。どうやらRustの拡張ライブラリをリンクしようとすると、wasi-vfs
とシンボルが被ってしまうようです。
wasi-vfsとRust拡張ライブラリ
wasi-vfs
もRustの拡張ライブラリもどちらもcrate-type=staticlib
でbuildしていました。staticlib
でbuildしたものはそれ単体で動作する必要があるため、buildした成果物にRustランタイムが含まれています。当然、リンク時にどちらにも同名のRustランタイムが存在するため、エラーとなっていました。
一番シンプルに回避する方法としては、どちらかのライブラリから該当するシンボルを削除することですが、wasmに対応したstripではうまく削除できませんでした。
この問題は、「二つのRustプログラムを別々にstaticlib
としてbuildしている」ことが問題であるので、「二つのRustプログラムを一つに結合してからstaticlib
としてbuildする」ことで回避できます。
Rustプログラムを結合する
「二つのRustプログラムを一つに結合」というのは、二つのプログラムを使用したライブラリを新たに作れば良いだけです。まずはcargo new lib_rust --lib
でRustプロジェクトを作成しCargo.toml
を書き換えます。
...
[lib]
crate-type = ["staticlib"]
[dependencies]
rust_gem = { path = "/Users/ahogappa/project/rust_gem" }
wasi_vfs = { path = "/Users/ahogappa/project/wasi-vfs", package = "wasi-vfs" }
dependencies
でpath
を使って指定することで、ローカルにあるRustをcrateとして使うことができます。
lib.rs
は以下のように書き換えます。
pub use wasi_vfs;
pub use rust_gem;
それぞれでエクスポートされているものはそのままにしたいため、再エクスポートするだけにしています。
また、wasi_vfs
とrust_gem
のCargo.toml
のcrate-type
にrlib
を追加して、外部ライブラリとして呼び出せるようにしておきます。
こうすることで、二つのプログラムを一つのプログラムとしてbuildすることができます。
改めてパックする
wasi_vfs
とrust_gem
を一つにしたものをlibwasi-vfs.a
としてruby.wasm
にリンクします。あとは、ruby.wasm
のREADMEにあるように、wasi-vfs
を使ってマウントしたいディレクトリを設定してあげます。
$ wasi-vfs pack ruby.wasm/rubies/wasm-pack-ruby/usr/local/bin/ruby --mapdir /src::./src --mapdir /usr::./ruby.wasm/rubies/wasm-pack-ruby/usr -o ruby.wasm
ホストの./src
ディレクトリにあるファイルがruby.wasm
内の/src
ディレクトリにマウントされます。同様に/usr
にもホストの内容をマウントします。マウントした内容をruby.wasm
として出力します。
出来上がったruby.wasm
をwasmtime
を使って実行します。
$ wasmtime ruby.wasm -- /src/hello.rb
"Hello from C Lang, ahogappa!"
"Hello from Rust, ahogappa!"
ruby src/hello.rb
と同じ結果を得られました!
当然ブラウザでも動作します。
まとめ
ruby.wasm
を使って、Rubyスクリプト全体をwasmに取り込ませることによって、前々回と前回の問題を解決しつつシングルバイナリにすることができました
また、wasmとしたことで副次的にマルチプラットフォーム対応もすることができました。
細かい箇所で考慮不足な部分も多くあり、gem化するにはまだまだですが、今後も調査と実装を続けていこうと思います。
-
これ以外にも自作のリンカーを作ってみるやrequireの挙動をパッチしてJITのように読み込むなどのアドバイスをいただけました。ちなみにご本人曰く「rubyistの中で一番シングルバイナリについて考えている」とのことです。 ↩︎
-
現時点ではIO#writeなどの書き込みはできません。 ↩︎
-
bundle gem gem_name --ext=rust
とすることでRustを使った拡張ライブラリのスケルトンが生成されるようになったため、サポートと表現しています。 ↩︎ -
この他にもwasmの生成に対応したclangやリンカの指定が必要ですが、今回は割愛します。 ↩︎
-
Rubyのバイディングを提供するcrateです。 ↩︎
Discussion