Claude Codeでワーカー並列数とバックプレッシャーを実測で決める
BullMQのワーカー並列数・レート制限・投入側の高水位を、負荷スクリプトの実測値で決める手順です。合否を返す検証コマンドをClaude CodeのStop hookに載せ、数値を詰めさせます。
ワーカーの並列数は、勘で決めると二通りに外れます。小さすぎればキューが溜まり、大きすぎれば下流のAPIやDBを溢れさせます。Claude Codeに「並列数を最適化して」と頼んでも、基準がなければ「それらしい値」が返るだけです。
この記事では、BullMQ 6.5.0とローカルのRedisで負荷をかけ、待ち時間と滞留数を測り、その数値でconcurrency・limiter・投入側の上限を決めます。最後に、その測定を「合格か不合格か」で返すスクリプトにして、Claude Codeが自分で数値を詰められる形にします。
並列数を決める前に、測るものを3つに絞る
見る値は3つで足ります。
- 到着率: 1秒あたりに投入されるジョブ数
- 処理時間: 1ジョブがワーカーを占有する時間
- 待ち時間: 投入から処理開始までの時間(p50とp95)
到着率が毎秒40件、処理時間が100ミリ秒なら、同時に動いているジョブの平均は40 × 0.1 = 4件です。並列数がこの4を下回ると、処理能力が到着率に追いつかず、キューは増え続けます。4ちょうどでは余裕がなく、バースト(まとまった到着)のたびに待ちが出ます。
この計算は目安であって、答えではありません。次の節で、実際に測って確かめます。
負荷スクリプトで並列数ごとの待ち時間を測る
処理時間100ミリ秒のジョブを、毎秒40件×5秒(計200件)投入します。1秒ごとに40件をまとめて入れるので、到着はバースト型です。job.timestamp(投入時刻)とハンドラ開始時刻の差を待ち時間として記録し、waitingの件数は100ミリ秒ごとにサンプリングします。
import { Queue, Worker } from 'bullmq';
const connection = { host: '127.0.0.1', port: 6390, maxRetriesPerRequest: null };
const [concurrency, rate, seconds, serviceMs, limitMax] =
process.argv.slice(2).map(Number);
const name = `bp-${concurrency}-${Date.now()}`;
const queue = new Queue(name, { connection });
const waits = [];
let done = 0, maxWaiting = 0;
const total = rate * seconds;
const worker = new Worker(name, async (job) => {
waits.push(Date.now() - job.timestamp);
await new Promise((r) => setTimeout(r, serviceMs));
done++;
}, { connection, concurrency,
...(limitMax ? { limiter: { max: limitMax, duration: 1000 } } : {}) });
const t0 = Date.now();
const sampler = setInterval(async () => {
const c = await queue.getJobCounts('waiting');
maxWaiting = Math.max(maxWaiting, c.waiting);
}, 100);
for (let s = 0; s < seconds; s++) {
const tick = Date.now();
await queue.addBulk(Array.from({ length: rate },
() => ({ name: 'j', data: {}, opts: { removeOnComplete: true } })));
await new Promise((r) => setTimeout(r, Math.max(0, 1000 - (Date.now() - tick))));
}
while (done < total) await new Promise((r) => setTimeout(r, 50));
clearInterval(sampler);
waits.sort((a, b) => a - b);
const p = (q) => waits[Math.floor(waits.length * q)];
console.log(JSON.stringify({ concurrency, limitMax: limitMax || null,
drainSec: +((Date.now() - t0) / 1000).toFixed(1),
maxWaiting, waitP50: p(0.5), waitP95: p(0.95) }));
await worker.close(); await queue.obliterate({ force: true }); await queue.close();引数は「並列数 到着率 秒数 処理ミリ秒 レート上限」の順です。並列数だけを変えて回します。
for c in 2 3 4 6 8; do node load.mjs $c 40 5 100 0; done出力は次のとおりです(drainSecは全200件を捌き終えるまでの秒数)。
| 並列数 | 処理能力(件/秒) | 最大滞留(waiting) | 待ちp50 | 待ちp95 | 完了までの秒数 |
|---|---|---|---|---|---|
| 2 | 処理能力(件/秒)20 | 最大滞留(waiting)120 | 待ちp503,141ms | 待ちp955,735ms | 完了までの秒数10.3 |
| 3 | 処理能力(件/秒)30 | 最大滞留(waiting)80 | 待ちp501,399ms | 待ちp952,499ms | 完了までの秒数6.9 |
| 4 | 処理能力(件/秒)40 | 最大滞留(waiting)40 | 待ちp50539ms | 待ちp95992ms | 完了までの秒数5.2 |
| 6 | 処理能力(件/秒)60 | 最大滞留(waiting)34 | 待ちp50321ms | 待ちp95631ms | 完了までの秒数5.0 |
| 8 | 処理能力(件/秒)80 | 最大滞留(waiting)32 | 待ちp50210ms | 待ちp95416ms | 完了までの秒数5.0 |
処理能力は「並列数 ÷ 処理時間」で、2と3では到着率の40件/秒を下回ります。その2行だけ、滞留が増え続けて完了が投入終了(5秒)より大きく遅れました。4以上では完了までの時間がほぼ5秒で頭打ちです。並列数を6から8に上げても、完了時間は変わらず、待ちp95が631ミリ秒から416ミリ秒に縮むだけでした。
並列数を増やす効果が小さくなる点を、ここで決めます。この条件なら、待ちp95を1秒以内に収める最小の並列数は4で、余裕を見て6が選べます。別の処理時間や到着率では、答えが変わります。数値ごと持ち歩かず、スクリプトごと持ち歩く理由です。
並列数を上げても解決しないとき
並列数は、ワーカーが待つだけの処理(外部HTTP、DBクエリ)に効きます。BullMQの並列数のドキュメントには、Node.jsが非同期処理で並行性を実現しているため、CPUを使う重い処理ではサンドボックス化プロセッサを使うほうがよい、という注記があります。処理が計算中心なら、concurrencyを上げても同時に走る数は増えません。
もう一つの選択肢が、ワーカー(プロセス)の台数を増やすことです。ドキュメントはこちらを推奨の構成としていて、並列性に加えて可用性が上がると説明しています。各ワーカーに高めのconcurrencyを持たせる併用も、問題ない運用として挙げられています。
レート制限は「全ワーカーで合計」の上限
下流のAPIに毎秒の上限があるときは、並列数でなくlimiterで縛ります。BullMQのlimiterはmaxとdurationで指定し、ドキュメントによれば制限はグローバルです。ワーカーが10台あっても、max: 10, duration: 1000なら全体で毎秒10件しか処理されません。制限に引っかかったジョブはwaitingに残ります。
並列数20に、毎秒10件のレート制限をかけて同じ負荷を流しました。
{"concurrency":20,"limitMax":10,"drainSec":19.2,"maxWaiting":150,
"waitP50":8036,"waitP95":15053}並列数が20あっても、200件を捌くのに19.2秒かかりました。200件 ÷ 毎秒10件 = 20秒に近い値で、律速は並列数でなくレート制限です。滞留も最大150件まで増えています。
ここで効くのが、並列数とlimiterの役割分担です。並列数は「同時に何件走らせるか」、limiterは「1秒に何件始めるか」を決めます。処理100ミリ秒なら毎秒10件を満たすのに必要な並列数は1です。それ以上に並列数を上げても、速くはなりません。
下流が429を返したときに動的に止めたい場合は、ワーカーのrateLimitメソッドが用意されています。待機時間を渡して呼んだあと、専用の例外(Worker.RateLimitError())を投げると、ジョブは失敗でなく待機に戻ります。
const worker = new Worker('myQueue', async () => {
const [isRateLimited, duration] = await doExternalCall();
if (isRateLimited) {
await worker.rateLimit(duration);
throw Worker.RateLimitError();
}
});受け側(自分のAPI)でのレート制限は別の問題で、実装はClaude CodeでRedisのレート制限を実装する記事で扱っています。
バックプレッシャーは「投入側が止まる」仕組み
並列数とレート制限は、ワーカー側の蛇口です。キューが溜まり続ける状況では、投入側にも上限が要ります。キューを無制限に伸ばすと、Redisのメモリが増え、待ち時間が実質的に無限になるためです。
ブローカーを使う世界では、これはプリフェッチとして現れます。RabbitMQでは、確認応答(ack)が済んでいない配信数の上限をプリフェッチ数で決め、上限に達すると配信を止めます。ドキュメントによれば、値が0なら無制限で、確認なしの配信が溜まるとメモリ消費が増えます。目安として100〜300の範囲が多くの場合に最適なスループットを与え、1は最も保守的で、スループットが大きく下がるとされています。BullMQのワーカー側で同時実行数と開始速度を絞る設定が、concurrencyとlimiterです。
プリフェッチを持たない投入側には、自前で高水位(HWM)を置きます。投入前にwaitingの件数を見て、上限を超えていたら受け付けない(またはHTTPで429を返す)形です。上のスクリプトに、次の2行を足して再実験しました。
const room = Math.max(0, HWM - await queue.getWaitingCount());
shed += Math.max(0, rate - room); // 受け付けなかった件数
await queue.addBulk(Array.from({ length: Math.min(rate, room) }, makeJob));並列数2・HWM 20で、同じ毎秒40件を5秒流した結果です。
{"concurrency":2,"jobs":200,"accepted":98,"shed":102,"drainSec":5.1,
"maxWaiting":20,"waitP50":521,"waitP95":965}HWMなしの並列数2(待ちp95が5,735ミリ秒、完了10.3秒)と比べると、受け付けた98件の待ちp95は965ミリ秒に収まり、完了も5.1秒です。代わりに102件は受け付けられませんでした。バックプレッシャーは、遅延を拒否に変える取引です。拒否した分をクライアントの再送で受けるのか、捨てるのかは業務側で決めることで、スクリプトは決めてくれません。
合否を返すスクリプトにして、Claude Codeに渡す
ここまでは測定です。Claude Codeに数値を詰めさせるには、基準を「通るか、通らないか」にします。ベストプラクティスの文書も、合格・不合格を返す確認手段を渡すと、Claudeが自分で修正と再実行を繰り返せると説明しています。
次のスクリプトはload.mjsを呼び、待ちp95が1秒を超えるか、滞留が50件を超えたら終了コード1を返します。
// scripts/check-load.mjs
import { execFileSync } from 'node:child_process';
const c = process.env.WORKER_CONCURRENCY ?? '4';
const out = execFileSync('node', ['load.mjs', c, '40', '5', '100', '0'],
{ encoding: 'utf8' });
const r = JSON.parse(out.split('\n').filter((l) => l.startsWith('{')).pop());
console.log(JSON.stringify(r));
if (r.waitP95 > 1000 || r.maxWaiting > 50) {
console.error(`NG: waitP95=${r.waitP95}ms maxWaiting=${r.maxWaiting}`);
process.exit(1);
}
console.log('OK');並列数3と6で実行した結果です。
{"concurrency":3,...,"maxWaiting":80,"waitP50":1412,"waitP95":2551}
NG: waitP95=2551ms maxWaiting=80
{"concurrency":6,...,"maxWaiting":39,"waitP50":308,"waitP95":640}
OKCLAUDE.mdに「数値を勝手に変えない」を書く
並列数を詰めるタスクで起きやすい失敗は、通らない基準のほうを緩めて通すことです。基準はClaudeが触れない場所に置きます。
## ワーカーの並列数
- 並列数は `WORKER_CONCURRENCY` で渡す。値を決める前に
`node scripts/check-load.mjs` を実行し、出力の数値を報告に含める
- `scripts/check-load.mjs` の閾値は変更しない(変更が必要なら理由を報告して止まる)
- 下流APIの上限は毎秒10件。limiter の max はこの値を超えない.claude/settings.jsonのpermissions.denyにEdit(scripts/check-load.mjs)を入れておくと、書き換えの試みそのものが止まります。
Stop hookで「通るまで終わらせない」
「合格するまで続けて」とプロンプトに書く方法もありますが、強制するならStop hookです。終了コード2を返すとClaudeはターンを終えられず、標準エラー出力の内容が「終われない理由」として返ります。
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command",
"command": "node scripts/check-load.mjs 1>&2 || exit 2" }
]
}
]
}
}hookが連続で8回ブロックし、その間にツール呼び出しがなければ、Claude Codeは次のブロックを無視してターンを終えます。上限を変えるにはCLAUDE_CODE_STOP_HOOK_BLOCK_CAPを設定します。負荷試験は1回5〜10秒ほどかかるので、毎回のターン終了で走ると待ちが目立ちます。並列数の調整作業のあいだだけ有効にするなど、範囲を絞ります。
夜間に回すなら、止まる条件を先に決める
非対話モード(claude -p)で回すなら、ベストプラクティスの「無人で走らせる前の確認事項」が使えます。完了の定義、自己確認の手段、触れてよい範囲、止まる条件(--max-turnsなど)、結果を誰が承認するかの5点です。このタスクでは、完了の定義がcheck-load.mjsのOK、触れてよい範囲がWORKER_CONCURRENCYの設定箇所だけ、になります。
claude -p "WORKER_CONCURRENCY を、check-load が OK になる最小の値にして、\
実行した出力を貼って" \
--allowedTools "Read,Edit,Bash(node scripts/check-load.mjs)" \
--permission-mode dontAsk \
--max-turns 15最小値を探させる場合、二分探索のような動きになりますが、負荷試験はぶれます。1回の合格では判断せず、境界の値は3回続けて通るかを見るよう頼むと安全です。実際、並列数4は待ちp95が992ミリ秒と閾値ぎりぎりで、環境次第では不合格になる値でした。
本番の値に持ち込む前に見ること
ローカルの測定は、設計の初期値をつくる道具です。そのまま本番に持ち込まず、次の点を差し引いて読みます。
- 処理時間: 実際の外部APIは遅延がばらつく。固定100ミリ秒は理想化した値
- 到着パターン: 毎秒のバーストは均一。実際は山と谷が偏る
- 下流の上限:
limiterのmaxは、下流の契約値より低く置く
失敗時の再試行まで含めた挙動は、BullMQのワーカー実装記事にまとめています。ジョブ側でリクエスト間隔や並列数を固定する書き方は、Crawleeのクローラーの記事も参考になります。
まとめ
並列数は、到着率と処理時間の掛け算で初期値を出し、負荷スクリプトで待ちp95と滞留を測って決めます。下流に毎秒の上限があるなら、律速は並列数でなくlimiterです。キューを無制限に伸ばさないためには、投入側に高水位を置き、遅延と引き換えに拒否を許容する判断が要ります。
Claude Codeに任せる範囲は、基準を動かさない形で切ります。合否を返すスクリプトと、閾値を触らせない権限、Stop hookか--max-turnsの歯止めの3点がそろえば、数値の詰め込みは出力を見ながら任せられます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。