ブログに戻る

Rustによるエッジネイティブゲームサーバー:WASM上のTokioがマルチプレイヤーアーキテクチャにもたらすもの

公開日 2026年9月29日
Rustによるエッジネイティブゲームサーバー:WASM上のTokioがマルチプレイヤーアーキテクチャにもたらすもの AIを活用して生成

要点まとめ

エッジゲームサーバーをRust WASMとEmscriptenターゲットで構築する方法を詳しく解説。Tokio非同期ランタイムのエッジ統合、セッション管理・マッチメイキングへの応用、従来型マルチリージョン展開とのコスト比較まで網羅したマルチプレイヤーアーキテクチャ向け実践ガイド。

あなたのマルチプレイヤーバックエンドは単一の場所に存在している。おそらくus-east-1、運が良ければeu-west-1だろう。そのリージョン外のプレイヤーは全員、レイテンシ税を支払っている——しかもそれはパケットごとに複利で膨らんでいく。

サンパウロのプレイヤーがUS-Eastサーバーに接続すると、ゲームパケットが1つ処理されるまでに往復120〜150msかかる。ムンバイのプレイヤーがEU-Westに接続したら? 180〜220msだ。リアルタイムマルチプレイヤーにとって、これは「快適」と「プレイ不能」の違いを意味する。

従来の対策は高コストだ。複数リージョンに専用サーバー(Dedicated Server)を展開し、地理的ルーティングを設定し、データベースレプリカを個別に維持するには、リージョンごとに月額$3,000〜8,000の予算が必要になる。ほとんどのインディースタジオは、ローンチ後の収益でオーディエンスの存在が証明されるまで、このコストを正当化できない。

しかし、新しいアーキテクチャの可能性が生まれつつある。RustのゲームサーバーロジックをWebAssemblyにコンパイルし、世界中の300以上の都市にあるエッジノードで実行できるようになったのだ。Cloudflareは先頃、新しいEmscriptenコンパイルターゲットを介して、TokioベースのRustアプリケーションをWorkersプラットフォーム上で実行する実験的サポートをリリースした。マルチプレイヤーゲームのバックエンドへの影響は大きく、この記事ではRust WASMによるエッジゲームサーバーが実際にどのようなものか、どこで威力を発揮し、どこで失敗し、制約にどう対処してアーキテクチャを設計すべきかを詳しく解説する。

何が変わったのか:Emscriptenターゲットがライブラリの壁を打ち破る

従来、エッジワーカーでRustを実行するには、2つの不十分な選択肢のどちらかを選ぶ必要があった:

  1. wasm32-unknown-unknown — 計算専用関数には使えるが、ネイティブプラットフォーム機能が削ぎ落とされる。ソケットなし、ファイルシステムなし、タイマーも制限される。実際のゲームサーバーには役に立たない。
  2. ネイティブバイナリ — 従来のVMデプロイに制限されるため、リージョンごとのデータセンター価格になる。

wasm-bindgenの新しいwasm32-unknown-emscriptenターゲットはこれを変える。EmscriptenはWeb Platform APIの上でネイティブプラットフォーム機能(タイマー、ファイルシステム操作、ソケット)を仮想化する。wasm-bindgenのRust-to-JavaScript相互運用レイヤーと組み合わせると、以下が実現する:

  • 仮想化I/Oによる完全なTCPソケットサポート(Cloudflareチームは実際のTCPソケットを使うMinecraftサーバーを移植した)
  • ゲームループとtickレートのためのタイマーおよびスケジューリングサポート
  • ノンブロッキング操作のためのTokio非同期ランタイム統合
  • V8の最適化されたWebAssembly実行によるネイティブに近いパフォーマンス

結果として、Rustゲームサーバーは通常のUnixシステム上で動いていると認識しながら、実際には数百都市に分散したエッジインフラストラクチャ上で実行される。Emscriptenターゲットはtarget_family = unixを報告するため、ほとんどの低レベルシステムライブラリは修正なしで動作する。

ゲーム開発者にとって、これはRust WASMによる軽量なエッジゲームサーバーが、プレイヤーベースの90%以上に対して10ms未満のレイテンシでセッション管理、プレイヤー状態、入力検証を処理できることを意味する——データセンターへのデプロイは一切不要だ。

最小限のエッジゲーム状態ハンドラーの例は次のとおりだ:

use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Serialize, Deserialize, Clone)]
struct PlayerState {
    player_id: String,
    position: [f32; 3],
    health: u16,
    last_update: u64,
}

