Extismを使ったGoによるWebAssemblyプラグインシステムの構築:安全なサンドボックスとマルチ言語実行

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

クイックスタート(5分)

Extismは、WebAssemblyランタイム管理に伴う低レイヤーの煩わしさを解消します。GoのバックエンドからRust、TypeScript、C、Pythonなどで記述された信頼できないプラグインを実行する際にも、手動でのポインタ演算やリニアメモリオフセットの計算に悩まされる必要はありません。

まずはExtism CLIのインストールとGo SDKの取得から始めましょう。CLIはツールチェーンを設定し、wasm32-unknown-unknownまたはWASIをターゲットとするゲストモジュールを自動的にコンパイルします。

# Extism CLIのインストール(macOS/Linux)
curl https://get.extism.org/cli | bash

# 作業ディレクトリの作成
mkdir go-extism-plugins && cd go-extism-plugins
go mod init go-extism-plugins

# 公式Extism Go SDKのダウンロード
go get github.com/extism/go-sdk

セットアップの動作確認用に、ビルド済みのデモ用バイナリを取得します。Extismは迅速な検証向けに公式のサンプルモジュールを提供しています。

curl -LO https://github.com/extism/plugins/releases/latest/download/count_vowels.wasm

サンドボックスをロードし、テキストデータを渡して結果を取り出すために必要なGoコードはこれだけです。

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	extism "github.com/extism/go-sdk"
)

func main() {
	ctx := context.Background()

	wasmBytes, err := os.ReadFile("count_vowels.wasm")
	if err != nil {
		log.Fatalf("プラグインの読み込みに失敗しました: %v", err)
	}

	manifest := extism.Manifest{
		Wasm: []extism.Wasm{extism.WasmData{Data: wasmBytes}},
	}

	config := extism.PluginConfig{
		EnableWasi: true,
	}

	plugin, err := extism.NewPlugin(ctx, manifest, config, []extism.HostFunction{})
	if err != nil {
		log.Fatalf("プラグインの初期化に失敗しました: %v", err)
	}
	defer plugin.Close()

	exitCode, out, err := plugin.Call("count_vowels", []byte("Hello Extism WebAssembly!"))
	if err != nil {
		log.Fatalf("呼び出しエラー: %v (終了コード %d)", err, exitCode)
	}

	fmt.Printf("プラグインの応答: %s\n", string(out))
}

ターミナルから直接実行します。

go run main.go
# 出力: プラグインの応答: {"count": 8,"total": 8,"vowels": "aeiouAEIOU"}

これだけで完了です。わずか5分で、隔離された実行境界を構築できました。信頼できないゲストコードは、ホストのリソースにアクセスすることなく、1回の呼び出しあたりおよそ15〜40マイクロ秒で実行されます。

詳細解説

なぜGoの拡張アーキテクチャにWebAssemblyを採用するのか?

Goでプラグインアーキテクチャを構築する際、エンジニアは通常、標準ライブラリのpluginパッケージ、gRPC経由のサイドカー、またはTengoのような組み込みインタープリタの3つの選択肢から検討します。しかし、これらはいずれも規模が大きくなるにつれて課題が生じます。

  • Go標準のpluginパッケージは、同一のコンパイラバージョン、同一のCGOフラグ、一致する依存関係を必要とします。また、モジュールのアンロードやメモリの分離も行えません。
  • 外部のgRPCワーカープロセスは、ローカルネットワークレイテンシとして1〜5ミリ秒のオーバーヘッドを加えます。さらに、ヘルスチェックやプロセスのライフサイクル監視、IPCバッファの管理も複雑になります。
  • インタープリタ方式のスクリプトは、コンパイル済みバイトコードに比べて10倍から50倍低速です。また、ネイティブな型安全性も欠如しています。

WebAssemblyはこれらのトレードオフを回避します。ゲストコードはケーパビリティベースの仮想マシン内で実行されます。デフォルトでは、ゲスト環境からは環境変数が一切見えず、ホストのファイルシステムや開いているネットワークソケットへのアクセスも制限されます。プラグインが必要とするケーパビリティのみを選択して公開できます。

Extismが生のWasm連携における課題を解決する仕組み

WasmtimeやWazeroなどのランタイムは優れた低レイヤーのパフォーマンスを提供します。しかし、ホストとゲストの境界を越えて複雑なデータ構造を受け渡す作業は非常に煩雑です。手動でポインタのオフセットを計算し、メモリブロックを確保し、生のバイト列をコピーした上で、メモリを解放しなければなりません。

ゲストコードでfreeの呼び出しを1つでも忘れると、LinuxのOOM Killerが介入するまでホストのGoプロセスがメモリリークを起こし続けます。Extismはこのワークフローを標準化します。Plugin Development Kit(PDK)がホストSDKと連携し、構造化されたリニアメモリバッファを通じてゲスト側のメモリ割り当てを自動的に管理します。

データ交換パターン:Bytes、JSON、Protobuf

GoとExtismの間の境界は、生のバイトスライス([]byte)によって機能します。本番環境のワークロードでは、アドホックな文字列フォーマットを避け、明示的なシリアライズスキーマを採用してください。

// リクエストペイロードの定義
type TransformRequest struct {
	SourceURL string            `json:"source_url"`
	Headers   map[string]string `json:"headers"`
	Body      []byte            `json:"body"`
}

