Wasmで少しだけ手軽にRubyとRubyスクリプトを持ち運ぶ

やりたいこと

  • Ruby の環境を作らずに基本的なスクリプトを動作させたい
    • 非エンジニアの環境で 1 ファイルでコマンドを動作させたいというほどの環境の縛りはない
  • 簡単に Docker 環境で基本的なコードが動けばよい、程度

今回できたこと

  • Wasm 版 Ruby を Wasm Runtime 上で動かす
  • 基本的な Ruby コードと Wasm 版 Ruby を一つの Wasm module にパッケージし、それを Wasm Runtime 上で動かす
    • さらに Wasm module を compile しておいて起動を速くする
  • (Rubyについてはまだまだ課題は多いが)Wasm を使うことで直接実行バイナリを生成できない言語でも JVM + .war 程度の使い勝手である程度のことができることが分かった

実験に使ったのは

  • macOS 13.6.7 ( arm64 darwin 22 )
  • Ruby 3.2.2 / 3.3.1
  • wasmtime-cli 21.0.1
  • ruby_wasm 2.6.1
  • colima 0.6.8 + qemu 9.0.0 ( Docker server 24.0.7 / client 25.0.0 )

できなかったこと

  • standalone executable binary を作る

Ruby 3.2からWasm/WASI対応が標準らしい

ブラウザでRubyを動かしたいとは思ってないけど?

WASM は WebAssembly という名前なのでブラウザで動くのが主目的のような気がするかもしれないけど、実は(ブラウザという)「幅広いプラットフォームで動く制限された環境の上」で動作するということは新しい VM ができたということ。

WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.

WebAssembly

WASM はこの新しい VM のためのバイナリフォーマットであり、VM の仕様を WASI と呼ぶ。

WASI is designed to provide a secure standard interface for applications that can be compiled to Wasm from any language, and that may run anywhere—from browsers to clouds to embedded devices.

Introduction | WASI.dev

つまり、

  • WASI に対応した Wasm Runtime を準備
  • Ruby のコードを Wasm Runtime 上で動くようにする

この両方を満たせば、Ruby をゼロから準備しなくても Ruby の開発環境がなくても、ブラウザ以外の環境でも、Ruby のコードが動く、という意味になる。最近で言うといわゆる Edge Computing ってやつなんかが話題。1

Ruby 3.2 で達成されたことの意味はそういうことになる。

で、それぞれの Wasm Runtime が閉じていればお互いに影響を受けないので、docker compose みたいな感じで Wasm 版のプログラムが動くようになるのが安全、という世界に徐々になっていくのかもしれない。少なくとも Docker 側はそういう世界を考えているらしくて、

Introducing the Docker+Wasm Technical Preview | Docker

$ docker run --runtime=io.containerd.wasmedge.v1

みたいな感じで Wasm 版のイメージを動かせるようにすることができるようだ。ようだ、というのは今回はそこまで試していない。

Wasm Runtime

肝心の Wasm プログラムを動作させる環境だが、先ほどの

Introduction | WASI.dev

によると

辺りが優先的に名前が出てくるけど、Wasmtime がリファレンス実装という位置付けらしい。

なんかはいろんなことができるし、独自の Registry や Edge をサービスとして持っていて、Container Registy と Edge Computing Platform が一つになったような野心的なものになっているようだ。

ただ今回は standalone executable binary を作る目的で wasmer も試してみたが、うまくいかなかったので、このあとの記述では

ruby/ruby.wasm: ruby.wasm is a collection of WebAssembly ports of the CRuby.

に倣って Wasm Runtime は Wasmtime を前提とする。もし追試したい場合はインストール方法は確実で調べてもらえばよいが、macOS であるなら Homebrew から一発でインストールできる。

Wasm版Rubyを動かす

今回は JavaScript 環境は必要ないので rubygems だけでなんとかする。利用したのは ruby_wasm 2.6.1 なので、それ以前とは話が違っている可能性あり。正直とても簡単だった。

