はじめに:デモ成功が招く「アーキテクチャの幻」

Googleの「All Things Agentic Hackathon」のFortified Enterprise Fleetトラックに提出されたVES Fleetプロジェクトは、デモ環境では正常に動作していた。しかし、その裏では本番環境で致命的な問題を引き起こすアーキテクチャ上の弱点が隠れていた。これは一プロジェクトの失敗談ではなく、Cloud Run上でマルチエージェントシステムを構築する際に多くの開発者が直面する構造的な罠を如実に示す事例である。

Cloud Runの「軽トラフィック時に同じインスタンスを再利用する」挙動と「自動スケーリング」は、開発者の直感と異なる振る舞いを生む。デモ中はトラフィックが低く、常に同じインスタンスが再利用されるため、プロセス内のメモリに保持した状態が正しく共有されているように見える。しかし、本番環境でトラフィックが増加し複数のインスタンスが並列に起動した瞬間、その前提は崩壊する。

この記事では、デモ成功の裏にある技術的負債を特定し、Cloud Runの特性に合わせた堅牢なマルチエージェント基盤の設計指針を提示する。対象読者は、単なるAPI呼び出しの組み合わせではなく、システム全体の整合性とスケーラビリティを設計レベルで考慮する技術責任者である。

Cloud Runの挙動とマルチエージェント状態管理の根本的な矛盾

インスタンス再利用が隠蔽する状態管理の欠陥

Cloud Runはアイドル時にインスタンスを停止し、トラフィック増加時に新しいインスタンスを起動する。この挙動自体はコスト最適化のための合理的な設計だが、マルチエージェントシステムの状態管理にとっては根本的な制約となる。プロセス内のメモリ(リストや辞書など)に保持した状態は、インスタンスの生存期間に限定される。別のインスタンスが起動された時点で、そのメモリ空間は完全に独立しており、他のインスタンスの状態にはアクセスできない。

問題の核心は、この欠陥がデモ環境では見えない点にある。軽トラフィック時にはCloud Runが同じインスタンスを再利用するため、プロセスメモリ内のリストにイベントを蓄積する実装が「一見正常に」動作する。開発者はデモで期待通りの出力を確認し、状態管理の設計が妥当だと判断してしまう。しかし、スケーリングが発生した瞬間、各インスタンスが独立した状態を保持したまま並列に動作し始め、データの不整合が表面化する。

マルチエージェント協調に必要な「共有された真実」

マルチエージェントシステムにおいて、オケストレーターやエージェント間の協調には、すべてのコンポーネントが参照できる一貫性のある共有状態(Single Source of Truth)が不可欠である。リスクイベントの相関検知や、複数エージェントの協調アクションを調整するには、「どのエージェントがいつどのような状態か」を全インスタンスで同一の視点から確認できる仕組みが求められる。プロセスメモリはこの要件を満たさず、スケーリングが発生した時点でシステム全体の整合性を失う。

stateDiagram-v2
    [*] --> 低トラフィック: リクエスト到着
    低トラフィック --> 同一インスタンス再利用: メモリ状態が保持される
    同一インスタンス再利用 --> 正常に見える: デモではバグが隠蔽される
    正常に見える --> スケールアウト: トラフィック増加
    スケールアウト --> 複数インスタンス並列: 各インスタンスが独立したメモリ空間
    複数インスタンス並列 --> 状態不整合: インスタンス間で共有されない
    状態不整合 --> 相関検知の機能不全: 同一シグネチャが別インスタンスに分散

この状態遷移を踏まえると、Cloud Run上のマルチエージェントシステムでは「プロセスメモリに状態を置かない」ことが設計上の第一原則となる。状態の保存先を外部化し、すべてのインスタンスが同じデータソースにアクセスするアーキテクチャへの変更が、スケーラビリティを担保する必須条件である。

VES Fleetの事例:リスク相関通知における設計の失敗と修正

プロジェクト概要と相関通知の設計意図

VES Fleetは、ナイジェリア・デルタ地域のBori、Choba、Etcheという3つの実測地からの地下電気探査データを用いて、各サイトの汚染リスク閾値を個別に較正するシステムである。このプロジェクトはGoogleの「All Things Agentic Hackathon」のFortified Enterprise Fleetトラックへの提出物として開発された。