#[derive(Serialize, Deserialize)]
struct GameState {
    players: HashMap<String, PlayerState>,
    tick: u64,
}

#[wasm_bindgen]
pub struct EdgeGameServer {
    state: GameState,
}

#[wasm_bindgen]
impl EdgeGameServer {
    #[wasm_bindgen(constructor)]
    pub fn new() -> EdgeGameServer {
        EdgeGameServer {
            state: GameState {
                players: HashMap::new(),
                tick: 0,
            },
        }
    }

    /// Update a player's position — called per input frame
    pub fn update_player(
        &mut self,
        player_id: &str,
        x: f32,
        y: f32,
        z: f32,
    ) -> String {
        self.state.tick += 1;
        let entry = self.state.players
            .entry(player_id.to_string())
            .or_insert(PlayerState {
                player_id: player_id.to_string(),
                position: [0.0, 0.0, 0.0],
                health: 100,
                last_update: 0,
            });
        entry.position = [x, y, z];
        entry.last_update = self.state.tick;

        // Return authoritative state snapshot to the client
        serde_json::to_string(&self.state).unwrap_or_default()
    }

    /// Full state snapshot for a new player joining
    pub fn get_snapshot(&self) -> String {
        serde_json::to_string(&self.state).unwrap_or_default()
    }
}

これはWebAssemblyにコンパイルされ、Cloudflare Worker内にロードされ、近隣のプレイヤーに対して一桁ミリ秒のレイテンシでプレイヤー入力を処理する。wasm-bindgenレイヤーがRustとJavaScriptの橋渡しをし、WorkerのリクエストハンドラーがWASMモジュールを呼び出し、オーソリタティブな状態を更新して結果を返す。

Tokioの問題:同期エッジ上の非同期ランタイム

ゲームサーバーは単なるステートマシンではない。並行ネットワークI/O、タイマー、そしてバックエンド通信用のTCP接続を処理する必要がある。Rustでは、それがTokioの役割だ。

しかしCloudflare Workers(そして一般的なエッジランタイム)はシングルスレッドで、JavaScriptイベントループ内でホストされている。Tokioの非同期モデルは、スレッドのパーキングセマンティクスを使ったブロッキング操作を中心に構築されている。この2つのモデルは根本的に互換性がない。ブロッキングepoll_waitは共有イベントループ全体をフリーズさせ、そのワーカー上の他のすべてのリクエストを停止させてしまう。

Cloudflareのエンジニアは、上流のTokioに対する実験的パッチセットとして実装された2つの相補的アプローチでこれを解決した。

アプローチ1:WebAssembly JavaScript Promise Integration(JSPI)

JSPIはブロッキングWebAssembly呼び出しがスタックをサスペンドし、JavaScriptイベントループに制御を戻すことを可能にする。以前の呼び出しがサスペンドされている間に新しいWasm呼び出しが入ると、JSPIは別のWebAssemblyスタックを作成する——両者は衝突なく共存する。

問題はランタイム内部にある。Rustのスレッドローカルストレージはスタックスイッチを認識しない。Tokioはスレッドローカルを介してランタイムコンテキストを追跡するが、JSPIのスタックスイッチはスレッドスイッチではない。サスペンドされた2つのスタックが同じスレッドローカル状態を共有することになり、ランタイムが「すでにエンター済み」と判断するとパニックが発生する。

修正策は、各JSPIのenter、exit、suspend、resume呼び出しにおける協調的コンテキストスイッチングだ——各サスペンドスタックが独自のランタイムコンテキストを持つ、実質的な時分割多重スレッディングである。壊れやすいが機能はし、Cloudflareチームは自社のTokioパッチセットで動作することを検証した。

アプローチ2:Tokio用のLocalEventLoopランタイム

より汎用的な解決策は、Tokioの実行ループを2つの個別操作に分割する:

  1. drive() — 準備完了タスクを1バッチ分ポーリングして、すぐに戻る
  2. wake() — ホストのイベントループに「保留中の作業がある。準備ができたらdrive()を呼んでくれ」と通知する

スレッドをパーキングする代わりに、ランタイムはホストにシグナルを送る。ホスト(Workerのリクエストハンドラー)は自身のマイクロタスクサイクル中にdrive()を呼び出す。Tokioは決してブロックせず、ホストのスケジューリングに協調する。

use tokio::runtime::LocalHandle;

