🦅

[Wasmtime]Rustのwasm32-wasip2でHTTPリクエストクライアントとWebサーバーを動かす(waki)

2024/10/27に公開

はじめに

https://zenn.dev/tkithrta/scraps/79805811a77a07

Rust Version 1.82.0 (2024-10-17)にPromote wasm32-wasip2 to Tier 2.がきたのでaxumをWASMで動かすために最近様々な情報を集めています。
Tokioのさらに低レイヤーにあるmio(Metal I/O)がwasm32-wasip2に対応していないのでほとんどのWebアプリケーションフレームワークは動きませんが、WASI専用のHTTPリクエストクライアント&Webサーバーライブラリのwakiがwasm32-wasip2に対応していたので試しに使ってみることにしました。

https://github.com/wacker-dev/waki

セットアップ

https://wasmtime.dev/

$ curl https://wasmtime.dev/install.sh -sSf | bash
$ export PATH="$HOME/.wasmtime/bin:$PATH"

まずはWASM/WASIのランタイム、Wasmtimeをインストールします。

$ brew install wasmtime

Homebrew(Linuxbrew)でもインストールできます。

$ rustup default 1.82.0-x86_64-unknown-linux-gnu
$ rustup target add wasm32-wasip2

続いてRustのバージョンを1.82以上にして、wasm32-wasip2ターゲットを入手します。

$ wasmtime -V
wasmtime 26.0.0 (c92317bcc 2024-10-22)
$ rustc -V
rustc 1.82.0 (f6e511eec 2024-10-15)
$ cargo -V
cargo 1.82.0 (8f40fc59f 2024-08-21)
$ rustup target list --installed 
wasm32-wasip2
x86_64-unknown-linux-gnu

それぞれインストールできました。

$ cargo init
    Creating binary (application) package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ cargo add waki -F json
    Updating crates.io index
      Adding waki v0.4.0 to dependencies
             Features:
             + json
             - multipart
    Updating crates.io index
     Locking 49 packages to latest compatible versions
      Adding hashbrown v0.14.5 (latest: v0.15.0)
      Adding zerocopy v0.7.35 (latest: v0.8.7)
      Adding zerocopy-derive v0.7.35 (latest: v0.8.7)
$ cargo add serde_json
    Updating crates.io index
      Adding serde_json v1.0.132 to dependencies
             Features:
             + std
             - alloc
             - arbitrary_precision
             - float_roundtrip
             - indexmap
             - preserve_order
             - raw_value
             - unbounded_depth

cargo initコマンドでCargo.toml, targetディレクトリ用の.gitignore, srcディレクトリにmain.rsを作成して、wakiとSerde JSONを追加します。

Cargo.toml
[package]
name = "main"
version = "0.1.0"
edition = "2021"

[dependencies]
serde_json = "1.0.132"
waki = { version = "0.4.0", features = ["json"] }

これで準備ができました。

HTTPリクエストクライアント

まずはHTTPリクエストクライアントを作ってみます。

main.rs
use serde_json::Value;
use std::time::Duration;
use waki::Client;

fn main() {
    let req = "https://httpbin.org/get";
    let headers = [("User-Agent", "WASI/0.2")];
    let res = Client::new()
        .get(req)
        .headers(headers)
        .connect_timeout(Duration::from_secs(5))
        .send()
        .unwrap();
    let status = res.status_code();
    let body = res.json::<Value>().unwrap();
    println!("GET {req} {status}");
    println!("BODY {body}");
}

reqwestみたいで使いやすいですね。

$ cargo build -r --target=wasm32-wasip2
   Compiling main v0.1.0 (/workspace/main)
    Finished `release` profile [optimized] target(s) in 0.75s

ターゲットをwasm32-wasip2にして、Releaseプロファイルでビルドします。

$ wasmtime target/wasm32-wasip2/release/main.wasm
Error: failed to run main module `target/wasm32-wasip2/release/main.wasm`