システムの核心機能は、オケストレーターによるリスク相関通知である。オケストレーターは複数のサイトエージェントから送信されるリスクイベントを時間窓内で監視し、同一のリスクシグネチャが複数サイトで検出された際に相関通知を発火させる。地理的に分散した3サイトから同時に類似のシグナルが検出されれば、局所的な異常ではなく広域的な汚染事象の可能性が高いと判断し、上位へのエスカレーションを行うという設計意図である。

プロセスメモリ実装での機能不全

当初の実装では、オケストレーターが受信したリスクイベントをプロセス内のメモリに蓄積し、時間窓内の相関判定を行っていた。デモ環境ではトラフィックが低く同じインスタンスが再利用されるため、この実装は正常に動作した。しかし、本番環境で3サイトのエージェントが異なるCloud Runインスタンス上で実行されるようになると、問題が顕在化した。

具体的には、Boriサイトのエージェントが検出したリスクイベントがインスタンスAのメモリに蓄積され、Chobaサイトのエージェントが検出した同一シグネチャのイベントがインスタンスBのメモリに蓄積される。両者は別々のメモリ空間に存在するため、オケストレーターは「2サイトで同一シグネチャが検出された」という事実を認識できず、相関通知が発火しない状態に陥った。地理的に分散したデータソースを統合するシステムにおいて、内部状態管理の設計が不十分であることが、機能全体の信頼性を毀損する典型的な事例である。

この事例から読み取れるのは、単一インスタンスで動作するデモが「アーキテクチャの正しさ」を証明しないという点である。スケーリングの観点から状態管理の設計を最初から見直さなければ、本番環境での信頼性は確保できない。

解決策:Firestoreへの状態移行と共有レコードの実装

VES Fleetプロジェクトでは、オケストレーターの最近のイベント状態管理をプロセスメモリからFirestoreへ移行し、すべてのCloud Runインスタンスが同じ共有レコードを読み書きするように設計を変更した(DEVの解説記事)。これにより、どのインスタンスでエージェントが実行されていても、最新のリスクイベント履歴と相関状態に一貫性を持ってアクセス可能になった。

設計上の判断基準は、Cloud Runのインスタンスが任意のタイミングでスケールイン・アウトするという特性に適合できるかという点にある。Firestoreはサーバーレスなデータストアであり、外部からの接続プール管理が不要で、コンテナが起動した時点で即座にデータにアクセスできる。一方、プロセスメモリはインスタンスの生存期間と完全に紐付いており、スケールアウトが発生した時点で状態が断絶する。

トランザクションによる競合回避

複数インスタンスが同時に同じドキュメントを書き換える可能性があるため、Firestoreのトランザクション機能を活用する。トランザクション内で現在の状態を読み取り、条件を満たす場合のみ書き込みを行うことで、並行更新時のデータ破損を防ぐ。さらに、Firestoreのリアルタイムリスナーを組み合わせることで、状態の変更が他のインスタンスに即座に反映され、相関検知の判断に遅延が生じない。

from google.cloud import firestore

db = firestore.Client()

def process_event(event: dict):
    """Pub/Sub経由で受信したリスクイベントを共有状態に反映する"""
    doc_ref = db.collection("orchestrator").document("state")

    with db.transaction() as transaction:
        snapshot = transaction.get(doc_ref)
        if not snapshot.exists:
            transaction.set(doc_ref, {"events": [], "version": 0})
            return
        data = snapshot.to_dict()
        events = data["events"]
        events.append(event)
        transaction.update(doc_ref, {
            "events": events,
            "version": data["version"] + 1
        })

書き込み頻度が高い場合、Firestoreのレートリミットに注意が必要である。リスクイベントの発生頻度が上限に近づく可能性がある場合は、時間窓内のイベントをバッチでまとめて書き込むなど、更新の粒度を調整する設計が有効である。

観点プロセスメモリFirestore
スケーラビリティ単一インスタンスに限定全インスタンスから同時アクセス可
一貫性スケールアウト時に破損トランザクションで保証
実装コスト低い(追加依存なし)中(SDK導入・スキーマ設計)
デバッグ難易度低(メモリ内を直接確認)中(クエリやログで追跡)

非同期通信:Pub/Subを活用したイベント駆動型アーキテクチャ

