Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

Cloud Runのレート制限はインスタンス間で共有されない(maxOutputTokensも無かった)

公開 読了時間 約8分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

匿名のmagic linkから呼べる生徒チャットは、分単位のレート制限がCloud Runの1インスタンス内のメモリに閉じていて端末cookieも作り直せたため、出力トークンにも呼び出し回数の総量にも上限が無く、リンクが1本漏れるだけでGeminiの従量が青天井になる状態だった。

結論

匿名のmagic linkから呼べる生徒チャットには、1回あたりの出力トークンにも、1日の合計呼び出し回数にも上限がありませんでした。 分単位のレート制限は実装されていましたが、それはCloud Runの1インスタンス内のメモリに閉じた制限で、インスタンスが複数立ち上がっていればインスタンスの数だけ別々にカウントされる上、識別キーの一つである端末cookieはクライアント側で毎回作り直せました。2026-10-01のセキュリティ監査でこの状態が見つかり、同日中に出力トークン上限と全インスタンス共有の日次上限をかけましたが、その修正自体にもレビューで2件の見落としが見つかっています。

症状というより「最初から自己申告されていた限界」

これは本番で実際に濫用された事故ではありません。見つかったのは、既存のレート制限の実装そのものに最初から書かれていたコメントの中です。

/**
 * F06 (#42 第1スライス): 生徒 Q&A の **二重キー・レートリミット**。
 *
 * 受け入れ条件: 「magic_link あたり 1 分 10 質問、1 端末 cookie あたり 1 分 10 質問」。
 *
 * **スコープと限界(正直に明記)**: 状態は module-level の Map で Cloud Run の 1 インスタンス内に
 * 閉じる **per-instance** 制限。複数インスタンス構成での全体上限は #155(分散レート制限・共有
 * ストア)が要る。本 limiter は単一プロセス内の濫用・暴走への第一防壁(defense-in-depth)。
 *
 * **メモリ境界(#437 Low-4、公開エンドポイント DoS 対策)**: magic_link / cookie キーは詐称可能で、
 * 攻撃者は毎リクエストでユニークなキーを送り ... Map を無制限に肥大化させ得る
 */

実装者自身が「これはper-instanceの制限で、全体上限には別のチケットが要る」「cookieは詐称可能」と最初から明記していました。この制限は「1インスタンス内の濫用・暴走への第一防壁」としては機能しますが、Cloud Runが複数インスタンスにスケールしている状態で、magic link 1本が漏れた場合にどこまで呼び出せるかという問いには答えていません。1インスタンスあたり1分10回までだとしても、インスタンスが5つ動いていれば合計で1分50回、cookieを毎回作り直せば端末側の上限は無効化できます。さらに、1回あたりの出力トークンにも思考トークンにも上限が設定されていなかったため、「〇〇を1万回繰り返して」のような質問1つでモデル上限まで出力させることもできました。これらを組み合わせると、1本のリンクが漏れるだけでGeminiの従量に実質的な天井が無い状態になります。

原因

2026-10-01のセキュリティ監査で、この「正直に明記された限界」と「出力トークン無制限」が組み合わさると実際にどれだけの従量になりうるかが改めて評価され、構造的な上限が無いことが指摘されました。対応の方針は2つに分かれています。

  1. 1回あたりの出力を構造で絞る — chat-stream.ts に CHAT_MAX_OUTPUT_TOKENS を追加し、streamText に渡す。
/**
 * 1 回答あたりの出力トークン上限(2026-10-01 セキュリティ監査・従量の上限を構造で固定する)。
 *
 * 生徒チャットは匿名(magic link)から呼べるので、「〇〇を 1 万回繰り返して」のような質問で
 * モデル上限まで出力させるとリンク 1 本で従量が青天井になる。掲示物に基づく短答(ADR-028)には
 * 1024(日本語で約 1,500 字)で足りる。思考トークンも出力課金なので thinking は 0 に固定する
 */
