Article

WebAssemblyを2年本番運用して分かった、採用すべきケースとそうでないケース

高田晃太郎
WebAssemblyを2年本番運用して分かった、採用すべきケースとそうでないケース

主要ブラウザの約97%がWebAssembly(以下、Wasm)を標準実装し、FastlyやCloudflare、Shopifyなどブラウザ外での運用も進むことは、すでに広く知られています¹²³⁴。標準化の前進とランタイムの成熟(wasmtimeの安定化やWASIの普及)により、実サービスへの適用範囲は着実に広がっています⁵。公開事例や検証では、CPU負荷の高い処理をRustやC/C++からWasmに移すことで、テールレイテンシ(p99などの高位パーセンタイル)が改善し、インフラコストの圧縮につながるケースが報告されています。一方で、移植しても遅くなる、運用が読みにくくなるといった反例も少なくありません。この記事では、なぜ差が生まれるのかをアーキテクチャ・実装・運用の観点から整理し、採用すべきケースと避けるべきケースを、コードとベンチマーク例で具体化します。

採用すべきケース:CPU集約・安全な拡張・エッジ最適化

強みは、CPU負荷が高い処理を高密度に走らせやすいこと、未信頼コードを安全にサンドボックス化できること(外部と隔離した実行)、そして配布と起動が軽いことにあります⁶。アルゴリズムが明確でメモリアクセスが予測しやすい処理──たとえば画像・音声のフィルタリング、最適化やスコアリングのような数値計算、既存のC/C++/Rust資産の再利用は相性が良い¹⁰。プラグインやユーザー提供関数を動かす拡張機構でも、権限を最小化した実行環境を用意しやすく、安全性とスケールを両立しやすい⁵。さらにエッジ環境(ユーザーに近い分散実行基盤)では、コンテナより起動・配置が軽く、コールドスタート(無稼働から初回応答まで)が有利になる場面があります⁸。A/B比較の公開事例でも、CPU使用量の低下や応答時間の中央値改善が観測されていますが、設計次第で差は大きく変わります。

ブラウザでの実装:ストリーミングコンパイルとフォールバック

ブラウザでは、ストリーミングコンパイル(ダウンロードしながらコンパイル)とインテグリティ(SRIによる改ざん検出)を組み合わせると、配布と起動のオーバーヘッドを抑えられます⁹。例として、画像フィルタをWasmで読み込み、失敗時はJavaScript版へフォールバックする実装を示します。

<script type="module">
  async function loadFilter() {
    try {
      const res = await fetch('/wasm/image.wasm', {
        integrity: 'sha256-REPLACE_WITH_SRI_HASH',
        cache: 'force-cache'
      });
      const imports = { env: { abort: () => {} } };
      const { instance } = await WebAssembly.instantiateStreaming(res, imports);
      return instance.exports;
    } catch (e) {
      console.warn('Wasm failed, falling back', e);
      return await import('/fallback/image-js.js');
    }
  }
  const filter = await loadFilter();
  // filter.blur(...) を呼び出す
</script>

このようにフォールバック経路を常設しておくと、未知の端末や旧環境でもサービス継続性を担保できます。加えて関数境界を粗く設計し、呼び出し回数を減らして大きめのバッチで処理することが、性能面の鍵になります⁶。

サーバー/CLIでの実装:Node.jsとWASIの併用

Node.js 18以降にはWASIが同梱されています⁵。WASI(WebAssembly System Interface)は、ファイルI/OやクロックなどOS的機能を標準化して提供するインターフェイスで、CLIやバッチ処理の移植を容易にします。次の例では、WASIでビルドしたモジュールを読み込み、標準入出力を用いたフィルターを実行します。

import { readFile } from 'node:fs/promises';
import { WASI } from 'node:wasi';
import { env } from 'node:process';

const wasi = new WASI({ args: [], env, preopens: { '/work': './work' } });
const wasm = await WebAssembly.compile(
  await readFile('./target/wasm32-wasi/release/img.wasm')
);
const instance = await WebAssembly.instantiate(wasm, {
  wasi_snapshot_preview1: wasi.wasiImport
});
wasi.start(instance);

WASIを使うとファイルI/Oやクロックなどのケイパビリティ(許可された機能)が明示化され、意図しないアクセスを抑制できます⁵。ユーザー提供ルールやプラグインの実行に用いると、障害ドメインを分割して安全性と可用性を高める設計に寄与します。

Rustからの移植:データの受け渡しを意識する

RustはWasmターゲットが成熟しており、ブラウザ向けにはwasm-bindgen(JSとの相互運用を簡素化)、サーバー向けにはWASIが定番です⁶。重要なのは、オブジェクトを頻繁に往復させず、連続領域のバッファでやり取りする設計にすることです⁶。

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn box_blur(pixels: &[u8], width: u32, height: u32, radius: u32) -> Vec<u8> {
    let mut out = vec![0u8; pixels.len()];
    // シンプルなボックスブラー(実装省略)
    // 実コードではSIMDやラインキャッシュを意識して最適化
    out
}

