2023年6月現在、WebAssembly System Interface (以下 WASI) は、その大きな理想についての日本語の記事やドキュメントが充実しているようには思えない。
本記事では実際に WASI を使ってみて、その思想についての筆者なりの理解を紹介したい。
ビジネスとしての使用という目的のためであれば、現段階で学習するのは効率が良いとは思えない。
しかし、損得は別にしてエンジニアとして WASI に興味のある人も居るだろう。
また、WASI の基本的な方針を学ぶには機能の少ない今の方が良いかもしれない。
本ドキュメントではサンプルコードとして Rust を用いるが、本質的な部分は Rust を知らなくとも本質部分は理解可能と考えている。
前回の復習
前回の勉強会の詳細については、 WEBASSEMBLY - What's the right thing to write? をご参照ください。
System Call と User Land
OS の上で動くアプリケーションの処理は大きく 2 種類に分かれる。
- OS へのリクエスト (System Call)
- Input / Output
- Memory 確保、解放
- ...
- CPU と Memory を直接つかった計算 (User Land 上の処理)
- 四則演算
- 確保済みの Memory へのアクセス (Read / Write)
- ...
WebAssembly の方針
WebAssembly とは、多くのコンピューターで動作する事を目指した VM の仕様である。
しかし System Call を行うと、その処理は OS 依存となってしまいポータビリティーを下げる。
それを防ぐため、WebAssembly は User Land 上の処理しか出来ないように規格で定められている。
そして System Call が使えないという制限を補うために WebAssembly は他のプログラム言語と連携して動く事を前提としている。
WebAssembly の使い方としては、下記の 2 通りになる。
- WebAssembly では System Call を使わない部分のコードだけを記載、外部プログラムからライブラリの様に使用する
- System Call を実行する外部プログラムの関数を WebAssembly に Export し、WebAssembly からそれらの関数をライブラリーの様に実行する
WASI の方針
WebAssembly は System Call を禁止する事で、どんな環境 (OS) でも動作する事を目指した規格である。
一方で、外部プログラムに依存してしまう欠点もあった。
WASI は WebAssembly を実行する外部プログラムに関する規格だ。
具体的には System Call を実行するために WebAssembly に Export する関数や WebAssembly の実行方法等が決められている。
言い方を変えると、WASI は WebAssembly のポータビリティーを犠牲にして外部プログラム依存を減らすための規格と言えるかもしれない。
また、新しく作ることが出来るので、近年の IT 事情に適応する事にも力を注いでいるように見える。
執筆時 (2023 年 6 月) 状況整理
執筆時の状況を筆者なりに整理してみる。
WASI Preview 1
既に規格が決定し、リリースされている。
主にファイル操作(標準入出力、標準エラー出力を含む)に関する規格が決められている。
セキュリティーについて良く考えられており、好印象だ。
ただし、新しくて規格も使い方もシンプルな間はこういう物かもしれない。
VMWare や Docker も、最初は素晴らしいと思ったが使っている間に不便な点も見つかってきた。
ネットワークに関する事は何も決まっていない(何も出来ない)事には注意しよう。
WASI Preview 2
WASI Preview 1 に加えて主に NetWork に関する規格を追加する予定だ。
その他、規格の決め方についても変更が有った。
(イメージとしては組織変更が近いかもしれない。)
一言で言うと、「全部を一つの規格にするのではなく、規格を細分化しよう」という形だ。
2023 年中の決定を目指すとアナウンスされている。
また、WASI の代表的な実装である wasmtime のレポジトリを見る限り、開発環境が整う日も遠くなさそうに見える。
WASIX
WASIX は WASI と名前は良く似ているが、全く別の物だ。
WASI は Bytecode Alliance が中心となって進めており WASIX は Wasmer が中心となっている。
ザックリ言うと、WASIX は WebAssembly に POSIX の規格を移植したような規格になっている。
そのため過去の資産を利用しやすい。
例えば Linux 用に作成された C 言語のアプリケーションが WASIX でそのまま動いたりする。
実をいうと、筆者は WASIX の利点がよく理解できていない。
WASIX は POSIX の悪いところも移植してしまうのでは無いか?
WASIX は Docker の劣化コピーを作りたいのだろうか?
(WebAssembly を使用する以上、パフォーマンスのオーバーヘッドは必ず存在するだろう。)
環境設定
筆者のテスト環境と、設定方法について簡単に紹介。
テスト環境
- Debian 11 (WSL2 on Windows11)
- Rust
- Cargo 1.69.0
- rustup 1.26.0
- wasmtime 9.0.3
wasmtime のインストール
wasmtime は WASI ランタイムの実装の一つである。
Linux の場合、下記のコマンドを実行すると、"$HOME/bin/wasmtime" というパスにインストールされる。
(root 権限必要無し)
$ curl https://wasmtime.dev/install.sh -sSf | bash
筆者は試していないが、MacOS でもこの方法でインストール可能とのこと。
Windows では ここ からインストーラーをダウンロードできるようだ。(筆者は未確認)
Rust のインストール
WASI の動作確認プログラムのため、Rust をインストールする。
詳細は こちら を参照。
Hello World
WASI は外部プログラムを書かずに実行可能だ。
(規格に沿った外部プログラムが用意されている。)
WASI では標準出力は特に設定をせずとも使用できる。
その事の確認のため、Rust で Hello world のプログラムを作成し、WASI して実行してみる。
Rust 動作確認
Rust の動作確認として Hello world を実行してみる。
上記の方法(普通の方法)で Rust をインストールした場合、cargo という Rust 開発のツールもインストールされているはずだ。
最初に cargo で Rust プロジェクトを作成する。
おそらくサンプルとして hello world が実装されていると思うので、動作確認までしてみる。
$ cargo new hello_world
$ cd hello_world
$ cargo run
Hello, world!
万一、Hello world が実行されない場合は、src/main.rs というファイルの内容を一回全て削除し、下記の 3 行で上書きする。
fn main() {
println!("Hello, world!");
}
この状態で再度実行すると正常に動作するはずだ。
$ cargo run
Hello, world!
WASI でコンパイル、実行
先ほどの動作確認では Rust のコードは OS 用の実行ファイルとしてコンパイルされて実行されていた。
ここでは、同じコードを WASI としてコンパイルして実行してみる。
まず最初に、WASI 用のコンパイル環境を整える為に下記のコマンドを実行する。
$ rustup target add wasm32-wasi
次に、下記のコマンドでコンパイルする。
$ cargo build --target wasm32-wasi
すると、target/wasm32-wasi/debug/hello_world.wasm というファイルが出来ているはずだ。
$ ls target/wasm32-wasi/debug/hello_world.wasm
target/wasm32-wasi/debug/hello_world.wasm
拡張子から分かるように、このファイルは WebAssembly のフォーマットであり外部プログラムが無いと実行できない。
wasmtime はその為の外部プログラムだ。
作成された WebAssembly のファイルは下記の様に実行できる。
$ wasmtime target/wasm32-wasi/debug/hello_world.wasm
Hello, world!
ファイルのコピーコマンド
前述のように WASI ではファイル操作も可能だ。
そこで、ファイルコピーコマンドを実装してみる。
なお、この章の内容は WASI tutorial から抜粋した。
Rust でプログラムを作成
先ほどと同様に、cargo で Rust のプロジェクトを作成しよう。
$ cargo new file_copy
$ cd file_copy
次に、src/main.rs を一回全て削除し、下記の内容で上書きする。
use std::env;
use std::fs;
use std::io::{Read, Write};
fn process(input_fname: &str, output_fname: &str) -> Result<(), String> {
let mut input_file =
fs::File::open(input_fname).map_err(|err| format!("error opening input {}: {}", input_fname, err))?;
let mut contents = Vec::new();
input_file
.read_to_end(&mut contents)
.map_err(|err| format!("read error: {}", err))?;
let mut output_file = fs::File::create(output_fname)
.map_err(|err| format!("error opening output {}: {}", output_fname, err))?;
output_file
.write_all(&contents)
.map_err(|err| format!("write error: {}", err))
}
fn main() {
let args: Vec<String> = env::args().collect();
let program = args[0].clone();
if args.len() < 3 {
eprintln!("usage: {} <from> <to>", program);
return;
}
if let Err(err) = process(&args[1], &args[2]) {
eprintln!("{}", err)
}
}
本ドキュメントは Rust 解説文では無いが、一応内容を簡単に紹介する。
興味が無い人は次の章まで読み飛ばして欲しい。
最初に main 関数を読んでみる。
fn main() {
let args: Vec<String> = env::args().collect();
let program = args[0].clone();
if args.len() < 3 {
eprintln!("usage: {} <from> <to>", program);
return;
}
if let Err(err) = process(&args[1], &args[2]) {
eprintln!("{}", err)
}
}
main 関数は引数の数をチェックし、プログラム名を含めて 3 個未満だった場合は usage を表示して終了する。
それ以外の場合は process という関数を呼び、process がエラーを返した場合はその内容を表示する。
コピー処理の実態は process という関数である。
fn process(input_fname: &str, output_fname: &str) -> Result<(), String> {
let mut input_file =
fs::File::open(input_fname).map_err(|err| format!("error opening input {}: {}", input_fname, err))?;
let mut contents = Vec::new();
input_file
.read_to_end(&mut contents)
.map_err(|err| format!("read error: {}", err))?;
let mut output_file = fs::File::create(output_fname)
.map_err(|err| format!("error opening output {}: {}", output_fname, err))?;
output_file
.write_all(&contents)
.map_err(|err| format!("write error: {}", err))
}
process は引数を 2 個とり、エラーが発生した場合はそのエラーを返す。
引数名から想像がつくと思うが、1 個目の引数はコピー元のファイルパス、2 個目の引数はコピー先のファイルパスだ。
中身は下記の 5 行である。
- コピー元のファイルを開く。失敗したらエラーを返して終了。
-
contents
という変数を定義、初期化 - コピー元のファイルを全て読み込み、
contents
に保存。失敗したらエラーを返して終了。 - コピー先のファイルを開く。失敗したらエラーを返して終了。
-
contents
の内容をコピー先のファイルに書き込む。失敗したらエラーを返して終了。
WASI でコンパイル、実行
Hello world の動作確認を行っていれば既に WASI のコンパイル環境は整っているはずなので、コンパイルは次のコマンドだけで実行できる。
$ cargo build --target wasm32-wasi
すると、target/wasm32-wasi/debug/file_copy.wasm というファイルが出来ているはずだ。
$ ls target/wasm32-wasi/debug/file_copy.wasm
target/wasm32-wasi/debug/file_copy.wasm
続いて動作確認をしてみる。
準備として foo.txt というファイルを作成し、これを bar.txt というファイルにコピーする。
$ echo 'FOO' > foo.txt
$ wasmtime target/wasm32-wasi/debug/file_copy.wasm foo.txt bar.txt
error opening input foo.txt: failed to find a pre-opened file descriptor through which "foo.txt" could be opened
意に反してエラーになってしまった。
エラーメッセージも興味深い。
failed to find a pre-opened file descriptor
とは、どのような意味だろう?
WASI ではセキュリティー上の方針に基づき wasm にファイルを開く権限を与えていない。
ランタイムがファイルやディレクトリを開き、それを wasm に渡す。
また、ランタイムはユーザーから許可が無いとファイルを開かない。
上記の知識が有ればエラーメッセージの意味が分かるはずだ。
( file descriptor
とは多くの言語でファイルポインターとかファイルハンドルとか言われる物の OS レベルの言い方と考えてほしい。)
failed to find a pre-opened file descriptor
とは「ランタイムである wasmtime が既に開いて渡してくれるはずの file descriptor が見つからない」と言っているのだ。
つまり、本来あるべき処理フローは下記の様になる。
- ユーザーが wasmtime に foo.txt や bar.txt を開く許可を出す
- wasmtime が foo.txt や bar.txt を開いて file_copy.wasm に渡す
- file_copy.wasm が渡されたファイル間で中身のコピーを行う
しかし、今回は下記のフローとなってしまった。
- ユーザーは wasmtime にファイルを開く許可を出さない
- wasmtime は許可が無いのでファイルを開かないし、file_copy.wasm に渡さない(エラーも吐かない)
- file_copy.wasm が「wasmtime がファイルを渡してくれない」とエラーを吐く
なので、先ほどのエラーを解消するためには wasmtime でファイルを開く事を許可すればよい。
$ wasmtime --dir=. target/wasm32-wasi/debug/file_copy.wasm foo.txt bar.txt
$ cat bar.txt
FOO
wasmtime の実行時に --dir=.
というオプション引数を加えた。
コピー元ファイル、コピー先ファイル共にカレントディレクトリなので、これだけで両方開くことが出来る。
--dir
オプションは複数つける事も可能だ。
例えばコピー先ファイルが別のディレクトリーの場合は下記のように実行すればよい。
$ wasmtime --dir=. --dir=/tmp target/wasm32-wasi/debug/file_copy.wasm foo.txt /tmp/bar.txt
一応補足しておくと、wasmtime 自体は OS で動作する普通のアプリケーションである。
OS のアクセス権が正しく設定されていれば、ディレクトリーやファイルを自由に開くことが出来る。
WASI のセキュリティーの考え方
WASI のファイル操作に関する特殊な仕様だが、その意図としてはセキュリティーに対する独自の考え方が有るようだ。
詳細は こちら に書いてあるので、筆者なりの解釈を簡潔にまとめてみる。
昔は administrator の管理する 1 台のコンピューターを複数人のユーザーで使用していた。
専門家である administrator は信頼できるアプリケーションのみをインストールしてユーザーに提供していた。
その様な状況におけるセキュリティー上の懸念として、他のユーザーに共有の重要なファイル(ライブラリやアプリケーション等)を破壊されたり、自分の機密データを閲覧される可能性があった。
(問題の行為が悪意を持って行われるか、操作ミスで行われるかは不明だが。)
このような問題に対応するため、多くの OS ではファイルには実行ユーザーに紐づくアクセス権を設定している。
しかし、現在では administrator 不在のコンピューターを 1 人で使用する事が多くなっている。
そのため他のユーザーの操作によるセキュリティー上の脅威は、かつてほど大きくない。
一方で、利用可能なアプリケーションの数も増えてきた。
専門化ではない一般ユーザーには、その中から信頼できるアプリケーションを選ぶ事は難しい。
アプリケーションを作っているプログラマーも他人の作成したライブラリを使う事が多く、そのアプリケーションがどのファイルにアクセスするか責任もって断言する事が難しくなってきている。
このような状況では、実行ユーザーに紐づくアクセス権だけでは不十分だ。
そこで WASI では、実行時にアクセス可能なファイルをユーザーが指定できるようにした。
これならばアプリケーションにバグや悪意のあるコードが混じっていても、その影響範囲を絞る事が出来る。
まとめ
WASI は近年の IT 事情に対応した環境を作ろうとしている。
筆者も、実行前にユーザーがチェックしやすい仕組みは良いと思う。
一方で WASI は新しく、未完成な規格である。
今後、チェック項目は増え、複雑化していくだろう。
するとセキュリティー上の穴が生まれるかもしれないし、起動時のチェックは形骸化してくるかもしれない。
規格の制定が時代の変化の速さに対応できるかどうかも不明だ。
引き続き注視していきたい。