はじめに:なぜKVキャッシュがセキュリティリスクになるのか
大規模言語モデル(LLM)の推論処理において、KVキャッシュは繰り返し計算の回避を通じて推論速度を大幅に向上させる仕組みである。Transformerアーキテクチャの各レイヤーで生成されるKeyとValueのベクトルをメモリ上に保持し、次のトークン生成時に再計算しないことで、デコードフェーズの計算負荷を軽減する。
しかし、この仕組みには見落とされがちな性質がある。KVキャッシュは「パフォーマンス最適化のための中間状態」でありながら、マルチテナント環境ではテナント固有の情報が保持される共有メモリ領域として機能しうる。あるユーザーのプロンプトから生成されたKVキャッシュが、別のユーザーのリクエスト処理時に参照されれば、それはデータ混入、すなわち情報漏洩に直結する可能性がある。
Cloudflare Workers AIにおけるGLM-5.3の事例では、他ユーザーのデータが含まれるクロスリクエストKVキャッシュ漏洩が発生した(Cloudflare公式ブログ)。この事象は、単一の実装バグというより、マルチテナント推論基盤の設計において「状態の所有権」が曖昧に扱われた結果として起きたものである。
本記事は、LLMアプリケーションのアーキテクトおよびセキュリティエンジニアを対象に、この事例を「リソース境界」と「状態管理の分離」という設計観点から再構築する。表面的なバグ報告にとどまらず、推論基盤を設計・運用する際に直面するトレードオフと、その解決策を具体的に示すことを目的とする。
事例分析:GLM-5.3におけるKVキャッシュ漏洩の技術的経緯
GLM-5.3の推論基盤では、プレフィルフェーズとデコードフェーズを異なるGPUノードで処理するデカップルド構成が採用されていた。この構成において、プレフィルGPU側のKVキャッシュプールサイズは681,280トークンに設定されていた一方、モデルのコンテキスト長は1,048,576トークンであった(Cloudflare公式ブログ)。
この不一致が問題の起点である。プールサイズを超える長いプロンプトが到着すると、推論ランタイム(SGLang)はリクエストを拒否する。しかし、当時の実装には、この拒否処理においてKVキャッシュプールから該当エントリを適切に解放する処理が欠落していた。結果として、拒否されたリクエストに関連するKVキャッシュがプール内に残存し、次のリクエスト処理時にデコードノードへ誤って転送されるという経路が成立した。
漏洩が発生したのは681,280トークンを超える長いプロンプトを使用した場合であり、意図的な悪用の証拠は確認されなかった(Cloudflare公式ブログ)。一定期間に多数の影響が確認された。
flowchart TD
A["クライアントリクエスト"] --> B{"プロンプト長 ≤ 681,280トークン?"}
B -->|"Yes"| C["プレフィル処理"]
C --> D["KVキャッシュをプールに格納"]
D --> E["デコードノードへ転送"]
B -->|"No"| F["リクエストを拒否"]
F --> G["KVキャッシュがプールに残留"]
G --> H["次のリクエスト処理時に誤転送"]
H --> I["他ユーザーデータが混入したレスポンス"]
この事象の本質は、エラーハンドリング(リクエスト拒否)のパスにおいて、正常系で実行される「状態の解放」がスキップされた点にある。リソース境界(プールサイズ)とモデル能力(コンテキスト長)の不一致が前提条件となり、その不一致をトリガとする異常系処理で状態管理が破綻した、という二段階の設計欠陥が重なった結果である。
根本原因:マルチテナント推論基盤における「状態」の扱い方
KVキャッシュを「パフォーマンス最適化の副産物」として捉える限り、その管理は推論ランタイムの内部実装詳細に委ねられる。しかし、マルチテナント環境では、KVキャッシュはテナント固有の機微情報を保持する状態として再定義する必要がある。この再定義が、設計判断の起点を変える。
状態管理には大きく二つの戦略がある。一つは「プーリング(再利用)」であり、解放されたKVキャッシュをプールに保持し、後続のリクエストで再利用することでメモリ効率と推論速度を向上させる。もう一つは「隔離(破棄)」であり、リクエスト完了または拒否の時点でKVキャッシュを即座に破棄し、次への再利用を許さない。両者はパフォーマンスとセキュリティのトレードオフにある。
| 観点 | プーリング(再利用) | 隔離(破棄) |
|---|---|---|
| 推論速度 | 高速(再計算回避) | 低速(毎回再計算) |
| メモリ効率 | 高(共有利用) | 低(即解放) |
| データ混入リスク | 境界管理が不十分なら高 | 構造的に低 |
| 実装複雑度 | 高(有効期限・所有権管理) | 低(単純な解放処理) |
SGLangの修正(プルリクエスト#38704)は、拒否されたリクエストのKVキャッシュを適切に解放する処理を追加したものである。表面的にはバグフィックスに見えるが、設計レベルでは「状態の有効期限管理」を明示的に実装したことに相当する。KVキャッシュが「いつまで有効か」「誰のものか」をランタイムが追跡する責任を、暗黙の前提から明示的なロジックへ移行させた。
一方で、CloudflareはプレフィルKVプールを約204万トークンに拡張し、コンテキスト長を上回る設定に変更した(Cloudflare公式ブログ)。これは「プールサイズ < コンテキスト長」という設定ミスを解消する対策であり、トリガ条件を除去するものである。しかし、根本原因である「エラーパスでの状態解放の欠落」は、この設定変更だけでは解消されない。プールサイズが十分大きい環境でも、他の理由(OOM、タイムアウト、ノード障害)でリクエストが中断されれば、同様の残存状態が生まれる可能性がある。
ここで明確にすべき設計原則は、「再利用は例外、隔離がデフォルト」という方針である。セキュリティ要件が高いマルチテナント環境では、KVキャッシュの再利用は明示的な承認と監査ログの付与を条件に行うべきであり、デフォルトではリクエスト境界で状態を破棄する設計を前提とする。プーリングによるパフォーマンス利得は、境界管理の複雑さを正しく実装できた場合のみ享受できるものである。
防御設計1:リクエスト境界での状態クリアリング戦略
前述の事例が示す通り、KVキャッシュの残存は「正常系の処理フロー」ではなく「異常パス」で発生する。プールサイズ超過による拒否、ノード間通信のタイムアウト、GPUメモリ不足による強制解放——これらはすべて、リクエストの正常完了を前提とした設計ではカバーされないケースである。防御設計の第一歩は、これらの異常パスにおいて状態がどこに、どのタイミングで解放されるかを明示的に定義することである。
リクエスト境界での明示的な状態分離
推論パイプラインは通常、プレフィル(プロンプトのKVキャッシュ生成)とデコード(トークン生成)の2段階で構成される。この2段階の境界に、テナントIDをキーとした状態チェックポイントを設ける設計を推奨する。具体的には、デコードノードがKVキャッシュを受信した際に、付随するメタデータ(テナントID、リクエストID、生成時刻)を検証し、不一致を検知した場合はキャッシュを破棄してリクエストを拒否する、というゲート機能を設ける。
このゲートは、単に「正しいデータが来ているか」の確認にとどまらない。SGLangの事例(Cloudflare公式ブログ)では、拒否されたリクエストのKVキャッシュがデコードノードへ転送されたという事実は、プレフィルノード側のエラーハンドリングが「リクエストを拒否する」処理のみを実行し、「関連するメモリ領域を解放する」処理を伴わなかったことを示している。つまり、拒否処理と状態解放が分離されていたのである。
エラーハンドリング時の状態クリーンアップ
この教訓から導かれる実装上の指針は、「リクエストのライフサイクル管理」をリソース管理と紐付けることである。擬似コードで表現すると、以下のようになる。
// 擬似コード:リクエスト境界でのKVキャッシュ管理
// 実際のAPI名は使用している推論ランタイムの公式ドキュメントを参照すること
class InferenceRequest:
tenant_id: str
request_id: str
kv_cache_handle: KVCacheHandle # メモリ領域への参照
def on_prefill_complete(self):
# プレフィル完了時:メタデータをKVキャッシュに付与
self.kv_cache_handle.attach_metadata(
tenant_id=self.tenant_id,
request_id=self.request_id,
timestamp=now()
)
# デコードノードへ転送
decode_node.transfer(self.kv_cache_handle)
def on_rejected(self, reason: str):
# 拒否時:KVキャッシュを即時破棄
self.kv_cache_handle.invalidate()
audit_log.record(
event="kv_cache_invalidated",
tenant_id=self.tenant_id,
request_id=self.request_id,
reason=reason
)
def on_timeout(self):
# タイムアウト時:同様に即時破棄
self.kv_cache_handle.invalidate()
audit_log.record(
event="kv_cache_invalidated",
tenant_id=self.tenant_id,
request_id=self.request_id,
reason="timeout"
)
重要なのは、on_rejectedとon_timeoutの両方でinvalidate()が呼ばれることである。SGLangのバグ(PR #38704)は、まさにこのinvalidate()が拒否パスで呼ばれなかったことに起因する。エラーハンドリングの各分岐で、リソース解放が必ず実行されることを保証する設計(RAII的なパターンや、finallyブロックへの解放処理の配置)が不可欠である。
さらに、マルチテナント環境では「隔離がデフォルト、再利用が例外」という方針をコードレベルで強制すべきである。KVキャッシュを再利用する場合は、再利用の条件(同じテナントID、TTL内のキャッシュ、コンテキストの一致)を明示的にチェックし、監査ログに記録する。この監査ログは、後日のインシデント調査において「どのキャッシュがどのリクエストに再利用されたか」を追溯するための根拠となる。
sequenceDiagram
participant C as クライアント
participant P as プレフィルノード
participant D as デコードノード
participant M as メモリプール
C->>P: リクエスト送信 (tenant_id=T1)
P->>P: KVキャッシュ生成
P->>M: メタデータ付与 (T1, req_id)
P->>D: KVキャッシュ転送
alt 正常系
D->>D: メタデータ検証 (tenant_id一致)
D->>D: デコード開始
D->>C: レスポンス返却
D->>M: KVキャッシュ解放
else 拒否系 (プールサイズ超過)
P->>P: リクエスト拒否
P->>M: KVキャッシュ即時破棄
P->>C: エラーレスポンス
else タイムアウト
P->>M: KVキャッシュ即時破棄
P->>C: タイムアウト通知
end
防御設計2:メモリ管理とプールの分離
防御設計1が「リクエスト単位」での状態管理を扱ったのに対し、本セクションでは「メモリプール単位」での分離戦略を論じる。KVキャッシュプールはGPUメモリ上に配置される共有リソースであり、その設計がデータ混入リスクの基盤となる。
プールサイズとコンテキスト長の整合性保証
Cloudflareの事例では、KVキャッシュプールのサイズとモデルのコンテキスト長の不一致がトリガ条件となったとされている(Cloudflare公式ブログ)。Cloudflareがプールサイズを拡張したものの、根本解決にはならなかったことを示唆している。なぜなら、プールサイズが十分大きい環境でも、他の理由でリクエストが中断されれば残存状態が生まれるからだ。
この問題を構成管理レベルで防ぐためのベストプラクティスとして、デプロイ時に以下のバリデーションチェックを実行することを推奨する。
- プールサイズ >= コンテキスト長:モデルの最大コンテキスト長に対して、KVキャッシュプールが十分大きいかを検証する。不足している場合はデプロイをブロックする。
- プールサイズ >= 最大同時リクエスト数 × 平均コンテキスト長:マルチテナント環境では、同時接続数と平均プロンプト長から必要なプールサイズを推定し、オーバーコミットを検知する。
- ノード間転送時のメタデータ必須チェック:プレフィルからデコードへのKVキャッシュ転送時に、テナントID・リクエストID・生成時刻のメタデータが付与されていない場合は転送を拒否する。
これらのチェックは、CI/CDパイプラインのデプロイ前ステージに組み込むことで、設定ミスが本番環境に到達するのを防ぐ。特に「プールサイズ >= コンテキスト長」のチェックは、モデルのアップグレード時にコンテキスト長が変更されるケースを想定して、モデル設定ファイルとリソース設定ファイルの両方を参照して検証する必要がある。
オーバーコミット防止とリーク検知
動的なメモリ割り当てでは、プールサイズが十分でも、割り当てと解放の非同期化により一時的なオーバーコミットが発生しうる。これを防ぐためには、割り当てリクエストをキューイングし、プールに空きがある場合にのみ処理する方式(バックプレッシャー)を適用する。また、リーク検知のための以下のモニタリング指標を常時収集することを推奨する。
- 未解放KVキャッシュ量:リクエスト完了後に解放されていないKVキャッシュの総量。閾値超過時にアラート。
- クリア失敗数:キャッシュ無効化処理が例外を送出した回数。0が正常値であり、1でも調査対象。
- メタデータ不一致検出数:デコードノードでメタデータ検証に失敗した回数。データ混入の直接的な兆候。
- プール使用率:プールサイズに対する現在の使用率。90%超でバックプレッシャーを起動。
中小企業・スタートアップにおいては、自前でこの種のメモリ管理を実装するコストは高い。信頼された推論ランタイム(SGLang、vLLM等)の最新バージョンを利用し、ベンダーが提供するメモリ管理機構に依存する方が、セキュリティパッチの適用が迅速であるという点で有利である(Cloudflare公式ブログ)。自前実装が不可避な場合でも、少なくとも上記のバリデーションチェックとモニタリング指標は実装すべき最低限の要件として位置づけるべきである。
実装レベルでの検証:SGLangとvLLMの対応比較
主要なオープンソース推論ランタイムにおけるKVキャッシュ管理の実装には差異があり、セキュリティ観点での成熟度も異なる。ここではSGLangとvLLMを比較し、中小企業が導入時に確認すべきポイントを整理する。
SGLangとvLLMのKVキャッシュ管理の差異
SGLangはRadixAttentionという機構により、KVキャッシュをツリー構造で管理し、共通接頭辞(prefix)のキャッシュを再利用する設計を採用している。この設計はパフォーマンスに優れる一方、キャッシュの再利用が「同じプロンプト接頭辞を持つ異なるリクエスト」間でも発生しうるため、テナント境界をまたぐ再利用が起きないよう、RadixツリーのキーにテナントIDを含める必要がある。
vLLMはPagedAttentionという機構により、KVキャッシュをページ単位で管理する。ページはリクエスト単位で割り当てられ、リクエスト完了時に解放される設計が基本である。この方式はテナント境界が明確である一方、ページ断片化によるメモリ効率の低下というトレードオフを持つ。
SGLangにおいて、拒否されたリクエストのKVキャッシュが解放されないバグが修正された事例がある(PR #38704)。Cloudflareはv0.5.22以降へのアップデートを推奨している(Cloudflare公式ブログ)。この事例が示す通り、推論ランタイムのバージョン管理はセキュリティ管理と同様の重要度を持つ。ベンダーがリリースノートに「セキュリティ修正」と明記した場合、優先的に適用すべきである。
| 観点 | SGLang | vLLM |
|---|---|---|
| KVキャッシュ管理方式 | RadixAttention(ツリー構造) | PagedAttention(ページ単位) |
| テナント分離の難易度 | 中(prefix再利用時の境界管理が必要) | 低(リクエスト単位で割り当て/解放) |
| パフォーマンス | 高(prefixキャッシュの再利用率が高い) | 中(ページ断片化のオーバーヘッド) |
| 既知のセキュリティ修正 | v0.5.21で拒否時の解放バグ修正 | 公式ドキュメントを確認 |
| 中小企業導入時の注意点 | Radixツリーのキー設計にテナントIDを含める | ページサイズと同時接続数の整合性を確認 |
境界値テストとデータ混入チェック
推論ランタイムの導入時やバージョンアップ時に、以下のテストケースを実行することを推奨する。
- プールサイズ境界テスト:プールサイズ(例:681,280トークン)の直前(681,279トークン)と超過(681,281トークン)のプロンプトを送信し、超過時にリクエストが正しく拒否され、かつ関連KVキャッシュが解放されることを確認する。
- 連続リクエスト混入チェック:テナントAの長いプロンプト送信後、テナントBの短いプロンプトを即座に送信し、BのレスポンスにAのデータが含まれていないことを検証する。これはCloudflareの事例(Cloudflare公式ブログ)で発生した漏洩と同型のシナリオである。
- タイムアウト時の状態確認:デコードノードの応答を人為的に遅延させ、タイムアウト発生後にメモリプールに未解放のKVキャッシュが残存していないことを確認する。
これらのテストは、CI/CDパイプラインに組み込むことで、推論ランタイムのバージョンアップ時やモデルの交換時に自動実行されるようにする。特に境界値テストは、モデルのコンテキスト長やプールのサイズ設定が変更されるたびに再実行する必要がある。設定変更がトリガとなるバグは、コード変更を伴わないため、通常の回帰テストでは検知されにくいという特性を持つ。
運用・監視:漏洩検知とインシデントレスポンス
KVキャッシュ漏洩は、外部からの攻撃が前提ではなく、内部の状態管理の欠陥が原因となるため、従来のWAFやIDSでは検知できない。検知の核心は「レスポンスに他テナントのデータが混入していないか」を事後に検証する仕組みにある。
トークンシーケンスの重複検出
具体的な検知手法として、同一時間帯に生成された複数テナントのレスポンス間で、連続するトークン列(n-gram)の重複をスキャンするアプローチが有効である。Cloudflareの事例では、一定期間中に複数のレスポンスに他ユーザーのデータが含まれていたとされている(Cloudflare公式ブログ)。もしこの期間中に、レスポンス間のn-gram重複率を閾値で監視していたら、初期段階での異常検知が期待できたかもしれない。
実装上は、推論サーバーのレスポンスログをストリーム処理し、直近の一定時間窓(例えば5分間)で生成された全レスポンスに対して、長さ10トークン以上の共通部分列を検出する。検出された場合はアラートを発火し、該当リクエストのメタデータ(テナントID、タイムスタンプ、プロンプト長)を保持する。ここで重要なのは、検知自体がデータ漏洩を増幅させないことである。ログに生データ(プロンプト本文)を保存しないようにし、ハッシュ値やトークンIDの配列のみを保持する設計にすべきである。
インシデントレスポンスの手順と責任分界
漏洩が確認された場合の対応は、以下の順序で進める。
- 即時停止:該当推論ノードのトラフィックを切り、KVキャッシュプールのメモリを解放する。プールを解放しないままノードを再起動すると、メモリダンプに他テナントデータが残る可能性がある。
- 影響範囲の特定:漏洩が確認された時刻前後の全リクエストを遡り、プロンプト長がプールサイズを超えていたリクエストを抽出する。Cloudflareの事例では、特定の条件を満たす長いプロンプトを使用した場合にのみ漏洩が発生し、意図的な悪用の証拠は見つからなかったとされている(Cloudflare公式ブログ)。このように、トリガー条件を特定することで影響範囲を絞り込める。
- パッチ適用と再検証:推論ランタイムの修正バージョンに更新し、前述の境界値テストと連続リクエスト混入チェックを再実行する。
- 影響ユーザーへの通知:漏洩したデータの種類(プロンプト内容、システムプロンプト、ツール呼び出し履歴など)を特定し、影響を受けたテナントに通知する。
ここで設計上の判断が分かれるのが、この監視プロセスの責任所在である。セキュリティエンジニアが「データ混入の検知」を、インフラエンジニアが「メモリプールの状態監視」をそれぞれ担当する場合、境界が曖昧になり検知の遅延が生じる。推奨されるのは、推論サーバーがエクスポートするメトリクス(未解放KVキャッシュ量、クリア失敗回数、プール使用率)を、セキュリティとインフラの両方が参照できる共通のダッシュボードに集約し、アラートのエスカレーション経路を事前に定義しておくことである。
設計判断のトレードオフ:パフォーマンス vs セキュリティ
KVキャッシュの再利用は、推論のレイテンシとコストに直接影響する。プレフィル段階で計算されたKVキャッシュを破棄せずに次のリクエストで再利用できれば、同一モデル・同一長のプロンプトに対する推論時間が短縮される可能性がある。逆に、リクエストごとにキャッシュを完全破棄すれば、セキュリティリスクは最小化する傾向にあるが、毎回のプレフィル計算コストがそのまま発生する。
分離の粒度は、パフォーマンスへの影響と実装コストの両面で大きく異なる。
| 分離レベル | パフォーマンス影響 | 実装コスト | 推奨場面 |
|---|---|---|---|
| プロセス単位(テナントごとに独立プロセス) | 大(メモリ重複) | 中(プロセス管理) | 高機密・少数テナント |
| セッション単位(リクエストごとに破棄) | 中(プレフィル再計算) | 小(ランタイム設定) | 標準的なマルチテナントSaaS |
| メモリページ単位(同一プール内でタグ分離) | 小 | 大(カスタムランタイム) | 高スループット・大規模基盤 |
中小企業・スタートアップが現実的に採用しやすいのは「セッション単位」である。推論ランタイムの設定でキャッシュ再利用を無効化し、リクエスト完了時にKVキャッシュを破棄する。これにより、プールサイズ超過時の残存データの問題は構造的に発生しにくくなる。性能面では、プレフィル計算が毎回発生するため、長いプロンプトではレイテンシが上昇するが、セキュリティ要件が厳しい環境では許容すべきコストとなり得る。
「デフォルトで安全」な設計を確認するためのチェックリストを以下に示す。
- プールのサイズ設定がモデルのコンテキスト長以上であるか(構成バリデーション)
- リクエスト拒否・タイムアウト時にKVキャッシュが破棄されるか(エラーパスのテスト)
- 推論ランタイムのバージョンが既知の脆弱性修正を含むか(定期アップデート)
- レスポンス間のデータ混入を検知する監視が稼働しているか(n-gram重複検出など)
- 設定変更(プールサイズ、モデル交換)時に境界値テストが自動実行されるか(CI統合)
このチェックリストは、新モデルの投入時やインフラの構成変更時に、セキュリティレビューの一環として再評価すべきものである。パフォーマンス最適化とセキュリティ隔離のバランスは、単一の正解ではなく、ビジネスのリスク許容度に応じて調整する継続的な判断である。
まとめ:LLM推論基盤における「状態」の再定義
本記事で扱ったKVキャッシュ漏洩事例は、他ユーザーのデータが含まれたという事実(Cloudflare公式ブログ)を通じて、マルチテナント推論基盤における「状態」の扱いが、従来のアプリケーション開発とは根本的に異なることを示した。
従来のWebアプリケーションでは、状態管理の失敗はセッションハイジャックやCSRFのような既知の攻撃パターンに帰着しやすい。一方、LLM推論基盤におけるKVキャッシュは、モデルの内部表現そのものを保持する「中間状態」であり、その漏洩はモデルの出力内容を通じて間接的に現れる。この間接性が、検知の難しさと影響範囲の特定を困難にしている。
CloudflareはSGLang v0.5.22以降へのアップデートを推奨し、v0.5.21でバグが修正されたことを報告している(Cloudflare公式ブログ)。この対応は、単一のバグフィックスにとどまらず、「推論ランタイムのバージョン管理をセキュリティ管理の一部として扱う」という運用方針の転換を意味する。推論サーバーのバージョンは、アプリケーションの依存ライブラリと同様に、既知の脆弱性リストと照合して管理すべきである。
データ混入リスクを完全にゼロにすることは、共有インフラ上では原理的に困難である。しかし、本記事で示した設計原則——リクエスト境界での状態クリアリング、プールとコンテキスト長の整合性検証、デフォルトで隔離する方針——を実装することで、リスクは大幅に低減可能である。
今後、マルチモーダル推論(画像・音声のKVキャッシュ)やエージェント連携(ツール呼び出し履歴の保持)が進むにつれ、推論基盤が保持する「状態」の種別と複雑さはさらに増大する。本記事で提示した「状態の境界を明示的に定義し、エラーパスでも一貫して管理する」という原則は、これらの新しい状態種別にも適用される。基盤設計の初期段階でこの視点を導入することが、後から追従するよりも低いコストでセキュリティを確保する道である。
関連記事
- AIエージェントの暴走を防ぐ:while(true)ループの限界とEventStoreによる状態管理の実践
- Apple SiliconのVMでLLM推論を高速化する:Metal GPUパsthroughの実践と注意点
- ブラウザ内で完結するAIエージェント:WebMCPとローカル推論によるプライバシー・レイテンシー両立の実装設計
参考
本記事は海外の技術トレンド「Cross-request KV cache exposure in the GLM-5.3 Workers AI model (155 responses)」(Hacker News)で話題のテーマをもとに、両儀システムソリューションズが独自に解説したものです。