CodeGym /コース /ChatGPT Apps /システムのレジリエンス: timeouts, circuit breakers, bulkheads, Webho...

システムのレジリエンス: timeouts, circuit breakers, bulkheads, Webhook ストーム対策

ChatGPT Apps
レベル 16 , レッスン 2
使用可能

1. なぜ ChatGPT App で「レジリエンス」を考えるのか

一般的な Web アプリでは、ユーザーは URL やブラウザのスピナーが見え、ページを更新できます。ChatGPT では、ユーザーが見るのは1つの画面、つまりチャットとあなたの App だけです。もしどこかが遅ければ、誰のせいかは区別できません。OpenAI なのか、あなたの Gateway なのか、決済か、隣の分析マイクロサービスなのか。ユーザーにとってはすべて「ChatGPT + あなたの App」です。

tool-call3060 秒もぶら下がると、モデルは待ち続け、良くて遅延を謝る程度。悪い場合は、あなたのバックエンドからのデータの代わりに幻覚した回答を返すこともあります。だからレジリエンスは SRE や uptime だけの話ではなく、回答品質、モデルの振る舞い、Store のメトリクスにも直結します。

ChatGPT App のエコシステムには、いくつかの独立した経路があります:

  • ChatGPT ↔ MCP Gateway
  • Gateway ↔ あなたの backend/REST サービス(Gift REST API、Commerce REST API、Analytics Service など)
  • あなたのサービス ↔ 外部 API(LLM、決済、カタログ)
  • 着信 Webhook(ACP、Stripe、その他統合) ↔ あなたのハンドラ

問題は、どこか1か所の障害がカスケードを引き起こしうることです。Gateway はハングしたサービスを律儀に待ち、ワーカーは詰まり、コネクションは枯渇し、クライアントはリトライを始め、数分で典型的な「地獄モード」になります。今日扱う4つのパターンは、まさにそれを防ぐためのものです。

  • Timeouts — 永遠には待たない。
  • Circuit breaker — 閉ざされたドアに頭突きを続けない。
  • Bulkheads — 「区画」を作って、全船沈没を防ぐ。
  • Webhook ストーム対策 — Webhook は重複・スパイク・リトライが前提だと認め、備える。

2. Timeouts: いつまでも待たない

タイムアウトとは何か、なぜ必須か

タイムアウトとは、依存先(DB、MCP サーバ、外部 HTTP API、モデル)からの応答をコードが待つ最大時間です。指定時間内に応答がなければ、その呼び出しは失敗とみなし、リソースを解放し、わかりやすいエラーまたはフォールバックを返します。

タイムアウトがないと、リクエストは以下のようになります:

  • 無限に待ち続ける、
  • コネクションやスレッドプールを専有する、
  • 後続のリクエストをブロックする、
  • カスケード障害を引き起こす。

パターンはシンプルです。「35 秒の予測可能な失敗の方が、5 分の無言よりずっと良い」。

タイムアウトは複数レイヤで存在します:

  • プロキシ/ロードバランサ(Cloudflare、Nginx)レベル、
  • MCP Gateway レベル(マイクロサービスへの HTTP クライアント)、
  • 各サービス内部(DB、外部 API、LLM への呼び出し)。

ChatGPT 全体としては、通常の操作での tool-call の合計時間は 510 秒、特に重い処理でも最大 2030 秒を目指すのが妥当です。これ以上は、ほぼ確実に悪い UX になります。

シンプルな fetchWithTimeout(TypeScript)

実践から始めましょう。GiftGenius MCP Gateway には、gift レコメンダ、commerce サービス、分析にアクセスする補助的な HTTP クライアントがあります。標準の fetch をタイムアウト付き関数でラップします:

// src/gateway/httpClient.ts
export async function fetchWithTimeout(
  url: string,
  opts: RequestInit & { timeoutMs?: number } = {}
) {
  const { timeoutMs = 5000, ...rest } = opts;
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await fetch(url, { ...rest, signal: controller.signal });
  } finally {
    clearTimeout(timeoutId);
  }
}