export const CHAT_MAX_OUTPUT_TOKENS = 1024;
const result = streamText({
  model: vertex(modelId),
  system: req.system,
  prompt: req.user,
  ...toGenerationOptions({ maxOutputTokens: CHAT_MAX_OUTPUT_TOKENS, thinkingBudget: 0 }),
});
  1. 回数の総量を全インスタンス共有で絞る — 新設の daily-quota.ts が、学校単位・1日300回の上限を、既存のCloud SQLテーブル(ADR-027 ai_rate_limit_windows)を使って全インスタンス共有でカウントします。新しいテーブルもマイグレーションも不要でした。
/**
 * 分あたりの制限(`rate-limit.ts`)は Cloud Run の 1 インスタンス内に閉じ、端末 cookie はクライアントが
 * 毎回作り直せる。そのため漏れた magic link 1 本で、インスタンス数 × 10 回/分の Gemini 呼び出しを
 * 一日中回せ、従量に上限が無かった。ここで **全インスタンス共有の上限**を 1 本かける。
 */
export const CHAT_DAILY_LIMIT_PER_SCHOOL = 300;

1回あたり最大でも入力約3,000トークン+出力1,024トークンで約0.6円なので、1校の最悪値は約180円/日・約5,500円/月という見積もりが、同じコメントに残っています。判定不能(DB接続の失敗など)はVertexを呼ばずに503を返すfail-closed設計です。

直し方(レビューで見つかった2件を含む)

この修正はPRレビューで2回、見落としを指摘されています。どちらも「正しい方向の修正自体が新しい問題を作っていた」パターンです。

見落とし1: 日次上限の判定をRLSトランザクションの中でやろうとしていた

最初の実装は、生徒チャットのRLSトランザクションを開いた中で日次上限のテーブルを読みに行く形でした。これはpackages/db/src/client.tsに以前から書かれていた一般則に反します。

/**
 * ## 使用規律(プール枯渇・整合性)
 *
 * - **fork は「葉のクエリ」単位でのみ使う。fork の中で fork を呼ばない**: 外側レーンが接続を
 *   保持したまま内側レーンの接続を待つと、プール (`createDbClient` max:10) 満杯時に全リクエストが
 *   互いを待つ枯渇デッドロックになりうる。
 */
const sqlClient = postgres(url, { max: 10, onnotice: () => {} });

接続プールの上限は10、待ちタイムアウトは無し。生徒チャットのRLSトランザクションは回答が終わるまで1接続を握り続けるため、その中で日次上限チェックのために別の接続をプールから取りに行くと、同時に10件のリクエストが来ただけで全リクエストが互いを待ち合う形になりえます。レビューでこの懸念が指摘され、sse-handler.tsでトランザクションを開く前に判定するよう移しました。

// 2.5) 学校単位の日次上限(daily-quota.ts、全インスタンス共有)。**RLS tx を開く前に**数える
//      (tx は回答の最後まで接続を握るので、その中で別接続を取るとプールが詰まる・Reviewer #1401)。
const quotaRejection = await checkChatDailyQuota(args.schoolId, question);
if (quotaRejection) {
  return errorOnlySse(quotaRejection, args.setCookieHeader ?? null);
}

拒否は他の拒否経路と揃えて、200ステータスのSSE errorフレーム1枚で返しています。

見落とし2: スコープ外判定の対象テキストが、実際にGeminiへ送る文と違った

日次上限は「従量が発生しない質問」を数えないようにしています。空の質問・長すぎる質問(executeChatが400で弾く)と、学習・進路以外の範囲外の質問(classifyScopeがout_of_scopeと判定するもの)です。最初の実装はこの範囲外判定を、ユーザーが送った生の文に対して行っていました。

export function scopeTextAsExecuteChatSees(question: string): string {
  return redactSuspectedNames(maskPII(question, []).masked);
}