/// Edge worker entry point — no blocking, cooperates with JS event loop
#[wasm_bindgen]
pub async fn handle_game_request(
    handle: &LocalHandle,
    request_body: &str,
) -> String {
    // Parse incoming player input
    let input: PlayerInput = serde_json::from_str(request_body)
        .unwrap_or_default();

    // Spawn async work on Tokio's local event loop
    // The host JS event loop drives this via wake()/drive()
    let result = handle.spawn_local(async move {
        // Async operations: DB read, backend API call, timer
        let player_data = fetch_player_profile(&input.player_id).await;
        apply_game_logic(input, player_data).await
    }).await;

    result.unwrap_or_else(|_| String::from("{\"error\":\"tick_failed\"}"))
}

LocalEventLoop設計は、汎用のTokio機能として提案された——エッジワーカーだけでなく、既存のイベントループにTokioを統合する必要があるWindowsやmacOSのネイティブUIアプリケーションも対象としている。この汎用性により、上流での採用確率が高まる。

エッジゲームサーバーが実際に機能する領域(そして機能しない領域)

具体的に考えよう。エッジネイティブゲームサーバーは専用サーバーの万能な代替品ではない。特定のレイテンシ敏感型ワークロードのための特定のツールだ。

エッジで威力を発揮するワークロード

プレイヤーセッション管理(10ms未満) 認証状態、接続メタデータ、セッショントークンを最寄りのエッジノードに置く。セッショントークン検証のための大陸間ラウンドトリップが不要になる。同時接続10,000人のゲームでは、毎秒10,000回のセッション検証を中央集権的にではなくローカルで処理できる。

カード/ターン制ゲームの軽量オーソリタティブ状態 カードゲームの状態(手札の内容、ボードレイアウト、ターン順序)は通常、プレイヤーあたり10KB未満だ。エッジノードはこれを5ms未満の読み取りレイテンシで維持する。Hearthstone、Slay the Spireのマルチプレイヤー、その他のターン制ストラテジーはこのモデルに綺麗にマッピングできる。

プレゼンスとハートビートの集約 「誰がオンラインか」というクエリは、中央集権的データベース呼び出しではなくローカル読み取りになる。エッジノードはリージョンごとにハートビートを追跡し、定期的に(5〜10秒ごと)グローバルビューに集約する。

リーダーボードのパーティショニング エッジノード上のリージョナルリーダーボードは、スケジュールに従ってグローバルランキングに集約される。プレイヤーはローカルランクを即座に確認でき、グローバルランクは数秒以内に更新される。これは「自分のリージョン内でのランク」がグローバルランクと同じくらい重要な競争ゲームで特に効果的だ。

マッチメイキングのステートマシン ロビーフロー(プレイヤーキュー、スキル区分、ルーム作成)は、定期的に同期するエッジローカルのステートマシンにマッピングされる。マッチメイキングロジックがプレイヤーの最寄りノードで実行されるため、キュー待ち時間が短縮される。

エッジで機能しないワークロード

オーソリタティブな物理シミュレーション 20以上のエンティティに対する衝突検出を含む60Hz物理tickの実行は、典型的なエッジワーカーのCPU予算を超える。Cloudflare Workersにはリクエストごとに10〜50msのCPU時間制限がある(プランによる)。中程度の複雑さのシーンでの単一物理フレームは専用ハードウェアで2〜8msかかる——エッジの予算に近すぎて信頼性が確保できない。

大規模ワールド状態の同期 ゲーム状態が約1MBを超えると、エッジワーカーでのシリアライズと転送コストが法外になる。MMOのワールド状態、大規模テレイン、エンティティ数の多いシーンは、永続メモリを備えた従来のサーバーに属する。

高負荷フレームループを伴う永続TCP接続 Emscripten上のTokioはTCPソケットをサポートしているが、フレームごとの処理(60Hz)を伴う長時間接続の維持は、エッジワーカーの実行時間制限に抵触する。ほとんどのエッジプラットフォームは、呼び出しごとに30秒〜5分の実行ウィンドウを強制する。

アーキテクチャパターン:エッジ+リージョナルハイブリッド

実用的なパターンは「オールエッジ」でも「オール従来型」でもない——バックエンドをレイテンシ敏感型レイヤーと計算集約型レイヤーに分割することだ。

Player Device
      │
      ▼
┌─────────────────────────────────────────┐
│         EDGE NODE (nearest city)         │
│  • Session management                    │
│  • Player state cache (read-heavy)       │
│  • Input validation & rate limiting      │
│  • Presence / heartbeat tracking         │
│  • Regional leaderboard queries          │
└──────────────────┬──────────────────────┘
                   │ (periodic sync, 1-5s)
                   ▼