これで Gateway のコードでは、生の fetch は二度と使わず、必ずこのヘルパー経由にできます:

// src/gateway/giftClient.ts
import { fetchWithTimeout } from "./httpClient";

export async function callGiftService(path: string) {
  const res = await fetchWithTimeout(
    process.env.GIFT_SERVICE_URL + path,
    { timeoutMs: 4000 }
  );

  if (!res.ok) {
    throw new Error(`gift_service_${res.status}`);
  }
  return res.json();
}

このやり方なら、gift サービスがハングしても 4 秒で接続を中断し、限界まで保持するのではなく ChatGPT に MCP エラーを返せます。

GiftGenius におけるタイムアウトの設置ポイント

GiftGenius の例では:

  • Gateway レベル: Gift REST API、Commerce REST API、Analytics Service / REST API への呼び出しにタイムアウト。
  • これら各サービス内: DB、ACP/決済、外部レコメンド API への呼び出しにタイムアウト。
  • Gateway の入口: ChatGPT からのリクエストに対する全体のタイムアウト(tool-call を「永遠のスピナー」にしない)。

最上位の待ち時間は、内部より少し長くすることが重要です。たとえば Gateway がバックエンドを 5 秒待ち、バックエンドが DB を 3 秒待つなら、処理とシリアライズの余裕ができます。

タイムアウトを ChatGPT モデルにどう伝えるか

ChatGPT には、意味のあるエラーを返すことが大切です。無言で接続を落とさないでください。抽象的な 500 より、モデルがユーザーに説明できる構造化された MCP エラーを返した方がよいです(例: 「ギフト選定サービスが混雑しています。少し時間をおいて再試行してください。」など)。

つまり Gateway ではタイムアウト時に:

  1. AbortError や自前の timeout_… を捕捉する。
  2. 意味のあるコードと短い説明を持つ MCP 応答を形成する。
  3. それをモデルが人にどう伝えるか判断できるようにする。

タイムアウトはハングしたリクエストを解決しますが、依存先が大量に落ち始めた場合、同じ失敗を量産する「雪崩」自体は止められません。そこで次の防御層 — circuit breaker が必要です。

3. Circuit breaker: 死にかけたサービスへの「自動遮断」

直感: タイムアウトだけでは足りない理由

すでにタイムアウトで個々の呼び出しの待ち時間を制限する方法は学びました。タイムアウトは単一の呼び出しを守ります。しかし依存先が「完全に」死んでいる(たとえば commerce サービスがリクエスト毎に OOM(Out Of Memory)で落ちる)場合でも、我々は呼び続け、毎回 35 秒待ってエラーを受け、ネットワークと CPU を消費してまた待つことになります。

Circuit breaker(ブレーカ)は記憶を足します。エラーやタイムアウトを監視し、一定以上多くなるとそのサービスに一切リクエストを送らなくなります。代わりに即座に失敗やフォールバックを返します。しばらくしてから half-open モードで慎重に再試行します。

典型的な状態:

  • Closed — 正常。リクエストは流れる。
  • Open — サービスは「死んでいる」と見なす。リクエストは送らず、即エラー。
  • Half-open — 限定数だけ試行。成功が続けば closed に戻り、再度失敗すれば open に戻る。

簡単な circuit breaker のスキーム

小さなダイアグラム:

stateDiagram-v2
    [*] --> Closed
    Closed --> Open: エラーが多すぎる
    Open --> HalfOpen: クールダウン経過
    HalfOpen --> Closed: 複数回連続成功
    HalfOpen --> Open: 再びエラー
    Open --> Open: 即時拒否

TypeScript によるミニ実装

本番では通常ライブラリを使います(Node.js なら opossum や軽量な自作など)。ただメカニズムの理解にはコンパクトなクラスで十分です。

commerce モジュール呼び出しの周りに置く、極めて単純化したブレーカの例:

// src/gateway/circuitBreaker.ts
type State = "closed" | "open" | "half-open";

export class CircuitBreaker {
    private state: State = "closed";
    private failureCount = 0;
    private nextAttemptAt = 0;