サイトエージェントはリスクがエスカレートした際にイベントをパブリッシュし、オケストレーターはPub/Subを通じてリアルタイムでメッセージを受信して反応する(DEVの解説記事)。この非同期通信の導入により、エージェント間の結合度が低減し、オケストレーターの負荷変動に対して柔軟に対応できる構成となった。

同期HTTP呼び出しでは、オケストレーターのインスタンスが冷間起動(Cold Start)中の場合、サイトエージェント側のリクエストがタイムアウトするリスクがある。Pub/Subを用いることで、メッセージがキューイングされ、オケストレーターのコンテナが起動完了した時点で処理が開始される。これにより、Cold Startの遅延がシステム全体の可用性に影響を与えることを防げる。

重複送信とイデンプテンシー

Pub/Subの配信保証はat-least-onceであり、同じメッセージが複数回配信される可能性がある。オケストレーター側では、各イベントに一意のIDを付与し、Firestoreに書き込む前にそのIDが既に存在するかどうかを確認する。存在する場合は処理をスキップすることで、相関判定への二重計上を防ぐ。

また、メッセージの順序保証はトピック全体では提供されない。時間窓内の相関判定において順序が重要な場合は、イベントにタイムスタンプを付与し、 Firestore側で到着順ではなくタイムスタンプ順に並べ替えてから判定を行う設計が安全である。

flowchart LR
    A["サイトエージェント"] -->|"リスクイベントをパブリッシュ"| B["Pub/Sub トピック"]
    B -->|"メッセージをプッシュ"| C["オケストレーター (Cloud Run)"]
    C -->|"イデンプテンシー確認"| D["Firestore"]
    C -->|"相関判定"| E["相関通知"]

LLMの役割定義:計算ロジックと自然言語生成の分離

GoogleのAgent Development Kit経由で利用されるGeminiは、決定されたリスク数値を再計算せず、決定結果に基づいて人間が読める要約文をドラフト作成する役割のみを果たす(DEVの解説記事)。この役割の限定は、単なるコスト削減のためではなく、システム全体の予測可能性を担保するための設計判断である。

LLMを推論エンジンとして組み込むと、同じ入力に対して出力が微妙に変動する非決定的な挙動が、リスク判定の結果に直接影響を及ぼす。閾値超過の判定や相関シグネチャの照合といった、正解が一意に定まる処理はコード側で実装し、LLMには「この数値が何を意味するかをオペレーター向けに説明する」という自然言語生成のみに委ねることで、判定ロジックの再現性が保証される。

分離がもたらす運用上の利点

ロジックと生成を分離することで、リスク閾値の変更や相関判定のアルゴリズム修正時にLLMのプロンプトを再チューニングする必要がなくなる。コード側のテストで正しさを検証すればよく、LLMの出力品質は別の軸で評価できる。本番環境で「なぜこの通知が発火したのか」を説明する必要が生じた場合、コードのロジックを辿れば再現可能であり、LLMのブラックボックスに依存しない。

加えて、コスト管理の観点からも有効である。リスクイベントの発生頻度が高い場合、全イベントに対してLLMを呼び出すとトークン消費が過大になる。コード側で「閾値超過のみLLMに要約を依頼する」というフィルタを設けることで、LLMの呼び出し回数を必要最小限に抑えられる。

スケーラブルなマルチエージェント基盤の設計パターン比較

状態管理の保存先を選択する際は、単なる「速さ」ではなく、Cloud Runのインスタンスライフサイクルとの整合性を評価する必要がある。Cloud Runはトラフィックに応じてインスタンスをスケールアウトし、アイドル時はスケールインするため、プロセス内に状態を保持する設計はスケーラビリティの観点から課題が生じる。保存先として候補になるのは、Firestore、Redis(Memorystore)、プロセスメモリの3系統である。

プロセスメモリはレイテンシが最小だが、前述したようにスケールアウトの瞬間に状態が分断される。デモ環境では軽トラフィック時に同一インスタンスが再利用されるため一見正常に動作するが、これはCloud Runの挙動に依存した偶発的な動作であり、再現性のある設計ではない。

Firestoreはドキュメント単位のトランザクションとリアルタイムリスナーを提供し、どのインスタンスからアクセスしても同じ共有レコードに到達できる。書き込み頻度が高い場合はレートリミットやコストに影響が出るが、リスクイベントのような「秒〜分単位で数件程度」の更新頻度であれば実用的な範囲に収まる。