export async function checkChatDailyQuota(
  schoolId: string,
  rawQuestion: string,
  opts: { quota?: RateLimiter; nowMs?: number } = {},
): Promise<ChatQuotaRejection | null> {
  const v = validateQuestion(rawQuestion);
  if (!v.ok) return null;
  if (classifyScope(scopeTextAsExecuteChatSees(v.question)).verdict === "out_of_scope") return null;
  ...

実際にGeminiへ渡す際はexecuteChat側でPIIマスクや氏名のマスクをかけた後の文でスコープ判定をしています。生の文で判定すると、homework@example.comのようにマスク処理でまるごと消える語だけの質問が「範囲外=カウントしない」と判定される一方、実際には加工後の文に対してGeminiの呼び出し自体は実行されてしまい、日次上限をすり抜けられる状態になっていました。修正ではscopeTextAsExecuteChatSeesでexecuteChatと同じマスク処理(maskPII(q, []) → redactSuspectedNames)をかけた後の文で判定するように揃えています。

気づけなかった理由

分単位・cookie単位のレート制限自体は、実装した時点でコメントに「per-instanceの限界」も「cookie詐称可能」もそのまま書かれていました。隠れていたわけではありません。それでも対応が先送りされていたのは、この制限が「単一インスタンス内の濫用への第一防壁」としては正しく機能していたためです。全体の従量が青天井になるという結論は、per-instanceの限界とcookieの詐称可能性と出力トークンの無制限という3つの個別の既知事項を組み合わせて初めて出てくるもので、それぞれを単体で見ている限りは「defense-in-depthの一層」として妥当に見えます。今回はセキュリティ監査という形で、個別には認識されていた3つの限界を一つの経路として評価し直したことで、組み合わさったときの実際の従量を初めて数値として出すに至りました。

よくある質問

Q1なぜ分単位のレート制限だけでは防げなかったのですか?

分単位の制限はCloud Runの1インスタンス内のメモリに閉じていて、インスタンスの数だけ別々にカウントされます。識別キーの一つである端末cookieはクライアント側で毎回作り直せたため、漏れたmagic link 1本あれば実質この制限をすり抜けて呼び出し続けられる状態でした。

Q2日次上限の判定をRLSトランザクションの外に出したのはなぜですか?

判定をトランザクションの中で行うと、その接続を保持したまま別の接続をプールから取りに行くことになります。プールの上限が10・待ちタイムアウトが無いため、同時に10件のリクエストが来ただけで全リクエストが詰まりかねないという設計上の懸念から、トランザクションを開く前に判定するよう移しました。

Q3出力トークンの上限だけでは不十分だったのですか?

1回あたりの出力を絞っても、呼び出し回数そのものに歯止めが無ければ合計の従量は青天井のままです。1回の出力を1024トークンに抑えた上で、学校単位の1日あたりの呼び出し回数にも別途、全インスタンス共有の上限をかけています。

Q4スコープ外判定のバイパスとは具体的にどんな状態でしたか?

日次上限のスコープ外判定を加工前の生の文に対して行っていたため、メールアドレスのようにマスク処理で丸ごと消える語だけの質問が「範囲外」と誤判定されてカウントされず、それでいて実際にはGeminiへの呼び出し自体は行われ、上限をすり抜けられる状態でした。

確認した環境

  • ai ^5.0.52 / @ai-sdk/google-vertex ^3.0.140(Vertex Gemini、既定モデル gemini-2.5-flash)
  • Cloud SQL 接続は postgres.js 経由、プール上限 max:10(packages/db/src/client.ts)
  • 2026-10-01 に本番コードのセキュリティ監査で発覚し、同日中に修正(#1401)

この記事の根拠

  • TypeScriptファイル 13〜22行目コミット 681ad2d
  • TypeScriptファイル 51〜59行目コミット 64feb38
  • TypeScriptファイル 75〜80行目コミット 64feb38
  • TypeScriptファイル 13〜40行目コミット 64feb38
  • TypeScriptファイル 70〜94行目コミット 64feb38
  • TypeScriptファイル 176〜182行目コミット 64feb38
  • TypeScriptファイル 133〜137行目コミット 4e80de2

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。