cd ../返回博客
$Engineering//2026年6月4日//9 min read

99.95% の SLO を支えるエンジニアリング — フェイルオーバー、ファーストバイト監視、課金そろばん

Brievio が月間可用性 99.95% をどう守るか。12 のアップストリームにまたがる重み付きルーティング、50ms のファーストバイト監視、トランザクションによる残高予約、そして何より効く地味な運用基盤を解説します。

Brievio が公開している SLO は チャット API の月間可用性 99.95% です。マーケティング上の 数字に聞こえるかもしれませんが、これは本物です — エラーバジェットを 月あたり 21 分に抑え込む数字であり、ローンチ以来、毎月この基準を 満たし続けています。本稿では、その裏側で実際に何が動いているのかを お話しします。アップストリームのフェイルオーバー、ファーストバイトの ウォッチドッグ、課金そろばん、そしてこれら以上に効いている地味な 運用の積み重ねです。

現実 — どのアップストリームにも調子の悪い日がある

私たちは舞台裏で約 12 のアップストリームと通信しています。どの月を とっても、それぞれが少なくとも一度は 5〜30 分の部分的な劣化を起こします。 リージョン障害のこともあります(Anthropic の us-east-1 は今四半期に 2 回のインシデントがありました)。静かなトークンバケットの締め付けの こともあります(Vertex AI のレートリミッターは月曜に渋くなります)。 プロバイダー全体の障害のこともあります(kie.ai は 3 月に 47 分間 ダウンしました)。

単一のアップストリームが落ちたときにフェイルオーバーが無ければ、あなたの SLO はそのプロバイダーの SLO — から自分の通信オーバーヘッドを 引いた値 — そのものになります。これは実質的に 99.5% 前後の天井を作ります。 99.9% を突破するには、いかなる単一アップストリームの障害でも落ちない ことが必要で、99.95% にはさらに、2 つ同時の障害でも落ちない ことが求められます。

レイヤー 1 — 重み付き候補ルーティング

ホストしているモデルごとに、1〜4 本のアップストリーム経路があります。 ディスパッチャーはそれらを重みの高い順にたどり、その重みは直近の 成功率(アップストリームごと・リージョンごとの直近 100 呼び出し)に 基づいてリアルタイムに減衰します。us-east-1 が 5xx を返し始めると、 その重みは約 5 秒以内に us-west-2 を下回り、顧客が気づく前に新しい リクエストはそちらへ流れなくなります。

dispatch.ts
// アップストリームディスパッチャーの擬似コード。実際の実装
// (TypeScript、サーバー専用)もこれに近い形になっている。
async function dispatch(req: ChatRequest): Promise<ChatResponse> {
  const candidates = pickCandidates(req.model);
  // candidates = [{provider: "anthropic-direct", weight: 100},
  //               {provider: "google-vertex",   weight:  80},
  //               {provider: "kie-wholesale",   weight:  60}]
  // 重みが低い = バックアップ。初期の重みは直近の成功率から決まる。

  const deadline = Date.now() + 540_000;          // ハードな 540 秒の壁
  let lastError: Error | null = null;

  for (const candidate of candidates) {
    const budgetMs = chunkBudget(deadline, candidates.length);
    try {
      return await Promise.race([
        callUpstream(candidate, req),
        timeout(budgetMs),
      ]);
    } catch (err) {
      if (!isRetryable(err)) throw err;          // 4xx → フェイルオーバーしない
      recordFailure(candidate, err);              // 重みはリアルタイムで減衰する
      lastError = err;
    }
  }
  throw lastError ?? new Error("all upstreams exhausted");
}

ここでは、間違えやすい 2 つの点が重要です。

  • 4xx ではフェイルオーバーしない。 アップストリームからの 400 は、リクエストそのものが不正だったという 意味です — 別のアップストリームに投げ直しても、同じ 400 がただ 遅く返ってくるだけです。フェイルオーバーを起こすのは 408、429、5xx、 そしてネットワークエラーだけです。
  • 候補ごとのバジェットに上限をかける。 540 秒のデッドラインを 3 候補に分けるなら、各候補に約 150 秒を 与え、その間にストリーミングを始めるか死ぬかさせます。最初の候補に 540 秒すべてを渡してはいけません — そこで固まったら、バックアップに 回す時間が残りません。

