💎

WebAssembly対応でRubyは何が出来るようになったのか?

2022/12/21に公開

はじめに

Ruby 3.2よりWASIベースのWebAssemblyサポートされました。
https://rubykaigi.org/2022/presentations/kateinoigakukun.html#day1
これによってRubyをブラウザ上で実行できるようになるわけですが、実はWASMはブラウザの外での動作も可能になっており、OSに依存しないライブラリを作ったり、Edge Computingで実行させたり、シングルバイナリの実行可能ファイルにしたりと色々な事が出来るようになります。

今回はRubyのWASM対応でどんなことが出来るようになったかをまとめたいと思います。

WebAssemblyとは?

WASMに関して少しおさらいをしておきましょう。
WebAssemblyは仮想命令セットアーキテクチャです。WASMとも言います。RustやCをはじめとした多くの言語からコンパイルする事が出来、Webブラウザの中で高速にそしてセキュアに処理を実行するための仕組みとして登場しました。

元々ブラウザ向けに登場した仕組みですが、そのポータビリティとセキュリティに注目が集まっており、ブラウザの外での実行をするランタイムも増えています。また標準のWASMには含まれていないシステムコールをサポートするWASIという仕様も登場し、Bytecode Allianceで策定が進んでいます。
https://bytecodealliance.org/

WASMに変換してブラウザで実行してみる

Rubyをブラウザで実行する一番簡単な方法はruby-head-wasm-wasiを使う方法です。
これによって以下のようにJSの代わりのように利用する事が出来ます。

<html lang="ja">
<head>
    <title>RubyWasm</title>
    <meta charset="utf-8" />
</head>
  <script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/browser.script.iife.js"></script>
<body>
    <div id="content">
        <button id="button">Post</button>
        <input type="text" id="message"></input>
        <div id="logs"></div>
    </div>
</body>
<script type="text/ruby" charset="UTF-8">
    require "js"

    document = JS.global[:document]
    button = document.getElementById 'button'
    logs = document.getElementById 'logs'
    message = document.getElementById 'message'

    button.addEventListener "click" do |e|
        text = "#{Time.now.to_s} : #{message[:value]}"
        tag_p = document.createElement('p');
        tag_p.append(document.createTextNode(text));
        logs.append(tag_p)
    end
  </script>
</html>

Webrick辺りでWebサーバを起動してブラウザでアクセスしてみます。

$ ruby -run -e httpd . -p 8000

DOMとの連携も出来て色々出来そうですね!

RubyスクリプトをWASMに変換してブラウザの外で実行する

WASMはブラウザの中で動かすのも面白いですが、WASMの外で動かすのも非常に面白いです。なので、RubyスクリプトをWASMに変換したいと思いますが一つ注意点があります。というのも例えばRustやCのような実行形式にコンパイルされる言語であればそのままWASMに変換するだけですが、Rubyのようなスクリプト言語ではスクリプトだけをWASM化出来ないのでRuby本体もWASMに変換する必要があります。そのため以下のようにRubyスクリプト実行するためのRuby本体をまとめてWASMにパッキングします。

ref: https://note.com/taizo9/n/n6bab3518273c
ただWASM及びそれをブラウザの外で動かす仕様のWASIにはファイルシステムがありませんので、Rubyではインメモリのファイルシステムであるwasi-vfsを使い、そこにRubyスクリプトを仮想的に配置して実行する仕組みのようです。

具体的な手順としてはruby.wasmをダウンロードして、wasi-vfsを使ってスクリプトとパックします。以下にLinuxでの手順を記載します。WindowsやMacの場合は適宜読み替えてください。

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
$ mv wasi-vfs /usr/local/bin/wasi-vfs
$ wasi-vfs --version
wasi-vfs-cli 0.2.0

ruby.wasmの取得とpack

まずは動作させるスクリプトを作ります。

$ mkdir src
$ echo "puts 'Hello WASM.'" > src/my_app.rb

つづいて以下の手順でパッキングします。

# ruby.wasmの取得
$ curl -L https://github.com/ruby/ruby.wasm/releases/latest/download/ruby-head-wasm32-unknown-wasi-full.tar.gz| tar xfvz -
$ mv head-wasm32-unknown-wasi-full/usr/local/bin/ruby ruby.wasm
# WASMへのRubyスクリプトのパック
$ wasi-vfs pack ruby.wasm --mapdir /src::./src --mapdir /usr::./head-wasm32-unknown-wasi-full/usr -o my-ruby-app.wasm

次に変換したWASMを実行します。WASIのランタイムはWasmtimeなど色々ありますが、今回はWasmerを使います。以下のようにインストールします。

$ apt-get install cargo
$ curl https://get.wasmer.io -sSfL | sh
$ wasmer --version
wasmer 3.1.0