Redisはインメモリのため読み書きのレイテンシが極めて低い。しかしCloud Runのコンテナ内からアクセスするには外部接続(VPC接続やプロキシ経由)の構成が必要となり、インスタンスがスケールインされてもRedis側は独立して存続する。この「状態がコンテナの外に存在する」という性質はFirestoreと同様だが、接続プールの管理やタイムアウト設定など運用上の追加要素が増える。

読み取り中心で更新頻度が低い状態(例:較正済み閾値の参照)はFirestore単体で十分であり、高頻度な一時状態(例:時間窓内のイベントバッファ)はRedisに置き、最終的な永続化はFirestoreで行うハイブリッド構成も選択肢に入る。ただし、構成要素が増えるほどデバッグと監視の複雑度も上がるため、まずはFirestore単体で要件を満たせるかを確認してから拡張を検討するのが合理的な進め方である。

保存先レイテンシスケーラビリティコスト整合性実装複雑度
プロセスメモリ最小(μs級)不可(スケールアウトで破綻)無料インスタンス内で一貫低
Firestore中(数十ms〜)高い(外部サービス)読み書き単位課金トランザクションで保証中
Redis(Memorystore)低(ms級)高い(外部サービス)メモリ容量課金単一ノードで一貫中高(接続構成)

実装手順:オケストレーターの状態同期コード例

以下は、Pub/Subからリスクイベントを受信してFirestoreにトランザクショナルに書き込む処理の最小構成例である。言語はPythonで示すが、設計上の要点は言語に依存しない。

トランザクションによるイベント書き込み

import firebase_admin
from firebase_admin import firestore

db = firestore.client()

def handle_risk_event(event: dict):
    """Pub/Subトリガーから呼ばれるハンドラ。"""
    site_id = event["site_id"]
    risk_value = event["risk_value"]
    signature = event["signature"]
    timestamp = event["timestamp"]

    doc_ref = db.collection("orchestrator").document("state")

    def transaction(transaction):
        doc = doc_ref.get(transaction=transaction)
        if not doc.exists:
            doc_ref.set({
                "recent_events": [],
                "version": 0
            }, transaction=transaction)
            return

        data = doc.to_dict()
        # 時間窓(例: 300秒)内のイベントのみ保持
        window_start = timestamp - 300
        recent = [e for e in data["recent_events"]
                  if e["timestamp"] >= window_start]
        recent.append({
            "site_id": site_id,
            "risk_value": risk_value,
            "signature": signature,
            "timestamp": timestamp
        })

        # 相関検知: 同一シグネチャが2サイト以上で検出
        sites = {e["site_id"] for e in recent
                 if e["signature"] == signature}
        should_alert = len(sites) >= 2

        doc_ref.update({
            "recent_events": recent,
            "last_updated": timestamp,
            "version": data["version"] + 1,
            "alert_triggered": should_alert
        }, transaction=transaction)

    db.transaction(transaction)

この実装で重要なのは、getとupdateを同じトランザクション内で実行している点である。Cloud Runの複数のインスタンスが同時にイベントを受信した場合、Firestoreのトランザクション機構が書き込みの直列化を保証するため、イベントの欠落や二重計上が発生しにくくなる。

イデンプテンシーとリトライ

Pub/Subの配信保証はat-least-onceであり、同じメッセージが複数回配信される可能性がある。このため、メッセージに一意のID(例:サイトID+タイムスタンプ+シグネチャのハッシュ)を付与し、Firestoreに書き込む前に「このIDが既に処理済みか」を確認する処理を前置する。処理済みのメッセージは即座にACKしてスキップすることで、重複による状態の二重更新を防ぐ。

Firestoreの書き込みがレートリミットに抵触して失敗した場合、指数バックオフ(exponential backoff)でリトライし、一定回数を超えた場合はデッドレターキューへ振り分ける。Cloud RunのHTTPトリガーでは、5xxを返すと自動的にリトライされるため、一時的な失敗時は503を返してCloud Run自身に再配送を委ねる構成も有効である。

落とし穴:デモ環境と本番環境のギャップを埋めるテスト戦略

