0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WIT・cargo-componentで実装するWasmマルチ言語プラグインシステム

0
Last updated at Posted at 2026-04-25

WIT・cargo-componentで実装するWasmマルチ言語プラグインシステム

この記事でわかること

  • WebAssembly Component ModelとWIT(WebAssembly Interface Types)の基本概念と設計思想
  • cargo-componentwit-bindgenを使ったRustゲストコンポーネントの実装方法
  • Wasmtimeをホストランタイムとして、Rust・C・JavaScriptの3言語でプラグインを動かす方法
  • プラグインシステムにおけるセキュリティモデルとパフォーマンス特性の理解
  • WASI 0.2/0.3の現状と、本番適用時の制約・注意点

対象読者

  • 想定読者: Rustの基礎文法を理解しているMLエンジニア・ソフトウェアエンジニア
  • 必要な前提知識:
    • Rustの基本的な構文(structimpltrait
    • WebAssemblyの概念(ブラウザで動くバイナリフォーマット、という程度の理解でOK)
    • コマンドラインツール(cargonpm)の基本操作

結論・成果

WebAssembly Component Modelを使うと、WITファイル1つで定義したインターフェースに対して、Rust・C・JavaScriptなど複数言語でプラグインを実装できます。Wasmtimeの公式ベンチマークによると、コンポーネントのコールドスタートは約1〜5ms、関数呼び出しオーバーヘッドはマイクロ秒単位と報告されています。コンパイル後のバイナリサイズはRustで約72KB、Cで約56KBと軽量で、コンテナベースのプラグインシステムと比較してメモリフットプリントを大幅に削減できます。

一方で、Component Modelはまだ標準化プロセスの途上(W3C Phase 2/3)にあり、ツールチェーンの互換性問題やブラウザでのネイティブサポート不在など、本番適用には慎重な判断が必要です。

WebAssembly Component Modelの基礎を理解する

従来のWebAssemblyモジュールが抱える問題

従来のWebAssembly(Wasm)モジュールは、数値型(i32i64f32f64)しかインターフェースでやり取りできません。文字列や構造体を渡すには、線形メモリ上のポインタとサイズを手動でやり取りする必要がありました。

この制約は、異なる言語で書かれたモジュール同士を連携させる際に大きな障壁となります。たとえばPythonで書いた文字列をRustモジュールに渡す場合、メモリレイアウトの違いを吸収するグルーコードを手書きする必要がありました。Component Modelは、この「インピーダンスミスマッチ」を解決するために設計されています。

Component Modelの3つの中核概念

Component ModelはWITWorldInterfaceの3つの概念で構成されます。

概念 役割 ML/Pythonでの類推
WIT インターフェース定義言語(IDL) Protocol Buffersの.protoファイル
Interface 関数と型の集合(契約) PythonのProtocolクラス
World Import/Exportの全体像 Pythonパッケージの__init__.py

WIT(WebAssembly Interface Types)は、コンポーネント間のインターフェースを定義するIDLです。stringlist<T>record(構造体に相当)、enumvariant(Rustのenumに相当)、result<T, E>などの型を使えます。

Interfaceは、関数シグネチャと型定義をまとめたものです。Pythonのtyping.Protocolのように「この関数群を実装してください」という契約を表します。

Worldは、コンポーネントが「何をインポートし、何をエクスポートするか」の全体像を定義します。ホスト側が提供する機能(import)とプラグイン側が実装すべき機能(export)を1つのWorldにまとめます。

WASI 0.2とComponent Modelの関係

WASI(WebAssembly System Interface)は、WasmコンポーネントがファイルシステムやネットワークなどのOS機能にアクセスするための標準インターフェースです。WASI 0.2.0は2024年1月にリリースされた安定版で、Component Modelと密結合しています。

WASI 0.2ではWorldの概念が導入され、wasi:cli(コマンドラインアプリ)やwasi:http(HTTPハンドラ)など、ドメインごとのインターフェースセットが定義されています。Rustコンパイラは1.82以降でwasm32-wasip2ターゲットをネイティブサポートしており、rustup target add wasm32-wasip2で追加できます。

次期リリースのWASI 0.3は、Component Modelにネイティブ非同期サポートを追加します。Wasmtime 37以降でプレビュー対応しており、stream<T>future<T>型により、コンポーネントレベルの関数を非同期で実装・呼び出しできるようになります。ただし、WASI 0.3はまだリリース候補段階であり、APIの命名が変更される可能性があるため、本番利用には注意が必要です。

注意: WASI 0.3は2026年4月時点でプレビュー段階です。Spin v3.5(2025年11月)で初のRC対応が実装されましたが、プレビュー間の互換性が保証されないため、プロダクション依存は避けてください。

WITでプラグインインターフェースを設計する

WITファイルの基本構造

実際にプラグインシステムを構築してみましょう。まず、プラグインが実装すべきインターフェースをWITで定義します。

以下の例では、テキスト処理プラグインのインターフェースを定義します。ホスト側がログ機能と設定取得を提供し、プラグイン側がテキスト変換処理をエクスポートする構成です。

// wit/world.wit
package myapp:text-processor;

interface types {
    record process-result {
        output: string,
        metadata: list<key-value>,
    }

    record key-value {
        key: string,
        value: string,
    }

    enum process-error {
        invalid-input,
        unsupported-format,
        internal-error,
    }
}

interface host-api {
    use types.{key-value};
    log: func(level: log-level, msg: string);
    get-config: func(key: string) -> option<string>;

    enum log-level {
        debug,
        info,
        warn,
        error,
    }
}

interface plugin {
    use types.{process-result, process-error};
    get-name: func() -> string;
    get-version: func() -> string;
    process: func(input: string) -> result<process-result, process-error>;
}

world text-plugin {
    import host-api;
    export plugin;
}

なぜこの設計を選んだか:

  • types interfaceに共有型を切り出すことで、ホスト・ゲスト両側で型の不整合を防ぐ
  • result<T, E> を使うことで、エラー処理がRustのResultやPythonの例外と自然にマッピングされる
  • log-level enumでログレベルを制限し、プラグインが任意の文字列ログレベルを使うことを防ぐ

WIT設計のベストプラクティス

WITのインターフェース設計にはいくつかの注意点があります。

ベストプラクティス 理由
型定義を専用interfaceに分離 ホスト・ゲスト間で型の不整合が起きにくい
result<T, E> でエラーを明示 言語間でエラーハンドリングが統一される
option<T> で欠損値を表現 null/None/undefinedの違いを吸収
list<T> でコレクションを渡す メモリレイアウトの違いをCanonical ABIが吸収
関数の引数は3個以下に recordにまとめることで拡張性を確保

ハマりポイント: WITの型名はkebab-case(ハイフン区切り)で書く必要があります。Rustのsnake_caseやJavaScriptのcamelCaseへの変換はwit-bindgenが自動で行います。たとえばprocess-resultはRustではProcessResult、JavaScriptではprocessResultになります。

cargo-componentでRustプラグインを実装する

開発環境のセットアップ

Rustでゲストコンポーネントを開発するために、以下のツールをインストールします。

# cargo-component のインストール
cargo install cargo-component

# wasm32-wasip2 ターゲットの追加(Rust 1.82+)
rustup target add wasm32-wasip2

# プロジェクトの作成
cargo component new --lib text-upper-plugin
cd text-upper-plugin

cargo component new --libで生成されたプロジェクトには、wit/ディレクトリとCargo.tomlwit-bindgen依存が自動設定されます。

Rustゲストコンポーネントの実装

先ほど定義したWITファイルをwit/ディレクトリに配置し、プラグインを実装します。

// src/lib.rs
// cargo-component が wit-bindgen を内部で呼び出し、
// WIT定義からRustバインディングを自動生成する

// 生成されたバインディングを使用
mod bindings;
use bindings::exports::myapp::text_processor::plugin::Guest;
use bindings::myapp::text_processor::host_api;
use bindings::myapp::text_processor::types::{
    KeyValue, ProcessError, ProcessResult,
};

struct TextUpperPlugin;

impl Guest for TextUpperPlugin {
    fn get_name() -> String {
        "text-upper".to_string()
    }

    fn get_version() -> String {
        "0.1.0".to_string()
    }

    fn process(input: String) -> Result<ProcessResult, ProcessError> {
        if input.is_empty() {
            host_api::log(host_api::LogLevel::Warn, "Empty input received");
            return Err(ProcessError::InvalidInput);
        }

        let output = input.to_uppercase();
        let char_count = output.chars().count();

        host_api::log(
            host_api::LogLevel::Info,
            &format!("Processed {} characters", char_count),
        );

        Ok(ProcessResult {
            output,
            metadata: vec![
                KeyValue {
                    key: "original_length".to_string(),
                    value: input.len().to_string(),
                },
                KeyValue {
                    key: "transform".to_string(),
                    value: "uppercase".to_string(),
                },
            ],
        })
    }
}

bindings::export!(TextUpperPlugin with_types_in bindings);

ビルドとコンポーネントの確認

# コンポーネントとしてビルド
cargo component build --release

# 生成されたWasmコンポーネントの確認
ls -la target/wasm32-wasip2/release/*.wasm
# text_upper_plugin.wasm  約72KB

# WITインターフェースの確認
wasm-tools component wit target/wasm32-wasip2/release/text_upper_plugin.wasm

cargo component buildは内部でwit-bindgenを呼び出し、WIT定義からRustのtrait定義を生成します。開発者は生成されたGuest traitを実装するだけです。

よくある間違い: cargo component buildと通常のcargo build --target wasm32-wasip2を混同するケースがあります。cargo build単体ではComponent Model形式のWasmファイルが生成されません。cargo component buildは内部でコアWasmモジュールをComponent Model形式に変換(wasm-tools component new相当)する処理を含んでいます。

Wasmtimeホストアプリケーションを実装する

ホスト側の基本構造

プラグインを読み込んで実行するホストアプリケーションをRustで実装します。Wasmtimeのcomponentモジュールとbindgen!マクロを使います。

// host/src/main.rs
use anyhow::Result;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fs;
use std::path::Path;
use wasmtime::component::{bindgen, Component, Linker};
use wasmtime::{Engine, Store};

// WITからホスト側バインディングを自動生成
bindgen!({
    world: "text-plugin",
    path: "../wit",
});

// ホスト側の状態管理
struct HostState {
    config: HashMap<String, String>,
}

// ホストAPIの実装(プラグインが呼び出す関数)
impl myapp::text_processor::host_api::Host for HostState {
    fn log(&mut self, level: myapp::text_processor::host_api::LogLevel, msg: String) {
        let level_str = match level {
            myapp::text_processor::host_api::LogLevel::Debug => "DEBUG",
            myapp::text_processor::host_api::LogLevel::Info => "INFO",
            myapp::text_processor::host_api::LogLevel::Warn => "WARN",
            myapp::text_processor::host_api::LogLevel::Error => "ERROR",
        };
        eprintln!("[{level_str}] [plugin] {msg}");
    }

    fn get_config(&mut self, key: String) -> Option<String> {
        self.config.get(&key).cloned()
    }
}

プラグインの読み込みと実行

struct PluginInstance {
    name: String,
    version: String,
    instance: TextPlugin,
    store: Store<HostState>,
}

fn load_plugin(
    engine: &Engine,
    linker: &Linker<HostState>,
    path: &Path,
    config: HashMap<String, String>,
) -> Result<PluginInstance> {
    let component = Component::from_file(engine, path)?;
    let state = HostState { config };
    let mut store = Store::new(engine, state);

    let instance = TextPlugin::instantiate(&mut store, &component, linker)?;

    let name = instance
        .myapp_text_processor_plugin()
        .call_get_name(&mut store)?;
    let version = instance
        .myapp_text_processor_plugin()
        .call_get_version(&mut store)?;

    Ok(PluginInstance {
        name,
        version,
        instance,
        store,
    })
}

fn main() -> Result<()> {
    let engine = Engine::default();
    let mut linker = Linker::new(&engine);

    // ホストAPI関数をLinkerに登録
    TextPlugin::add_to_linker(&mut linker, |state| state)?;

    let plugins_dir = Path::new("./plugins");
    let mut plugins: Vec<PluginInstance> = Vec::new();

    // プラグインディレクトリからWasmファイルを読み込み
    for entry in fs::read_dir(plugins_dir)? {
        let path = entry?.path();
        if path.extension().and_then(OsStr::to_str) == Some("wasm") {
            let config = HashMap::from([
                ("locale".to_string(), "ja-JP".to_string()),
            ]);
            match load_plugin(&engine, &linker, &path, config) {
                Ok(plugin) => {
                    println!("Loaded plugin: {} v{}", plugin.name, plugin.version);
                    plugins.push(plugin);
                }
                Err(e) => eprintln!("Failed to load {}: {e}", path.display()),
            }
        }
    }

    // プラグインを実行
    for plugin in &mut plugins {
        let result = plugin
            .instance
            .myapp_text_processor_plugin()
            .call_process(&mut plugin.store, "Hello, Component Model!")?;

        match result {
            Ok(output) => {
                println!("[{}] Output: {}", plugin.name, output.output);
                for kv in &output.metadata {
                    println!("  {}: {}", kv.key, kv.value);
                }
            }
            Err(e) => eprintln!("[{}] Error: {:?}", plugin.name, e),
        }
    }

    Ok(())
}

ホスト側の依存関係

# host/Cargo.toml
[dependencies]
wasmtime = { version = "29", features = ["component-model"] }
anyhow = "1"

注意: wasmtimeのバージョンとゲスト側のwit-bindgen/cargo-componentのバージョンには互換性の制約があります。Wasmtime 29.x系ではcargo-component 0.18以降を使用してください。バージョン不整合があると、コンポーネントのインスタンス化時にリンクエラーが発生します。

トレードオフ: Wasmtimeのbindgen!マクロはビルド時にWITファイルを読み込んで型安全なRustバインディングを生成するため、コンパイル時間が増加します。大規模なWIT定義では、ビルド時間が数十秒増えることがあります。一方で、実行時の型チェックが不要になるため、ランタイムパフォーマンスは向上します。

多言語プラグインを実装する

Component Modelの大きな利点は、同一のWIT定義に対して複数言語でプラグインを実装できることです。ここではCとJavaScriptでの実装例を示します。

Cゲストプラグインの実装

CでWasmコンポーネントを作成するには、wit-bindgenのCジェネレータとWASI SDKを使用します���WASI SDKはClangベースのツールチェーンで、C/C++をWASIターゲットにコンパイルできます。

# WITからCバインディングを生成
wit-bindgen c ../wit -w text-plugin --out-dir gen
// c-plugin/plugin.c
#include "gen/text_plugin.h"
#include <string.h>
#include <ctype.h>

// プラグイン名を返す
void exports_myapp_text_processor_plugin_get_name(
    exports_myapp_text_processor_plugin_get_name_ret_t *ret
) {
    text_plugin_string_dup(&ret->value, "text-reverse-c");
}

// バージョンを返す
void exports_myapp_text_processor_plugin_get_version(
    exports_myapp_text_processor_plugin_get_version_ret_t *ret
) {
    text_plugin_string_dup(&ret->value, "0.1.0");
}

// 文字列を反転する処理
void exports_myapp_text_processor_plugin_process(
    text_plugin_string_t *input,
    exports_myapp_text_processor_plugin_process_ret_t *ret
) {
    size_t len = input->len;
    if (len == 0) {
        ret->is_err = true;
        ret->val.err = MYAPP_TEXT_PROCESSOR_TYPES_PROCESS_ERROR_INVALID_INPUT;
        return;
    }

    // 文字列を反転
    char *reversed = malloc(len + 1);
    for (size_t i = 0; i < len; i++) {
        reversed[i] = input->ptr[len - 1 - i];
    }
    reversed[len] = '\0';

    // ホストAPIのログ関数を呼び出し
    text_plugin_string_t log_msg;
    text_plugin_string_dup(&log_msg, "Reversed string");
    myapp_text_processor_host_api_log(
        MYAPP_TEXT_PROCESSOR_HOST_API_LOG_LEVEL_INFO,
        &log_msg
    );

    ret->is_err = false;
    text_plugin_string_dup(&ret->val.ok.output, reversed);
    free(reversed);

    // メタデータ設定
    ret->val.ok.metadata.len = 1;
    ret->val.ok.metadata.ptr = malloc(
        sizeof(myapp_text_processor_types_key_value_t)
    );
    text_plugin_string_dup(
        &ret->val.ok.metadata.ptr[0].key, "transform"
    );
    text_plugin_string_dup(
        &ret->val.ok.metadata.ptr[0].value, "reverse"
    );
}
# WASI SDKでコンパイル → コアWasmモジュール → Component変換
/opt/wasi-sdk/bin/clang \
    plugin.c gen/text_plugin.c gen/text_plugin_component_type.o \
    -o module.wasm -mexec-model=reactor

wasm-tools component new module.wasm -o text-reverse-c.wasm
# 生成サイズ: 約56KB

JavaScriptゲストプラグインの実装

JavaScriptではcomponentize-js(またはjco)を使ってWasmコンポーネントに変換します。

// js-plugin/plugin.js
import { log } from "myapp:text-processor/host-api";

export const plugin = {
  getName() {
    return "text-wordcount-js";
  },

  getVersion() {
    return "0.1.0";
  },

  process(input) {
    if (!input || input.trim().length === 0) {
      return { tag: "err", val: "invalid-input" };
    }

    const words = input.split(/\s+/).filter((w) => w.length > 0);
    const wordCount = words.length;
    const charCount = input.length;

    log("info", `Counted ${wordCount} words in ${charCount} characters`);

    return {
      tag: "ok",
      val: {
        output: `Words: ${wordCount}, Characters: ${charCount}`,
        metadata: [
          { key: "word_count", value: String(wordCount) },
          { key: "char_count", value: String(charCount) },
          { key: "transform", value: "wordcount" },
        ],
      },
    };
  },
};
# JavaScriptをWasmコンポーネントに変換
npx @bytecodealliance/componentize-js plugin.js \
    --wit ../wit \
    --world-name text-plugin \
    -o text-wordcount-js.wasm
# 生成サイズ: 約200KB(JSランタイム込み)

言語別の比較

項目 Rust C JavaScript
バイナリサイズ 約72KB 約56KB 約200KB
ビルドツール cargo-component WASI SDK + wasm-tools componentize-js
バインディング生成 自動(wit-bindgen内蔵) wit-bindgen cで手動生成 componentize-jsが処理
型安全性 コンパイル時チェック 手動管理(ポインタ操作) 実行時チェック
GCの有無 なし なし あり(StarlingMonkey)
適したユースケース パフォーマンス重視プラグイン レガシーC資産の活用 迅速なプロトタイピング

制約条件: JavaScriptプラグインはStarlingMonkeyランタイムをバンドルするため、バイナリサイズがRust/Cの3〜4倍になります。また、Go(TinyGo)で生成したコンポーネントは約332KBと報告されており、Goランタイムの組み込みがサイズ増加の要因です。処理性能が重視される場面ではRustまたはCを、開発速度を優先する場面ではJavaScriptの利用が適しています。

パフォーマンス特性とセキュリティモデルを理解する

パフォーマンスの特性

Component Modelのパフォーマンスは、従来のネイティブ関数呼び出しと比較してオーバーヘッドがあります。主要なコスト要因を理解しておくことが重要です。

コスト要因 概要 影響度
Canonical ABI変換 文字列・構造体のシリアライズ/デシリアライズ 中〜高(データサイズに比例)
メモリコピー ホスト⇔ゲスト間はメモリ共有不可 高(大量データ転送時)
コンポーネントのインスタンス化 Engine生成、コンパイル、Store作成 初回のみ(1〜5ms程度)
関数呼び出しオーバーヘッド Wasm VMのコンテキストスイッチ 低(マイクロ秒単位)

よくある間違い: 「Wasmはネイティブに近い速度で動く」と期待して、大量のデータを頻繁にホスト⇔ゲスト間で転送する設計にしてしまうケースがあります。Canonical ABIの変換コストはデータサイズに比例するため、大量データの処理ではバッチ化(一度にまとめて渡す)やストリーミング(WASI 0.3のstream<T>)を検討すべきです。

パフォーマンス最適化のパターン

以下のパターンでオーバーヘッドを軽減できます。

// ❌ 非効率: 1行ずつプラグインに渡す
for line in lines {
    plugin.call_process(&mut store, &line)?;
}

// ✅ 効率的: バッチ処理
// WIT側で list<string> を受け取るバッチ関数を定義
// process-batch: func(inputs: list<string>) -> list<result<process-result, process-error>>;
let results = plugin.call_process_batch(&mut store, &lines)?;

Engine再利用パターンも重要です。Engineのインスタンス化にはコンパイルキャッシュの構築が含まれるため、複数プラグインを読み込む場合は1つのEngineを共有し、プラグインごとにStoreを分離します。

let engine = Engine::default(); // 1回だけ作成

// プラグインごとにStoreを分離(状態の独立性を保証)
let store_a = Store::new(&engine, HostState::new());
let store_b = Store::new(&engine, HostState::new());

セキュリティモデル

Component ModelのセキュリティはCapability-based Security(ケイパビリティベースセキュリティ)に基づいています。プラグインは明示的にインポートした機能以外にはアクセスできません。

セキュリティ特性 説明
メモリ分離 ホストとゲストはメモリ空間を共有しない
明示的インポート WITで宣言された関数のみ呼び出し可能
ファイルシステムアクセス制御 WASI経由で、ホストが明示的に許可したディレクトリのみ
ネットワークアクセス制御 WASI経由で、ホストが明示的に許可した接続のみ
リソース制限 Wasmtime設定でメモリ上限・実行時間制限を設定可能

これは、コンテナ(Docker)のセキュリティモデルとは根本的に異なります。コンテナはプロセス分離を提供しますが、カーネルを共有するため攻撃面が広いのに対し、Wasmコンポーネントは仮想マシンレベルの分離を提供します。

// Wasmtimeでリソース制限を設定する例
let mut config = wasmtime::Config::new();
config.consume_fuel(true); // 実行ステップ数の制限を有効化

let engine = Engine::new(&config)?;
let mut store = Store::new(&engine, state);
store.set_fuel(1_000_000)?; // 100万ステップまでに制限

制約条件: Component Modelのセキュリティモデルは強固ですが、サイドチャネル攻撃(タイミング攻撃など)に対する防御は保証されていません。暗号処理など、タイミングに敏感な処理をプラグインに委任する場合は、追加の対策が必要です。

よくある問題と解決方法

問題 原因 解決方法
cargo component buildで「WIT parse error」 WITの型名がkebab-caseでない processResultprocess-resultに修正
ホストでのインスタンス化時にリンクエラー Wasmtime/wit-bindgenバージョン不整合 Cargo.lockを削除して依存を再解決
JavaScriptプラグインでimportが見つからない componentize-jsのWITパス指定ミス --wit引数をWITディレクトリのパスに修正
Cプラグインでメモリリーク *_free関数の呼び忘れ 生成コードの*_freeを各アロケーション後に呼ぶ
コンポーネントサイズが予想以上に大きい デバッグ情報の含有 --releaseビルドとwasm-opt -Osで最適化
WASI 0.3のasync関数が動かない ランタイムバージョン不足 Wasmtime 37以降にアップデート

まとめと次のステップ

まとめ:

  • WebAssembly Component ModelとWITにより、言語に依存しない型安全なプラグインインターフェースを定義できる
  • cargo-componentを使えば、Rustでのゲストコンポーネント開発はGuest traitを実装するだけで完了する
  • ホスト側はWasmtimeのbindgen!マクロで型安全なバインディングを自動生成し、Engine/Linker/Storeの3層構造でプラグインを管理する
  • C・JavaScriptからも同一のWIT定義に対してプラグインを実装でき、バイナリサイズは56KB〜200KB程度
  • セキュリティはCapability-basedモデルで、メモリ分離と明示的インポートにより安全にサードパーティコードを実行可能

次にやるべきこと:

参考


注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?