┌─────────────────────────────────────────┐
│       REGIONAL GAME SERVER               │
│  • Authoritative physics / simulation    │
│  • World state management                │
│  • Heavy AI processing                   │
│  • Database writes (strong consistency)  │
└──────────────────┬──────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────┐
│       PERSISTENCE LAYER                  │
│  • Player accounts & authentication      │
│  • Inventory / progression storage       │
│  • Leaderboard persistence               │
│  • Cloud save with conflict resolution   │
└─────────────────────────────────────────┘

エッジレイヤーは、近接性の恩恵を受けるすべての処理(セッション検証、入力サニタイズ、キャッシュされたプレイヤーデータの読み取り、プレゼンス追跡)を担当する。リージョナルサーバーへの同期は定期的に行われ、非クリティカルな状態は1〜5秒ごと、ダメージやインベントリ変更などのオーソリタティブなアクションは即時実行される。

このアーキテクチャにより、ほとんどの地理的リージョンのプレイヤーにとって、一般的な操作の体感レイテンシは120〜200msから5〜15msに短縮される。競技性の高いマルチプレイヤーゲームにとって、これは「機敏」と「もっさり」の違いだ。

Unreal Engineマルチプレイヤーでの状態乖離のデバッグを経験したことがあるなら、明確な境界なしに整合性モデルを混在させると、まさに再現が最も難しい種類のデシンクが発生することをご存じだろう。エッジとリージョナルの分割はそれらの境界を明確にする。エッジは結果整合性、リージョナルはオーソリタティブ(強整合性)だ。

コスト分析:エッジ vs. 従来型マルチリージョン

同時接続10,000人のマルチプレイヤーゲームにおける、現実的な月間コスト比較は次のとおりだ:

インフラ 従来型マルチリージョン エッジ+リージョナルハイブリッド
US-Eastサーバー $400–800/月 $400–800/月
EU-Westサーバー $400–800/月 $400–800/月
アジア太平洋サーバー $400–800/月 —
エッジコンピュート(300+都市) — $50–200/月
地理的ルーティング/DNS $50–100/月 $50–100/月
合計 $1,250–2,500/月 $900–1,900/月

エッジアプローチは、第3のリージョン展開の必要性を排除し、そのリージョンのプレイヤーをエッジノードでカバーする。100,000 CCUでは節約効果はさらに拡大する——セッションや軽量状態ワークロードにおいて、従来型マルチリージョンはエッジハイブリッドアプローチの3〜4倍のコストがかかる。

エッジコンピュートの料金は通常、リクエスト単位または呼び出し単位であるため、変動するトラフィックパターン(ゲームサーバーがピーク時とオフピーク時に経験するまさにそのパターン)に対してコスト効率が良い。

リージョナル専用インフラのアイドルコンピュートコスト削減戦略については、Fortniteのサーバーハイバネーション提案の分析で、低トラフィック時間帯にサーバーをスケールダウンする手法を解説している。

エッジネイティブゲームバックエンドの5つのベストプラクティス

1. レイテンシ感応度で状態を分割する ゲーム状態はすべて、レイテンシクリティカル(プレイヤー位置、入力状態、セッションデータ)と整合性クリティカル(ワールド状態、進行度セーブ、リーダーボード)の2つのカテゴリに分類される。前者はエッジノードに置き、後者はリージョナルサーバーまたは永続化レイヤーに置く。500msの陳腐化を許容できる状態は、エッジに置くべきだ。

2. 結果整合性を前提に設計し、テストする 異なる都市のエッジノードは、ゲーム状態について一時的に不一致になる。これを設計上の制約として受け入れよう。エッジキャッシュ状態には、最終書き込み優先(last-write-wins)セマンティクスまたはCRDTを使用する。2つのエッジノードが同じプレイヤーの入力を同時に処理する状況をシミュレートし、マージが有効な結果を生成することを検証する明示的テストを書く。

#[cfg(test)]
mod consistency_tests {
    use super::*;

    #[test]
    fn two_edge_nodes_concurrent_update_merges_cleanly() {
        let mut state_a = GameState::new();
        let mut state_b = GameState::new();

        // Simulate two edge nodes processing input for same player
        state_a.update_player("player_1", 10.0, 0.0, 5.0);
        state_b.update_player("player_1", 12.0, 0.0, 3.0);

        // Merge with last-write-wins (higher tick wins)
        let merged = merge_states(&state_a, &state_b);
        let player = merged.players.get("player_1").unwrap();

        // Later tick's position should win
        assert_eq!(player.position, [12.0, 0.0, 3.0]);
    }
}