// レスポンスペイロードの定義
type TransformResponse struct {
	StatusCode int    `json:"status_code"`
	Data       string `json:"data"`
	Error      string `json:"error,omitempty"`
}

plugin.Call("handle_event", payloadBytes)に渡す前に、Goの構造体をJSONまたはProtocol Buffersにエンコードします。その後、返されたバイト列をデシリアライズします。これにより、RustやAssemblyScriptなど多様なゲスト言語との間で、型安全な通信を維持できます。

高度な使い方

Host Function(ホスト関数)を安全に公開する

完全な隔離は安全ですが、プラグインが特定のホストサービスを必要とする場合には実用的ではありません。実際の拡張機能では、許可リストにあるキャッシュキーを照会したり、セキュリティ監査ログを記録したりする必要が頻繁に生じます。ExtismはこれをHost Function(ホスト関数)によって解決します。

以下のスニペットは、インメモリのKey-Valueリーダーをゲストサンドボックスにエクスポートする例です。

kvStore := map[string]string{
	"feature_flag_beta": "true",
	"rate_limit_per_min": "120",
}

hostKVLookup := extism.NewHostFunctionWithStack(
	"host_kv_read",
	func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
		// プラグインメモリから文字列引数のオフセットを読み取る
		keyOffset := stack[0]
		keyBytes, err := p.ReadBytes(keyOffset)
		if err != nil {
			stack[0] = 0
			return
		}

		val, exists := kvStore[string(keyBytes)]
		if !exists {
			stack[0] = 0
			return
		}

		// ゲスト内にメモリを割り当てて値を書き戻す
		valOffset, err := p.WriteBytes([]byte(val))
		if err != nil {
			stack[0] = 0
			return
		}

		stack[0] = valOffset
	},
	[]extism.ValueType{extism.ValueTypePTR},
	[]extism.ValueType{extism.ValueTypePTR},
)

// 初期化時に関数を登録する
plugin, err := extism.NewPlugin(ctx, manifest, config, []extism.HostFunction{hostKVLookup})

メモリとCPUのリソース制限を適用する

厳格なリソース制限を設けずに、信頼できないユーザープラグインを実行してはなりません。不具合のあるアルゴリズムは、無限ループを引き起こしたり、数ミリ秒で数ギガバイトものRAMを消費したりする恐れがあります。

標準的な64 KiBのWasmページ単位を使用し、Manifest内でメモリ消費の上限を設定します。

// ゲストの割り当てを16ページに制限(16 * 64 KiB = 1 MiB)
memoryMaxPages := uint32(16)

manifest := extism.Manifest{
	Wasm: []extism.Wasm{
		extism.WasmPath{Path: "./untrusted_plugin.wasm"},
	},
	Memory: &extism.ManifestMemory{
		MaxPages: &memoryMaxPages,
	},
}

Goのcontext.WithTimeoutで呼び出しをラップし、暴走した実行ループからシステムを保護します。

timeoutCtx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()

// コンテキストのキャンセルにより、長時間実行されているゲストの処理を即座に中断
exitCode, res, err := plugin.CallWithContext(timeoutCtx, "process", inputData)

実践的なヒント

プラグインをシングルスレッドのインスタンスとして扱う

ExtismのPluginインスタンスは厳密にシングルスレッド設計です。同一のポインタに対して並行してplugin.Call()を呼び出すと、リニアメモリが破損します。

バッファ付きのGoチャネル内でインスタンスをプーリングし、サービスを保護します。

type PluginPool struct {
	pool chan *extism.Plugin
}

func NewPluginPool(size int, manifest extism.Manifest, config extism.PluginConfig) (*PluginPool, error) {
	ch := make(chan *extism.Plugin, size)
	for i := 0; i < size; i++ {
		p, err := extism.NewPlugin(context.Background(), manifest, config, nil)
		if err != nil {
			return nil, err
		}
		ch <- p
	}
	return &PluginPool{pool: ch}, nil
}

func (p *PluginPool) Execute(fn string, data []byte) ([]byte, error) {
	plugin := <-p.pool
	defer func() { p.pool <- plugin }()

	_, out, err := plugin.Call(fn, data)
	return out, err
}

インスタンスのプーリングにより、ミューテックスロックを使わずに競合状態(レースコンディション)を排除できます。本番環境を模したテストでは、8コアのホスト上で8インスタンスのプールを使用し、ジッターをほぼ生じさせることなく毎秒22,000件以上のリクエストを処理しました。

登録前にプラグインのエクスポート関数を検証する

S3からダウンロードされたり、管理用API経由でアップロードされたりしたサードパーティ製プラグインには、必要なエントリポイントが存在しない可能性があります。ユーザーリクエストの処理時にエラーになるのを防ぐため、起動時にシンボルを検証してください。

if !plugin.FunctionExists("custom_filter") {
	return fmt.Errorf("プラグインの検証に失敗しました: エクスポート 'custom_filter' が見つかりません")
}

データコントラクトのバージョンを明示的に管理する

プラグインの作成者は、コアとなるGoエンジンとは独立してモジュールを更新します。すべてのJSONやProtobufペイロードにコントラクトバージョンを付与することで、データパイプラインを保護してください。

{
  "schema_version": "v1.2",
  "trace_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "payload": {
    "item_id": 42091
  }
}

6ヶ月前にコンパイルされたプラグインがv1.0データを返した場合でも、Goホスト側はクラッシュすることなく後方互換性のあるフォールバック処理を適用できます。

Share: