📦

RubyスクリプトをWASMにパックしてみる

2023/09/06に公開

はじめに

この記事では、任意のRubyスクリプトをシングルバイナリにパックすることで、Rubyの実行環境がなくても実行できるように、可搬できるバイナリを作ろうという一連の記事の第三弾です。

前回までのあらすじ

前々回前回でRustのRubyをバインディングするcrateを使って、Rubyスクリプトをシングルバイナリにパックする実験をしていました。

前回手法の問題

前回までの手法は以下の二つの大きな問題があります。

  • 拡張ライブラリに対応できない
  • 動的requireに対応できない

拡張ライブラリに対応できない

前回までの手法では、スクリプトがrequireで読み込むものはRubyコードであることを前提としていたため、拡張ライブラリを読み込んでいる場合は、動作することができません。

動的requireに対応できない

ここでの動的requireとは例えば以下のようなコードです。

sample.rb
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さんのキーノート発表で印象に残っている人も多いのではないでしょうか。
https://rubykaigi.org/2022/presentations/kateinoigakukun.html#day1
この発表の中のデモでブラウザでrubyを動かしていますが、当然requireなども使われています。これはどのように実現しているのかというと、wasi-vfsというものを使ってwasmの中にrequireされるコードを含めていました。

wasi-vfs

スライド中でも説明がありましたが、wasm化したRubyの中に仮想的なファイルシステム(vfs)を構築して、その中に必要なファイルやディレクトリごとマウントすることで、RubyのコードからはrequireFile.read[2]で指定するパスがそのまま使える仕組みです。


このwasi-vfsを使い「対象のスクリプトを含めたすべてのファイルをwasi-vfs内に組み込む」ことで、一枚のスクリプトファイルにすることなく、シングルバイナリにできます。
また、動的requireの問題については、「シングルバイナリにしたいRubyコードはbundlerを使ったgem管理をしていること」を前提にすれば、bundlergemを使いgemのソースコードを全て取得して、wasi-vfsに取り込ませることができます。

パックする

パックまでの手順は、

  1. Rubyスクリプトで使われている、拡張ライブラリのソースコードを取得する
  2. 拡張ライブラリを、wasm向けにbuildする
  3. buildしたものをruby.wasmにリンクする
  4. wasi-vfsを使って、Rubyスクリプトをruby.wasmにマウントする

以上の手順でパックできそうです。

拡張ライブラリは多くはC言語で記述されていると思いますが、Rustを使った拡張もサポート[3]されるようになりました。
なので今回はC言語の他に、Rustを使った拡張ライブラリを含むスクリプトをパックしてみようと思います。
今回の記事で行ったことはこちらのレポジトリにまとめていますので、説明しきれていない点など参照してみてください。
https://github.com/ahogappa0613/ruby-pack-wasm

また、今回のスクリプトで使用している拡張ライブラリは以下のレポジトリにおいています。
https://github.com/ahogappa0613/c_lang_gem
https://github.com/ahogappa0613/rust_gem

また、実行環境は以下のとおりです。

$ 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

対象のスクリプト

今回パックするスクリプトの内容は以下のとおりです。

src/hello.rb
require 'c_lang_gem'
require 'rust_gem'

p CLangGem.hello('ahogappa')
p RustGem.hello('ahogappa')

requireしているc_lang_gemrust_gemはそれぞれC言語とRustを使った拡張ライブラリです。直接requireしているのはRubyスクリプトですが、内部で拡張ライブラリをrequireしている形になっています。

拡張ライブラリ

CLangGemとRustGemの内容は以下のとおりです。
まずはC言語の拡張ライブラリです。

c_lang.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の拡張ライブラリです。

lib.rs
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したものに手を加えていきました。
https://github.com/ruby/ruby.wasm
また、Rustの拡張ライブラリのbuildにバージョン指定された、clangwasi-sysrootが必要なので、自前buildするためにwasi-sdkもforkしておきます。
https://github.com/WebAssembly/wasi-sdk
さらにruby.wasmでは内部でwasi-vfslibwasi-vfs.aとしてbuildしたものリンクしており、通常はプリコンパイルされたものをダウンロードしてリンクしますが、今回はソースコードが欲しいので、これもforkしておきます。
https://github.com/kateinoigakukun/wasi-vfs

bundler gemを使って拡張ライブラリのソースコードを取得する

Gemfileに記述されたgemはbundlergemを使うことでさまざまな情報を取得できます。

bundler
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、リンクしています。なのでこの流れに乗ってあげれば勝手にリンクまでしてくれます。
bundlergemを使って拡張ライブラリのソースコードを取得すると、extconf.rbがあります。このファイルを実行することでmakefileを作成することができます。
このmakeを実行すると、通常は*.bundleというファイルが出来上がります。今回は*.aが欲しいのでmake c_lang_gem.aを実行[4]します。また、extconf.rbを実行するときにディレクトリにdependというファイルがあると、作成するmakefileの最終行に内容がコピーされます。今回は内容としては以下のようなものを用意します。

depend
link.filelist:
	echo $(foreach obj,$(OBJS),$(abspath $(obj))) > $@

c_lang_gem.a: link.filelist

c_lang_gem.aをターゲットとしてmakeするとlink.filelistファイルを生成するように指示します。このファイルはリンク時に使用します。

Rustの拡張ライブラリをbuildする

Rustの拡張ライブラリの方は以下のようなオプションでコンパイルします。

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の拡張ライブラリで使用しているmagnuscrate[5]で必要なruby.hconfig.hなどの位置を指定しています。また、wasm向けにbuildする場合wasi-sdkにあるwasi-sysroot以下のファイルにあるstdio.hなどを使うように指定します。
後半のrustcwasm32-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.alibrust_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を書き換えます。

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" }

dependenciespathを使って指定することで、ローカルにあるRustをcrateとして使うことができます。
lib.rsは以下のように書き換えます。

lib.rs
pub use wasi_vfs;
pub use rust_gem;

それぞれでエクスポートされているものはそのままにしたいため、再エクスポートするだけにしています。
また、wasi_vfsrust_gemCargo.tomlcrate-typerlibを追加して、外部ライブラリとして呼び出せるようにしておきます。

こうすることで、二つのプログラムを一つのプログラムとしてbuildすることができます。

改めてパックする

wasi_vfsrust_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.wasmwasmtimeを使って実行します。

ruby.wasmを実行する
$ 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化するにはまだまだですが、今後も調査と実装を続けていこうと思います。

脚注
  1. これ以外にも自作のリンカーを作ってみるやrequireの挙動をパッチしてJITのように読み込むなどのアドバイスをいただけました。ちなみにご本人曰く「rubyistの中で一番シングルバイナリについて考えている」とのことです。 ↩︎

  2. 現時点ではIO#writeなどの書き込みはできません。 ↩︎

  3. bundle gem gem_name --ext=rustとすることでRustを使った拡張ライブラリのスケルトンが生成されるようになったため、サポートと表現しています。 ↩︎

  4. この他にもwasmの生成に対応したclangやリンカの指定が必要ですが、今回は割愛します。 ↩︎

  5. Rubyのバイディングを提供するcrateです。 ↩︎

Discussion