レイヤー 2 — ファーストバイトのウォッチドッグ(50ms)

アップストリームのインシデントの半分はハードな失敗ではありません — 接続は開き、リクエストは送られ、その後に何も起きないのです。応答も なく、エラーもなく、ただ沈黙だけ。素朴なリトライはタイムアウトいっぱい まで待ってから次へ移り、ユーザーから見えるレイテンシーを倍にします。

私たちのディスパッチャーは、リクエストが回線に乗った後に アグレッシブな 50ms のファーストバイトタイマー を起動します。その窓のあいだに応答が 1 バイトも見えなければ、中断して 次へ進みます。50ms は知覚の閾値より下なので、正常系では顧客に遅延は 見えません。失敗系では、顧客のリクエストは人間が知覚できる 1 フレーム以内にバックアップのアップストリームへフェイルオーバー します。

ttfb-watchdog.ts
// ファーストバイト検知 — アップストリームが固まったら高速に失敗させる。
async function callUpstream(c: Candidate, req: ChatRequest) {
  const ac = new AbortController();
  // リクエストが回線に乗ってから 50ms 以内に 1 バイトの応答も
  // 見えなければ、そのアップストリームは沈黙とみなして次へ進む。
  const firstByteWatchdog = setTimeout(() => ac.abort("ttfb-50ms"), 50);

  const res = await fetch(c.url, {
    method: "POST",
    body: JSON.stringify(req),
    signal: ac.signal,
  });

  clearTimeout(firstByteWatchdog);
  if (!res.body) throw new Error("no-body");

  // これでファーストバイトを取得できた。ストリームをそのまま返す。
  // クライアントは届いた端から全チャンクを受け取る — バッファリングなし。
  return new Response(res.body, {
    headers: { "content-type": "text/event-stream" },
  });
}

この 50ms という値は、私たち自身のアップストリーム TTFB の P99 を 計測して導き出しました。最も遅いアップストリームでさえ、99% のケースで 40ms 未満に最初の応答バイトを返します。50ms を超えるものはノイズでは なくシグナルです。この数字は、あなた自身のアップストリームの P99 に 合わせて調整してください。

レイヤー 3 — 課金そろばん

信頼性は稼働率だけの話ではありません — 負荷の下でも課金が正しく 保たれるかどうかの話でもあります。初期には、同時に走った 2 本の リクエストが両方とも残高チェックを通過し、両方ともコミットして、 ユーザーのアカウントが $0.12 のマイナスに陥るレースがありました。 その修正が、書き込み時の予約でした。

reserve-balance.ts
// 「課金そろばん」 — 推定コストを先に予約しておき、大きな同時リクエスト
// 2 本が両方とも残高チェックを通過して残高を超過することを防ぐ。
async function reserveBalance(userId: string, estCents: number) {
  return await db.transaction(async (tx) => {
    const bal = await tx.balance.findUnique({ where: { userId } });
    if (bal.balanceCents - bal.reservedCents < estCents) {
      throw new InsufficientQuotaError();
    }
    await tx.balance.update({
      where: { userId },
      data: { reservedCents: { increment: estCents } },
    });
    return { release: () => releaseReservation(userId, estCents) };
  });
}

すべての呼び出しは、まず最大コストを先に見積もり(入力トークン × 入力 レート + max_tokens × 出力レート)、その金額を単一トランザクションで ウォレットに予約し、実際の使用量が判明した後に未使用分を解放します。 同時リクエストはトランザクション層で直列化されます。超過は、決して 起きません。

それ以上に効いている地味な部分