    constructor(
        private readonly failureThreshold = 5,
        private readonly cooldownMs = 30_000
    ) {}

    async call<T>(fn: () => Promise<T>): Promise<T> {
        const now = Date.now();

        if (this.state === "open") {
            if (now < this.nextAttemptAt) {
                throw new Error("circuit_open");
            }
            this.state = "half-open";
        }

        try {
            const result = await fn();
            this.onSuccess();
            return result;
        } catch (err) {
            this.onFailure();
            throw err;
        }
    }

    private onSuccess() {
        this.failureCount = 0;
        this.state = "closed";
    }

    private onFailure() {
        this.failureCount++;
        if (this.failureCount >= this.failureThreshold) {
            this.state = "open";
            this.nextAttemptAt = Date.now() + this.cooldownMs;
        }
    }
}

commerce クライアントでの使用例:

// src/gateway/commerceClient.ts
const commerceBreaker = new CircuitBreaker(3, 20_000);

export async function callCommerce(path: string) {
    return commerceBreaker.call(async () => {
        const res = await fetchWithTimeout(
            process.env.COMMERCE_URL + path,
            { timeoutMs: 3000 }
        );
        if (!res.ok) throw new Error(`commerce_${res.status}`);
        return res.json();
    });
}

ここでは、commerce が大量にエラーを返したりタイムアウトし始めると、数回の失敗後にブレーカが open に遷移します。この状態では cooldownMs の間、そもそもサービスへ行かず、circuit_open のエラーを即座に返します。

ブレーカがサービスを遮断したとき、ChatGPT にどう見せるか

ChatGPT の観点では、次のようにすると良いです:

  • MCP エラー「commerce_unavailable」や「gift_service_overloaded」を素早く返す。
  • 「決済サービスは一時的に利用できません。あとで試しましょう。」のように、明確な説明を付ける。
  • 無限リトライの陰にエラーを隠さない。

このケースでは「速く正直な失敗」の方が、長時間のフリーズより良いのです。特にチェックアウトでは、ユーザーは 40 秒のスピナーの末に「何かがうまくいきませんでした」と言われるより、正直なメッセージの方が受け入れやすいでしょう。

タイムアウトとブレーカは「悪い」あるいは落ちた依存先から守ってくれますが、1種類の負荷がすべてのリソースを食いつぶして他の部分を窒息させる問題は解決しません。そこでさらにもう一層 — bulkheads が必要です。

4. Bulkheads: 区画化で全滅を防ぐ

船のアナロジー

bulkhead パターンは船の隔壁(区画)に由来します。1区画に穴が空いても、船全体に浸水しません。アーキテクチャでは、リソースを分割し、遅い/詰まったサービスが CPU・コネクション・プールなどを食いつくして、クリティカルパスまで落とさないようにします。

マイクロサービスでは通常、次のように実現します:

  • 個別の HTTP コネクションプール、
  • スレッド/ワーカープール、
  • キュー/トピック、
  • クリティカルな操作用の別 DB クラスタ、など。

要は、ギフトのレコメンドサービスが遅くなったとしても、そのサービス向けのリソースだけを使い切り、チェックアウトや認証を巻き添えにしない、ということです。

Node.js と MCP Gateway における Bulkheads

Node.js にはクラシックな意味でのスレッドはありません(イベントループとワーカーはあります)。しかし各方面の同時実行数を制限できます。

例: Gateway には3つの外部依存があります:

  • Gift サービス(ギフト選定。LLM が重い)。
  • Commerce サービス(チェックアウト、ACP)。
  • Analytics サービス(イベントのロギング)。

それぞれへの同時リクエスト数に簡単な上限を設けられます。

例えば、並列度を制限する小さな「セマフォ」:

// src/gateway/bulkhead.ts
export class Bulkhead {
    private active = 0;
    private queue: (() => void)[] = [];

    constructor(private readonly maxConcurrent: number) {}

