WASIはWasm(WebAssembly)のコミュニティーグループによって開発されている、Wasmのシステムインターフェースの仕様です。2023年8月にリリースされたGo 1.21からWASI 0.1へのビルドがサポート1されたり、翌年の2024年1月にWASI 0.2がリリース2されたりと、近年ますます注目されている技術です。
WASIは、Wasmの標準的なシステムインターフェースを提供することを目的としています。本書がWASIの仕様を理解する手助けとなれば幸いです。
本書は5つの章から構成されています。興味のある章から読み進めても問題ありません。以下、各章の章の概要を紹介します。
第1章 前提知識
WASIを解説するための前提知識を紹介します。Wasmについての簡単な解説と、WASIが生まれるに至った背景について解説します。また、本書で使用しているコマンドツールも第1章で紹介します。
第2章 WASI 0.1
WASI 0.1(プレビュー1)の基本的な仕様の解説と、WASI 0.1のインターフェースを用いた実装例を紹介します。2024年の1月にWASI 0.2がリリースされましたが、WASI 0.2が普及するにはもう少し時間がかかると思われます。実務でWASI 0.1を使用しているのであれば、本章が理解の助けになるでしょう。
第3章 コンポーネントモデル
WASI 0.2(プレビュー2)の仕様のベースとなるコンポーネントモデルについて解説します。コンポーネントモデルを採用したことによって、WASI 0.2はWASI 0.1とは全く異なるインターフェース仕様となりました。WASI 0.1はWasmの基本的な仕様の上に構築されていますが、WASI 0.2の仕様はコンポーネントモデルの仕様の上に構築されています。そのため、コンポーネントモデルのみを解説する章を設けています。第4章を読む前に、本章から読み進めることをお勧めします。
第4章 WASI 0.2
WASIの最新仕様であるWASI 0.2(プレビュー2)の基本的な仕様の解説と、WASI 0.2のインターフェースを用いた実装例を紹介します。WASI 0.1の実装例(第2章)と同様の実装例を紹介しているため、インターフェースがどのように変化したのか比較したい場合は第2章とあわせて読むことをお勧めします。
第5章 今後の展望
現在開発されているWasmのエコシステムについて紹介します。WasmのレジストリーであるWarg、Wasmコンポーネントを組み合わせるためのツールであるWACなどのエコシステム周りの開発が進んでいます。Wargは現在パブリックベータ版が公開されているため、それらの使用方法についても紹介します。
特に断りがない場合、本書で紹介しているコマンドおよびサンプルコードは全てLinuxもしくはUNIX(macOS)を前提に記述しています。また、サンプルコードはすべてGitHub上のリポジトリー3に公開しています。記載しているコードの断片から実装の全体像を読み取れない場合などは、ライセンスの範囲で自由にお使いください。
本書に記載されている会社名、製品名などは、一般に各社の登録商標または商標、商品名です。会社名、製品名については、本文中では©、®、™マークなどは表示していません。
本書で使用しているコマンドツールを簡単に紹介します。
Deno1は、TypeScriptとJavaScriptのランタイムです。公式サイトに記載されている下記のコマンドを実行することで、インストールすることができます。
$ curl -fsSL https://deno.land/install.sh | sh
...
Wasmtime2は、WASI 0.1とWASI 0.2の両方をサポートしているWasmのランタイムです。GitHubのREADMEに記載されている下記のコマンドを実行することで、インストールすることができます。
$ curl https://wasmtime.dev/install.sh -sSf | bash
...
Rust3は、システムプログラミング言語です。公式サイトに記載されている下記のコマンドを実行することで、インストールすることができます。
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
...
RustはWasmをビルドするためのエコシステムが整っているため、本書ではWasmの実装例にRustを使用しています。RustからWasmにビルドする場合、--targetオプションを用いてビルドターゲット(wasm32-unknown-unknownもしくはwasm32-wasip1)を指定します。これらのビルドターゲットがビルド環境のコンピューターに入っていない場合、次のコマンドでビルドターゲットを追加することができます。
$ rustup target add wasm32-unknown-unknown
...
$ rustup target add wasm32-wasip1
...
wasm-tools4はWasmのバイナリーフォーマットとテキストフォーマットの相互変換や、Wasmの解析に用いるコマンドです。Rustのパッケージマネージャーであるcargoコマンドからインストールすることができます。
$ cargo install wasm-tools
...
cargo-component5は、WASI 0.2やWasmコンポーネントをビルドするのに使用するコマンドです。Rustのパッケージマネージャーであるcargoコマンドのサブコマンドとして提供されており、cargoコマンドからインストールすることができます。
$ cargo install cargo-component --locked
Wasm (WebAssembly)は、ウェブブラウザー上で高速にプログラムを実行することを目的に開発された技術です。JavaScriptが人にとって理解しやすいテキストフォーマットであるのに対して、Wasmはコンピューターにとって理解しやすいバイナリーフォーマットとして設計されています。そのため、Wasmはコンピューター上で高速にプログラムを実行することができます。
バイナリーフォーマットがコンピューターにとって理解しやすい一方で、バイナリーフォーマットは人が読み書きするのに適していません。そのため、Wasmにはバイナリーフォーマットと相互変換可能なテキストフォーマットであるWAT(WebAssembly Text Format)6が用意されています。WATはS式と呼ばれる構文を採用しており、次のように書くことができます。
(module
(func $add (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add
)
(export "add" (func $add))
)
WATを用いることでバイナリーフォーマットを扱いやすくはなりますが、それでもJavaScriptなどと比べると、まだまだ扱いにくい言語であることに変わりはありません。そのため、通常はRustなどの言語を用いてプログラムを書き、Wasmにコンパイルします。
#[no_mangle]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
Wasmは1ファイル1モジュールの構成になっており、モジュール単位でインスタンス化して実行します。Wasmモジュールを実行するには、モジュールファイルを読み込み、モジュールからインスタンスを作成する必要があります。ウェブ上でWasmを実行する場合、次のようなJavaScriptのコードを書く必要があります。
// ファイル名
const filename = new URL("example.wasm", import.meta.url);
// バイナリーを取得
const binary = await fetch(filename).then((res) => res.arrayBuffer());
// モジュールを作成
const module = new WebAssembly.Module(binary);
// インスタンスを作成
const instance = new WebAssembly.Instance(module);
// 実行
console.log(instance.exports.add(1, 2)); // 3
このJavaScriptのコードをHTMLファイルに埋め込むか、Denoなどのランタイムを用いることで実行することができます。
$ deno run --allow-read example.js
3
Wasmにはウェブ上で高速にプログラムを実行できるという特徴以外にも、セキュリティー(安全性)とポータビリティー(移植性・柔軟性)にも注目すべき特徴があります。JavaScriptのコードを例に、Wasmのセキュリティーについて解説します。
JavaScriptを用いて「Hello, world!」を標準出力(/dev/stdout)に出力するプログラムを実行する場合、次のようなコードを書きます。
console.log("Hello, world!");
$ deno run hello.js
Hello, world!
このプログラムを実行すると、ターミナル上に「Hello, world!」と表示されます。この出力先である標準出力は、OSが管理しているリソースです。OSは管理しているリソースにアプリケーション(OS上で実行するプログラム)がアクセスできるように、インターフェース(API)を提供しています。このOSが提供しているインターフェースのことを、システムインターフェースと呼びます。
JavaScriptのランタイムは、console.logが実行されるとOSのシステムコール7を行い、標準出力に内容を出力します。
一方でWasmはサンドボックスとして設計されているため、OSのシステムインターフェースを直接実行することができません。そのため、Wasm単体では「Hello, world!」を標準出力に出力することができないようになっています。
Wasmから「Hello, world!」を出力するには、Wasmの外側の世界(JavaScriptのランタイム)に代わりに「Hello, world!」を出力してもらう必要があります。
Rustを用いて、「Hello, world!」を出力するWasmをビルドする場合、次のようなコードを書く8必要があります。
// 実行時にインポートするモジュールの定義
#[link(wasm_import_module = "env")]
extern "C" {
fn write(ptr: *const u8, len: usize);
}
#[no_mangle]
pub fn _start() {
let msg = b"Hello, world!";
unsafe {
write(msg.as_ptr(), msg.len());
}
}
このコードからビルドしたWasmを実行するには、次のようなJavaScriptのコードを書く必要があります。
const filename = new URL(
"target/wasm32-unknown-unknown/release/hello.wasm",
import.meta.url,
);
// WebAssemblyの代わりに標準出力に出力する関数
function write(ptr, len) {
console.log(new TextDecoder().decode(
new Uint8Array(memory.buffer, ptr, len),
));
}
const { instance } = await WebAssembly.instantiateStreaming(
fetch(filename),
// モジュールのインポート
{ env: { write } },
);
const memory = instance.exports.memory;
instance.exports._start();
ビルドして実行すると、「Hello, world!」がターミナル上に表示されます。
$ cargo build --release --target wasm32-unknown-unknown
Compiling hello v0.1.0 (/wasi-book-example/第1章/hello)
Finished `release` profile [optimized] target(s) in 0.20s
$ deno run --allow-read hello.js
Hello, world!
このようにWasmは直接OSのリソースにアクセスすることができないため、Rustのコード上でWasmのインスタンス作成時にインポートするenvモジュールを定義し、envモジュールのwrite関数を呼び出しています。
JavaScriptのコード上では、ポインターと文字列長を受け取り、Wasmのメモリー上から文字列を読み込んでconsole.logを呼び出すwrite関数を実装しています。このwrite関数をインスタンス作成時にインポートするenvモジュールの関数として、Wasmに渡しています。
このようにすることで、Wasmは「Hello, world!」を標準出力に出力することができます。
インスタンス作成時にwrite関数をインポートしない場合はどうなるでしょうか?
const filename = new URL(
"target/wasm32-unknown-unknown/release/hello.wasm",
import.meta.url,
);
const { instance } = await WebAssembly.instantiateStreaming(
fetch(filename),
{ env: {} },
);
instance.exports._start();
$ deno run --allow-read hello.js
error: Uncaught (in promise) LinkError: WebAssembly.instantiate(): Import #0 "env" "write": function import requires a callable
...
このようにwrite関数をインポートしないようコードを書き換えて実行すると、エラーとなりWasmを実行することができません。
Wasmでは、実行するランタイム側がアクセスさせたくない機能をインポートしないようにすることで、Wasmの実行を制限することができます。
write関数をインポートしないようにするとWasm自体を実行できなくなるため、代わりに何もしないwrite関数をインポートすることで機能を制限することもできます。
const filename = new URL(
"target/wasm32-unknown-unknown/release/hello.wasm",
import.meta.url,
);
function write(_ptr, _len) {
// DO NOTHING
}
const { instance } = await WebAssembly.instantiateStreaming(
fetch(filename),
{ env: { write } },
);
instance.exports._start();
$ deno run --allow-read hello.js
このようにWasmにはOSのリソースに直接アクセスする方法が存在しないため、Wasmを実行するランタイム側がWasmの挙動を簡単に制御することができます。
WasmのバイナリーフォーマットはIntelやARMなどのCPUアーキテクチャーや、LinuxやWindowsなどのOSに依存していません。そのため、ランタイムさえあればCPUやOSを問わず、同じバイナリーフォーマットのWasmを実行することができます。
ここまでDenoを用いてWasmを実行する例を紹介しましたが、次のようなHTMLファイルを用意することで、ウェブ上でも同じWasmを実行することができます。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Example</title>
<script type="module" src="hello.js"></script>
</head>
<body>
<p>Open DevTools to see the console output.</p>
</body>
</html>
$ python3 -m http.server
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
...
ランタイムさえあればどこでも実行できるというのは、Wasmだけの特徴ではありません。Javaも同様に、ランタイムであるJVM(Java Virtual Machine)があれば、OSやCPUアーキテクチャーに依存することなくプログラムを実行することができます。
近年Wasmが注目されているのはOSやCPUに依存しないバイナリーフォーマットであることだけでなく、軽量で高速かつ安全に実行できるという特徴が揃っているからです。元々はウェブ上で高速にプログラムを実行することを目的に開発された技術でしたが、この特徴を活かすことでエッジコンピューティングのような限られたリソース上でプログラムを実行したり、Linuxコンテナの置き換えといった用途での活用が期待されている技術です。
Wasmから「Hello, world!」を出力するためには、Wasmの代わりに「Hello, world!」を出力するためのモジュールをインポートする必要がありました。
本書ではenvモジュールのwrite関数をインポートする実装例を紹介しましたが、別の人はconsoleモジュールのlog関数をインポートするWasmを実装するかもしれません。また、同じwrite関数でも、引数や戻り値が異なるかもしれません。
Wasmは、OSが管理しているリソースへのアクセス方法を定義していません。そのため、実装者によって必要なインポートモジュールやエクスポートする関数が異なる可能性があります。Wasmの実行者は、インポートしないといけないモジュールは何か、エクスポートされている関数は何かを知らないと、Wasmを実行することができません。
Rustの他にも、Wasmへのビルドをサポートしている言語にGoやDartなどがあります。これらの言語からWasmをビルドし、実行する方法を見てみましょう。
次のようなGoのコードを用意します。
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello, world!")
}
GoのコードをWasmにビルドするには、次のコマンドを実行します。
$ env GOARCH=wasm GOOS=js go build -o hello.wasm hello.go
ビルドされたWasmを実行するには、このWasmのインターフェース(インポート/エクスポート)を知る必要があります。GoはWasmを実行するためのJavaScriptのグルーコードを提供しています。次のコマンドを実行し、Goのグルーコードをコピーしましょう。
$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .
最後に、次のようなJavaScriptのコードを書くことで実行することができます。
import "./wasm_exec.js";
const go = new Go();
const filename = new URL("./hello.wasm", import.meta.url);
const { instance } = await WebAssembly.instantiateStreaming(
fetch(filename),
go.importObject,
);
go.run(instance);
$ deno run --allow-read hello.js
Hello, world!
次のようなDartのコードを用意します。
void main() {
print('Hello, world!');
}
DartのコードをWasmにビルドするには、次のコマンドを実行します。
$ dart compile wasm -o hello.wasm hello.dart
Generated wasm module 'hello.wasm', and JS init file 'hello.mjs'.
ビルドすると、hello.wasmと一緒にJavaScriptのグルーコード(hello.mjs)も出力されます。Dartの場合は次のような実行用のJavaScriptのコードを書くことで、Wasmを実行することができます。
import { instantiate, invoke } from "./hello.mjs";
const filename = new URL("./hello.wasm", import.meta.url);
const instance = await instantiate(
WebAssembly.compileStreaming(fetch(filename)),
);
invoke(instance);
$ deno run --allow-read ./hello.js
Hello, world!
GoとDartそれぞれからビルドしたWasmはどちらも同じ「Hello, world!」を出力するプログラムですが、提供されているインターフェースが異なるため、実行するのに必要なJavaScriptのコードも異なります。
それぞれのWasmがどのようなモジュールをインポートしようとしているのかを確認すると、次のようになります。
$ wasm-tools print hello.wasm | grep '\(import '
(import "dart2wasm" "_83" ...
(import "dart2wasm" "_84" ...
(import "dart2wasm" "_194" ...
...
$ wasm-tools print hello.wasm | grep '\(import '
(import "gojs" "runtime.scheduleTimeoutEvent" ...
(import "gojs" "runtime.clearTimeoutEvent" ...
(import "gojs" "runtime.resetMemoryDataView" ...
...
DartからビルドされたWasmがdart2wasmモジュールをインポートする必要があるのに対して、GoからビルドされたWasmはgojsモジュールをインポートする必要あり、完全に異なるインターフェース仕様であることがわかります。
このようにビルドする環境によって必要なインターフェースが異なると、Wasmを実行するために必要なインターフェースを都度調べる必要があります。Goの例では、公式が用意しているグルーコードを使うことで実行できますが、Dartの場合はビルド時に一緒に生成されるグルーコードを使う必要があります。
Wasmは優れたポータビリティー性を持っていますが、標準化されたインターフェースが仕様に存在しないために、ビルド済みのWasmだけを他者に渡しても簡単に実行することができないという課題があります。この課題を解決するために、Wasmの標準化されたシステムインターフェースの仕様を策定しようという動きがあります。その標準化を目指しているWasmのシステムインターフェース仕様が、WASI(WebAssembly System Interface)です。
WASIを用いて、「Hello, world!」を出力するWasmを作成してみましょう。
先ほどのGoのコードをWASI 0.1にビルドして実行すると、次のようになります。
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello, world!")
}
$ env GOARCH=wasm GOOS=wasip1 go build -o hello.wasm hello.go
$ wasmtime run hello.wasm
Hello, world!
このように、GoからビルドされたWasmをWasmtimeを使用して、簡単に実行することができます。
Dartは現状WASIをサポートしていないため、代わりにWASI 0.1をサポートしているRustを使用してWASI 0.1にビルドしてみましょう。
$ cargo new hello
...
fn main() {
println!("Hello, world!");
}
RustからWASI 0.1に対応したWasmをビルドするには、次のコマンドを実行します。
$ cargo build --release --target wasm32-wasip1
Compiling hello v0.1.0 (/wasi-book-example/第1章/wasi/hello)
Finished `release` profile [optimized] target(s) in 0.29s
RustからビルドされたWasmを実行するには、Goと同様にWasmtimeを使用します。
$ wasmtime run target/wasm32-wasip1/release/hello.wasm
Hello, World!
このようにGoとRustそれぞれからビルドしたWasmに必要なインターフェースが何かを意識することなく、Wasmtimeを使用するだけで簡単に実行することができました。これはGoとRustがWASI 0.1の仕様を満たしたWasmをビルドし、WasmtimeがWASI 0.1をサポートしているためです。
それぞれのインターフェースの違いは、次のようになります。
$ wasm-tools print target/wasm32-wasip1/release/hello.wasm | grep '\(import '
(import "wasi_snapshot_preview1" "fd_write" ...
(import "wasi_snapshot_preview1" "environ_get" ...
(import "wasi_snapshot_preview1" "environ_sizes_get" ...
(import "wasi_snapshot_preview1" "proc_exit" ...
$ wasm-tools print hello.wasm | grep '\(import '
(import "wasi_snapshot_preview1" "sched_yield" ...
(import "wasi_snapshot_preview1" "proc_exit" ...
(import "wasi_snapshot_preview1" "args_get" ...
...
インポートしている関数の数が異なるものの、どちらもwasi_snapshot_preview1モジュールをインポートしていることがわかります。このwasi_snapshot_preview1モジュールがWASI 0.1のモジュールです。
このように共通のインターフェース仕様であるWASI 0.1を用いることで、どの言語からビルドされたWasmかを意識することなく実行することができるようになります。
第1章ではWASIの仕様を解説するための前提知識として、Wasmとはどういうものでどんな特徴があるのかを紹介しました。また、WASIがなぜ必要とされているのか、WASIを使うとどういったメリットがあるのかを紹介しました。次章からは、WASIの仕様について解説していきます。