🌤️

WasmEdge と QuickJS で React Streaming SSR のコンテナを作ったら 5MB に収まってしまった

2022/09/15に公開
1

JavaScript で作ったツールを実行ファイルにする方法を考えていたところ「Wasm(WASI) で JavaScript を使えたらいけるのでは」と思いました。

そこで少し調べてみたところ、世の中は React の SSR を Wasm でやってやろうぜ というところまで進んでいました。今回はその辺についてなど。 (WASI で JavaScript を実行ファイルにするのは別途記事にしたいと思っています)

WasmEdge と QuickJS

日本語の情報が少なかったので WasmEdge と QuickJS、およびその組み合わについて少し説明など。

WasmEdge(Wasm と WASI)

WasmEdge は Wasm(WASI) の実装の 1 つで、ざっくり言うと Wasm をブラウザー以外で動作させるための WASI ランタイムとなります。

ここでいきなり WASI というワードが出てきましたが、「Wasm と何が違うの?」的な話は下記のスクラップが参考になります。

WASI の実行環境としては WasmerWasmtime などが有名ですが、WasmEdge は Cloud(Edge) で動かすことも意識しているそうです。この辺は CNCF 関連でニュースになっていました[1][2]

QuickJS

QuickJS は組み込み環境などでも動作する JavaScript(ECMAScript)の実行環境です。ES2020 にも対応しているので、いまどきなコードを(おそらくは最初の印象よりも)利用できます。

また、QuickJS を使うと自分で開発しているアプリケーションへ JavaScript 実行環境を組み込めます。

wasmedge-quickjs

WasmEdge では アプリケーション開発用の言語に JavaScript も含まれていますが、Rust などとは少し対応が異なります。

構成としては .js のコードを Wasm 化するのではなく、QuickJS を Wasm 化しています。その上で .js コードが実行されます。

このような構成は Wasmer CLI の例としても出てきますが、wasmedge-quickjs は特徴の 1 つとして Node.js との互換性が挙げられます。

たとえば、以下のように process を import しているコードも実行できます。

リスト 1-1 process を import するコード

import * as os from 'os';
import * as std from 'std';
import * as process from 'process'

args = args.slice(1);
print('Hello', ...args);
setTimeout(() => {
    print('timeout 2s');
}, 2000);

let env = process.env
for(var k in env){
    print(k,'=',env[k])
}

図 1-1 エラーなしで実行可能

$ wasmedge --dir .:. --env VAR1=val1 wasmedge_quickjs.wasm example_js/hello.js 
Hello
VAR1 = val1
timeout 2s

ただし、実装されていない API も多いので「ある程度の互換はあるかな」くらいに考えておくのがよさそうです。

React Streaming SSR とは

こちら説明がなくて良さそうですが、下記の記事がわかりやすかったです。

wasmedge-quickjs で React Streaming SSR を試してみる

WasmEdge のドキュメントに React SSR のページがありますが少し長いです。今回はサンプルの react_ssr_stream をとりあえず動かしてみたいと思います。

実際に動かすには以下のような手順になります。

  • WasmEdge の実行環境(CLI)をインストール
  • wasmedge-quickjs のwasmedge_quickjs.wasm を用意
  • サンプル(react_ssr_stream)をビルド
  • WasmEdge の CLI で wasmedge_quickjs.wasm からビルドされたサンプルを実行

各種環境を整えるのが少し手間なので、今回は devcontainer を用意しました。Codespace を作成するか、VSCode の Remote - Containers などで開くと Terminal から利用できます。

WasmEdge の実行環境をインストール

今回は devcontainer 内にインストールしてあるので、Terminal を開けば wamsedge を実行できます。

手動でインストールする場合は下記に手順があります。

wasmedge_quickjs.wasm を用意

devcontainer 内の ~/tmp/wasmedge-quickjs にリポジトリを clone してあるので、ここでビルドします。

図 3-1 .wasm をビルド

$ cd ~/tmp/wasmedge-quickjs/
$ cargo build --release --target wasm32-wasi
    Updating crates.io index                
  Downloaded encoding v0.2.33
  Downloaded encoding-index-singlebyte v1.20141219.5
  Downloaded encoding-index-tradchinese v1.20141219.5
<snip...>

これで target/wasm32-wasi/release/wasmedge_quickjs.wasm にビルドされたファイルが保存されています。

