WasmtimeでLLM生成コードをミリ秒隔離:重いDockerを脱却する実行基盤
LLM が生成したスクリプトをサーバーで実行したいが、リクエストごとに Docker コンテナを起動すると遅く、メモリも食います。かといって使い回すとテナント間の隔離が弱くなります。この記事では、WebAssembly ランタイムの Wasmtime でこのトレードオフを緩和する設計を整理します。メモリ・命令数・時間の制限、WASI による I/O 制御、Python/JS の実行パイプライン、本番での起動短縮と障害分離を、手元で試せる粒度で説明します。
Docker運用の限界とWebAssembly(Wasm)サンドボックスという選択肢
コンテナはプロセスを分離する仕組みで、ホストカーネルを共有します。LLM 生成コードのような信頼できない入力には、seccomp や gVisor、Firecracker などを重ねて守る構成が一般的です。ただし、強い隔離を選ぶほど起動時間とメモリ常駐コストが増えます。インタラクティブな用途では、1 回の実行が数十ミリ秒で終わるのにセットアップが数百ミリ秒かかる、という逆転が起きがちです。
WebAssembly はこの前提を変えます。Wasm モジュールは、線形メモリと型付きの命令セットの中でしか動けず、ホストの機能は明示的に渡した import 関数経由でしか呼べません。つまり デフォルトで何もできない ところから権限を足していく設計(ケイパビリティベース)になります。コンテナのように「何でもできる環境から禁止事項を引く」方式とは逆です。
Wasmtime は Bytecode Alliance が開発する Wasm ランタイムで、WASI と Component Model に対応しています。事前コンパイルとプール型アロケータを組み合わせると、インスタンス化はマイクロ秒〜ミリ秒のオーダーに収まります。具体的な値はワークロードと環境に依存するため、自分のスクリプトで必ず計測してください。
ただし Wasm は万能ではありません。ランタイム自体のバグはサンドボックス脱出につながりうるため、後述のとおりプロセス分離などと組み合わせる前提で使います。
Wasmtimeのアーキテクチャとリソース制限(メモリ・CPU命令数・実行時間)の仕組み
Wasmtime では Engine がコンパイル設定とコード生成を担い、Store が 1 回の実行に対応する状態(インスタンス、メモリ、fuel など)を保持します。テナントや実行ごとに Store を作って破棄する設計にすると、状態の持ち越しを防げます。制限もすべて Store 単位で設定します。
制御したい資源は 3 種類あり、それぞれ別の機構が対応します。
- メモリ:ResourceLimiter(StoreLimitsBuilder)で線形メモリ、テーブル、インスタンス数の上限を指定します。
- CPU 命令数:fuel 消費を有効にすると、命令の実行に応じて燃料が減り、0 になった時点でトラップします。決定的で再現性があります。
- 実行時間:epoch interruption を有効にし、別スレッドから Engine の epoch を進めます。Store に設定した期限を超えるとトラップします。公式ドキュメントでも、fuel より実行時のオーバーヘッドが小さい方式と説明されています。
let mut config = Config::new();
config.consume_fuel(true);
config.epoch_interruption(true);
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
let limits = StoreLimitsBuilder::new()
.memory_size(64 * 1024 * 1024) // 64MiB
.instances(4)
.build();
let mut store = Store::new(&engine, HostState::new(limits));
store.limiter(|s| &mut s.limits);
store.set_fuel(500_000_000)?;
store.set_epoch_deadline(1); // 1 tick で期限切れ
別スレッドで engine.increment_epoch() を 100ms 間隔などで呼べば、tick 数 × 間隔が実質のタイムアウトになります。API 名やシグネチャは Wasmtime のメジャーバージョンで変わることがあります(fuel の設定メソッドなど)。導入時は利用バージョンの docs.rs を確認してください。
使い分けの目安は次のとおりです。ループの暴走を時間で確実に止めたいなら epoch、課金や公平性のために消費量を正確に測りたいなら fuel です。両方有効にしても構いません。無限ループ対策としては epoch だけでも機能します。
Component ModelとWASIによる最小限のI/Oアクセス制御の実装パターン
Wasm にファイルやネットワークを触らせるには、WASI を通じて権限を渡します。Wasmtime では WasiCtxBuilder で付与する機能を組み立てます。何も指定しなければ、ファイルシステムもソケットも使えません。LLM 生成コードの実行では、この「何も渡さない」を起点にします。
let wasi = WasiCtxBuilder::new()
.arg("main.py")
.env("LANG", "C.UTF-8")
.preopened_dir("/srv/jobs/tenant-a/work", "/work",
DirPerms::all(), FilePerms::all())?
.build();
ここで渡しているのは、テナントごとの作業ディレクトリ 1 つだけです。ゲストからは /work の外が見えません。標準出力はホスト側でバッファに受けて、サイズ上限を設けてください。出力の暴走もメモリを圧迫します。
Component Model では、ゲストとホストの境界を WIT(インターフェース定義)で型付きに記述できます。たとえば「外部 HTTP は禁止、代わりに許可リスト済みの fetch-json(url) だけを提供する」といった粗い権限をホスト側で実装できます。ソケットを直接開放するより、検査ポイントを 1 箇所に絞れる点が利点です。
interface host-tools {
fetch-json: func(url: string) -> result<string, string>;
log: func(message: string);
}
world sandbox {
import host-tools;
export run: func(input: string) -> result<string, string>;
}
bindgen! マクロで WIT からホスト側のトレイトを生成し、fetch-json の実装内で URL 検証、タイムアウト、レスポンスサイズ制限、SSRF 対策(プライベート IP の拒否)を行います。LLM のツール呼び出し設計と同じで、渡すツールの面積が攻撃面 です。
Python/JSスクリプトをWasmへコンパイルして実行するパイプライン設計
Python や JS は、Wasm へのたどり着き方が 2 通りあります。
- インタープリタ同梱方式:CPython(wasm32-wasi が公式サポート対象のプラットフォームに含まれています)や QuickJS などのインタープリタ自体を Wasm 化し、スクリプトを入力として渡します。
- コンポーネント化方式:componentize-py(Python)や jco の componentize(JS)で、スクリプトとインタープリタを 1 つの Component にまとめます。Javy のように JS を QuickJS 入りの Wasm にする選択肢もあります。
LLM 生成スクリプトは毎回内容が変わるため、前者が扱いやすい構成です。インタープリタ Wasm は 1 回だけ事前コンパイルしておき、リクエストごとにスクリプト文字列を渡して実行します。
LLM生成コード
→ 静的チェック(サイズ上限、不要なimportの拒否など。補助的な防御)
→ /work/main.py に書き込み
→ Store作成(fuel / epoch / メモリ上限)
→ python.wasm(事前コンパイル済み)を引数 main.py で実行
→ stdout / stderr / 終了コードを回収して破棄
静的チェックは回避されうるので、安全性の根拠は Wasm 側の制限に置いてください。チェックは無駄な実行を減らす補助にとどめます。
注意点は、numpy など C 拡張を持つパッケージです。wasm32-wasi 向けにビルド済みのものがなければ使えません。実行環境で使えるライブラリは、標準ライブラリ中心に絞り、LLM へのプロンプトにも「使えるモジュール」を明記すると失敗が減ります。ネイティブ拡張が必須のワークロードは、Wasm には向きません。
エラーはトラップ種別で分類します。fuel 切れ、epoch 期限切れ、メモリ上限超過を区別して LLM にフィードバックすれば、「処理を軽くして再生成してください」といった自己修正ループに使えます。
本番運用におけるコールドスタート短縮と障害分離のベストプラクティス
起動時間のうち支配的なのは、多くの場合 Wasm のインスタンス化ではなくコンパイルとインタープリタの初期化です。対策は 3 つあります。
- 事前コンパイル:
Engine::precompile_module(Component なら対応する API)で生成した成果物を保存し、起動時にデシリアライズします。信頼できる自前生成物だけを読み込んでください。デシリアライズは unsafe 扱いです。 - InstancePre とプールアロケータ:
Linker::instantiate_preでリンク解決を済ませ、PoolingAllocationConfigでメモリスロットを事前確保します。インスタンス化時の割り当てコストを抑えられます。 - 初期化済みスナップショット:Wizer でインタープリタの初期化後の状態を焼き込む方式があります。componentize-py もこの仕組みを利用しています。
障害分離では、Wasm のトラップはホストプロセスを落とさないものの、ランタイムのバグやホスト関数の不具合、OOM は別問題だと考えてください。実運用では、ワーカーを別プロセスに分け、cgroup や rlimit で外側の上限を設けます。Linux なら seccomp で、ワーカープロセスが使えるシステムコールも絞れます。Wasm は内側の壁、OS の仕組みは外側の壁という多層構成です。
テナント間のデータ漏洩を避けるには、Store と作業ディレクトリを実行ごとに作り直します。ワーカープロセスは一定回数の実行後に再起動すると、メモリ断片化や未知のリークの影響も限定できます。Spectre 系のサイドチャネルまで厳密に守る必要があるなら、テナントごとにプロセスを分けるのが無難です。
最後に、計測項目を最初から入れておきます。起動時間、fuel 消費量、ピークメモリ、トラップ種別の 4 つをメトリクス化すれば、制限値の調整根拠になります。まずは Docker 版と同じスクリプトを両方で動かし、p50 と p99 の起動レイテンシを比べるところから始めてください。


