📦

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

2022/12/31に公開

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)
おそらくこれと同等の概念であり、ファイルを読み書きする共通のインターフェースによって多態性を実現しているもの。
これによって、Wasmをホスト上で実行するかブラウザ上で実行するかに関わらず、ファイルシステムの読み書き同様の動きが可能になる。
具体的には、ブラウザ上でも 任意のファイルパスから、該当するファイルの内容を読み出す といったことができるようになる。

なぜVFSが必要だったのか

VFSが必要になった背景は、RubyKaigi2022の Ruby meets WebAssembly のパート(23:00辺り)で説明されている。

超雑に要約するとこんな感じ。(勝手な想像も盛り盛り配合されています)
🤩 CRubyWasmで動くようになったぞ!!

😮 インタプリタだから、処理系だけじゃなくてスクリプトファイル(.rbファイル)も一緒に配布しないといけなくない!?

🤔 ホスト上にスクリプト配置して実行してね!はちょっと面倒じゃない!?

😃 Wasmのバイナリにファイルも一緒に埋め込んじゃえば良いのでは!?

🤗 そんでもって、 CRubyがホスト上でファイルを読み出すインターフェース で埋め込んだファイルも読み出せるようにしちゃえば良いんじゃない!?

つまり 実行したいスクリプトも埋め込んじゃって、ワンバイナリでWasmCRubyでアプリケーションを配布 できてしまうということ。
ちなみに、上記の動画内でも説明されているが、この仕組みは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-libcfd_readが呼ばれた時、そのままホスト上のファイルシステムに対して読み込む処理にジャンプする
  • wasi-vfsで埋め込み後
    • wasi-libcfd_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!
WizerWasmPre-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_dircap-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していきたいと思いました。

Appendix

GitHubで編集を提案

Discussion