Caused by:
    0: component imports instance `wasi:http/types@0.2.2`, but a matching implementation was not found in the linker
    1: instance export `fields` has the wrong type
    2: resource implementation is missing

動かそうとしたらエラーが出ました。
Caused byの0に書かれている通り、wasi:httpが見つからないので使えないみたいです。
なので以下のように-Sオプションでhttpを渡してあげると動くようになります。

$ wasmtime -S http target/wasm32-wasip2/release/main.wasm
GET https://httpbin.org/get 200
BODY {"args":{},"headers":{"Host":"httpbin.org","X-Amzn-Trace-Id":"..."},"origin":"...","url":"https://httpbin.org/get"}

最初はヘッダーを設定せずにビルドしたので、User-Agentヘッダーすらない情報が返ってきました。
User-Agentヘッダーを適当に決めて、再度ビルドして動かしてみます。

$ wasmtime -S http target/wasm32-wasip2/release/main.wasm
GET https://httpbin.org/get 200
BODY {"args":{},"headers":{"Host":"httpbin.org","User-Agent":"WASI/0.2","X-Amzn-Trace-Id":"..."},"origin":"...","url":"https://httpbin.org/get"}

いい感じのUser-Agentヘッダーが追加されたので満足です。

Webサーバー

続いてWebサーバーを作ってみます。

main.rs
use std::collections::HashMap;
use waki::{handler, ErrorCode, Request, Response};

#[handler]
fn home(req: Request) -> Result<Response, ErrorCode> {
    let query = req.query();
    let content = query.get("content").map_or("Hello, World!", |v| v);
    let body = HashMap::from([("content", content)]);
    println!("CONTENT {content}");
    Response::builder().json(&body).build()
}

fn main() {}

JavaScriptのfetchのようにhandlerマクロにRequest, Responseに関するコードを書いて、bodyやjsonを返すだけです。
WebアプリケーションフレームワークやHTTPリクエストクライアントのようにクエリやヘッダーも追加できますが、URL Patternのようなルーティングはまだできないみたいです。

なお、main関数は使わないので必要ないのですが、バイナリとしてビルドしようとするとエラーが出てしまうので、main関数に何もしない処理を書いておく必要があります。

$ cargo build -r --target=wasm32-wasip2
   Compiling main v0.1.0 (/workspace/main)
    Finished `release` profile [optimized] target(s) in 0.75s
$ wasmtime -S http target/wasm32-wasip2/release/main.wasm

ビルドして先ほどと同じコマンドで動かそうとしても何も起こりません。
それもそのはず、先ほどmain関数に何もしない処理を書いたからこうなります。

そこでwasmtime serveコマンドを使います。

$ wasmtime serve target/wasm32-wasip2/release/main.wasm
Error: component imports instance `wasi:cli/environment@0.2.0`, but a matching implementation was not found in the linker

Caused by:
    0: instance export `get-environment` has the wrong type
    1: function implementation is missing

あれ???
動かそうとしたらまたエラーが出てしまいました……。

エラーメッセージに書かれている通り、wasi:cliが見つからないので使えないみたいです。
なので以下のように-Sオプションでcliを渡してあげると動くようになります。

$ wasmtime serve -S cli target/wasm32-wasip2/release/main.wasm
Serving HTTP on http://0.0.0.0:8080/

なるほどCLIにログを表示するために使っているんですね。
curlで確認してみましょう。

$ curl -i http://localhost:8080/
HTTP/1.1 200 OK
content-type: application/json
transfer-encoding: chunked
date: Sun, 27 Oct 2024 08:00:00 GMT

{"content":"Hello, World!"}

ちゃんと動いていて感動。

$ wasmtime serve -S cli target/wasm32-wasip2/release/main.wasm
Serving HTTP on http://0.0.0.0:8080/
stdout [0] :: CONTENT Hello, World!

標準出力の内容がログにも表示されました。