なお、リリースページからダウンロードもできますが、この場合はサイズが増えます。

react_ssr_stream をビルド

example_js/react_ssr_stream にサンプルのプロジェクトがあるので、ここで依存パッケージをインストールしビルドします。

図 3-2 パッケージのインストールとビルド

$ npm install
<snip...>

$ npm run buiuld

> build
> rollup -c rollup.config.js

./main.mjs → dist/main.mjs...
babelHelpers: 'bundled' option was used by default. It is recommended to configure this option explicitly, read more here: https://github.com/rollup/plugins/tree/master/packages/babel#babelhelpers
(!) Unresolved dependencies
https://rollupjs.org/guide/en/#warning-treating-module-as-external-dependency
http (imported by main.mjs)
stream (imported by stream?commonjs-external)
util (imported by util?commonjs-external)
(!) Plugin replace: @rollup/plugin-replace: 'preventAssignment' currently defaults to false. It is recommended to set this option to `true`, as the next major version will default this option to `true`.
created dist/main.mjs in 4.1s

これで dist/main.mjs にビルドされたファイルが保存されました。なお、警告メッセージにあるように stream などの依存関係は解決されていませんが、これは実行時に import されます。

実行してみる

example_js/react_ssr_stream ディレクトリーで以下のように実行します。

$ wasmedge --dir .:. --dir ./modules:../../modules ../../target/wasm32-wasi/release/wasmedge_quickjs.wasm dist/main.mjs

各パラメーターなどは以下のようになっています。

  • --dir .:. - ランタイムにカレントディレクトリのアクセスを許可する(dist/main.mjs の読み込みに利用)
  • --dir ./modules:../../modules - ../../modules./modules としてアクセスすることを許可する(main.mjs からの import に利用)
  • ../../target/wasm32-wasi/release/wasmedge_quickjs.wasm - コンパイルされた QuickJS の .wasm ファイル
  • dist/main.mjs - 実行される .js の指定

実行すると Port 8001 で listen するのでブラウザーから開いてみます。

図 3-3 ブラウザーで開くとページが表示される

localhost:8001 をブラウザーで開いているスクリーンショット

図 3-4 Stream により時間差で更新される
ブラウザーをリロードし時間差で更新されることを確認しているスクリーンショット

図 3-5 DevTools で確認すると 1 ファイルとして受信されている

ブラウザーで DevTools を表示しているスクリーンショット

また curl で開くと時間差でソースが受信されています。

図 3-6 時間差で受信される
curl で受信時の時間差を確認しているスクリーンショット

この辺は前述の記事にあるように動作しているので問題はないと思います。

コンテナで実行してみる

WasmEdge はコンテナとして実行できるというので試してみます。

コンテナというと Docker が有名ですが、現在の Docker はコンテナのランタイムを操作する実装の 1 つとなります。この辺の話は長くなるので下記の記事などを参照してみてください。

上記を読んで改めて「Wasm をコンテナとして実行できる」の意味を考えると、 コンテナの低レベルランタイムが .wasm を (Linux の実行ファイルのように)実行できる となります。

よって、Wasm をコンテナとして実行する場合は「対応している低レベルランタイムとそれを利用できるツールセット」を用意することになります。

現時点では WaamEdge のドキュメントを見ると crun が対応しているようなので[3][4]、これを元に「イメージのビルドは buildah 、全体の管理は podman」という構成にしてみます。

buildah と podman については以下が参考になります。

環境の構築

今回は WasmEdge のドキュメントを参考に、さきほど試した devcontainer 上で構築してみます。

crun は --with-wasmedge 付きでビルドされたものが必要になるので、以下の方法でインストールします。

図 4-1 WasmEdge 対応の crun をインストール

$ wget -qO- https://raw.githubusercontent.com/second-state/wasmedge-containers-examples/main/crio/install.sh | bash

<snip...>

$ crun --version
crun version 1.6.0.0.0.7-5159
commit: 515912848d18bdc29bc87a9d6164fd0fdad6f2a6
spec: 1.0.0
+SYSTEMD +SELINUX +APPARMOR +CAP +SECCOMP +EBPF +CRIU +WASM:wasmedge +YAJL

通常は上記方法だけで動きますが、今回は Container 内で動かすのでそのままでは動作しません。対応のために Storage Driver を変更します。 /etc/containers/storage.condriver 指定を以下にように変更します。