3. エッジワーカーの実行時間にハードバウンドを設定する ほとんどのエッジプラットフォームは、呼び出しごとに30秒〜5分の制限を課している。tickロジックは早期にプロファイリングしよう。専用サーバーで45msかかる単一ゲームtickは、WASM起動オーバーヘッドと仮想化I/Oのため、エッジインフラでは60〜80msかかる可能性がある。Rustのweb_sysパフォーマンスAPIを使って計測する:

use web_sys::window;

fn timed_tick<F: FnOnce() -> R, R>(label: &str, f: F) -> R {
    let perf = window()
        .expect("no window")
        .performance()
        .expect("no performance API");
    let start = perf.now();
    let result = f();
    let elapsed = perf.now() - start;

    if elapsed > 50.0 {
        web_sys::console::warn_1(
            &format!("[{}] tick exceeded 50ms budget: {:.2}ms", label, elapsed).into(),
        );
    }
    result
}

4. エッジノードはライトスルーキャッシュではなくリードスルーキャッシュとして使う 読み取りはエッジから、書き込みはリージョナルへ。エッジノードは頻繁に読み取られるプレイヤーデータ(プロフィール、ロードアウト、最近のマッチ履歴)をキャッシュし、他のプレイヤーに影響する状態変更または永続化が必要な変更のみをオーソリタティブストレージに転送する。これにより、エッジノードの高速性とデータの整合性が両立する。ほとんどのゲームセッションの読み取り/書き込み比率は10:1以上だ。

5. エッジとリージョナル間の同期ラグをファーストクラスのメトリクスとして監視する オーソリタティブ状態から大きく乖離したエッジ状態は、最悪の種類のバグを生む。断続的で、地理に依存し、ローカルでの再現がほぼ不可能だ。初日から同期レイヤーに可観測性を組み込もう。同期間隔を追跡し、エッジキャッシュ状態とオーソリタティブ状態の乖離を測定し、ラグがゲームの許容範囲(非クリティカル状態では通常1〜3秒、戦闘関連データでは500ms未満)を超えたらアラートを出す。

次のマルチプレイヤープロジェクトへの示唆

エッジワーカー向けRustのEmscriptenターゲットは、専用ゲームサーバーの代替ではない。従来のマルチリージョン展開が高コストかつ不完全にしか解決できなかった「ラストマイル」レイテンシ問題を解決する、新しいアーキテクチャレイヤーだ。

マルチプレイヤーゲームを開発中で、サーバーへのレイテンシがプレイヤーの不満を招いているなら(特に主要サーバーリージョン外のプレイヤーから)、バックエンドの分割を検討しよう。セッション管理、プレゼンス、軽量状態はエッジノードに移し、物理、AI、ワールドシミュレーションはリージョナルインフラに残す。両者を定期的な同期レイヤーで接続する。

ツールは実験的だが、今日すでに機能する。CloudflareのRust Workersの例は、TCPソケット、Tokio非同期、ステートフルなDurable Objectsが連携して動作することを示している。このパターンは、より多くのスタジオが採用するにつれて急速に成熟していくだろう。

小さく始めよう。既存バックエンドにエッジキャッシュ型セッション検証を追加し、最も遠隔地のプレイヤーに対するレイテンシ改善を測定する。数値が良ければ、より多くのエッジネイティブな状態管理に拡張する。

このアーキテクチャの永続化側(エッジノードが同期先とするプレイヤー認証、クラウドセーブ、リーダーボード)には、horizOnがリビジョン認識型の競合解決を備えたアカウント連携クラウドセーブ、クロスデバイスリーダーボード、マルチプロバイダー認証を提供する。エッジサーバーはリアルタイム状態に集中し、horizOnがサーバー再起動後も生存する必要があるすべての処理を担当する。

グローバル展開を見据えたマルチプレイヤーバックエンドの設計を始める準備はできただろうか? まず、ゲーム状態操作のうちどれがレイテンシ敏感型で、どれが整合性クリティカルかをマッピングすることから始めよう。その単一の設計判断が、他のすべてを決定づける。


出典:wasm-bindgenの新しいEmscriptenターゲットによるWorkersのネイティブRustサポート

このダッシュボードは以下のチームによって愛情を込めて作られています Projectmakers

© 2026 projectmakers.de

unknown-v1.103.4 / unknown-v--