    async run<T>(fn: () => Promise<T>): Promise<T> {
        if (this.active >= this.maxConcurrent) {
            await new Promise<void>((resolve) => this.queue.push(resolve));
        }
        this.active++;

        try {
            return await fn();
        } finally {
            this.active--;
            const next = this.queue.shift();
            if (next) next();
        }
    }
}

サービスごとの利用例:

// src/gateway/clients.ts
import { Bulkhead } from "./bulkhead";

const giftBulkhead = new Bulkhead(10);      // 最大 10 並列
const commerceBulkhead = new Bulkhead(3);   // チェックアウトは厳しめ
const analyticsBulkhead = new Bulkhead(50); // 多くてもよい

export async function callGiftWithBulkhead(fn: () => Promise<any>) {
    return giftBulkhead.run(fn);
}

export async function callCommerceWithBulkhead(fn: () => Promise<any>) {
    return commerceBulkhead.run(fn);
}

したがって、GPT が「30 個の重いギフト選定を実行して」と大量に要求しても、同時には最大 10 個までに制限され、チェックアウトは独自の上限のもとで動作を継続できます。

GiftGenius で欲しい区画

GiftGenius では、次の区画を分けるのが妥当です:

  • ギフト選定(LLM が重い。重要度は低めなので遅延容認)。
  • Checkout/ACP(超重要。最大限防御)。
  • 分析/ログ(重要だが多少の遅延は容認)。

より高度な構成では、それぞれを別クラスタとして独立リソースでデプロイしますが、本講義では重要なのは「二次的な機能に、すべての酸素を吸われないようにする」という考え方です。

これら3つのパターン(タイムアウト、circuit breaker、bulkheads)は、外部に出ていく依存先への呼び出しをどう扱うかに関わるものです。しかし、完璧にチューニングされた発信側であっても、着信イベントの洪水で倒されるクラスの脅威があります。典型例が Webhook ストームです。

5. Webhook ストーム: 世界があなたに送りつけてくる頻度の方が高いとき

現実世界の Webhook のふるまい

レジリエンス問題の第4の源は、着信イベントです。ACP、Stripe、その他のシステムからの Webhook。タイムアウト、circuit breaker、bulkhead を整えていても、彼らは本物の「ストーム」を引き起こします。

Webhook はリクエスト・レスポンス型の HTTP ではなく、外部システム(Stripe、ACP、外部ストア等)からの「push」イベントです。いくつか厄介な性質があります:

  • 少なくとも1回(at-least-once)配送 — 重複は避けられない。
  • 配送順序は保証されない。
  • エラー時はリトライする傾向(1秒後、次は 10 秒後、その次は1分後… あなたが 2xx を返すまで)。
  • ピーク時(セールなど)にはバーストし、「ストーム」を形成する。

ハンドラが非冪等かつ長時間実行だとボトルネックになり、キューは詰まり、リトライがストームをさらに悪化させます。結果として DB、キュー、ワーカープールを詰まらせ、連鎖的にシステム全体を壊し得ます。

ストームに対する基本原則

ストームで生存率を大幅に上げる考え方がいくつかあります:

まずは queue-first, process-later。理想的には、着信 Webhook は同期的に重い仕事をしてはいけません。代わりに署名/フォーマットを最速で検証し、ジョブをキューに積んで 200 OK を返します。処理はワーカーが非同期で行います。ChatGPT に「即時の確認」が必要なら、別の通知経路を持てます。

次に、ハンドラの冪等性。同一オペレーションの再送 Webhook で「注文をもう一度作る」や「二重に課金する」べきではありません。通常は idempotency key または eventId を保存し、すでに処理済みかを検査します。

さらに、受信側でのレート制限と circuit breaker。送信者がストーム状態でも、あなた側で次のことができます:

  • IP/サブスクリプション/エンドポイントごとの RPS を制限、
  • 429503 を一時的に返し、リトライを減速、
  • 壊れた下流(例: 注文 DB)へ流さないよう、ブレーカを使う。

GiftGenius における Next.js の Webhook ハンドラ例

ACP/決済システムが注文ステータスの Webhook を POST /api/commerce/webhook に送ってくると仮定します。目的は次のとおりです:

  • イベントを素早く受け取り、キューに積む、
  • 同期処理しない、
  • 重複で壊れない。