この記事を読むエンジニアは皆、ディスパッチャーの図にうなずきながら 「なるほど、重み付きリトライにファーストバイトのウォッチドッグ、 了解」と思うはずです。けれども、私たちの SLO を月また月と支え続けて きたのは、もっと地味なものたちです。

  • すべての cron にハートビートを。 トークンの ローテーション、残高のスイープ、使用量のロールアップ — それぞれが完了時に Healthchecks.io のエンドポイントを叩きます。 ハートビートが欠けると 5 分以内にアラートが出ます。私たちは 明示的なアラームよりも、欠けたハートビートからより多くの障害を 捕まえてきました。
  • 本当に正確なステータスページを。 brievio.com/status は 90 秒ごとに、すべてのモデルに対して合成チェックを走らせます。 アップストリームが劣化すると、顧客が気づく前に黄色に変わります。
  • 必要になる前のランブック。 私たちが経験した インシデントは、顧客影響が出る前に捕まえたものも含めて、すべてが ランブックの項目を生みました。午前 4 時のページャーにはチェック リストがあり、エンジニアは考える必要がありません。
  • あらゆる場所に Sentry、ホットパスにはプロファイリング。 ディスパッチャーはフェイルオーバーの判断ごとに、候補・理由・残り バジェットを記録します。劣化が忍び込んだら、検索はこうです —「reason=ttfb-50ms のフェイルオーバーを、この 1 時間ぶん、 アップストリーム別に見せて」。

私たちがやらないこと

99.95% のゲートウェイならやりそうだと思われがちでも、あえて避けて いることがいくつかあります。

  • マルチリージョンのアクティブ-アクティブ データベース。 私たちのデータベースは単一リージョン(iad)です。レプリケーション 遅延は、デバッグしたくない課金の不整合を生みます。iad が完全に ダウンした場合は読み取り専用モードへ退避します — 顧客はモデルの 呼び出しは続けられますが(ディスパッチャーはエッジでステートレス です)、1 時間ほど使用履歴は見られなくなります。このトレードオフは 私たちにとって正解です。
  • こっそりモデルを差し替えること。 一部の アグリゲーターは、Claude Opus に到達できないと Haiku へルーティング します。私たちはしません — 払ってもいない別のモデルを渡されるより、 503 を受け取ったほうがましだと考えるからです。ディスパッチャーが フェイルオーバーする先は別のアップストリーム上の同一モデルだけです。
  • 先回りのリトライ。 Anthropic の 429 には意味が あります。すぐにリトライすれば、全員にとって問題が悪化します。 私たちのバックオフはジッター付きの指数バックオフで、最大 5 回・ 合計 30 秒の待機を上限としています — エラーガイドで推奨しているのと 同じポリシーです。

バジェットが実際にどこへ消えるのか

2026 年 5 月の私たちのエラーバジェットは 21 分 36 秒でした。使ったのは 次のとおりです。

  • 3 分 12 秒: デプロイ中の us-east-1 Anthropic の 一瞬の不調。フェイルオーバーが作動してリクエストの欠落はゼロでしたが、 その窓のあいだ P99 レイテンシーが目標を超えて跳ねました。
  • 1 分 50 秒: 動画モデルでの kie.ai の 5xx バースト。 リトライはすべて成功しましたが、呼び出しごとのレイテンシーが SLO を 破りました。
  • ハードダウンタイムはゼロ。リトライの余地なく 5xx を 返したリクエストは 1 件もありませんでした。

21:36 のバジェットに対して 5:02 の消費 — 23% の使用で、目標内に 十分収まっています。残りの 77% こそが、私たちが積極的にデプロイし、 次の不意打ちを吸収できる余地になります。

これがあなたにとって何を意味するのか

Brievio の上で構築するなら、自前のプロバイダーフェイルオーバーも、 自前の TTFB ウォッチドッグも、自前の Anthropic 対 Vertex の ルーティングも実装する必要はありません。それこそが要点です — ひとつの base URL、ひとつのベアラートークン、本物のモデル、そして SLO を守るのは私たちの仕事です。

もしあなたが別のゲートウェイを作っていて、これをアイデア 探しに読んでいるなら言っておきます。ディスパッチャーは 600 行の TypeScript です。複雑なのはそこではありません。複雑なのは、障害 モードのランブック、ハートビート、ステータスページ、そして信頼できる システムへと積み重なっていく数か月ぶんの小さな運用の傷跡です。 出荷する前に、そこに備えてください。

現在の稼働率の数字とリージョン別の内訳は brievio.com/statusで、完全なエラー分類は エラードキュメントでご覧いただけます。