バージョンが3.1.0以上ならOKです。バージョン3から結構実装が変わっているらしく3.0.2などでは動作しませんでした。#116でruby.wasmにチケット上げたら@kateinoigakukunさんがWasmer側にチケット振ってくださったのでLinux版は修正され3.1から反映されました。ただ残念ながらWindowsのWasmerは3.1.0では修正され切っておらず、現在Wasmer側で対応中です。楽しみに待ちましょう。

では、Wasmerで先ほど作った``を実行します。エントリーポイントはあくまでruby.wasmなので、wasi-vfs上の/src/my_app.rbを読み込むように引数で指定する必要があります。

$ my-ruby-app.wasm -- /src/my_app.rb
Hello WASM.

無事、WASMに変換したRubyが実行できましたね!
ただ、これ実は初回アクセスが結構かかります。私の環境だと90秒くらいかかりました。というのもwasmerはデフォルトではJITで実行されますが、ruby.wasmはかなり大きいので変換に時間が掛かるようです。二回目以降は ~/.wasmer/cache/にコンパイル済みのバイナリ(.wasmu)を持つので高速に起動します。私の環境では200msくらいでした。

WasmerはJITだけでは無くAOTにも対応しているので、以下のように事前にコンパイルして実行も出来ます。

$ wasmer compile -o my-ruby-app.wasmu my-ruby-app.wasm
$ wasmer my-ruby-app.wasmu -- /src/my_app.rb
Hello WASM.

こちらであれば初回でなくてもペナルティなく実行できます。ただし、CPUやOSなど同じアーキテクチャでないといけないので、ポータビリティが下がってしまうので使い方には注意が必要です。

WASMから実行可能ファイルを作成する

Rubyをシングルバイナリな実行可能ファイルにするのは人類の永遠の夢ですよね?
https://qiita.com/koduki/items/4096e37f4323e727558c
https://zenn.dev/koduki/articles/f2e37169564c12

実はWasmerはLinuxやWindowsネイティブの実行可能ファイルを作成する事が出来ます。これであればWasmerや他のランタイムをインストールしてない環境にも配布する事が出来ます。操作は以下の通りです。

$ wasmer create-exe -o ./my-ruby-app my-ruby-app.wasm
$ ./my-ruby-app /src/my_app.rb
Hello WASM.

これでGo言語のようにRubyで作ったCLIのコマンドやアプリがシングルバイナリで配布が簡単になりますね!

...まあ、ポータブルどこ行った? という感じもするのでこのあたりは便利だけど言語によっては必要無いかもですね。(異なる言語で作った)WASM同士をスタティックリンクして一つのバイナリに出来るようになると熱いけれど。

wasi-preset-argsを使って引数を不要にする

RubyをWASM、そして実行可能ファイルに出来て色々と取り回しが便利になりました。ただ、毎回Rubyスクリプトのパスを指定するのは少し面倒です。何とかならないかなー、って呟いてたら@kateinoigakukunさんが「こんなのあるよ!」とwasi-preset-argsについて教えてくれました。

こちらを使う事で毎回のスクリプトのパス指定が不要になります。

まずは、インストールします。

$ cargo install --git https://github.com/kateinoigakukun/wasi-preset-args.git --all-features
$ export PATH=$HOME/.cargo/bin:$PATH

以下のように実行して引数を組み込んだWASMを作成します。

$ wasi-preset-args my-ruby-app.wasm -o my-ruby-app-withargs.wasm -- /src/my_app.rb
$ wasmer my-ruby-app-withargs.wasm
Hello WASM.
$ wasmer create-exe -o ./my-ruby-app my-ruby-app-withargs.wasm
$ ./my-ruby-app
Hello WASM.

スクリプトのパスを指定しないバイナリが作れましたね!

Bundlerを利用したRubyスクリプトのWASMへの変換

Rubyを使うならやはりGems、そしてBundlerは使いたいですよね? Ruby3.2でもC拡張を使わないようなGemは取り込めるようです。早速やってみましょう。
実施に際してはこちらのIssueGistが参考になりました。

thorを使った簡単なCLIを作成してみます。

$ bundle init
$ bundle add thor
$ bundle config set --local path 'vendor/bundle'
$ bundle install
$ cat <<EOS > app.rb
require "bundler/setup"
require "thor"

class MyCLI < Thor
    desc "hello NAME", "say hello to NAME"
    def hello(name)
      puts "Hello #{name}"
    end
end

MyCLI.start(ARGV)
EOS

以下のようにruby-head-wasm32-unknown-wasi-fullを取得して動作させてみます。

