wasi-vfsでパックしたバイナリ(CRuby)を眺めてみた
wasi-vfsでパックしたバイナリ(CRuby)を眺めてみた
Ruby 3.2.0でWASIベースのWasmへのコンパイルがサポートされた。
その中でも個人的に気になったのはこの記載。
さらに、WASIの上にVFSを実装しました。これにより、Rubyアプリを単一の.wasmファイルに容易にパッケージ化できます。Rubyアプリの配布が少し簡単になります。
どういうバイナリになっているのかなど、気になったので調べてみたいと思う。
WASIの上にVFSとは?
VFS
というと真っ先に思い浮かんだのが、Unix系のシステムで聞く仮想ファイルシステム(Virtual File System)。
おそらくこれと同等の概念であり、ファイルを読み書きする共通のインターフェースによって多態性を実現しているもの。
これによって、Wasmをホスト上で実行するかブラウザ上で実行するかに関わらず、ファイルシステムの読み書き同様の動きが可能になる。
具体的には、ブラウザ上でも 任意のファイルパスから、該当するファイルの内容を読み出す といったことができるようになる。
なぜVFSが必要だったのか
VFSが必要になった背景は、RubyKaigi2022の Ruby meets WebAssembly のパート(23:00
辺り)で説明されている。
超雑に要約するとこんな感じ。(勝手な想像も盛り盛り配合されています)
🤩 CRuby
がWasm
で動くようになったぞ!!
⬇
😮 インタプリタだから、処理系だけじゃなくてスクリプトファイル(.rb
ファイル)も一緒に配布しないといけなくない!?
⬇
🤔 ホスト上にスクリプト配置して実行してね!はちょっと面倒じゃない!?
⬇
😃 Wasm
のバイナリにファイルも一緒に埋め込んじゃえば良いのでは!?
⬇
🤗 そんでもって、 CRuby
がホスト上でファイルを読み出すインターフェース で埋め込んだファイルも読み出せるようにしちゃえば良いんじゃない!?
つまり 実行したいスクリプトも埋め込んじゃって、ワンバイナリでWasm
のCRuby
でアプリケーションを配布 できてしまうということ。
ちなみに、上記の動画内でも説明されているが、この仕組みはCRuby
に限った話ではなく Wasmに処理したいファイルも埋め込みたい ケースであれば効果あり。(動画内でもCPython
が動いたと言われている)
この仕組みを実現するために開発されたのが、wasi-vfs
というソフトウェア。
wasi-vfs
wasi-vfs
は、任意のパスでファイルをWasm
バイナリに埋め込んで、WASIのインターフェースから埋め込んだファイルを参照するためのコードを差し込んでくれる。
使ってみる
とりあえず使ってみる。
公式リポジトリのGetting started with CRuby
を参考にやっていく。
wasi-vfsのインストール
# WASI_VFSのバージョン指定
$ export WASI_VFS_VERSION=0.2.0
# ダウンロードして展開
$ curl -LO "https://github.com/kateinoigakukun/wasi-vfs/releases/download/v${WASI_VFS_VERSION}/wasi-vfs-cli-x86_64-unknown-linux-gnu.zip"
$ unzip wasi-vfs-cli-x86_64-unknown-linux-gnu.zip
Wasmビルド済みのCRubyをダウンロードして展開
# ビルド済みのCRuby(Wasm)をダウンロードして展開
$ curl -LO https://github.com/ruby/ruby.wasm/releases/download/2022-03-28-a/ruby-head-wasm32-unknown-wasi-full.tar.gz
$ tar xfz ruby-head-wasm32-unknown-wasi-full.tar.gz
# ruby.wasmにrename
$ mv head-wasm32-unknown-wasi-full/usr/local/bin/ruby ruby.wasm
Packing
# ソースコード用のディレクトリを作って、スクリプトを用意
$ mkdir src
$ echo "puts 'Hello, wasi-vfs'" > src/my_app.rb
# CRubyのバイナリに埋め込み
$ wasi-vfs pack ruby.wasm --mapdir /src::./src --mapdir /usr::./head-wasm32-unknown-wasi-full/usr -o my-ruby-app.wasm
# 引数に埋め込んだRubyスクリプトのパスを指定して実行
$ wasmer my-ruby-app.wasm -- /src/my_app.rb
Hello, wasi-vfs
ワンバイナリで実行できている。SUGOI。
バイナリはどうなっているのか?
実際に手元で動かすことができた所で、どんなバイナリになっているのか気になるので少し潜ってみる。
何が埋め込まれている?
ますは何が埋め込まれているのか気になる。
バイナリを直接読むのはボリューム的にもしんどいので、WAT
に変換してから中身を見てみる。
# WATに変換 (wasm2watのインストールは割愛)
$ ./wasm2wat path/to/my-ruby-app.wasm -o path/to/wat-file
吐き出されたWAT
ファイルを眺めてみると、データセクションに ソースコード(puts 'Hello, wasi-vfs'
) や ソースファイルのパス(my_app.rb
) が見える。
...
(data (;10456;) (i32.const 20576048) "S.UTF-8\00\10\10\00\00\22\00\00\00puts 'Hello, wasi-vfs!'\0a./wa\11\00\00\00\88\e59\01\18\c99\00\10\00\00\00\12\00\00\00\00KIN\18\00\00\00@\f79\01\13\00\00\000\e69\01p\f79\01\00\00\00\00\13\00\00\00\80\f79\01\a0\f79\01\00\00\00\00\13\00\00\00my_app.rb\00\00\00\11\10\00\00x\06?\01(\e79\01")
...
どうやらデータセクションにファイルの内容やパスが丸々埋め込まれているようだ。
ただ、 ソースファイルやパスが埋め込まれているだけ ではVFSのように機能しない。
その辺については、wasi-vfs/crates/wasi-vfs-cli/src/module_link.rs
に、 上記の他にどんなものが埋め込まれているのか がコメントで書いてある。
めちゃくちゃざっくりまとめるとwasi-vfs
はトランポリンコードを埋め込んでいて、これによってfd_read
というインターフェースから様々な挙動にジャンプすることができるようになっている。
トランポリンコードの挙動はこんな感じ。
-
wasi-vfs
で埋め込み前-
wasi-libc
のfd_read
が呼ばれた時、そのままホスト上のファイルシステムに対して読み込む処理にジャンプする
-
-
wasi-vfs
で埋め込み後-
wasi-libc
のfd_read
が呼ばれた時、wasi_vfs_fd_read
が呼ばれ別のWASI
実装($wasi_vfs_fd_read.command_export
)にジャンプする
-
どうやって埋め込んでいる?
なにが埋め込まれているのかざっくりとわかったので、実際にどうやって埋め込んでいるのかwasi-vfs
のソースコードをチラ見してみる。
wasi-vfs pack
が実行されたときに呼ばれるのは、wasi-vfs/crates/wasi-vfs-cli/src/lib.rs:49
の部分。
...
App::Pack {
input,
map_dirs,
output,
} => {
...
for (guest_dir, host_dir) in map_dirs {
wizer.map_dir(guest_dir, host_dir);
}
let wasm_bytes = std::fs::read(&input)?;
let output_bytes = wizer.run(&wasm_bytes)?;
std::fs::write(output, output_bytes)?;
}
...
見た感じ、埋め込む処理が記載されているのはwizer
というクレートらしい。
map_dir
に 埋め込み元のディレクトリ と 埋め込み先のディレクトリ のパスを渡しているのが確認できる。
Wizer
map_dir
の細かい処理について見ていく前に、Wizer
について軽く見てみる。
The WebAssembly Pre-Initializer!
Wizer
はWasm
のPre-Initializer
というもので、Wasm
のロード時に行われる初期化処理を事前に実行し、初期化済みのスナップショットをWasm
に書き出すらしい。
これによって、ロード時間が短縮されパフォーマンスが向上するとのこと。
この初期化のタイミングで、関数をrenameしたりディレクトリをバイナリにマップするなどの処理を組み込むことができる。
Wizer::map_dir
実際にwasi-vfs
から呼ばれているmap_dir
メソッドはwizer/src/lib.rs:413
にある。
...
/// When using WASI during initialization, which guest directories should be
/// mapped to a host directory?
///
/// The `map_dir` method differs from `dir` in that it allows giving a custom
/// guest name to the directory that is different from its name in the host.
///
/// None are mapped by default.
pub fn map_dir(
&mut self,
guest_dir: impl Into<PathBuf>,
host_dir: impl Into<PathBuf>,
) -> &mut Self {
self.map_dirs.push((guest_dir.into(), host_dir.into()));
self
}
...
やっている事自体は、マッピングする対象のディレクトリを配列に追加しているだけ。
ここで追加されたディレクトリは実際に初期化処理を実行するタイミングで参照される。
Wizer::run
run
メソッドが定義されているのは、wizer/src/lib.rs:460
。
ここはmap_dir
も含め諸々設定されたオプションを用いて、実際に初期化処理を実行する所。
...
/// Initialize the given Wasm, snapshot it, and return the serialized
/// snapshot as a new, pre-initialized Wasm module.
pub fn run(&self, wasm: &[u8]) -> anyhow::Result<Vec<u8>> {
// Parse rename spec.
let renames = FuncRenames::parse(&self.func_renames)?;
...
if cfg!(debug_assertions) {
...
}
let config = self.wasmtime_config()?;
let engine = wasmtime::Engine::new(&config)?;
let wasi_ctx = self.wasi_context()?; // wasi_contextの取得
...
wasi_context
メソッドというのを呼び出して、WASI向けのバイナリを初期化するための情報を取得するようになっている。
先程マッピング対象のディレクトリを追加したmap_dirs
が処理されるのも、このwasi_context
メソッドの中。
Wizer::wasi_context
wasi_context
の実態が記載されているのは、wizer/src/lib.rs:673
。
allow_wasi
でWASIをサポートするよう設定されていればWasiCtxBuilder
をインスタンス化してビルダーをセットアップする処理に続いていて、そうでなければNone
を返すようになっている。
fn wasi_context(&self) -> anyhow::Result<Option<WasiCtx>> {
if !self.allow_wasi {
return Ok(None);
}
let mut ctx = wasi_cap_std_sync::WasiCtxBuilder::new();
...
for (guest_dir, host_dir) in &self.map_dirs {
log::debug!(
"Preopening directory: {}::{}",
guest_dir.display(),
host_dir.display()
);
let preopened = wasmtime_wasi::sync::Dir::open_ambient_dir(
host_dir,
wasmtime_wasi::sync::ambient_authority(),
)
.with_context(|| format!("failed to open directory: {}", host_dir.display()))?;
ctx = ctx.preopened_dir(preopened, guest_dir)?;
}
...
マッピングする処理が書いてあるのはwizer/blob/main/src/lib.rs:694
。
ここで呼ばれているwasmtime_wasi::sync::Dir::open_ambient_dir
はcap-std
クレートで実装されているもので、bytecodealliance/cap-std
のリポジトリを見てみるとディレクトリを開くため関数のっぽい。(今回は詳細は割愛)
Use Dir::open_ambient_dir to open a plain path. This function is not sandboxed, and may open any file the host process has access to.
おそらく、この関数で取得したDir
オブジェクトをビルダーに詰め込んで、実際にWasiCtx
をビルドする際に使うんだろう。
と思って、WasiCtxBuilder.preopened_dir
の実装をチラ見してみたらそんな雰囲気。
impl WasiCtxBuilder {
...
pub fn preopened_dir(mut self, fd: u32, dir: Dir) -> Self {
let dir = Box::new(crate::dir::Dir::from_cap_std(dir));
self.0.insert_dir(fd, dir);
self
}
...
⬇ preopened_dir
から更に呼ばれているinsert_dir
。
impl WasiCtx {
...
pub fn insert_dir(&mut self, fd: u32, dir: Box<dyn WasiDir>) {
self.table_mut().insert_at(fd, Box::new(dir))
}
...
pub fn table(&self) -> &Table {
&self.table
}
pub fn table_mut(&mut self) -> &mut Table {
&mut self.table
}
...
⬇ 更に呼ばれているTable.insert_at
。
impl Table {
...
/// Insert a resource at a certain index.
pub fn insert_at(&mut self, key: u32, a: Box<dyn Any + Send + Sync>) {
self.map.insert(key, a);
}
...
HashMap
に突っ込んでいるみたいなので、実際にディレクトリを読むのはwizer
の初期化処理でバイナリを展開するとき?
こうやって埋め込まれたディレクトリは、最終的にwasi-vfs/src/trampoline_generated.rs
などのトランポリンコードがlibwasi_vfs.a
の形でリンクされたバイナリ(今回の場合はCRuby
のバイナリ)から見えるようになるので、スクリプトファイルが必要なインタプリタなどでもワンバイナリで配布できるようになるみたい。
最後に
実際に手元で動かしてみて、ワンバイナリでRubyのアプリケーションが動くことには感動。
CNCFのLandscape的にはWasmEdge
がContainer Runtimeのカテゴリにマップされているが、このwasi-vfs
も相まって益々Wasmもコンテナ技術に近い印象を受けた。
(というか隔離されたプロセス空間に、任意のファイルシステムを埋め込める概念的にはもはやソレじゃないか?)
埋め込むファイルのサイズに比例してバイナリも肥大化するのはそうなのだが、この辺のサイズは削減する方法などあるのか?(dockerとかは実際にビルド時にCOPYとかするとどうなってるんだろ)
色々気になることもあるので、今後も調べてWatchしていきたいと思いました。
Discussion