Cloudflare Workers AIセキュリティ障害報告:クロステナントKVキャッシュリーク対応ラングック
要点まとめ
解説:Cloudflare Workers AI GLM-5.3で発生したクロステナントKVキャッシュリークについて、プールサイズとコンテキスト長の不一致、拒否リクエストの誤受理という原因、3つの検出シグナル、修正手順、ゲームバックエンドのセッションプール監査への応用までラングック形式で提示する。
プレイヤー向けAI機能が、別のスタジオのプロンプト文面を含む応答を返した。クラッシュではない。500エラーでもない。HTTP 200と、呼び出し元のリクエストと一致するトークン数を伴って返された、筋の通った回答だった。
これこそがマルチテナントバグの最悪の形だ。出力がもっともらしいため、誰もチケットを起票しない。
Cloudflareは2026年8月27日にまさにこの事象に見舞われた。GLM-5.3 Workers AIモデルからの155件の応答に、他の顧客のセッションのデータが含まれていた。影響を受けたすべてのリクエストは681,280トークンを超える長さだった。修正には2つの変更(設定1件、エンジンパッチ1件)が必要で、検出の経緯は修正そのものよりも教訓に富む。
以下がラングックだ。何が壊れたのか、自社スタックで同種の障害をどう検出するのか、どう修復するのか、再発をどう防ぐのか。
実際に何が壊れたのか
2つの独立した問題が重なった。どちらか一方だけなら、耐えられたはずだ。
問題1: 誰も検証していなかったキャパシティ不一致
CloudflareはGLM-5.3を1,048,576トークンのコンテキストウィンドウでデプロイした。prefill GPU上のKVキャッシュプールは681,280トークンを保持していた。
このギャップが問題なのは、プールサイズが設定値ではないからだ。SGLangはモデル重みのロード後に残ったGPUメモリから起動時にプールをサイズ決めする。広告上のコンテキスト長は別の設定値である。システム内で両者を一致させる仕組みは何もなく、両者が乖離しても警告する仕組みもない。
リリース前テストは合格した。HTTPステータスコードは200、ストリーミングボディにエラーフレームはなく、スループットも正常に見えた。テストが検証していたのはトランスポートであり、コンテンツではなかった。
問題2: 拒否されたのに生存を報告し続けたリクエスト
プロンプトがプールを超えると、SGLangのスケジューラはそれを拒否した。拒否パスはクライアント向けにエラーを記録し、プロンプトを1トークンのプレースホルダに置き換えて、メモリを割り当てずにリクエストをバッチ機構から排出できるようにした。シングルノードではこれは正しく機能した。プレースホルダが実行され、バッチが完了し、クライアントはエラーを受け取った。
分離型prefill/decodeモード(prefill GPUがKVキャッシュを構築し、RDMAでトークンを生成するdecode GPUへ転送する構成)では、シーケンスは以下のようになった。
- ブートストラップ。 ルーターはdecodeにプロンプトが8ページ分だと伝えた。decodeは8ページ分のKVページを確保した。
- Prefillの受理。 キューは
req.finished()がFalseを返すすべてのリクエストを受理した。拒否されたリクエストはfinished_reasonではなくto_finishに拒否を格納するため、finished()は依然としてFalseを返していた。拒否されたリクエストは受理された。 - Prefillのフォワードパス。 プロンプトはすでに1トークンのプレースホルダに置き換えられていた。prefillはその単一トークンに対して、ごく簡単なフォワードパスを実行した。
- KV転送。 prefillは1ページ分のKVをdecodeに送り、「転送完了」を通知した。
- Decodeの受理。 decodeのキューも同じ
finished()チェックを使用しており、ハンドオフを受け入れた。 - Decodeのアテンション。 decodeは確保された8ページすべてにアテンションしながらトークンを生成した。このリクエストが書き込んだのはページ0のみだった。ページ1〜7には依然として他テナントのプロンプトと生成途中のデータが残っていた。
- 応答。 クライアントはHTTP 200、一致する
prompt_tokens数、そして古いキャッシュページから組み立てられた筋の通った回答を受け取った。
681,280トークンのしきい値を超えるすべてのリクエストがこの挙動を示した。このバグは2026年7月6日にissue #30233としてアップストリームに報告されており、影響を受けたデプロイメントはまだ修正を取り込んでいなかった。
プレイヤーより先に検出する
ステータスコードだけで分離性をテストしても意味がない。必要なのは3つのシグナルだ。
シグナル1: 実行中エンジンから読み取るプールサイズとコンテキスト長
これは設定ファイルではなく、起動時とCIで確認する。プールは残りメモリから導出されるため、量子化の変更、テンソル並列度の変更、CUDAグラフサイズの調整で静かに変動する。
# deploy_gate.py - run before promoting any disaggregated inference config
import json, os, sys, urllib.request
CONTEXT_LEN = 1_048_576 # the model's advertised window
HEADROOM = 1.25 # pool must exceed context, not merely match it
def server_info(base_url: str) -> dict:
with urllib.request.urlopen(f"{base_url}/get_server_info", timeout=10) as r:
return json.load(r)
def kv_pool_tokens(info: dict) -> int:
# SGLang reports the pool it sized at startup; field names vary by version
for key in ("max_total_num_tokens", "kv_pool_tokens", "max_total_tokens"):
if key in info:
return int(info[key])
raise RuntimeError("KV pool size not exposed by this build - fail closed")
def main(prefill_url: str) -> None:
pool = kv_pool_tokens(server_info(prefill_url))
required = int(CONTEXT_LEN * HEADROOM)
print(f"kv_pool={pool} required={required} context={CONTEXT_LEN}")
if pool < required:
print("FAIL: pool smaller than context window; over-length prompts reach the rejection path")
sys.exit(1)
print("OK: pool covers the full context window")
if __name__ == "__main__":
main(os.environ.get("PREFILL_URL", "http://localhost:30000"))
ロードバランサーがトラフィックを受け付ける前に、すべてのprefillノードに対して実行する。非ゼロの終了コードはデプロイをブロックする。
シグナル2: KVキャッシュ内のカナリアマーカー
長いプロンプトに一意のセンチネル文字列を仕込み、リクエストを完了させる。その後、その文字列を絶対に含まないはずの2番目の長いプロンプトを送信する。確認するのはステータスコードではなく、返ってきたテキストだ。
import uuid, requests
def canary_check(base_url: str, token: str, long_prompt_tokens: int) -> None:
sentinel = f"CANARY-{uuid.uuid4()}"
filler = "lorem ipsum " * (long_prompt_tokens // 2)
requests.post(base_url, json={
"prompt": f"{sentinel} {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
# A fresh, unrelated request must never surface the previous sentinel
r = requests.post(base_url, json={
"prompt": f"Summarize this: {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
assert sentinel not in r.text, f"KV cache leak: {sentinel} surfaced in a fresh response"
受け付ける予定の最長プロンプト、正確に境界値、そして境界値+1トークンの3パターンで実行する。拒否パスが発火するのは境界値のケースだ。
シグナル3: 良すぎるレイテンシ
フル長のprefillには測定可能な時間がかかる。8ページのプロンプトが1ページ分の時間で返ってきたなら、それは他人のキャッシュを読んでいる。プロンプト長ごとにprefill所要時間をログに記録し、長文コンテキストのバケットのp5(最速5%)でアラートを出す。異常に速い長文プロンプトは勝利ではなく、リークのシグネチャだ。
修正手順
1. プールにフルコンテキストを保持させる
CloudflareはGLM-5.3の設定をGLM-5.2に合わせて変更し、prefillプールが約204万トークン(1,048,576トークンのコンテキストの約2倍)を保持できるだけのGPUメモリを確保した。コンテキストウィンドウに収まるプロンプトは常にプールにも収まるため、有効なリクエストが拒否パスに到達することはもうない。
2. それでも拒否パスを安全にする
プールサイジングだけでは脆い。量子化、テンソル並列度、CUDAグラフサイズ、モデルバージョンの変更はいずれも、見落としやすい形でプールを動かす。
エンジンパッチは、保留中の拒否を持つリクエストを、ブートストラップ、割り当て、KV転送が発生する前に、分離型アーキテクチャの受理キューで元のエラー付きで破棄する。decodeノードはまた、転送されたトークン数が自身が確保した数と一致することを検証する。これはPR #38704としてアップストリームに取り込まれ、#38935でフォローアップされ、SGLang v0.5.20〜v0.5.22でリリースされた。
SGLangをどこかで実行しているなら、v0.5.22以上に固定すべきだ。これは「次のスプリントで対応」という類のアップグレードではない。
3. 感触ではなく再現スクリプトで検証する
Cloudflareはモデルを復元する前に、研究者の再現スクリプトと自社のカナリアマーカーテストの両方で修正を検証した。敵対的テストと内部テストの2つの独立したテストだ。モデルやフリートを再び有効化する前に満たすべき、正しい基準だ。
「200が返った」はテストではない理由
テスト失敗の根本原因は範囲が狭く、まったくもって平凡なものだった。チームは長文コンテキスト条件下でHTTPレスポンスコードとエンジン性能を検証したが、モデルの出力と一貫性は検証していなかった。ベンチマークが合格したのは、ベンチマークが「応答したか?」を尋ねており「正しく応答したか?」を尋ねていなかったからだ。
ホステッドAI機能にとって、正しさのチェックは安価だ。自社のゲームバックエンドでは、同等のギャップは通常もっと大きい。以下が転用可能なルールだ。共有リソースプールには、プールのサイズとシステムが受け付ける最大リクエストのサイズを結びつけるアサーションが必要であり、そのアサーションは設定ファイルではなく実プロセスに対して実行されなければならない。
同じバグクラスはゲームバックエンドに存在する
「KVキャッシュページ」を「セッションスロット」に置き換えると、このインシデントはほぼそのまま再現する。
マッチスロット、プレイヤー状態スロット、またはアクターバッファの固定プールを割り当てるとする。リクエストは8スロットを確保する。受理チェックがstate == Validではなくstate != Finishedをテストするため、拒否された予約もそのまま受理される。スロット0だけが初期化された状態で、そのスロットは次のロビーに引き渡される。スロット1〜7には前のマッチのプレイヤーID、インベントリ、位置データが残ったままである。
これは、侵害に耐えるゲームバックエンドの設計で取り上げた失敗モードと同じだ。リークは高度な攻撃である必要はない。コードが実際には強制していない所有権モデルが存在するだけで十分だ。
バグを含むパターンとその修正をC#で示す。
public sealed class SessionSlotPool
{
private readonly SessionSlot[] _slots;
private readonly Queue<int> _free = new();
// BUG: release without invalidation leaves stale state for the next tenant
public void ReleaseUnsafe(int[] indices)
{
foreach (var i in indices) _free.Enqueue(i);
}
// FIX: invalidate on release, stamp a generation, verify on read
public void Release(int[] indices)
{
foreach (var i in indices)
{
_slots[i].Reset(); // zero player list, inventory, match id
_slots[i].OwnerId = null;
_slots[i].Generation++; // stale handles now fail the check
_free.Enqueue(i);
}
}
public bool TryRead(int index, long expectedGeneration, out SessionSlot slot)
{
slot = _slots[index];
return slot.Generation == expectedGeneration && slot.OwnerId != null;
}
}
世代カウンタは安価な部分だ。「スロットが上書きされていてほしい」という願望を、厳格でテスト可能な拒否に変換する。古いハンドルに対する読み取りは、別のプレイヤーのデータを黙って返す代わりに失敗する。
分離性チェックリスト
- 起動時に実行中プロセスから、プールサイズとコンテキスト長を照合するアサーションを行う。 設定ファイルからではない。非ゼロの終了コードはデプロイをブロックする。
- 「拒否(rejected)」を「完了(finished)」とは異なる終端状態として扱う。 割り当てやハンドオフの前、つまり受理時に中止し、元のエラーを返す。
- 転送数を両側で検証する。 decodeが8ページを確保したなら、8ページが到着したことを確認しなければならない。1ページの転送が「完了」を報告するのが、まさにこのバグだ。
- コンテンツのアサーションで障害パスをテストする。 カナリアマーカー、センチネル文字列、境界値+1のプロンプトを使用する。確認するのはレスポンスボディであり、決してステータスコードではない。
- キャパシティ変更のたびにゲートを再実行する。 量子化、テンソル並列度、バッチサイズ、モデルバージョンはすべてプールを動かす。それぞれを分離性に関わる変更として扱う。
項目5こそ、チームが飛ばしがちな項目だ。プールが宣言値ではなく残りメモリから導出される以上、すべてのキャパシティ変更はセキュリティ変更でもある。同じゼロサム思考は手動でサイズ決めするあらゆるリソースプールに当てはまる。ゼロウェイストなサーバーハイバネーション分析のトレードオフの計算も同じだ。
分離のコスト
分離性にはコストがかかる。そのトレードオフは正直に語るべきだ。
KVプールを681,280から約204万トークンに倍増すると、本来ならより多くの同時リクエストを保持できるGPUメモリを消費する。同時リクエスト数が減れば、生成トークンあたりのコストは上がる。prefillフリートにとって、そのヘッドルームは販売できないキャパシティだ。
より安価な代替案もある。小さなプールを維持し、スケジューラに到達する前にエッジで長すぎるプロンプトを明示的なHTTP 400で拒否する方法だ。スループットは維持できるが、正当な長文コンテキストのリクエストを失敗させる——エージェント型コーディングモデルにとって、それは製品の大部分にあたる。
Cloudflareはキャパシティを犠牲にして正しさを選んだ。プレイヤー向け機能では、それがほぼ常に正しい。代替案——別のプレイヤーのデータを含む高速で安価な、もっともらしい応答——は、ポストモーテムで擁護できるトレードオフではない。
分離の負担は実際にはどこにあるのか
すべてのマルチテナントシステムはこの決定を迫られる。どの共有リソースをプールして再利用するのか、そして無効化(インバリデーション)の所有権は誰にあるのか。GPU KVページについては、答えは「推論エンジンが担うが、v0.5.22までは不十分だった」だ。
ゲームのプレイヤー状態については、答えは通常「あなたのデータベース、そしてあなたの責任」だ。プールのセマンティクスをそもそも所有したくないなら、アカウントバインド型の状態サービスが攻撃面を移す。horizOnは、リビジョンを認識した読み書きを備えたアカウントバインド型Cloud Saveを提供しており、どのスロットが古いかをサーバーが推測する代わりに、クライアントが競合を解決する。
それは分離性の作業をなくさない——何もなくせない。実際に制御できる境界に絞り込むだけだ。
今週やるべきこと
所有するすべての共有プールを監査し、3つの質問をする。プールサイズはシステムが受け付ける最大のリクエストを証明可能な形でカバーしているか?拒否されたリクエストは何かを割り当てる前に終了するか?受信側は、到着したものが自身が確保したものと一致することを検証するか?
「そのはず」という答えが1つでもあれば、十分に長いリクエストを待っている同じバグを抱えている。最初に取り組むべきなのは、別のプレイヤーの応答に中身が現れた場合に最も被害が大きいプールだ。
プレイヤー状態のプールを保守するのをやめる準備ができたら、horizOnを無料で試してほしい——認証、アカウントバインド型Cloud Save、リーダーボード、リモートコンフィグ、クラッシュレポートを、データベースとセッションモデルをゼロから構築することなく利用できる。APIドキュメントなら約10分で統合の全体像を把握できる。