$ curl -i http://localhost:8080/?content=foo
HTTP/1.1 200 OK
content-type: application/json
transfer-encoding: chunked
date: Sun, 27 Oct 2024 08:00:00 GMT

{"content":"foo"}

クエリも使えます。

$ wasmtime serve -S cli target/wasm32-wasip2/release/main.wasm
Serving HTTP on http://0.0.0.0:8080/
stdout [0] :: CONTENT Hello, World!
stdout [1] :: CONTENT foo

期待通りの動作で満足しました。

合成

最後にHTTPリクエストクライアントとWebサーバーを合体させたWebアプリを作ってみます。

main.rs
use serde_json::{json, Value};
use std::time::Duration;
use waki::{handler, Client, ErrorCode, Request, Response};

#[handler]
fn home(req: Request) -> Result<Response, ErrorCode> {
    let query = req.query();
    let req = query
        .get("id")
        .filter(|v| v.starts_with("http:") || v.starts_with("https:"))
        .map_or("https://httpbin.org/get", |v| v);
    let req_headers = [
        ("Accept", "application/activity+json"),
        ("User-Agent", "WASI/0.2"),
    ];
    let res = Client::new()
        .get(req)
        .headers(req_headers)
        .connect_timeout(Duration::from_secs(5))
        .send()
        .unwrap();
    let status = res.status_code();
    let body = res.json::<Value>().unwrap_or(json!({}));
    let res_headers = [("Content-Type", "application/activity+json")];
    println!("GET {req} {status}");
    Response::builder().json(&body).headers(res_headers).build()
}

fn main() {}

私はActivityPub実装を作る時にContent-Type: application/activity+jsonをGETすることがよくあるので、クエリにURLを渡すだけで簡単にGETしてきてくれるアプリを作りました。
これならWebブラウザからも使えます。

$ cargo build -r --target=wasm32-wasip2
   Compiling main v0.1.0 (/workspace/main)
    Finished `release` profile [optimized] target(s) in 0.75s
$ wasmtime serve -S cli target/wasm32-wasip2/release/main.wasm
Serving HTTP on http://0.0.0.0:8080/

ビルドして動かします。

$ curl -i http://localhost:8080/
HTTP/1.1 200 OK
content-type: application/activity+json
transfer-encoding: chunked
date: Sun, 27 Oct 2024 08:00:00 GMT

{"args":{},"headers":{"Accept":"application/activity+json","Host":"httpbin.org","User-Agent":"WASI/0.2","X-Amzn-Trace-Id":"..."},"origin":"...","url":"https://httpbin.org/get"}

クエリなしのルートパスにアクセスしてエラーが出たら困るので、HTTPBinからGETしてくるようにしました。

$ wasmtime serve -S cli target/wasm32-wasip2/release/main.wasm
Serving HTTP on http://0.0.0.0:8080/
stdout [0] :: GET https://httpbin.org/get 200

標準出力の内容もログにちゃんと表示されています。

$ curl -i http://localhost:8080/?id=https://mastodon.social/@Gargron
HTTP/1.1 200 OK
content-type: application/activity+json
transfer-encoding: chunked
date: Sun, 27 Oct 2024 08:00:00 GMT

{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",...}

Content-Type: application/activity+jsonをGETできました。

$ wasmtime serve -S cli target/wasm32-wasip2/release/main.wasm
Serving HTTP on http://0.0.0.0:8080/
stdout [0] :: GET https://httpbin.org/get 200
stdout [1] :: GET https://mastodon.social/@Gargron 200

WASIでWebアプリが作れてとても満足しました。
ちなみにこの.wasmファイルのサイズは390KBぐらいです。

おわりに

WASMやWASIに関するニュースを見るたびに、一体何に使うの? と思うことがよくあるのですが、実際にコードを書いて使ってみると最近のJavaScript/TypeScriptランタイムやサーバーレスアプリケーションサービスと何ら変わらないことが分かりました。
今後のWASMやWASIの発展に期待しつつ、早くaxumをWASMで動かしたい今日この頃です。

Discussion