呼び出し側ではTypedArrayを用い、コピー回数を最小化するのが定石です。SharedArrayBufferやSIMD、スレッドを併用する場合は、COOP/COEP(クロスオリジンの分離設定)やユーザー環境の制約も検討対象になります⁶。

エッジでの配備:軽量起動とキャパシティの両立

エッジ環境では、Wasmの短いコールドスタートが効いてきます⁸。Cloudflare Workersのような環境でモジュールを読み込み、要求ごとに実行する例を示します³。

export default {
  async fetch(request, env, ctx) {
    const mod = await WebAssembly.compileStreaming(fetch(env.WASM_URL));
    const instance = await WebAssembly.instantiate(mod, {});
    const url = new URL(request.url);
    const n = Number(url.searchParams.get('n') ?? 1000);
    const result = instance.exports.hash(n);
    return new Response(String(result), { status: 200 });
  }
};

この形はスパイク的なトラフィックにも強く、コンテナ型の同等実装に比べコールドスタートが短くなる傾向があります。環境によっては、ランタイムがモジュールを即時再利用できるようキャッシュ戦略を設計に織り込むとよいでしょう³。

避けるべきケース:境界往復とメモリ膨張、DOM多用

Wasmは万能ではありません。関数を細切れにしてホスト(JSなど)と大量に往復する設計は、多くの場合ネイティブJSより遅くなります⁶⁷。小さな文字列処理や、複雑なDOM操作が主役の処理は、Wasm化しても優位が出にくい⁶。軽量処理を膨大な回数で往復させると境界コスト(言語間コールのオーバーヘッド)が支配的になり遅くなりがちですが、同じ処理をWasm側でバッチ化して往復を一回にまとめると高速化が得られることがあります。つまり、勝ち筋は設計で作れますが、設計を誤ると簡単に負けます。

境界コストの計測:粗いAPIに寄せる

以下はブラウザで境界コストを可視化する簡易ベンチです。反復でネイティブJSとWasmを比較し、次にバッチ化して差を見るという流れです⁷。p50(中央値)だけでなく、必要に応じてp95/p99(上位5%/1%のレイテンシ)も観察してください。

import init, { add, add_batch } from './pkg/sum_wasm.js';

await init();

function bench(label, fn) {
  const t0 = performance.now();
  const v = fn();
  const t1 = performance.now();
  console.log(label, v, (t1 - t0).toFixed(2), 'ms');
}

bench('JS add loop', () => {
  let s = 0; for (let i = 0; i < 10_000_000; i++) s += i + 1; return s;
});

bench('Wasm add loop', () => {
  let s = 0; for (let i = 0; i < 10_000_000; i++) s += add(i, 1); return s;
});

bench('Wasm add_batch', () => {
  const n = 10_000_000; const buf = new Int32Array(n*2);
  for (let i = 0; i < n; i++) { buf[i*2] = i; buf[i*2+1] = 1; }
  return add_batch(buf);
});

この種の測定は、Wasm化する価値のある粒度をチームで共有するのに役立ちます。単体の関数を逃がすのではなく、データの準備から後処理までを1ラウンドで終えるインターフェースを設計するほうが、再現性のある高速化につながります⁶。

また、メモリ使用量が肥大化するケースは避けるべきです。数百MB〜GB級のヒープ(連続領域)を確保・コピーする設計は、ブラウザではガベージコレクタとの相互作用で待ち時間が発生し、サーバーでもNUMAやページフォールトの影響が顕在化します。Wasmのメモリは連続領域で拡張されますが、急激な拡張を強いるとスループットとテールレイテンシが悪化します。I/O待ちが支配的な処理では、CPUを最適化しても体感は変わりません。まず律速段階(ボトルネック)を特定し、CPUがボトルネックであると確信できたときにWasmを検討するのが合理的です⁶。

設計と運用の勘所:キャッシュ・観測・セキュリティ

実運用で効くのは、キャッシュ戦略、観測性、ケイパビリティベースのセキュリティを最初から織り込むことでした。ブラウザではコンパイル済みモジュールをIndexedDBに置き、ハッシュでバージョン管理しながら無停止で切り替えます⁶。エッジやサーバーではプロセス内キャッシュと署名検証を併用し、サプライチェーンリスクを抑制します。

ブラウザでのコンパイルキャッシュ

WebAssembly.Moduleは構造化複製が可能なので、IndexedDBに格納して再利用できます⁶。失敗時の復旧経路も忘れずに用意します。