デモ環境で「動いた」ことを確認して本番へ移行すると、スケールアウトが発生した瞬間に状態の不整合が表面化する。このギャップを埋めるためには、意図的にマルチインスタンス環境を再現してテストする戦略が不可欠である。

スケールアウトの再現テスト

Cloud Runの最小インスタンス数を2以上に設定し、並行してリクエストを送ることで、複数のインスタンスが同時に稼働する状態を再現する。この状態でリスクイベントを複数サイトから送信し、相関通知が正しく発火するかを検証する。プロセスメモリベースの実装であれば、インスタンスAで受信したイベントがインスタンスBでは見えないため、相関検知が失敗する可能性がある。このテストをデモ環境でも実行しておけば、本番移行前に設計上の欠陥を特定できる。

sequenceDiagram
    participant A as インスタンスA
    participant B as インスタンスB
    participant FS as Firestore
    participant PS as Pub/Sub

    Note over A,PS: 失敗シナリオ(プロセスメモリ)
    A->>A: サイトXのイベントをメモリに保持
    B->>B: サイトYのイベントをメモリに保持
    A->>A: 相関判定: サイトYのイベントが見えない
    Note over A,B: 相関通知が発火しない(バグ)

    Note over A,PS: 正常シナリオ(Firestore)
    A->>PS: サイトXのイベントをパブリッシュ
    PS->>A: メッセージを受信
    A->>FS: トランザクションで書き込み
    FS-->>A: 書き込み成功
    B->>PS: サイトYのイベントをパブリッシュ
    PS->>B: メッセージを受信
    B->>FS: トランザクションで書き込み
    FS-->>B: 書き込み成功
    B->>FS: 共有ドキュメントを読み取り
    FS-->>B: サイトXとYのイベントを確認
    B->>B: 相関判定: 同一シグネチャが2サイトで検出
    Note over B: 相関通知を発火

このシーケンスの比較から分かるように、Firestoreを介した構成では、どのインスタンスがどのイベントを処理しても、最終的に同じ共有状態から相関判定を行うため、結果がインスタンスの割り当てに依存しにくくなる。

配信保証の差異を意識した設計検証

Pub/Subのat-least-once配信とFirestoreのトランザクションは、それぞれ異なる保証レベルを持つ。Pub/Subは「最低1回は届く」を保証するが、重複は保証外である。Firestoreのトランザクションは「競合しない」を保証するが、メッセージ自体の重複を消去する機能はない。このため、前述のイデンプテンシーチェックが両者のギャップを埋める役割を果たす。テストでは、同じメッセージを意図的に2回配信し、状態が二重更新されないことを確認するケースを追加すべきである。

加えて、Cloud Runのインスタンスがスケールインされた後、スケールアウトで新しいインスタンスが起動した際に、Firestoreから最新の状態を正しく取得できるか(つまり「コールドスタート後に状態が失われないか」)も検証する。このシナリオはデモでは再現しにくいが、本番で頻繁に発生するパターンである。Cloud Runのメトリクスからインスタンスの起動タイミングを確認しながら、その直後のイベント処理が正しく行われるかをログで追跡する仕組みを構築しておくことが、本番での障害対策に役立つ。

運用と監視:マルチエージェントシステムの健全性維持

運用フェーズでは、インフラ・メッセージング・データストアの3レイヤーで監視指標を設定し、ボトルネックを早期に特定する仕組みを構築する。

Cloud Runのコンテナインスタンス数は、スケーリングが期待通りに動作しているかを確認する基本指標となる。インスタンス数が0に落ちた直後に大量のイベントが到着した場合、コールドスタートの遅延が処理の遅れに直結する。このタイミングでのレイテンシを記録しておくと、スケーリングポリシー(最小インスタンス数の設定や、スケーリングの感度)の調整に活用できる。

Pub/Subのメッセージ配信遅延は、オケストレーターの処理能力が追いついているかを示す。サイトエージェントがリスクイベントをパブリッシュしてから、オケストレーターが実際に処理を開始するまでの時間が一定の閾値を超えた場合は、オケストレーターのインスタンス数不足か、Firestoreへの書き込みがボトルネックになっているかの切り分けが必要になる。どちらの要因かによって対策が異なるため、この切り分けを迅速に行えるログ構造を事前におくと、障害時の対応時間の短縮に寄与する。