リスト 4-1 Storage Driver を VFS へ変更

driver = "vfs"

buildah のインストールはドキュメントの方法だとエラーになるので少し変更して実行します(長いのでファイルにコピーして実行してください)。

リスト 4-2 buildah のインストール

sudo apt-get -y install software-properties-common

export OS="xUbuntu_20.04"
sudo bash -c "echo \"deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/$OS/ /\" > /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list"
sudo bash -c "curl -L https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/$OS/Release.key | apt-key add -"

sudo add-apt-repository -y ppa:alexlarsson/flatpak
sudo apt-get -y -qq update
sudo apt-get -y install bats git libapparmor-dev libdevmapper-dev libglib2.0-dev libgpgme-dev libseccomp-dev libselinux1-dev skopeo-containers go-md2man containers-common
sudo apt-get -y install golang-1.16 make

mkdir -p ~/buildah
cd ~/buildah
export GOPATH=`pwd`
git clone https://github.com/containers/buildah ./src/github.com/containers/buildah
cd ./src/github.com/containers/buildah
PATH=/usr/lib/go-1.16/bin:$PATH make bin/buildah
sudo cp bin/buildah /usr/bin/buildah
buildah --help

最後に podman をインストールします。

図 4-2 Podman をインストール

$ sudo apt-get install podman

イメージをビルド

環境ができたので React Streaming SSR を実行できるコンテナのイメージをビルドしてみます(前節で各種ファイルをビルドしている前提で進めます)。

まずはディレクトを作成し必要なファイルをコピーしておきます。

図 4-3 各種ファイルをコピー

$ mkdir -p ~/tmp/container
$ cd ~/tmp/container
$ cp ../wasmedge-quickjs/example_js/react_ssr_stream/dist/main.mjs .
$ cp -r ../wasmedge-quickjs/modules .
$ cp ../wasmedge-quickjs/target/wasm32-wasi/release/wasmedge_quickjs.wasm .

同じディレクトリーに Dockerfile を作成します。ここではサンプルのあわあせて CMD.wasm を指定しています[5]

リスト 4-3 Dockerfile

FROM scratch
ADD main.mjs modules wasmedge_quickjs.wasm /
CMD ["/wasmedge_quickjs.wasm", "/main.mjs"]

準備ができたのでビルドしてみます。ここで --annotation を指定することにより Wasm 用コンテナのイメージだと明示されます。

図 4-4 --annotation 付きでビルド

$ buildah build --annotation "module.wasm.image/variant=compat-smart" -t react_ssr_stream .
STEP 1/3: FROM scratch
STEP 2/3: ADD main.mjs modules wasmedge_quickjs.wasm /
STEP 3/3: CMD ["/wasmedge_quickjs.wasm", "/main.mjs"]
COMMIT react_ssr_stream
Getting image source signatures
Copying blob 0e23d946294b done  
Copying config 5cb21751e8 done  
Writing manifest to image destination
Storing signatures
--> 5cb21751e85
Successfully tagged localhost/react_ssr_stream:latest
5cb21751e85f61c9a94bb7bf41c36113d2c4f3697a1b2d9d8310b13fcb856154

とくにエラーもなくイメージが作成できました。サイズを確認すると 4.52MB となっています[6]

図 4-5 作成されたイメージを確認

$ podman image ls
REPOSITORY                       TAG         IMAGE ID      CREATED             SIZE
localhost/react_ssr_stream       latest      5cb21751e85f  About a minute ago  4.52 MB

コンテナとして実行

イメージができたので podman で run できますが、podman のデフォルト設定では標準の crun が使われてしまいます。

図 5-1 デフォルト設定で run するとエラー

$ podman run --rm -it --publish 8001:8001 localhost/react_ssr_stream:latest
{"msg":"exec container process `/wasmedge_quickjs.wasm`: Exec format error","level":"error","time":"2022-09-13T05:04:43.000371086Z"}

今回は run のオプションでラインタイムを切り替えることで対応しています。

図 5-2 Wasm 対応 crun を指定して実行

$ podman run --runtime /usr/local/bin/crun --rm -it --publish 8001:8001 localhost/react_ssr_stream:latest
listen 8001...

ブラウザーで開くと先ほどと同じように表示されます。

図 5-3 ブラウザーで開くとページが表示される

localhost:8001 をブラウザーで開いているスクリーンショット

停止は podman から container stop で行います。