async function openDB() {
  return await new Promise((resolve, reject) => {
    const req = indexedDB.open('wasm-cache', 1);
    req.onupgradeneeded = () => req.result.createObjectStore('modules');
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function loadWasmWithCache(url, key) {
  const db = await openDB();
  const tx = db.transaction('modules', 'readwrite');
  const store = tx.objectStore('modules');
  const cached = await new Promise(r => { const g = store.get(key); g.onsuccess = () => r(g.result); });
  if (cached) return await WebAssembly.instantiate(cached, {});

  const res = await fetch(url, { integrity: 'sha256-REPLACE' });
  const module = await WebAssembly.compileStreaming(res);
  store.put(module, key);
  return await WebAssembly.instantiate(module, {});
}

この方針により、二回目以降の起動時間は体感できるレベルで短縮が期待できます。ユーザーの帯域やCPU性能にばらつきがあるB2Cプロダクトでは、体験の安定化に有効です。

署名とロールバック:安全に速く

配布するモジュールは署名付きアーティファクトとして扱い、ロード時にサプライチェーンの整合性を検証します。CIでハッシュをメタデータに埋め込み、実行時に一致しない場合は即座に前バージョンへロールバックする設計にすると、エッジ全体での展開でも安全に高速化施策を回せます。これにより、不整合検知から切り戻しまでを短時間で完結させやすくなります。

観測とSLO:p50だけでなくp99を見る

最適化の効果は平均値(p50)に現れがちですが、ユーザー体験はテールが支配します。p95/p99を常時記録し、デプロイごとに回帰を自動判定するガードレールを入れると、効果の再現性が上がります。モジュール側ではエラーコードと内部カウンタをエクスポートし、ホスト側ではタイムアウト・再試行・フォールバックの三段構えを実装して、障害の波及を抑えます。

ROIと導入プロセス:小さく始めて拡張する

ビジネス価値の観点では、WasmでCPU時間を削減しつつ、スループットと体験品質を高められるかが焦点です。公開事例では、ホットパス(負荷の高い経路)をWasm化することで、vCPU時間が削減され、クラウド費用が数十%改善するケースもありますが、前提や設計次第で結果は変わります。ポイントは、最初から全てをWasm化しないこと。CPUがボトルネックであるホットパスを1〜2個に絞り、必ずA/B計測を入れ、勝ち筋が見えたら隣接領域へと段階的に広げる。境界設計の原則と計測手法をチームの共通言語にできれば、以降の適用は加速度的に進みます。

最後に、例外時の体験を守るためのエラーハンドリングとフェイルセーフのコアだけ示しておきます。読み込みから実行、フォールバックまでを一つの流れに閉じると、障害時の挙動が予測しやすくなります。

import * as Sentry from '@sentry/browser';

async function runSafe(task, fallback) {
  const abort = new AbortController();
  const t = setTimeout(() => abort.abort(), 1500);
  try {
    const res = await task({ signal: abort.signal });
    clearTimeout(t); return res;
  } catch (e) {
    Sentry.captureException(e);
    clearTimeout(t); return await fallback();
  }
}

const result = await runSafe(
  async () => {
    const { instance } = await WebAssembly.instantiateStreaming(fetch('/w.wasm'));
    return instance.exports.exec(/* args */);
  },
  async () => {
    const mod = await import('./fallback.js');
    return mod.exec(/* args */);
  }
);

まとめ:Wasmは“適材適所”なら勝てる

運用事例の蓄積から見えているのは、Wasmは魔法ではないが、適材適所では確実に効果を出せる実用技術だということです。CPUが律速するホットパス、未信頼コードの安全な実行、エッジでのレイテンシ削減といった文脈では、設計のセオリーに従えば再現性のある効果が期待できます。逆に、境界を細かく往復する設計やDOM中心のワークロード、巨大ヒープを必要とする処理は避けるか、まずはネイティブ最適化を済ませてから検討するのが賢明です。

次の一手として、プロダクトの計測ダッシュボードを開き、CPU時間が突出している関数を一つ選び、WasmでのプロトタイプとA/Bを4〜6週間で回してみてください。勝てる型が見えれば、チームの武器が一つ増えます。あなたのプロダクトにとって、最初に試すべきホットパスはどこでしょうか。

参考文献

  1. Can I use: WebAssembly support
  2. Fastly: Announcing Lucet: Fastly’s native WebAssembly compiler and runtime
  3. Cloudflare Developers: WebAssembly in Workers (runtime APIs)
  4. Shopify Engineering: Why We Chose WebAssembly
  5. Node.js v18.20.5 documentation: WASI (WebAssembly System Interface)
  6. web.dev: WebAssembly performance patterns for web apps
  7. Mozilla Hacks: Calls between JavaScript and WebAssembly are finally fast
  8. Cloudflare Blog: Eliminating cold starts with Cloudflare Workers
  9. MDN Web Docs: WebAssembly.instantiateStreaming()
  10. ソフトバンク ビジネスブログ: いまさら聞けない「WebAssembly(Wasm)」とは?