ブラウザの外のWebAssembly: WASIとサーバーサイドWasm
1. 概要
WebAssembly(Wasm)は、ブラウザで高性能コードを実行するために生まれました。しかし、その特性(コンパクト、厳密なサンドボックス、CPUアーキテクチャに非依存)はサーバーサイドでも非常に魅力的です。
この記事では、WASIが何なのか、なぜサーバーサイドWasmに注目が集まっているのかを理解します。Wasmモジュールをビルドし、CLIランタイムで実行し、Java 21アプリケーションに埋め込み、テストを書きます。記事の最後は、Wasmを使わない方がいい場合です。
2. なぜWebAssemblyはWASIが必要なのか?
2.1. “素の” Wasmは外部の世界では何もできない
純粋なWasmモジュールは、その線形メモリ(linear memory)上で計算することしかできません。ファイルを開く、環境変数を読む、システムクロックを取得する、またはソケットを開くことができません。外部との相互作用は、すべて、host(モジュール実行環境)が提供する関数をimportとして通す必要があります。
ブラウザでは、hostはJavaScriptです。ブラウザの外では、統一されたhostがまだありません。各runtimeが独自のAPI セットを定義していれば、あるruntimeのためにビルドされたモジュールは、別のruntimeで実行できません。
2.2. WASI: Wasm用の標準システムインターフェース
WASI(WebAssembly System Interface)は、ファイル、クロック、ランダム、環境変数、コマンドラインパラメータなどのOSのような操作のための標準APIセットです。また、より新しいバージョンではネットワークも含まれます。
WASIをターゲットにコンパイルされたモジュールは、Wasmtime、WasmEdge、Wasmerなど、WASIをサポートするどのruntimeでも実行できます。またはアプリケーションに埋め込まれたruntimeです。
最も重要な違いはcapabilityモデルです。モジュールはデフォルトでは何の権限も持っていません。Hostは明示的に権限を付与する必要があります。例えば、「./dataディレクトリを読む権限がある」というように。権限がないと、関数呼び出しは失敗します。これは、通常のプロセスとは逆のやり方です。通常のプロセスはそれを実行するユーザーのすべての権限を継承します。
2.3. WASI Preview 1、Preview 2、およびComponent Model
WASIは多くの段階を経てきました。一般的な2つの名前に出会うでしょう:
- Preview 1 (wasip1): C言語のような関数形式のAPI、最も広くサポートされています。
- Preview 2 (wasip2): Component Model上に構築されています。インターフェースはWITという名前のIDL言語で記述されます。異なる言語で書かれたコンポーネントは、整数だけではなく、文字列、レコード、リストなどの豊富なデータ型を通じて相互に呼び出すことができます。
サポートのレベルはruntimeとtoolchainによって異なります。選択する前に、使用しようとするruntimeのドキュメントを確認する必要があります。
3. なぜサーバーサイドWasmが注目されているのか?
主な理由は4つあります:
- デフォルトのサンドボックス: モジュールは自身のメモリ領域外にアクセスできず、付与されない限りシステム権限がありません。これは、顧客プラグインやユーザーがアップロードしたスクリプトなど、信頼できないコードを実行するのに非常に適しています。
- 高速起動、小さなフットプリント: モジュールは通常、コンテナまたは新しいJVMと比べて軽量で、はるかに高速に初期化されます。したがって、Wasmはserverlessとedgeに適しており、各requestが独立したインスタンスを必要とする場合があります。
- ポータブル: 1つの
.wasmファイルは、x86またはARM、LinuxまたはmacOSで実行されます。runtimeがあれば問題ありません。 - 多言語: Rust、C/C++、Go、Zig、AssemblyScript…はすべてWasmにコンパイルできます。JavaアプリケーションはJNIを必要とせずに、別の言語で書かれたロジックをロードできます。
コンテナとの簡単な比較:
| 項目 | コンテナ | Wasm + WASI |
|---|---|---|
| パッケージング単位 | userland OSを含むイメージ | 単一の.wasmファイル |
| 分離 | kernelのnamespace/cgroup | runtime のサンドボックス + capability |
| アーキテクチャ依存性 | archごとのイメージが必要 | 不要 |
| エコシステム | 非常に成熟 | 発展中 |
Wasmがコンテナを置き換えるわけではありません。2つは互いに補完することが多いです。コンテナ内でWasmランタイムを実行することもできます。
4. WASIプログラムをビルドして実行する
例ではRustを使います。RustのWasm toolchainはかなり便利だからです。同じ考え方は他の言語にも適用できます。
4.1. オーダーファイルを読むプログラム
// src/main.rs
use std::fs;
fn main() {
match fs::read_to_string("orders.csv") {
Ok(content) => {
let total: f64 = content
.lines()
.filter_map(|line| line.split(',').nth(1))
.filter_map(|amount| amount.trim().parse::<f64>().ok())
.sum();
println!("注文の総額: {total}");
}
Err(e) => eprintln!("ファイルが読めません: {e}"),
}
}
プログラムはorders.csvを読み、2番目の列(金額)を取得して合計します。Wasmに特別な点はありません: std::fsはコンパイル時にWASI呼び出しにマップされます。
targetを追加してビルドします:
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
結果はtarget/wasm32-wasip1/release/order-report.wasmファイルです(パッケージ名によります)。
4.2. Wasmtimeで実行してcapabilityが動作するのを確認
データファイルを作成します:
ORD-1,120.5
ORD-2,79.5
ORD-3,300
ディレクトリ権限を付与しないで実行します:
wasmtime run target/wasm32-wasip1/release/order-report.wasm
ファイルが読めません: failed to find a pre-opened file descriptor through which "orders.csv" could be opened
モジュールは実行されますが、ファイルが見えません。ファイルシステムへのcapabilityがありません。現在のディレクトリに権限を付与します:
wasmtime run --dir=. target/wasm32-wasip1/release/order-report.wasm
注文の総額: 500
--dirフラグはモジュール用にディレクトリを「pre-open」します。モジュールはそのディレクトリだけを見て、/etcやホームディレクトリは見えません。
エラーメッセージの内容はruntimeのバージョン間で異なる可能性がありますが、動作は同じです。
注意: フラグの構文(
--dir、--env、ホストからゲストへのディレクトリマッピング)はWasmtimeのバージョン間で変更される可能性があります。マシンでwasmtime run --helpを確認してください。
5. JavaアプリケーションにWasmを埋め込む
サーバーサイドWasmはCLIから.wasmファイルを実行するだけではありません。非常に実用的な方法は、既存のアプリケーションにruntimeを埋め込むことで、プラグインまたは第三者が提供するロジックを実行することです。
Javaでは、2つの方向があります:
- ネイティブランタイム(Wasmtime、WasmEdge)へのバインディングをJNIまたはFFMを通じて行う。
- 完全にJavaで書かれたruntimeを使う。例えばChicory。ネイティブライブラリが必要ないため、簡単にパッケージ化でき、JVMがあるどこでも実行できます。
以下の例ではChicoryを使用しています。
5.1. 配送料金を計算するモジュール
パートナーがWasm形式で配送料金計算ロジックを提供していると仮定します:
// src/lib.rs, Cargo.toml に: [lib] crate-type = ["cdylib"]
#[no_mangle]
pub extern "C" fn shipping_fee(weight_grams: i32, distance_km: i32) -> i32 {
let base = 15_000;
let by_weight = (weight_grams / 500) * 5_000;
let by_distance = distance_km * 1_000;
base + by_weight + by_distance
}
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/shipping.wasm src/main/resources/
このモジュールはWASIを使いません。計算するだけなので、wasm32-unknown-unknownターゲットで十分で、モジュールはインポートを必要としません。
5.2. Maven依存関係
<properties>
<java.version>21</java.version>
<!-- Maven Centralで最新バージョンを確認してください -->
<chicory.version>REPLACE_WITH_LATEST</chicory.version>
</properties>
<dependencies>
<dependency>
<groupId>com.dylibso.chicory</groupId>
<artifactId>runtime</artifactId>
<version>${chicory.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Spring Boot 3.xを使用している場合、JUnitおよびAssertJのバージョンはすでにspring-boot-starter-testを通じて管理されています。
5.3. JavaからWasm関数を呼び出す
package dev.nonla.shipping;
import com.dylibso.chicory.runtime.ExportFunction;
import com.dylibso.chicory.runtime.Instance;
import com.dylibso.chicory.wasm.Parser;
import java.io.InputStream;
public class WasmShippingCalculator {
private final ExportFunction shippingFee;
public WasmShippingCalculator(String resource) {
try (InputStream wasm = getClass().getResourceAsStream(resource)) {
var module = Parser.parse(wasm);
Instance instance = Instance.builder(module).build();
this.shippingFee = instance.export("shipping_fee");
} catch (Exception e) {
throw new IllegalStateException("モジュール " + resource + " をロードできません", e);
}
}
public int fee(int weightGrams, int distanceKm) {
long[] result = shippingFee.apply(weightGrams, distanceKm);
return (int) result[0];
}
}
説明:
Parser.parse(...)はWasm bytecodeをモジュールモデルに読み込みます。Instance.builder(module).build()は独立したメモリを持つインスタンスを作成します。モジュールはインポートを行わないため、host関数を提供する必要はありません。instance.export("shipping_fee")はエクスポートされた関数を取得します。名前はRust側の#[no_mangle]と一致する必要があります。apply(...)はlong[]を受け取ります。Wasmの数値型(i32、i64など)は共通してlongとして表現されるため、自分でintにキャストします。
試行してみます:
var calculator = new WasmShippingCalculator("/shipping.wasm");
System.out.println(calculator.fee(1_200, 10));
35000
結果は15,000の基本料金、プラス体重による2 × 5,000 (1,200 / 500 = 2)、プラス距離による10 × 1,000で構成されています。
5.4. 動作をテストする
package dev.nonla.shipping;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class WasmShippingCalculatorTest {
static WasmShippingCalculator calculator;
@BeforeAll
static void setUp() {
calculator = new WasmShippingCalculator("/shipping.wasm");
}
@Test
void givenLightParcelNearby_whenFee_thenOnlyBaseAndDistance() {
assertThat(calculator.fee(300, 2)).isEqualTo(17_000);
}
@Test
void givenHeavyParcelFarAway_whenFee_thenAllComponentsAdded() {
assertThat(calculator.fee(2_000, 50)).isEqualTo(15_000 + 20_000 + 50_000);
}
@Test
void givenMissingModule_whenCreate_thenFailFast() {
assertThatThrownBy(() -> new WasmShippingCalculator("/not-found.wasm"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("not-found.wasm");
}
}
3番目のテストは、モジュールが見つからない場合、アプリケーションが最初のrequestまで待つのではなく、起動時にすぐにエラーを報告することを保証します。
注意:
Instanceは独立したメモリと状態を持っています。複数のスレッド間で安全に共有されることをデフォルトで想定しないでください。純粋な計算モジュールの場合、最も簡単な方法はrequestごとにインスタンスを作成するか、インスタンスプールを使用することです。パースされたモジュールは、複数のインスタンスを作成するために再利用できます。
5.5. モジュールがWASIを必要な場合
プラグインがファイルを読むかstdoutに出力する場合、WASIの関数をインポートします。インスタンスを作成するときに、これらの関数を提供する必要があります。Chicoryにはそのための専用のwasiモジュールがあります。原則はcapabilityです: プラグインが実際に必要とするディレクトリ、環境変数、およびstdout/stderrのみを提供します。便宜上、host環境全体を「継承」しないでください。
6. 実際の適用モデル
- プラグインシステム: 顧客またはパートナーがロジック(価格計算、ルール確認、データ変換)をカスタマイズできます。JVM内で直接彼らのコードを実行する必要がありません。
- ポリシーエンジン: 権限ルールをWasmモジュールにパッケージ化して、異なる言語で書かれた複数のserviceで共有します。
- Serverlessとedge関数: 各requestは分離されたインスタンスで実行され、高速に起動します。
- ネイティブライブラリの再利用: C/Rustライブラリ(パーサー、コーデック、アルゴリズム)をWasmにコンパイルしてJavaから呼び出し、JNIとJVM全体のクラッシュリスクを回避します。
- インフラストラクチャ拡張: 一部のプロキシ、データベース、およびメッセージブローカーはWasmによる拡張を許可します。
7. 使うべきではない場合
サーバーサイドWasmは魅力的ですが、常に正しい選択とは限りません:
- Javaでよく書かれており、分離が不要なロジック: Wasmレイヤーを追加するだけで、複雑性、データ転送コスト、デバッグの難しさが増加します。
- 重いマルチスレッド、GPUまたは専門的なSIMDが必要なワークロード: Wasmのスレッドとハードウェア機能のサポートはまだ制限されており、runtimeによって異なります。
- ネットワークとライブラリエコシステムに大きく依存するアプリケーション: WASI経由のSocket、TLS、データベースドライバは、実際のOS上ほど完全ではありません。多くのライブラリはまだWasmにコンパイルできません。
- 大量のデータとの往来: hostとguestの間でstring或いは配列を転送するたびに、linear memoryにコピーする必要があります。大きなペイロードでWasm関数を何百万回も呼び出す場合、このコストは重大です。複数の操作を大きな1つの呼び出しに統合してください。
- サンドボックスは絶対的な安全性を意味すると考えること: Wasmはメモリを分離しますが、無限ループやCPU消費を自動的には阻止しません。タイムアウト、メモリ制限、およびruntimeがサポートしている場合は実行中断メカニズム(fuel、epoch interruptionなど)が必要です。
- チームの準備不足: Wasmのtoolchain、debugger、observabilityはまだ発展中です。重要なシステムに導入しないでください。ただトレンドだからという理由で。
一般的なエラー:
- ターゲットを誤ってビルド。
std::fsまたはprintln!を使用するモジュールはWASIターゲットが必要です。純粋な計算モジュールはwasm32-unknown-unknownを使用します。 #[no_mangle]またはextern "C"を忘れます。これにより、関数がエクスポートリストに表示されません。- 型の混同: Wasmは基本的な数値型のみを持っています。文字列を転送する場合、linear memory内で割り当てを自分で管理するか、Component ModelとWITを使用する必要があります。
- 権限を広すぎます。(
--dir=/、環境全体を継承)。WASIの最大の利益を失います。
8. 結論
WASIはWebAssemblyをブラウザ技術から、サーバーサイド用のポータブルで軽量、厳密にサンドボックス化された実行形式に変えます。
capabilityモデル(付与されない限り権限がない)は、従来のプロセスとコンテナとの本質的な違いです。
この記事では、WASIプログラムをビルドし、ファイルシステムの権限がどのように制御されるかを確認しました。
また、純粋なJava runtimeを使用してWasmモジュールをJava 21アプリケーションに埋め込み、テストを作成しました。
信頼できないコードを実行する必要がある場合、多言語プラグインが必要な場合、または極めて高速な起動が必要な場合、Wasmを検討してください。
一方、問題がJavaサービス内の標準的なビジネスロジックだけである場合、最も単純なソリューションがしばしば最良です。