6月の最後の週、4サイト分の記事生成やリンク監査といった夜間バッチをひとつのプロセスに束ねる改修をしていたときのことです。処理を早く終わらせたくて Claude API 呼び出しの並列度を 8 から 24 に上げた途端、コンテナが落ちました。原因は 429 でもタイムアウトでもなく、メモリでした。レート制限の予算は何度も検算していたのに、ストリーミング接続 1 本がプロセス内でどれだけの資源を占有するかは、一度も測っていなかったのです。
Claude API の本番デプロイで「インフラ要件」を検討するとき、多くの方が最初にサーバースペックから考え始めます。けれどもモデルを動かすのは Anthropic 側(あるいは Bedrock / Vertex AI / Microsoft Foundry)であって、私たちのインフラではありません。私たちがサイジングすべきなのは「応答を待ち続ける側の資源」です。今回は、個人開発の夜間バッチと小規模な公開サービスの両方で使っている、同時実行数・待ち行列長・メモリを数字から導く手順を整理します。
サイジングするのは「モデルを動かす資源」ではなく「待ち続ける資源」
Claude を使うアプリケーションの統合レイヤーが本番で消費する資源は、突き詰めると次の 4 つに集約されます。
資源 決める数字 根拠になる入力 同時実行数 同時に開いておく API 接続の上限 到着率 λ と平均ストリーム時間 W(Little の法則) レート制限予算 RPM / 入力 TPM / 出力 TPM の消費見込み 実効到着率 × 平均トークン量 メモリ 接続 1 本あたりの RSS 増分 × 同時実行数 実測(後述のハーネス) 待ち行列長 受け付けてから処理開始まで待たせる件数の上限 許容待ち時間 × 実効到着率
CPU はほとんど問題になりません。ストリーミングの受信処理は I/O 待ちが支配的で、私の環境では 24 本並列でも CPU 使用率は 1 コアの 15% 前後でした。倒れるとしたら、レート制限か、メモリか、待ち行列の設計不備のどれかです。
なお、トラフィック規模・SLA・データレジデンシーといった上流の判断軸はClaude API を本番投入する前に決めておくインフラ要件 で整理しています。今回はその次の段階、決めた規模を具体的な設定値に落とす部分に絞ります。
Little の法則で必要同時実行数を出す
必要な同時実行数は、待ち行列理論の Little の法則でそのまま計算できます。
同時実行数 L = 到着率 λ(リクエスト/秒)× 平均滞在時間 W(秒)
ここで重要なのは、W が「最初の 1 文字が返るまでの時間(TTFB)」ではなく「ストリームを開いてから閉じるまでの全時間」だという点です。長文生成では、TTFB が 1 秒でもストリーム全体は 40 秒以上開きっぱなしになります。接続はその間ずっと占有されます。
私が運用している 2 つのワークロードで実際に計算してみます。
ワークロード 到着率 λ 平均ストリーム時間 W 必要同時実行数 L ヘッドルーム込み(×1.5) 夜間バッチ(90 タスクを 30 分で消化) 0.05 /秒 42 秒(長文生成) 2.1 本 4 本 チャット UI(ピーク時) 2.5 /秒 12 秒 30 本 49 本 プッシュ通知直後のスパイク 8 /秒(3 分間) 12 秒 96 本 145 本
夜間バッチの答えが「4 本で足りる」なのは意外に感じられるかもしれません。私自身、束ね改修の前は「タスクが 90 個あるから並列度も高いほど速い」と思い込んで 24 本に上げ、冒頭の OOM を踏みました。到着率が低ければ、並列度を上げても待ち時間はほとんど縮まず、メモリ消費だけが増えます。逆にチャット UI は、RPM で見るとささやかでも同時接続では 49 本を要求します。この「RPM は小さいのに同時実行は大きい」という乖離が、次の節の主題です。
平均ストリーム時間 W を縮める打ち手(リージョン選定・接続プーリング・プロンプトキャッシュ)はClaude API のレイテンシを下げる4つのインフラ施策 にまとめています。W が半分になれば、必要同時実行数もそのまま半分になります。
レート制限の予算と同時実行数は別の軸で検算する
レート制限(RPM / ITPM / OTPM)と同時実行数は、どちらか一方だけ見ていると必ず片方で事故ります。長いストリームは RPM をほとんど消費せずに接続を占有し、短い高頻度リクエストは接続をすぐ返すのに RPM を食い尽くします。両方を 1 つの計算機で同時に検算するのが安全です。
// capacity.ts — 必要同時実行数とレート予算を1か所で検算する計算機
type CapacityInput = {
qps : number ; // ピーク到着率(リクエスト/秒)
meanStreamSec : number ; // ストリーム1本の平均継続時間 W(秒)
retryRate : number ; // 再試行率 r(0.08 = 8%)
peakFactor : number ; // 到着の揺らぎ係数(私は 1.5〜2 で見ます)
avgInputTokens : number ;
avgOutputTokens : number ;
rpmLimit : number ; // 組織の RPM 上限
itpmLimit : number ; // 入力 TPM 上限
otpmLimit : number ; // 出力 TPM 上限
};
export function sizeCapacity ( c : CapacityInput ) {
// 再試行とピーク係数を織り込んだ「実効到着率」で全てを計算する
const effectiveQps = c.qps * ( 1 + c.retryRate) * c.peakFactor;
// Little の法則: 同時実行数 L = 到着率 λ × 滞在時間 W
const concurrent = Math. ceil (effectiveQps * c.meanStreamSec);
// 待ち行列は「60秒我慢すれば捌ける量」を上限にし、超えたら受付で断る
const queueDepth = Math. ceil (effectiveQps * 60 );
const rpm = effectiveQps * 60 ;
const itpm = rpm * c.avgInputTokens;
const otpm = rpm * c.avgOutputTokens;
return {
concurrent,
queueDepth,
budget: {
rpm: { used: Math. round (rpm), limit: c.rpmLimit, ok: rpm <= c.rpmLimit },
itpm: { used: Math. round (itpm), limit: c.itpmLimit, ok: itpm <= c.itpmLimit },
otpm: { used: Math. round (otpm), limit: c.otpmLimit, ok: otpm <= c.otpmLimit },
},
};
}
// 例: チャット UI(ピーク 2.5 QPS・平均ストリーム 12 秒・再試行 8%)
console. log ( sizeCapacity ({
qps: 2.5 , meanStreamSec: 12 , retryRate: 0.08 , peakFactor: 1.5 ,
avgInputTokens: 2200 , avgOutputTokens: 900 ,
rpmLimit: 4000 , itpmLimit: 2000000 , otpmLimit: 400000 ,
}));
// 期待出力:
// {
// concurrent: 49,
// queueDepth: 243,
// budget: {
// rpm: { used: 243, limit: 4000, ok: true },
// itpm: { used: 534600, limit: 2000000, ok: true },
// otpm: { used: 218700, limit: 400000, ok: true }
// }
// }
この例では RPM 消費は上限の 6% に過ぎないのに、同時実行は 49 本を要求しています。レート制限のダッシュボードだけ見て「余裕がある」と判断すると、接続数とメモリの見積もりが丸ごと抜け落ちます。逆にプロンプトが長い RAG 系ワークロードでは、同時実行に余裕があっても ITPM が先に尽きます。どちらが先に天井に当たるかはワークロードの形で決まるので、必ず両方を出力させて確認します。
なお 2026 年 7 月時点では Claude Sonnet 5 が各プランの既定モデルになり、導入価格(100 万入力トークンあたり 2 ドル・出力 10 ドル、2026 年 8 月 31 日まで)が適用されています。単価が下がると「もっと並列に回そう」という誘惑が働きますが、レート制限と同時実行の天井は単価とは無関係に残る、という点は見積もりの前提として意識しておきたいところです。
ストリーミング1本が食うメモリを実測する — 429 より先に OOM が来る
冒頭の障害の正体はここでした。ストリーミングのチャンクを「あとでまとめて使うから」と配列に保持したまま 24 本並列で走らせると、長文出力ではプロセスの RSS が想像より速く積み上がります。憶測で語っても仕方がないので、実測用の小さなハーネスを用意しました。
// stream-memory-probe.ts — 同時ストリーミング N 本の RSS 増分を実測する
// 実行例: PROBE_STREAMS=24 PROBE_RETAIN=1 npx tsx stream-memory-probe.ts
import Anthropic from "@anthropic-ai/sdk" ;
const client = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY });
const N = Number (process.env. PROBE_STREAMS ?? 8 );
const RETAIN = process.env. PROBE_RETAIN === "1" ; // 1: チャンクを配列に保持(悪い例の再現)
const retained : string [][] = [];
async function oneStream () : Promise < void > {
const chunks : string [] = [];
const stream = client.messages. stream ({
model: "claude-sonnet-5" ,
max_tokens: 8000 ,
// 長文を出させるプロンプト(実測では約6万字の日本語記事を生成させました)
messages: [{ role: "user" , content: "YOUR_LONG_FORM_PROMPT" }],
});
for await ( const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta" ) {
if ( RETAIN ) chunks. push (event.delta.text); // 悪い例: 全チャンクをヒープに保持
else void event.delta.text. length ; // 良い例: 逐次処理して参照を残さない
}
}
if ( RETAIN ) retained. push (chunks);
}
const before = process. memoryUsage ().rss;
const timer = setInterval (() => {
const mb = (process. memoryUsage ().rss - before) / 1024 / 1024 ;
console. log ( `rss delta: ${ mb . toFixed ( 1 ) } MB` );
}, 2000 );
await Promise . all (Array. from ({ length: N }, () => oneStream ()));
clearInterval (timer);
const finalMb = (process. memoryUsage ().rss - before) / 1024 / 1024 ;
console. log ( `final delta: ${ finalMb . toFixed ( 1 ) } MB / ${ N } streams` );
// 期待出力(例・RETAIN=1 / N=24): final delta: 166.3 MB / 24 streams
私の環境(Node 22・512MB コンテナ・平均出力約 6 万字)での結果です。数字そのものより「保持方針で 1 桁変わる」という比率を見てください。
保持方針 並列数 N RSS 増分(実測) 1 本あたり チャンク配列を保持(RETAIN=1) 8 +54 MB 約 6.8 MB チャンク配列を保持(RETAIN=1) 24 +166 MB 約 6.9 MB 逐次処理して捨てる(RETAIN=0) 8 +6 MB 約 0.8 MB 逐次処理して捨てる(RETAIN=0) 24 +19 MB 約 0.8 MB
Node プロセスのベースラインが約 90 MB、バッチ本体の作業メモリが 100 MB 強あったので、RETAIN=1 の 24 本(+166 MB)で 512 MB の天井に届いた計算になります。障害当日のダッシュボードとも符合しました。教訓は 3 つあります。チャンクは受け取った端から書き出して参照を残さないこと、完成テキストに対する JSON.stringify のような全量コピーを流れの中に置かないこと、そして「1 本あたりの実測値 × 同時実行数 + ベースライン」がコンテナのメモリ上限の 7 割を超えたら構成を見直すことです。
再試行とタイムアウトの増幅をヘッドルームに織り込む
Little の法則の λ には、ユーザー起点のリクエストだけでなく再試行も含まれます。再試行率 r が 8% なら実効到着率は 1.08 倍。さらにタイムアウト設定は W の最悪値を規定します。タイムアウトを 300 秒に設定していれば、ハングした接続は最長 300 秒スロットを占有し続けるので、同時実行数の見積もりは「平均 W」ではなく「タイムアウトで打ち切られるまで居座る少数の接続」を上乗せして考える必要があります。
私は「平均 W で計算した L」に対して、タイムアウト長 ÷ 平均 W の比が大きいワークロードほどヘッドルーム係数を厚くしています。目安として、タイムアウトが平均の 5 倍以内なら ×1.5、10 倍を超えるなら ×2 です。前掲の計算機で peakFactor にこの係数を畳み込めば、追加のコードは不要です。再試行そのものの設計(どの層が再試行を所有するか・429 の Retry-After をどう尊重するか)はこの記事の射程を超えるので、Claude API のジョブキューに優先度と公平性を組み込む設計 のバックプレッシャーの節を参照してください。
有界セマフォと待ち行列で「決めた数」を強制する
数字を決めても、コードが強制しなければ絵に描いた餅です。ライブラリを足すほどでもない規模なら、有界セマフォと上限付き待ち行列の 30 行で十分に機能します。
// bounded-gate.ts — 決めた同時実行数と待ち行列長を強制する最小ゲート
export class BoundedGate {
private running = 0 ;
private waiting : Array <() => void > = [];
constructor (
private maxConcurrent : number , // sizeCapacity() の concurrent をそのまま渡す
private maxQueue : number , // 同じく queueDepth
) {}
async run < T >( task : () => Promise < T >) : Promise < T > {
if ( this .running >= this .maxConcurrent) {
if ( this .waiting. length >= this .maxQueue) {
// 待たせるより先に断る(load shedding)。呼び出し側が後で再投入する
throw new Error ( "shed: queue is full" );
}
// 空きが出るまで待機(解放側が1件だけ起こすので追い越しは起きない)
await new Promise < void >(( resolve ) => this .waiting. push (resolve));
}
this .running ++ ;
try {
return await task ();
} finally {
this .running -- ;
this .waiting. shift ()?.(); // 待ち行列の先頭を1件だけ起こす
}
}
// 監視用ゲージ。定期的にログに書き出して見積もりと突き合わせる
get gauge () {
return { running: this .running, queued: this .waiting. length };
}
}
// 使い方:
// const gate = new BoundedGate(49, 243);
// const msg = await gate.run(() => client.messages.create({ /* ... */ }));
// setInterval(() => console.log(gate.gauge), 5000);
// 期待出力(ピーク時): { running: 49, queued: 87 } のように上限で頭打ちになる
ポイントは、あふれたら「待たせ続ける」のではなく「受付で断る」ことです。待ち行列を無限に伸ばすと、ユーザーには応答しないのにメモリと期待だけが積み上がり、回復後に古いリクエストの洪水が押し寄せます。断られた呼び出し側がどう振る舞うか(即時エラー表示か、遅延再投入か)は製品判断ですが、断る場所を統合レイヤーに 1 つ持つことが先決です。
見積もりを検証する30分の負荷手順
計算と実測が揃ったら、本番投入前に 30 分だけ検証の時間を取ります。私が毎回踏んでいる手順です。
想定ピークの 1.5 倍の到着率 で、平均と同じトークン量のリクエストを 10 分間流し続けます(前掲ハーネスの改造で十分です)
その間、RSS・イベントループ遅延・gate.gauge・429 の件数 の 4 つを 5 秒間隔で記録します
p95 の全ストリーム時間が見積もりの W × 1.5 以内に収まっているかを確認します。収まらなければ W の見積もりが楽観的だった証拠なので、計算機に実測値を入れ直します
RSS のピークがコンテナ上限の 70% を超えたら、同時実行数を下げるかメモリを増やします。「たまたま通った」を成功と見なさないことが肝心です
最後に待ち行列を意図的にあふれさせ、shed が発火すること・呼び出し側が想定どおり振る舞うことを確認します
この手順で拾える不具合の大半は、コードのバグではなく見積もりの入力値の誤りです。特に W は、開発中の短いプロンプトで測った値と本番の長いプロンプトの値が 3 倍以上ずれることが珍しくありません。
次の一歩 — 昨夜のログから λ と W を出してみる
最初の一歩として、すでに動いているワークロードのログから「直近 24 時間の到着率 λ」と「平均ストリーム時間 W」の 2 つを出し、L = λW を手元で計算してみてください。いま設定している並列度がその何倍になっているかを見るだけで、過剰なら OOM の芽を、過小なら無駄な待ち時間を、どちらも数字で発見できます。私自身、この 2 つの数字を出す習慣がなかったせいで冒頭の障害を招きました。同じ回り道をせずに済む方が一人でも増えれば幸いです。