WebAssembly上で動くLunaticランタイムを使ったHTTPサーバーを実装する
「Lunatic」という少し前から注目している技術があります。これは WebAssembly 上で動く Erlang にインスパイアされたランタイムで、Rust で実装されています。WebAssembly 形式でのバイナリを実行できる言語なら、どんな言語でもこのランタイムの上であれば理論的には動かすことができるようです。さまざまな言語のプラットフォームとして動く、セキュリティ面などの基本的な WebAssembly のメリットを享受することができます。
さて、Rust のエコシステムの一部として Lunatic を見てみると、Lunatic は tokio などと同様「非同期ランタイム」に位置付けられるものではないかと思います。下記の特徴をもつランタイムといえるでしょう。
- Lunatic は WebAssembly を利用していることから、たとえば C とのバインディング時にもより安全に利用できるなどのセキュリティ面でのメリットが強調されています。これは WebAssembly の提供するサンドボックス性によるもので、仮に C のコードに脆弱性等があったとしても、その影響範囲を現在実行しているプロセスに絞ることができる、といったようなメリットがあります。
- Lunatic 上で動くすべてのプロセスは、プリエンプティブにスケジュールされ、ワークスティーリングな非同期実行機構によって実行されるようです。[1]これにより普通にブロッキング処理をするようなコードを書いたとしても、ランタイム側で一切ブロッキングさせないように調整してくれるため、Lunatic 上で動かすことを想定したコードには async/await の類の操作は一切不要になります。この点については HTTP サーバーを実装する際に確認します。
- Lunatic は、最終的には WASI との完全な互換を担保する予定でいるようです。ただ、この互換性についてはまだ実現できてはいないようです。
私は Erlang/OTP 自体は詳しくないのですが Erlang/OTP の掲げる哲学が好きで、それを同様に標榜しているこのランタイムも好き、ということで今日は触ってみようと思います[2]。Lunatic はさまざまな用途での利用が期待できますが、とくに今回は Web アプリケーション開発におけるサーバーサイド部分の開発に焦点をあてて見ていきたいと思います。
記事の前提についてですが、 Rust と Rust のエコシステムをある程度知っている方を対象としています。そのため、Rust の Web に関係するエコシステムに関する基本的な説明はすべて省いています。あらかじめご了承ください。
Lunatic 上で動く HTTP サーバー
最近発見したのですが、この Lunatic ランタイム上で動かせる HTTP サーバーのクレートとして「submillisecond」があるようです。まだまだ利用できる機能は少ないですが、
- コンパイルが速い(おそらく、tokio をはじめとしたコンパイル時間を大幅に伸ばす要因になるクレートを Rust 側に含まないのでということかと思われるが、どうなんでしょうね)
- async を書かなくていい(全部 Lunatic 側で制御されるので)
- セキュリティ面も安心できる(Lunatic プロセスで全部のリクエストがハンドルされるので)
といった特徴があると README では説明されています。
実装は actix-web や Axum といった Rust で使われる他のクレートによく似ているため、それらのクレートを触ったことがある方であれば、すぐに使いこなせるようになる気がしています。ルーターにハンドラーの情報を登録しておいたり、関数を実装してそこでリクエストとレスポンスのやりとりを行わせるという点は、こうした他のクレートとほとんど大差ないと言えるはずです。
一方で、actix-web や Axum などとは違い、Lunatic 上で動かすアプリケーションは Rust の async/await を使う必要がありません。[3]submillisecond での実装例を後ほど見て確認しますが、そこに async/await は登場しません。もちろん、doc 曰く async/await や tokio が必要になる場面で要求されるような大規模な並行処理はそのまま対応可能なようです。さすがに通常の Rust と tokio の組み合わせと同等の性能は出ないとは思いますが、それなりに満足できる性能が出るのだとしたら非常に有力な選択肢になり得ます。
async/await は残念ながら少々複雑です。async/await 自体は便利ですし、大量の並行処理を必要とするような HTTP サーバーの開発などではなくてはならないものかとは思います。一方でその裏側にある仕組みは相当複雑で全容を把握するのがなかなか難しくなっています。通常関数から async/await 関数への切り替えは、基本は少しでよいのですが場合によっては大掛かりに実装を修正する必要があるなどの手間も発生します。非同期プログラミングを便利にはしますが、async/await を必要としない「通常のプログラミングスタイル」と比較すると別の複雑性を持ち込んでいるためです。[4]
実行環境のセットアップ
サンプルで試す環境のセットアップをしていきましょう。
リポジトリ
Lunatic のセットアップ
Lunatic はセットアップが必要です。まず、cargo install で Lunatic をインストールする必要があります。
$ cargo install lunatic-runtime
インストール後、バージョンを確認することができます。今回は v0.10.0 を使用しています。
$ lunatic --version
lunatic 0.10.0
新規プロジェクトのセットアップ
WebAssembly に関するセットアップが必要です。wasm32-wasi
をターゲットにビルドできるように設定します。
$ rustup target add wasm32-wasi
info: downloading component 'rust-std' for 'wasm32-wasi'
info: installing component 'rust-std' for 'wasm32-wasi'
これを行なった上で、新しいプロジェクトを作成しましょう。今回は「submillisecond-example」という新規プロジェクトを作成します。
$ cargo new submillisecond-example
次に、WASM を作成できるように Cargo に対する設定を追加します。.cargo/config.toml
を作成します。
$ mkdir .cargo
$ cd .cargo
$ touch config.toml
次のようなディレクトリ構成になっていれば一旦成功です。
$ ls -a --tree
.
├── .cargo
│ └── config.toml
├── Cargo.lock
├── Cargo.toml
└── src
└── main.rs
config.toml には次の記述を追加します。これにより、ビルド時に wasm32-wasi
で、かつ Lunatic 上で起動可能な実行可能ファイルが target
配下に作成されます。
[build]
target = "wasm32-wasi"
[target.wasm32-wasi]
runner = "lunatic"
プロジェクトをビルドしてみましょう。ビルドすると、target/wasm32-wasi/debug
以下に、今回使用したい submillisecond-example.wasm
が作成されていることも確認できるでしょう。
$ cargo build
$ ls --tree target --level 3
target
├── CACHEDIR.TAG
├── debug
│ ├── build
│ ├── deps
│ ├── examples
│ └── incremental
└── wasm32-wasi
├── CACHEDIR.TAG
└── debug
├── build
├── deps
├── examples
├── incremental
├── submillisecond-example.d
└── submillisecond-example.wasm
最後に、下記コマンドで Lunatic 上でビルドされた .wasm
を実行できます。
$ lunatic target/wasm32-wasi/debug/submillisecond-example.wasm
Hello, world!
いくつか簡単なエンドポイントを実装する
それではいくつか簡単なエンドポイントを実装していきます。今回はサンプル程度なので、JSON 形式のリクエストを送ると JSON 形式のレスポンスを返すエンドポイントを簡単に実装しようと思います。
下準備
最初に2つのクレートを利用できるように設定を追加します。
- submillisecond: 今回使う HTTP サーバー用のクレートです。JSON を利用したやりとりをしたいので、「json」という機能をオンにしておきます。
- serde: 「derive」という機能をオンにしておきます。
$ cargo add submillisecond -F json
$ cargo add serde -F derive
Cargo.toml の dependencies セクションが下記のようになっていれば、準備完了となります。
[dependencies]
serde = { version = "1.0.145", features = ["derive"] }
submillisecond = { version = "0.2.0-alpha1", features = ["json"] }
ユーザーの一覧を表示したり、送った内容をおうむ返しするエンドポイントを作る
今回実装したエンドポイントは下記です。
-
GET /users
: アプリケーションに登録済みのユーザーの一覧を返します。今回は実装の簡単のため、配列に入れたユーザー情報をそのまま JSON に変えて返しています。 -
POST /echo
: JSON 形式のリクエストを受け取り、そこから内容を読み取って所定のメッセージを返すエンドポイントです。
これらのエンドポイントを実装したコードは下記です。起動後は 3000 番ポートをリッスンするようになっています。
use serde::{Deserialize, Serialize};
use submillisecond::{router, Application, Json};
#[derive(Serialize)]
struct User {
id: String,
}
impl User {
fn new(id: impl Into<String>) -> Self {
User { id: id.into() }
}
}
// handler が正しく型解決されるためには、Debug が必要らしい。直感に反するので修正した方がいいとは思う。
#[derive(Deserialize, Debug)]
struct PersonalInfo {
name: String,
age: u8,
}
fn show_registered_users() -> Json<Vec<User>> {
Json(vec![
User::new("user-a"),
User::new("user-b"),
User::new("user-c"),
])
}
fn echo_personal_info(Json(req): Json<PersonalInfo>) -> String {
format!(
"Hi! Your name is {} and you're {} years old, right?",
req.name, req.age
)
}
fn main() -> std::io::Result<()> {
Application::new(router! {
GET "/users" => show_registered_users
POST "/echo" => echo_personal_info
})
.serve("0.0.0.0:3000")
}
実際にいくつか curl を使ってリクエストを送ってみましょう。まず GET /users
からですが、下記のように配列に含まれたユーザー情報を返すようになっています。
$ curl localhost:3000/users -v
* Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /users HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.79.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: application/json
< content-length: 49
<
* Connection #0 to host localhost left intact
[{"id":"user-a"},{"id":"user-b"},{"id":"user-c"}]
次に POST /echo
ですが、こちらは送った JSON のデータが反映されたメッセージが返されていることがわかります。
$ curl -X POST localhost:3000/echo -d '{"name": "Ayaka", "age": 24}' -H "Content-Type: application/json" -v
Note: Unnecessary use of -X or --request, POST is already inferred.
* Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> POST /echo HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.79.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 28
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: text/plain; charset=utf-8
< content-length: 54
<
* Connection #0 to host localhost left intact
Hi! Your name is Ayaka and you're 24 years old, right?
実装それ自体で目新しい点は特段ないと思いますが、ルーターにハンドラーを設定する部分でマクロ(router!
)が使われています。一見自由そうに見えるこの記述部分ですが、マクロであることから、所定のフォーマットに沿わない形式の入力があった場合にはコンパイルエラーとして検知されます。詳しいマクロの使い方やこれ以外のフォーマットについては README に割と多く書いてあるので、それらを参照しながら記述すると実装できるかと思います。
fn main() -> std::io::Result<()> {
Application::new(router! {
GET "/users" => show_registered_users
POST "/echo" => echo_personal_info
})
.serve("0.0.0.0:3000")
}
特徴的なのは async/await が一切登場していない点です。Rust の async/await は裏側で Future に変換され、その Future で切られた単位で[5]スケジューラーに登録され、実行調整されるという構成をしています。この代わりになることを Lunatic がやってくれているのだと思っていますが、実装を詳しく読んでいない関係で確証はありません。[6]
Lunatic プロセスを利用した実装をする
ところで、せっかく裏側には Lunatic ランタイムがあるということで、それらを使った事例も見てみたくなってくることでしょう。ちょうど examples に手頃なサンプルがあったので、これを少し改変して Lunatic が管理するプロセスを用いた実装をしてみたいと思います。
クレートの追加
「lunatic」と「uuid」を追加しています。lunatic は Rust から Lunatic の機能(たとえば、後述する Process など)を利用するために必要になります。uuid はこのあと実装するアプリケーションの実装内で UUIDv4 を用いて ID を生成させる箇所があるので追加しています。
lunatic = "0.11.4"
serde = { version = "1.0.145", features = ["derive"] }
submillisecond = { version = "0.2.0-alpha1", features = ["json"] }
uuid = { version = "1.1.2", features = ["serde", "v4"] }
サンプルコード
下記が Lunatic の「プロセス」を用いて実装してみたサンプルです。今回はよくある Todo リストを返したり登録したりできるサンプルを用意しました。Todo リスト自体はアプリケーション全体で共有するリストに登録できるようにしています。
use lunatic::{
process::{
AbstractProcess, Message, MessageHandler, ProcessRef, Request, RequestHandler, StartProcess,
},
supervisor::{Supervisor, SupervisorConfig},
};
use serde::{Deserialize, Serialize};
use submillisecond::{http::StatusCode, router, Application, Json};
use uuid::Uuid;
pub struct PersistentSupervisor;
impl Supervisor for PersistentSupervisor {
type Arg = String;
type Children = PersistentProcess;
fn init(config: &mut SupervisorConfig<Self>, name: Self::Arg) {
config.children_args(((), Some(name)))
}
}
pub struct PersistentProcess {
todos: Vec<Todo>,
}
impl AbstractProcess for PersistentProcess {
type Arg = ();
type State = Self;
fn init(_: ProcessRef<Self>, _: Self::Arg) -> Self::State {
PersistentProcess { todos: Vec::new() }
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Todo {
id: Uuid,
title: String,
description: String,
}
#[derive(Serialize, Deserialize)]
pub struct AddTodo(Todo);
impl MessageHandler<AddTodo> for PersistentProcess {
fn handle(state: &mut Self::State, AddTodo(todo): AddTodo) {
state.todos.push(todo);
}
}
#[derive(Serialize, Deserialize)]
pub struct ListTodo;
impl RequestHandler<ListTodo> for PersistentProcess {
type Response = Vec<Todo>;
fn handle(state: &mut Self::State, _: ListTodo) -> Self::Response {
state.todos.clone()
}
}
#[derive(Deserialize, Debug)]
pub struct CreateTodo {
title: String,
description: String,
}
fn create_todo(Json(req): Json<CreateTodo>) -> StatusCode {
let persistence = ProcessRef::<PersistentProcess>::lookup("persistence").unwrap();
let todo = Todo {
id: Uuid::new_v4(),
title: req.title,
description: req.description,
};
persistence.send(AddTodo(todo));
StatusCode::CREATED
}
fn list_todo() -> Json<Vec<Todo>> {
let persistence = ProcessRef::<PersistentProcess>::lookup("persistence").unwrap();
Json(persistence.request(ListTodo))
}
fn main() -> std::io::Result<()> {
PersistentSupervisor::start_link("persistence".to_string(), None);
// doc によると router! マクロの中身は下記のようにまとめられるようだが、
// 実際にこうして確認すると 404 NotFound になり、エンドポイントが認識されないようだ。
// "/api/todos" => {
// POST "/" => create_todo
// GET "/" => list_todo
// }
Application::new(router! {
POST "/api/todos" => create_todo
GET "/api/todos" => list_todo
})
.serve("0.0.0.0:3000")
}
「Supervisor」と「Process」
この実装で重要な役割をもつものが2つあります。
- Supervisor
- Process (AbstractProcess)
Process は Lunatic の中心的なコンセプトです。Lunatic のランタイムでは軽量スレッドを管理しますが、それを表現するのがこのプロセスです。プロセス間は分離されており、データの読み書きは後述する Message と Request という機能を使って行います。
Supervisor は Process の生死などを管轄する監視者にあたります。子にプロセスを持ちます。また、Supervisor が Process のパニックを検知すると、自動でそのプロセスを再起動させます。
この関係性は見覚えがある方がいるかもしれません。直接は言及されておらず関連があるかどうかははっきりとはわかりませんが、Erlang/OTP や Scala の Akka のアクターモデルを思い出させます。実際プロセスには Mailbox が用意されており、この Mailbox にメッセージを送る形でプロセス内のさまざまな操作を命令することができます。
今回は PersistentSupervisor
という型を用意しておき、その型に対して Supervisor
トレイトを実装しています。子に PersistentProcess
という後述する型をプロセスとして認識させたいので、それを指定します。
pub struct PersistentSupervisor;
impl Supervisor for PersistentSupervisor {
type Arg = String;
type Children = PersistentProcess;
fn init(config: &mut SupervisorConfig<Self>, name: Self::Arg) {
config.children_args(((), Some(name)))
}
}
プロセスは PersistentProcess
という型を用意しました。プロセスという概念自体は私もあまりわかってはいませんが、おそらくプロセスをまたいで共有させたいデータを持たせておくのがメインの目的なのではないかと思っています。今回はインメモリキャッシュを模したものとして Todo
というデータをもつベクターをプロセスに持たせています。のちに実装するハンドラを経由して、この共有されたデータに対する操作を行うことができます。
pub struct PersistentProcess {
todos: Vec<Todo>,
}
impl AbstractProcess for PersistentProcess {
type Arg = ();
type State = Self;
fn init(_: ProcessRef<Self>, _: Self::Arg) -> Self::State {
PersistentProcess { todos: Vec::new() }
}
}
MessageHandler と RequestHandler
ハンドラーは総じてプロセスの内部状態にアクセスし、内部にもつ状態に特定の操作をすることを定義する機能かと思われます。ただ、その操作の仕方によって2種類に分けられるようです。[7]
MessageHandler
は、送られてくる「リクエスト」を受け取ると裏で非同期にプロセス内部で保持する状態を変更する処理を進めます。レスポンスを返すことをとくに想定していないので、返り値はユニット型になっています。
RequestHandler
は、リクエストを受け取ったのち結果を得られるまでプロセスをブロックしておき、処理を一通り終えて結果を得られたらレスポンスを返すといった操作をします。結果を返すのでレスポンスの型を定義できます。
下記のコードでは、それぞれのハンドラの定義を書いてみたものです。AddTodo
や ListTodo
は、それぞれプロセスに対して送られてくるリクエストになります。
#[derive(Serialize, Deserialize)]
pub struct AddTodo(Todo);
impl MessageHandler<AddTodo> for PersistentProcess {
fn handle(state: &mut Self::State, AddTodo(todo): AddTodo) {
state.todos.push(todo);
}
}
#[derive(Serialize, Deserialize)]
pub struct ListTodo;
impl RequestHandler<ListTodo> for PersistentProcess {
type Response = Vec<Todo>;
fn handle(state: &mut Self::State, _: ListTodo) -> Self::Response {
state.todos.clone()
}
}
これらのハンドラは、使う側では「どの型でリクエストが送られたか」で判別できるようです。MessageHandler への送信には send
という関数、RequestHandler への送信には request
という関数を用います。具体的にどのハンドラが呼び出されるかはリクエストの型によって自動的に決定されるようです。
fn create_todo(Json(req): Json<CreateTodo>) -> StatusCode {
let persistence = ProcessRef::<PersistentProcess>::lookup("persistence").unwrap();
let todo = Todo {
id: Uuid::new_v4(),
title: req.title,
description: req.description,
};
persistence.send(AddTodo(todo));
StatusCode::CREATED
}
fn list_todo() -> Json<Vec<Todo>> {
let persistence = ProcessRef::<PersistentProcess>::lookup("persistence").unwrap();
Json(persistence.request(ListTodo))
}
このような設計にしておくことで、アクターモデルを利用することで得られる基本的なメリットを Lunatic でも同様に得られているのではないかと思います。とくにプロセス(というか軽量スレッドというか)をまたいでデータを読み書きするのが楽になるのは、実装面と性能面で大きなメリットがあると思います。
感想や評価
個人的には期待しているプロジェクトです。Rust で Akka 等に相当する技術を利用できるようになると、より Rust をさまざまなアプリケーションに導入していきやすくなると思います。
Lunatic 自体は、私が WebAssembly 側がまったくわからないため評価が難しいです。とくによくわかっていないのが、なぜ Lunatic を利用すると Rust の async/await が不要になるのかという点です。[8]漠然とした理解はしているつもりですが、それをすべて説明し切ろうと思うとおそらく WebAssembly 側を少しわかっている必要がありそうに思いました。あとは単純にこの手法で十分満足できる性能が得られるのかとかも気になっています。
調べている中で、Lunatic の内部的な話を作者が少しする動画が見つかりました。私もすべては見ていませんが、前半20分くらいを視聴して結構いろいろな情報を得ることができました。全部視聴し切るとより理解が深まるかもしれません。
Lunatic 自体はまだ私が理解の途上ということもあり、もう少し深掘りが必要そうです。年末に時間と興味がまだあったら考えてみようかと思っています。
HTTP サーバーのクレートにあたる submillisecond は、現時点ではまだまだ開発段階かもしれません。少なくとも私が触っていただけで、直感的でない箇所と router!
が提供するルーティング機構に不具合(いわゆる「動いてない」レベルのもの)、不要そうな標準出力が見つかったので、本番環境に今から入れるのは難しい段階なように思いました。これらは記事のコード中にコメントしている通りです。こちらも今後の発展に期待です。
ちなみに Lunatic プロジェクト自体は Y Combinator から出資を受けているみたいです。こういう革新的で野心的なプロジェクトにしっかりパトロンがつく、というのはやっぱりすばらしいと思いました。
-
"All processes running on Lunatic are preemptively scheduled and executed by a work stealing async executor." https://github.com/lunatic-solutions/lunatic#architecture ↩︎
-
余談ですが作者も Erlang/OTP が非常にお好きなようです。「Erlang/OTP and the way of structuring execution into actors left a specially strong impression. In a world where many are arguing between a monolithic and microservice based application structure approach, Erlang lets you start building your application on a single server (monolithic), but later allows you to scale up and get distribution for "free".」という文章は強く共感します。https://kolobara.com/lunatic/index.html#motivation ↩︎
-
Rust の async/await の課題については Lunatic のブログによくまとまっています。https://lunatic.solutions/blog/rust-without-the-async-hard-part/ ↩︎
-
書いていて思いましたが、そう考えると Go はよくできていますね。実際サーバーサイドの開発をしていて、まず今記述している内容が並行処理に関するものであることをほとんど意識することはないです。また、データのやりとりやキャンセルなども基本的には async/await のような非同期プログラミングは求められず、通常のプログラミングモデルで対処しきれますね。 ↩︎
-
「非同期タスク」などと呼んだりします。 ↩︎
-
WebAssembly 側で preemptive points を挿入してくれるということらしい、というところまでは動画をみてわかりました。https://youtu.be/M2B7VKFeF7o ↩︎
-
この辺りの整理はコード中のコメントをなんとか読んで解釈したものです。https://github.com/lunatic-solutions/lunatic-rs/blob/5b72686f7a18f6dc305b0bc1f695977741c581ec/src/process.rs#L16-L67 ↩︎
-
具体的には async/await で定義された非同期タスクのスケジューラーへの登録部分です。async/await を利用しないとこれは発生せず、基本的に全部を Lunatic のスケジューラーに登録することになるのだと思いますが、そうするとブロッキングかノンブロッキングかの区別はどうやってつけるのだろうか、などがよくわかっていません。 ↩︎
Discussion