Firestoreの読み書きユニット消費量は、イベントの頻度と相関する。想定外の消費量増加は、状態の読み取りが過剰に発生している設計上の問題を示唆する可能性がある。例えば、イベントのたびに全履歴を読み直している実装では、イベント数が増えるほど読み取りコストが二次的に増大する。この傾向を監視データから検知し、必要に応じてキャッシュ戦略やクエリの範囲を見直すきっかけにする。

アラート設定としては、各サイトエージェントの状態が一定時間更新されていない場合に発火させる仕組みが有効である。これは、エージェントのコンテナがクラッシュしたか、Pub/Subからのメッセージが届いていないかの初期検知に役立つ。自動復旧の手順としては、状態が古いと判定されたエージェントに対して、Firestoreから最新の共有状態を取得し直して処理を再開させるロジックを、オケストレーター側で実装しておくと、手動介入なしで回復できるケースが増える。

コスト管理の観点では、軽トラフィック時にインスタンスが不必要に維持されていないか、Firestoreの読み取りがイベント頻度に対して過剰になっていないかを定期的に確認する。特に、リアルタイムリスナーを多用する設計では、リスナーの登録数がエージェント数に比例して増えるため、不要なリスナーの解放を忘れがちである。エージェントがアイドル状態に移行した際にリスナーを解除する処理を、エージェントのライフサイクル管理に組み込んでおくことが望ましい。

セキュリティ面では、Firestoreへのアクセス権限を各エージェントの役割に最小限に絞り、サイトエージェントがオケストレーターのドキュメントを直接書き込めないようにする。Pub/Subのトピックとサブスクリプションへのアクセスも、サービスアカウント単位で認可を限定し、意図しないメッセージの書き込みや読み取りを防ぐ。マルチエージェント構成では、エージェント間の権限境界が曖昧になりがちであり、アクセス権限の設計をアーキテクチャの初期段階で明確にしておくことが、後からの権限見直しコストを減らす。

まとめ:堅牢なマルチエージェント基盤への道

デモでの成功は、アーキテクチャが本番環境でも正しく機能することを保証するものではありません。Cloud Runの軽トラフィック時のインスタンス再利用という特性が、プロセスメモリへの依存という設計上の弱点を見えにくくし、単一インスタンスでの正常動作を「全体的に正しい」という誤った確信につながることがあります。VES Fleetの事例のように、3サイト分のリスクイベントを時間窓内で相関させるという複雑な状態管理は、デモ環境では偶然に正しく動作するように見え、本番環境でスケーリングが発生した際に課題が生じる可能性があります。

Cloud Run環境でのマルチエージェントシステムにおいて、スケーラビリティを確保する鍵の一つは、プロセスメモリへの依存を最小限に抑え、Firestoreのような外部状態ストアを共有の真実(Single Source of Truth)として位置付けることです。すべてのインスタンスが同じ共有レコードを読み書きする設計を採用することで、インスタンスの増減に関わらず状態の一貫性を維持しやすくなります。Pub/Subによる非同期通信と組み合わせることで、エージェント間の結合度を下げつつ、スケールアウト時に処理の負荷分散を図ることが可能です。

LLMの役割を推論エンジンではなくインターフェース層に限定する設計判断も、システムの予測可能性を高める重要な要素です。リスク数値の計算や閾値判定はコードで実装し、LLMには決定結果の自然言語化のみを任せることで、ロジックの変更時にプロンプトの再調整が必要になる頻度を減らすことができます。この分離は、エージェントの振る舞いをデバッグする際の因果関係を追跡しやすくし、本番環境での不具合調査時間を短縮する効果も期待できます。

これらの設計パターンを理解するだけでなく、マルチインスタンス環境での状態同期テスト、Chaos Engineeringによる意図的な障害注入、そして運用時の3レイヤー監視を組み合わせることで、より信頼性の高いAIエージェントシステムの構築が可能になります。デモの成功に安住せず、スケーリングの境界で何が起きるかを実際に検証する姿勢が、技術責任者に求められる重要な判断基準となります。

関連記事

参考

本記事は海外の技術トレンド「My Cloud Run Multi-Agent Fleet Passed Its Demo. The Architecture Was Still Wrong.」(DEV)で話題のテーマをもとに、両儀システムソリューションズが独自に解説したものです。