$ curl -LO https://github.com/ruby/ruby.wasm/releases/latest/download/ruby-head-wasm32-unknown-wasi-full
.tar.gz
$ tar xfz ruby-head-wasm32-unknown-wasi-full.tar.gz
$ wasmer run --mapdir /usr::./head-wasm32-unknown-wasi-full/usr \
             --mapdir /root::./  \
             --mapdir /bundle::/usr/local/bundle  \
             --env BUNDLE_GEMFILE=/root/Gemfile  \
             --env BUNDLE_APP_CONFIG=/bundle  \
             head-wasm32-unknown-wasi-full/usr/local/bin/ruby -- /root/app.rb
Commands:
  app.rb hello NAME      # say hello to NAME
  app.rb help [COMMAND]  # Describe available commands or one specific command

無事動きましたね! 注意点がいくつかあります。

  • bundle execの代わりにスクリプト上でrequire "bundler/setup"を使う
  • ruby-head-wasm32-unknown-wasi-fullとbunlder installするRubyのバージョンを合わせる
  • BUNDLE_GEMFILEを環境変数で指定してGemfileの位置を伝える
  • DockerのrubyイメージのようにBUNDLE_APP_CONFIGが指定されている環境は/usr/local/bundleをマップし環境変数にも渡す

このあたりの対応が必要です。特に個人的にはバージョンを合わせる必要があるのにハマってしまいました。Rubyの基礎力が足りない... こんな感じでバージョンが一致してるかは確認できます。

$ ls ./head-wasm32-unknown-wasi-full/usr/local/lib/ruby/gems/
3.2.0+3
$ ls vendor/bundle/ruby/
3.2.0+3

ちなみにWebアプリケーションもWASM化出来ないかとSinatraを使ってみたのですが、以下のようなエラーになりました。

