wasi-vfsでパックしたバイナリ(CRuby)を眺めてみた

# wasi-vfsでパックしたバイナリ(CRuby)を眺めてみた
Ruby 3.2.0でWASIベースのWasmへのコンパイルがサポートされた。
https://www.ruby-lang.org/ja/news/2022/12/25/ruby-3-2-0-released/

その中でも個人的に気になったのはこの記載。
> さらに、WASIの上にVFSを実装しました。これにより、Rubyアプリを単一の.wasmファイルに容易にパッケージ化できます。Rubyアプリの配布が少し簡単になります。

どういうバイナリになっているのかなど、気になったので調べてみたいと思う。

# WASIの上にVFSとは？
`VFS`というと真っ先に思い浮かんだのが、Unix系のシステムで聞く[仮想ファイルシステム(Virtual File System)](https://ja.wikipedia.org/wiki/%E4%BB%AE%E6%83%B3%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0)。
おそらくこれと同等の概念であり、ファイルを読み書きする共通のインターフェースによって多態性を実現しているもの。
これによって、Wasmをホスト上で実行するかブラウザ上で実行するかに関わらず、ファイルシステムの読み書き同様の動きが可能になる。
具体的には、ブラウザ上でも **任意のファイルパスから、該当するファイルの内容を読み出す** といったことができるようになる。

## なぜVFSが必要だったのか
VFSが必要になった背景は、[RubyKaigi2022の **Ruby meets WebAssembly**](https://rubykaigi.org/2022/presentations/kateinoigakukun.html) のパート(`23:00`辺り)で説明されている。

超雑に要約するとこんな感じ。(勝手な想像も盛り盛り配合されています)
🤩 `CRuby`が`Wasm`で動くようになったぞ！！
⬇
😮 インタプリタだから、処理系だけじゃなくてスクリプトファイル(`.rb`ファイル)も一緒に配布しないといけなくない！？
⬇
🤔 ホスト上にスクリプト配置して実行してね！はちょっと面倒じゃない！？
⬇
😃 `Wasm`のバイナリにファイルも一緒に埋め込んじゃえば良いのでは！？
⬇
🤗 そんでもって、 **`CRuby`がホスト上でファイルを読み出すインターフェース** で埋め込んだファイルも読み出せるようにしちゃえば良いんじゃない！？

つまり **実行したいスクリプトも埋め込んじゃって、ワンバイナリで`Wasm`の`CRuby`でアプリケーションを配布** できてしまうということ。
ちなみに、上記の動画内でも説明されているが、この仕組みは`CRuby`に限った話ではなく **Wasmに処理したいファイルも埋め込みたい** ケースであれば効果あり。(動画内でも`CPython`が動いたと言われている)

この仕組みを実現するために開発されたのが、`wasi-vfs`というソフトウェア。

# [`wasi-vfs`](https://github.com/kateinoigakukun/wasi-vfs)
`wasi-vfs`は、任意のパスでファイルを`Wasm`バイナリに埋め込んで、WASIのインターフェースから埋め込んだファイルを参照するためのコードを差し込んでくれる。

# 使ってみる
とりあえず使ってみる。
[公式リポジトリの`Getting started with CRuby`](https://github.com/kateinoigakukun/wasi-vfs/wiki/Getting-Started-with-CRuby)を参考にやっていく。

## wasi-vfsのインストール
```shell
# 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をダウンロードして展開
```shell
# ビルド済みの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
```shell
# ソースコード用のディレクトリを作って、スクリプトを用意
$ 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`に変換してから中身を見てみる。
```shell
# WATに変換 (wasm2watのインストールは割愛)
$ ./wasm2wat path/to/my-ruby-app.wasm -o path/to/wat-file
```

吐き出された`WAT`ファイルを眺めてみると、データセクションに **ソースコード(`puts 'Hello, wasi-vfs'`)** や **ソースファイルのパス(`my_app.rb`)** が見える。
```lisp
  ...
  (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`](https://github.com/kateinoigakukun/wasi-vfs/blob/main/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`](https://github.com/kateinoigakukun/wasi-vfs/blob/main/crates/wasi-vfs-cli/src/lib.rs#L49)の部分。
```rust
            ...
            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`](https://github.com/bytecodealliance/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`](https://github.com/bytecodealliance/wizer/blob/main/src/lib.rs#L413)にある。
```rust
    ...
    /// 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`](https://github.com/bytecodealliance/wizer/blob/main/src/lib.rs#L460)。
ここは`map_dir`も含め諸々設定されたオプションを用いて、実際に初期化処理を実行する所。
```rust
    ...
    /// 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`](https://github.com/bytecodealliance/wizer/blob/main/src/lib.rs#L673)。
`allow_wasi`でWASIをサポートするよう設定されていれば`WasiCtxBuilder`をインスタンス化してビルダーをセットアップする処理に続いていて、そうでなければ`None`を返すようになっている。
```rust
    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`](https://github.com/bytecodealliance/wizer/blob/main/src/lib.rs#L694)。

ここで呼ばれている`wasmtime_wasi::sync::Dir::open_ambient_dir`は`cap-std`クレートで実装されているもので、[`bytecodealliance/cap-std`のリポジトリ](https://github.com/bytecodealliance/cap-std#how-do-i-obtain-a-dir)を見てみるとディレクトリを開くため関数のっぽい。(今回は詳細は割愛)
> 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`](https://github.com/bytecodealliance/preview2-prototyping/blob/106d19ae4f76fe2ef05764fafc969fea31c1d583/wasi-common/cap-std-sync/src/lib.rs#L88)の実装をチラ見してみたらそんな雰囲気。
```rust
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`。
```rust
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`。
```rust
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`](https://github.com/kateinoigakukun/wasi-vfs/blob/main/src/trampoline_generated.rs)などのトランポリンコードが`libwasi_vfs.a`の形でリンクされたバイナリ(今回の場合は`CRuby`のバイナリ)から見えるようになるので、スクリプトファイルが必要なインタプリタなどでもワンバイナリで配布できるようになるみたい。

# 最後に
実際に手元で動かしてみて、ワンバイナリでRubyのアプリケーションが動くことには感動。
[CNCFのLandscape](https://landscape.cncf.io/?selected=wasm-edge-runtime)的には`WasmEdge`がContainer Runtimeのカテゴリにマップされているが、この`wasi-vfs`も相まって益々Wasmもコンテナ技術に近い印象を受けた。
(というか隔離されたプロセス空間に、任意のファイルシステムを埋め込める概念的にはもはやソレじゃないか？)
埋め込むファイルのサイズに比例してバイナリも肥大化するのはそうなのだが、この辺のサイズは削減する方法などあるのか？(dockerとかは実際にビルド時にCOPYとかするとどうなってるんだろ)
色々気になることもあるので、今後も調べてWatchしていきたいと思いました。

# Appendix
- [`wasi-vfs`のリポジトリ](https://github.com/kateinoigakukun/wasi-vfs)
- [`wizer`のリポジトリ](https://github.com/bytecodealliance/wizer)