図 5-4 コンテナを停止

$ podman container stop strange_chebyshev

という感じでコンテナとしても動作させることができました。

考慮点

パッケージング(ビルド)がちょっと面倒

「各種ランタイムをどのように用意するか」「.js はどの程度の規模になるか」などで状況は変わってきますが、定型的なビルドのフローがないのでその辺が少し面倒かなという感じです。

WASI ランタイムの互換性

原理的にはコンパイルされた wasmedge-quickjs の .wasm ファイルは他の WASI ランタイムでも動作しそうですが、実際に試すとエラーになります。

図 6-1 WasmEdge 以外だとエラーになる

$ wasmtime --dir . wasmedge_quickjs.wasm async_demo.js 
Error: failed to run main module `wasmedge_quickjs.wasm`

Caused by:
    0: failed to instantiate "wasmedge_quickjs.wasm"
    1: unknown import: `wasi_snapshot_preview1::sock_open` has not been defined

実行環境を自前でインストールできる場合はよいのですが、コンテナランタイムで WasmEdge 以外を有効化されていると困ることになりそうです。

また、バインディングで WASI ランタイムをアプリケーション内に組み込む場合、WasmEdge のビルドが面倒だったりします(とくに Windows 用ビルド)。できればビルドしやすいランタイムでも使いたいのですが、ちょっと難しそうかなと[7]

パフォーマンス

ここまで触れませんでしたが、実行速度などは遅いようです。

Now, the choice of QuickJS as our JavaScript engine might raise the question of performance. Isn't QuickJS a lot slower than v8 due to a lack of JIT support? Yes, but ...

リンク先を読んだ感じでは、今回の React SSR のような場合だとサーバーレスファンクション的に使うならロード時間などでも判断してねということなのかなと。

なお .wasm 部分は wasmegecAOT コンパイルができるので、これを利用すると改善できることもあるようです(それでも Node.js よりは遅いようです)。

おわりに

wasmedge-quickjs を利用して React Streaming SSR のコンテナを作ってみました。

実際に試してみて「5 MB くらいのサイズで React の SSR が動いている」というのは面白かったです。また、コンテナとして WASI 実行環境が扱えるようになってきているのも興味深いところです。

そして、少し本題から外れますが、下記の記事にクラウドな WASI の面白い利用例があります。

今後、マネージドなコンテナなどでも WASI が普通にサポートされたら、React の SSR にも対応できる軽量な JavaScript 環境は面白い存在になるのかもしれません。

脚注
  1. サンドボックスプロジェクトとはなんぞは「CNCF連載始めます | フューチャー技術ブログ」が参考になります。 ↩︎

  2. 1 年前の記事だからなのか、少し実際の内容とは食い違っています(現状では Docker からコンテナとしての実行は難しいなど)。 ↩︎

  3. crun の configure を見ると Wasm(WASI)ランタイムとして Wasmer や Wasmtime も指定できそうです。 ↩︎

  4. YoukiWasmer に対応していました(Wasmtime や WasmEdge もサポートされそうな感じです)。 ↩︎

  5. Youki のドキュメントでは ENTRYPOINTを使っています。おそらくは Linux の実行ファイルのように使い分ければよいのかなと。 ↩︎

  6. modules ディレクトリーはそのままコピーしただけなので、ここはもう少し削れるかもしれません。 ↩︎

  7. Wasmtime + Javy だと特殊な設定をしなくとも Windows 用にビルドできたので WasmEdge も簡単にビルドできるとよいのですが…。 ↩︎

GitHubで編集を提案

Discussion

いしもっちいしもっち

ちょうどwasmedgeとJSについて調査していて詰まっていたので、とても参考になりました!
V8のようにJSランタイムとwasmランタイムの両方が存在していると思い込んでいて、理解できないことがいろいろあったのですがこの記事をみて腑に落ちました。まさか、JSランタイム自体がwasmコンパイルされているとはびっくりです。
quick.js.wasmのwatを見てみると関数テーブル上で1000関数ほど宣言されててなんでなんだろうとは思っていたのですが、、、

(table (;0;) 1027 1027 funcref)

WasmEdge 以外だとエラーになる

これについては、wasiを経由したsock通信自体がまだ仕様定義段階でwasmランタイムごとに実装が異なるためだと思われます!今後そこらへんの仕様が固まればどのランタイムでも動作可能になりそうです(楽しみですね)