/root/vendor/bundle/ruby/3.2.0+3/gems/rack-protection-3.0.5/lib/rack/protection/encrypted_cookie.rb:3:in `require': cannot load such file -- openssl (LoadError)
Did you mean?  open3
      from /root/vendor/bundle/ruby/3.2.0+3/gems/rack-protection-3.0.5/lib/rack/protection/encrypted_cookie.rb:3:in `<top (required)>'
      from /root/vendor/bundle/ruby/3.2.0+3/gems/sinatra-3.0.5/lib/sinatra/base.rb:1825:in `require'
      from /root/vendor/bundle/ruby/3.2.0+3/gems/sinatra-3.0.5/lib/sinatra/base.rb:1825:in `<class:Base>'
      from /root/vendor/bundle/ruby/3.2.0+3/gems/sinatra-3.0.5/lib/sinatra/base.rb:908:in `<module:Sinatra>'
      from /root/vendor/bundle/ruby/3.2.0+3/gems/sinatra-3.0.5/lib/sinatra/base.rb:20:in `<top (required)>'
      from /root/vendor/bundle/ruby/3.2.0+3/gems/sinatra-3.0.5/lib/sinatra/main.rb:28:in `require'
      from /root/vendor/bundle/ruby/3.2.0+3/gems/sinatra-3.0.5/lib/sinatra/main.rb:28:in `<module:Sinatra>'
      from /root/vendor/bundle/ruby/3.2.0+3/gems/sinatra-3.0.5/lib/sinatra/main.rb:3:in `<top (required)>'
      from /root/vendor/bundle/ruby/3.2.0+3/gems/sinatra-3.0.5/lib/sinatra.rb:3:in `require'
      from /root/vendor/bundle/ruby/3.2.0+3/gems/sinatra-3.0.5/lib/sinatra.rb:3:in `<top (required)>'
      from /root/app.rb:2:in `require'
      from /root/app.rb:2:in `<main>'

opensslなどのC拡張が必要になるためかと思います。この辺は地道に頑張るかエコシステムが整うのを待つしかないですが、例えばwapm/syrusakbary/opensslのようにWASMに変換したopensslもWASMのパッケージリポジトリであるWAPMにも登録されているのでC拡張の代わりにこの辺りを使う形に変換すると状況が変わるのかもしれません。

Dockerを使ってWASMとコンテナを連携させる

続いてDockerでWASMを実行してみましょう。「え、DockerでWASM?」と思う方も多いと思うのですが、実は先日リリースがあったDocker Desktop 4.15からベータ機能ですがWASMの実行に対応しています。
そもそもDockerがWASMを何故サポートするかといえばLinuxコンテナに似た性質を持ちつつ、アプリケーションのコンテナとしてはよりコンパクトでセキュアな性質が期待出来るからです。詳しくは以下の記事を見るのが良いと思います。
https://www.docker.com/blog/why-containers-and-webassembly-work-well-together/

DockerではWASMのランタイムであるWasmEdgeとそれをcontainerdからLinuxコンテナ等と同様に扱いをさせるrunwasiからなっています。

なので、単にDockerをWasmerのようなランタイムとして使うだけでは無く、docker-composeなどを使ってWASMで作ったアプリケーションとDockerコンテナのDBを連携させる、なんてことも簡単に出来ます。またWAPMとは別のレジストリとしてDockerHubをWASMの格納/共有先として利用する事も出来ます。

さて、実際に使っていきましょう。まずはベータ機能なので利用できるようにUse containerd for pulling and storing imagesにチェックを入れて適用します。

つづいてDockerfileを作成してWASMを含んだイメージをビルドします。私もちょっと勘違いしてたのですがWASMを直接実行するわけではないようですね。

# WASMの準備
$ mkdir src
$ echo "puts 'Hello WASM and Docker.'" > src/my_app.rb
$ wasi-vfs pack head-wasm32-unknown-wasi-full/usr/local/bin/ruby \
    --mapdir /src::./src \
    --mapdir /usr::./head-wasm32-unknown-wasi-full/usr \
    -o my_app_origin.wasm 
$ wasi-preset-args my_app_origin.wasm -o my_app.wasm -- /src/my_app.rb
# Docker
$ cat <<EOS > Dockerfile
# syntax=docker/dockerfile:1
FROM scratch
COPY my_app.wasm /my_app.wasm
ENTRYPOINT [ "my_app.wasm" ]
EOS

$ docker buildx build --platform wasi/wasm32 -t koduki/my_app .
$ docker run  \
>   --runtime=io.containerd.wasmedge.v1 \
>   --platform=wasi/wasm32 \
>   koduki/my_app
Hello WASM and Docker.

ビルド時に--platformを指定するのはM1 Macとかでx86を指定するのと同じフォーマットですね。合わせて実行時には--runtimeも指定するようです。実行結果をみると単なるDockerコンテナですが、以下のようにインスペクトでWASMが指定されているのが分かります。

$ docker image inspect koduki/my_app|jq '.[].Architecture'
"wasm32"
$ docker image inspect koduki/my_app|jq '.[].Os'
"wasi"

DockerHubに上げたい場合はふつうにpushをします。

$ docker push koduki/my_app

ちゃんと登録されてますね!

まだスタートラインという感じですが、使い慣れたDockerで動かせるのは良いですね。Dockerfileは不要になって欲しいかなぁ。
今回は試してませんがdocker-composeにも統合されているので以下のように指定できるみたいです。

services:
  app:
    image: michaelirwin244/wasm-example
    platform: wasi/wasm32
    runtime: io.containerd.wasmedge.v1
    ports:
      - 8080:8080

Edge Computing

FastlyやCloudfrareなどではCDN EdgeでWASMを実行する事が出来ますし、denoなどもWASMをサポートしており、マイクロサービス/ナノサービスの実行環境としてはWASMは使われ始めています。ちょっと今回はきちんと検証する時間が取れなかったのですが、例のごとく@kateinoigakukunが試されているようなので、時間がある時に私も確認してみたいと思います。
https://github.com/kateinoigakukun/ruby-compute-runtime
ちなみにCloudfrareにデプロイしようとするとサイズが大きすぎてデプロイ出来なかったので、そのあたりに対するケアも今後必要になるのかもしれません。

オマケ: wasmのコードをRubyから呼び出す

今回の記事の主な趣旨はRuby 3.2から対応したRubyのWASMへの変換で何が出来るようになるか? なのですが、逆方向もなかなか面白いです。つまりRustやCで書いたWASMをRubyから呼び出すケースです。
これが何が嬉しいの? 普通にC拡張で良くない?という気もしますが、WASMを使う事でWindows、Mac、Linuxに非依存のライブラリを作る事が出来ます。みんなNokogiriのビルドとか苦しみましたよね?
これはRubyだけでは無く、他の言語含めた全体のエコシステムの話になるのですが、そういう可能性も秘めているのが面白いですよね。ちょうど今年のアドベントカレンダーでやられてる方も見つけました。
https://qiita.com/kojix2/items/b233f1419b26f7fc0e1b

私もWasmer Gemを使ったサンプルを書こうと思ってたのですが以前は一度成功してたんですが、なんか今回は環境構築でエラーが出てしまったので、後日まとめて記事したいと思います。ちなみにWastimeも同じく対応したようなので選択肢が増えて良いですね。

https://github.com/wasmerio/wasmer-ruby
https://github.com/bytecodealliance/wasmtime-rb

まとめ

今回はRuby 3.2でWASM対応したのを記念して、RubyとWASMに関する内容をまとめてみました。まだまだ実装的にもドキュメント的にも粗削りな部分がエコシステム全体としてある気がしますが、とても面白い方向だと思っています。

RubyのWASM対応に限れば、現在は容量が結構多いので将来的にはJavaのJlinkみたいな形で必要なものだけモジュール化して取り込めると便利そうです。

それではHappy Hacking!

Discussion