簡易例(署名検証と実キューは省略。セキュリティとキューのモジュールで扱います):

// app/api/commerce/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";

// ここでは Redis/キューを使えるが、今は配列で疑似的に表現
const inMemoryQueue: any[] = [];
const processedEvents = new Set<string>(); // 冪等性(デモ用)

export async function POST(req: NextRequest) {
    const event = await req.json();

    const eventId = event.id as string;
    if (processedEvents.has(eventId)) {
        return NextResponse.json({ ok: true, duplicate: true });
    }

    // 実運用ではここで署名とスキーマを検証する

    inMemoryQueue.push(event); // バックグラウンド処理用にキューへ積む
    // バックグラウンドのワーカーが後で処理し、その ID を処理済みにする
    return NextResponse.json({ ok: true });
}

これは疑似実装ですが、重要な点は2つあります:

  1. 同期部分は極力軽くする。
  2. event.id を中心に冪等性を設計する。

実際には次を行います:

  • 外部キュー(SQS、RabbitMQ、Kafka)を使う、
  • 処理済みイベントを DB に保存する、
  • Webhook の署名とペイロードのバージョンを検証する、
  • 必要ならハンドラの周りにも Bulkhead/Breaker を適用する。

GiftGenius の文脈ではどう見えるか

ACP/Stripe と Webhook 連携する GiftGenius では、年末年始やブラックフライデーなどのピーク時にストーム対策が特に重要です。イベントは多岐にわたります:

  • intent 作成、
  • 決済の確定、
  • キャンセル、
  • 返金。

ハンドラの処理時間が延びる(例えば外部 API への問い合わせが原因)と、次のリスクがあります:

  • ACP がリトライを開始、
  • イベントが束になって到来、
  • 注文 DB とワーカープールが詰まる。

「queue first」+ 冪等性 + 入口でのレート制限は、こうしたシナリオへの保険になります。

6. これらのパターンはどう組み合わさるか

これらのパターンを「ギフトを選び、そのまま注文する」という実フローにまとめ、連携動作を見てみましょう。

「ChatGPT → Gateway → Gift Service → Commerce → Webhook」というチェーンを次のシナリオで考えます:

ユーザーがチャットで言う: 「ギフトを選んで、そのまま注文して」。

  1. モデルはあなたのツール suggest_and_checkout を呼ぶと判断。
  2. Gateway は fetchWithTimeout と gift サービス用の bulkhead を使って gift サービスを呼ぶ。
  3. gift サービスがハングすればタイムアウト。一定回数の失敗後、ブレーカが open になり、後続のリクエストは「gift_service_unavailable」の MCP エラーを即返す。
  4. gift サービスが応答すれば、Gateway は commerce サービスを呼ぶ(同様にタイムアウトと専用 bulkhead を使用)。
  5. commerce の問題には、gift より厳しめに設定された circuit breaker が個別に対処(チェックアウトはクリティカルなので)。
  6. 注文成功で ACP からあなたの /api/commerce/webhook に Webhook が飛ぶ。ハンドラはイベントをキューに入れて即応答。バックグラウンドのワーカーが処理し、同じ eventId の再送は重複として無視。

結果として:

  • ハングする選定サービスがチェックアウトを落とさない。
  • ハングする commerce によって全ての tool-calls が1分スピナー化するのを防ぎ、ChatGPT は意味のあるエラーを素早く受け取れる。
  • Webhook ストームがメインの HTTP 経路を壊さない。
  • どこで劣化させるかを制御できる(パーソナライズ推薦を一時停止する方が、決済を落とすより遥かにマシ)。

7. あなたの App のための小さな実践チェックリスト(語り口調)

要約すると、典型的な ChatGPT App(MCP/Gateway)では、次の問いを順番に確認するとよいでしょう。

まず、すべての外部呼び出しにタイムアウトがあるかを確認します。すべての fetch、DB や LLM へのリクエストは、fetchWithTimeout のようなラッパーを適切な値で使うべきです。どこかで無限にぶら下がる可能性が残っていないかが重要です。