Wasm 版 Ruby を入手する方法は

  1. GitHub Releases からビルド済みのバイナリを入手
  2. ruby_wasm で build する

の二つあるが、せっかくなので 2 の方法を試す。2

rbwasm build --ruby-version <3.2|3.3> -o <output>

とすると ./rubies/ 以下に指定したバージョンのもろもろのファイルが、そのうえで -o で指定した名前で Wasm 版 Ruby (Wasm module)が手に入る。仮に Ruby 3.3 を build した場合、これを wasmtime を利用して実行すると以下のように出力される。

$ wasmtime ruby33.wasm -v
ruby 3.3.1 (2024-04-23 revision c56cd86388) [wasm32-wasi]

RUBY_PLATFORM が wasm32-wasi になっているのが分かる。「またまたぁ、ほんとはそのまま動くんじゃないの?」と思って実行ビットを立てて試してみても、

$ ./ruby33.wasm -v
zsh: exec format error: ./ruby33.wasm

以下のように言われてそのままでは実行できないことが分かるし、file コマンドを試すと以下のように言われる。

$ file ruby33.wasm
ruby33.wasm: WebAssembly (wasm) binary module version 0x1 (MVP)

確かにベツモノだ。

Wasm版Rubyでスクリプトを実行する

ruby33.wasm
src/
  |- hello.rb
  `- dependencies.rb

こんな感じでコードを用意する。中身は超簡単なもの。

hello.rb

require_relative './dependencies'

puts 'Hello, World from Ruby'

dependencies.rb

puts 'required'

実行する。

$ wasmtime ruby33.wasm --dir ./src::/src /src/hello.rb
required
Hello, World from Ruby

なんかいろいろ warning が出たけど端折る。とりあえず自分で用意したスクリプトを読み込んで動作している。面倒なのは --dir の部分で、要は docker run なんかと一緒で VM なので、local の file system が VM 内の file system のどこに位置するのかを教えてあげないといけない。もちろんこれを毎回教えるのはメンドイ。

Wasm版Rubyと自作のコードをガッチャンコする

先ほどの ruby33.wasm と pack サブコマンドを使う。

$ rbwasm pack ruby33.wasm --dir ./src::/src -o hello.wasm

これで Wasm 版 Ruby と先ほど作った ./src 以下が一つの Wasm module である hello.wasm となって出力される。古い記事には wasi-vfs を単体で利用する説明があるが、現在の ruby_wasm ではその必要はなさそうだ。

※ Wasm 版 Ruby の install 方法の 1 である pre-build binary を使う場合、/usr 以下も –dir で map する必要があったり、実行バイナリの ruby をその /usr 以下に含めるとサイズが膨らんでしまうといった面倒な話が出てくるので、多少時間は掛かるが、rbwasm build で入れた方が結果的に考えることが減ってよいのではないかと思う。

実行する

できあがった hello.wasm の中には先ほどの ./src 以下のコードが /src 以下に map されて含まれているので、実行は –dir を省いて以下のように行う。

$ wasmtime hello.wasm /src/hello.rb

残念ながら /src/hello.rb という引数は必要だが、これで実体としてのファイルは wasmtime と hello.wasm の2つあれば動作するようになった。

portableであることを確認する

  • できあがった hello.wasm を ./src のない場所に移動
  • docker ( on colima ) を使って x86_64 Linux 環境内で動かす(x86_64 Linux 用 の wasmtime を持ち込む)

いずれも動く。

docker run --rm -ti -v .:/mnt debian:stable-slim /mnt/wasmtime /mnt/hello.wasm -v

動く。ただし docker 環境内では非常に遅い。

wasmtime compile

実は Wasm Runtime で Wasm module を動かす際にはネイティブのフォーマットに一度変換してから実行される。そのため実は Docker を利用していなくても Wasm Runtime 越しの実行は1回目は遅い。(ここまであえて触れてこなかったが)

2回目以降は前回のコンパイルの結果をユーザーに見えないようにキャッシュしてそれを利用しているので速い。少なくとも Wasmtime や Wasmer はそのように動作する。しかし docker 環境ではコンパイル結果をキャッシュする先が恐らく memory file system なので何回実行しても速くならない。しかしこのコンパイルを事前に行なって、compiled wasm module にしておくことで先ほどの遅さはかなり改善できる。

方法は実行時に利用する Wasm Runtime で compileする。先ほどの Docker 内の wasmtime で例を作ると以下のような感じになる。

$ docker run --rm -ti -v .:/mnt debian:stable-slim /mnt/wasmtime compile -o /mnt/hello.cwasm

できた cwasm ( compiled wasm module ) を動かすには –allow-precompiled を加えて

$ docker run --rm -ti -v .:/mnt debian:stable-slim /mnt/wasmtime --allow-precompiled /mnt/hello.wasm -v

全然違う。先ほどと同じように file を使うと

$ file ./hello.wasm
hello.cwasm: ELF 64-bit LSB relocatable, x86-64, version 1, not stripped

おぉなるほど。こうなると portable ではなくなってしまうが、決まったクラウドサービス上で動かしたい、といった場合はだいたい x86_64 Linux なので、あらかじめ compile した cwasm を利用することでRuby の実行環境をゼロから準備することなく、それなりの速度でそれなりの規模のスクリプトを動かすことは叶う。

まとめとおまけ

少なくとも、Ruby の実行環境を独自に用意することなく Wasm Runtime とパッケージした Wasm module だけである程度の速度で自分のコードが動くことは確認できた。

しかも他の Wasm module を作れますよというアプローチと違い、自分で書くコードで Wasm 対応を考える必要がないし、JS にも限定されない。Ruby が単体で動くバイナリを出力するコンパイラ機能を持たないため、Ruby 自体を Wasm 対応させるというアプローチで取り組んでいることが逆に効果的に作用して、実に手軽に Wasm Runtime 上での動作が実現できている状態になる。

これがどういうことかというと、例えば gcloud コマンドと Ruby の両方が動く Docker イメージを独自に作ることなく、gcloud コマンドが動くイメージ上に wasmtime と cwasm ファイルをポンと置くだけでそれなりの複雑さの作業が可能になる、ということを意味する。これはなかなかありがたい。3 Go 製のツールなどを Docker 環境上で「ちょい足し」で使う際に「これは便利だー」とよく思うので、これに近いことが Ruby でも実現できるのは非常にありがたい。

Docker + WASIの状況

Docker Desktop 4.15 で beta の機能として docker run のオプション

  • –platform ( wasi/wasm )
  • –runtime ( Wasm Runtime )

で指定することで直接実行できるようになっているらしい。この情報が出たのが 2022年12月なので、現状の正確なところは分からないが、少なくとも2024年5月現在、1年以上前に beta になった機能なので、そろそろ beta が取れ、Docker Desktop 以外の環境、特にホスティング環境での利用が進んでくるきっかけが見えてくると、だいぶ面白いことになりそう、と予想できる。

その頃には ruby.wasm 側も既存のコードとの互換性も向上しているかもしれないし、今後もこの辺りは期待の技術になりそう。

さらなる高速化

先ほど Wasm Runtime が Wasm module をネイティブのバイナリにコンパイルする工程を事前に準備する方法に触れたが、それをやっても Ruby がスクリプトを読み込んでコンパイルする処理は端折れない。この部分がどうしても Ruby を利用したコードが FaaS や Edge computing に向かない遅さの原因の一つだが、その部分は

Shopify/ruvy

が担っているらしいので、これもじっくり注目しておいてよいのかもしれない。

  1. Cloudflare Workers が一気に有名になったけど、似たようなサービスは他にもある 

  2. 実は 1 の方法はちょっとややこしい。 

  3. もちろん Cloud Run で真面目に環境を作って動かしてもいいんだけど、YAML や JSON を読み書きできて、ある程度の処理ができればよい、でも sh script ベースでやるのはつらい、みたいなものは案外多い。しかもパッケージ済みなので Ruby のバージョンアップなどもホスティング側の都合にあまり大きく左右されない。 

More