次に、最も脆弱な依存先を特定します。たいていは決済、ACP、大規模な外部 API、そして時に自前の注文 DB です。これらには circuit breaker を追加し、明らかに死んでいるサービスへのリトライ雪崩から守ります。同時に、ブレーカが open のとき ChatGPT をどう振る舞わせるかも決めておきます。

その後、リソースを「区画」として見直します。すべてが単一のコネクションプールとワーカープールを共有していないか。クリティカルな操作(ログイン、チェックアウト)がレコメンドや分析と独立した並列制限を持っているか。そうでなければ、簡単な bulkhead 実装(最低限の並列タスク上限制御)を追加します。

最後に、すべての着信 Webhook を監査します。idempotency key や eventId があるか、HTTP ハンドラ内で重い仕事を同期的にしていないか、下流が一時的に落ちたときにリトライの波を耐えられるか。できていないなら、ロジックをキューとバックグラウンドワーカーへ移します。

この順序のステップだけでも、過剰なインフラなしで非常に大きなレジリエンス向上が得られます。

8. timeouts・circuit breakers・bulkheads・Webhook ストームでありがちな落とし穴

誤り1: 下層にタイムアウトがない。
開発者は Gateway だけ、あるいはフロントだけにタイムアウトを入れがちで、バックエンド内部の DB、外部 API、LLM を忘れます。結果として外向きのリクエストには 5 秒のタイムアウトがあるように見えても、内部の DB や決済への1呼び出しが何分もぶら下がり、コネクションプールを塞いでカスケード障害を誘発します。

誤り2: 念のための巨大なタイムアウト。
タイムアウトを 60120 秒にすることがあります。「完了するまで待とう」という発想です。ChatGPT の文脈ではほぼ常に悪手です。ユーザーは離脱し、モデルは幻覚を始め、あなたのリソースはその間ずっと占有されます。510 秒での正直な失敗と明快な説明の方がはるかに望ましいです。

誤り3: UX を考えない circuit breaker。
「形だけ」でブレーカを入れても、作動時にユーザーやモデルへ不明瞭な 500、"ECONNREFUSED"、"axios error" などが飛ぶだけではいけません。GPT は状況をうまく説明できず、創作を始めます。人間にもモデルにも伝わるエラー文言は最初から設計しておきましょう。

誤り4: bulkhead 不在でリソースが混在。
典型パターン: レコメンド(または分析)の1サービスが遅くなり、DB のコネクションプールやスレッドプールを食い尽くす。その結果、チェックアウトやログインが巻き添えで死にます。リソースが分離されていないからです。最低限の bulkhead がないと、二次機能が本番全体を落とすことがあります。

誤り5: Webhook を通常のリクエストとして処理。
初心者は Webhook ハンドラを通常のコントローラと同様に書きがちです。長いビジネスロジック、外部 API への呼び出し、冪等性なし。リトライと重複がある環境では、イベントの二重処理、注文状態の不整合、負荷時のダウンにつながります。

誤り6: コマース系で冪等性を無視。
特に危険なのは、支払い Webhook が注文をもう一度作ったり、状態を二重に変更できてしまうこと。idempotency key の検査とイベント処理ステータスの保存がなければ、いつか二重課金や重複注文を引き起こします。

誤り7: すべてを setTimeout や「魔法の待ち」で直そうとする。
100ms 待てば大丈夫」的なアプローチでレースコンディションやストーム問題を回避しようとすることがあります。実際には挙動を不安定にするだけで、現実の障害からは守ってくれません。正しい道は、明示的なタイムアウト、circuit breaker、キュー、冪等性です。待ち時間の呪術ではありません。

誤り8: クリティカルパスの優先度付けがない。
チェックアウトやログインが分析やレコメンドと同じ制限の中にあると、どんな過負荷でも同じようにクリティカル・二次の両方を落としてしまいます。レジリエントな設計では、チェックアウトと認証は「聖域」です。専用リソース・専用上限・専用アラート・専用 SLO を与えます。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION