Claudeで負荷試験の結果を分析する — アプリとDBのボトルネック切り分け
k6とLocustの結果からp95の跳ね上がりを読み、ボトルネックがアプリ側かDB側かを切り分ける手順です。時系列の重ね方と、Claudeに渡す分析の依頼文までまとめます。
p95が跳ねたとき、その原因がアプリかDBかは、負荷ツールの数字だけでは決まりません。決め手は「跳ねた時刻に、ほかの系列が何をしていたか」です。RPS、アプリのCPU、DBの接続数と待ちの4本を同じ時間軸に重ねると、疑う先は2〜3か所に絞れます。
この重ね合わせと、各系列から仮説を並べる作業は、Claudeに任せやすい部分です。ただし「根拠の行を付けさせる」「入力にない数値を作らせない」という縛りがないと、それらしい断定だけが返ってきます。ここでは、k6とLocustの出力の読み方、DB側で取る最小限の計測、Claudeへの依頼文の順に進めます。シナリオの作り方はClaude CodeでLocust負荷テストのシナリオを書く手順とClaude Codeでk6負荷テストのスクリプトを作成する手順にあるので、ここでは結果が手元に出たところから始めます。
まず負荷を出す側が足りているかを確かめる
アプリとDBを疑う前に、負荷を出す側が狙いどおりに出せていたかを見ます。出せていなければ、p95は「送れた分」だけの数字になります。
k6ではdropped_iterationsが目印です。到着率ベースのexecutorでVUが足りず、開始できなかった反復の数を数えるカウンターです。ここが0でないなら、狙ったRPSに届いていません。vus_maxはVUの上限で、VUは事前に確保されます。
Locustの側で押さえておくのは時間の数え方です。--run-timeは開始時点から数えるため、ランプアップの時間も含みます。ランプアップ中は人数が増え続けているので、そこで出たp95を「定常時の値」と読むと判断を誤ります。結果を読むときはランプアップ区間を外します。
もう1点、--stop-timeoutを付けない場合、Locustは実行中のタスクをリクエストの完了を待たずに止めます。終了間際の数行は、読む対象から外しておくのが安全です。
負荷を出すマシン自身のCPUも、同じ時間軸に載せておきます。生成側が飽和していると、サーバーは暇なのにレイテンシだけが伸びて見えます。
k6はレイテンシの内訳で当たりを付ける
k6のhttp_req_durationは、http_req_sending、http_req_waiting、http_req_receivingの合計です。DNSの解決やTCP接続にかかった時間は含まれません。つまり接続まわりで詰まっていても、http_req_durationのp95は平穏に見えることがあります。
| 指標 | k6ドキュメントの説明 | 伸びたときに疑う先 |
|---|---|---|
http_req_blocked | k6ドキュメントの説明空いているTCP接続枠を待った時間 | 伸びたときに疑う先負荷生成側の接続枠、接続の使い回し不足 |
http_req_connecting | k6ドキュメントの説明TCP接続の確立にかかった時間 | 伸びたときに疑う先ネットワーク、サーバーのaccept待ち |
http_req_waiting | k6ドキュメントの説明レスポンスを待った時間(TTFB) | 伸びたときに疑う先サーバー側の処理全体(アプリもDBも含む) |
http_req_receiving | k6ドキュメントの説明レスポンス本体の受信時間 | 伸びたときに疑う先応答サイズ、帯域 |
ここから分かる境界線は1本だけです。http_req_waitingが支配的なら、時間はサーバー側で使われています。ただしその中でアプリとDBのどちらが長いかは、k6の指標からは分かりません。切り分けにはサーバー側の観測、つまりアプリのCPUやDBの接続数が要ります。
k6のしきい値は、http_req_duration: ['p(95)<200']のようにTrend指標へp(95)の条件を書きます。http_req_waitingやhttp_req_connectingにも同じ形で置けるので、合否判定に内訳の指標を加えておくと、失敗したときにどの区間が原因かを最初から見られます。
Locustは--csvで時系列を残しておく
Locustの標準出力は集計値が中心です。時系列で読みたいなら、実行時に--csvを付けて履歴ファイルを残します。
locust -f locustfile.py --headless -u 100 -r 5 -t 10m \
--csv result --csv-full-historyresult_stats.csv、result_stats_history.csv、result_failures.csvなどが出力されます。このうちresult_stats_history.csvが時系列で、直近10秒の移動窓の統計が追記されます。--csv-full-historyを付けると、集計行だけでなくエンドポイントごとの行も、2秒おきの書き込みのたびに追記されます。
10秒の窓は短いスパイクをならします。2〜3秒だけ跳ねて戻る遅延は、この履歴ではほとんど見えません。瞬間的な跳ねを探すときは、アプリ側のログやトレースで補います。
4本の時系列を同じ時間軸に重ねる
重ねる系列は次の4本です。
- 負荷側のRPSと、p95・エラー率
- アプリのCPU(コンテナなら使用率とスロットリング)
- DBの接続数と、アクティブなセッション数
- DBの待ちの内訳(ロック待ちかI/O待ちか)
p95が上がり始めた時刻を1つ決めて、その直前の数十秒に何が動いたかを縦に読みます。読み方の型を表にまとめます。
| 時系列の見え方 | まず疑う先 | 次に確かめること |
|---|---|---|
| RPSが頭打ち、アプリCPUが高止まり、DBに余裕がある | まず疑う先アプリ側のCPU律速 | 次に確かめることハンドラ別のプロファイル、同期処理の混入 |
| DB接続数が上限付近に張り付き、アプリCPUは低い | まず疑う先接続の枯渇、プールの待ち | 次に確かめることプールサイズとDBの上限の関係、http_req_waitingの伸び方 |
| DBのセッションが待ち状態で並び、待ちの種類がロック | まず疑う先ロック競合 | 次に確かめること長いトランザクション、同じ行への集中更新 |
| DBのCPUかI/Oが高く、特定のクエリが時間を占める | まず疑う先クエリと索引 | 次に確かめること実行回数×平均時間の上位、実行計画 |
| どの系列にも余裕があるのにp95だけ上がる | まず疑う先負荷生成側、または外部依存 | 次に確かめることdropped_iterations、生成側のCPU、外部APIの応答 |
この表の「まず疑う先」は仮説の置き場所であって、結論ではありません。1行目と2行目は見た目が似ていて、どちらもRPSが頭打ちになります。違いはアプリCPUが高いか低いかで、CPUが低いのに頭打ちなら、何かを待っています。
DB側で取る最小限の計測
DBの系列は、負荷をかけている間に定点で採っておく必要があります。試験が終わってからpg_stat_activityを見ても、すでに負荷は消えています。
PostgreSQLでは、max_connectionsの既定値はおおむね100です。カーネル設定が足りない環境ではそれより小さくなることがあり、変更にはサーバーの再起動が必要です。接続数を見るときは、この上限に対する割合で読みます。
状態別の本数と、待ちの種類は次のクエリで取れます。
SELECT state, wait_event_type, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state, wait_event_type
ORDER BY count(*) DESC;stateがidle in transactionのセッションは、トランザクションを開いたまま何も実行していない状態です。これが多いと、接続を握ったまま動かない本数が増えます。wait_event_typeのLockは重いロックの待ち、IOは入出力の完了待ちを表します。Clientはアプリ側からの入力を待っている状態で、DBが遅いわけではありません。
クエリ単位の上位は、pg_stat_statementsを有効にしていれば取れます。
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;負荷をかける前に統計をリセットしておくと、試験中のクエリだけが並びます。mean_exec_timeが小さくてもcallsが桁違いに多いクエリは、total_exec_timeで上位に来ます。1回あたりの速さより、呼び出しの回数を減らすほうが効く典型です。
定点採取はシェルのループで足ります。
while true; do
psql -At -F, -c "SELECT now(), count(*) FROM pg_stat_activity \
WHERE backend_type='client backend'" >> db_sample.csv
sleep 5
doneMySQLなら、Threads_connected(開いている接続数)、Threads_running(スリープしていないスレッド数)、Max_used_connections(起動後に同時に使われた接続数の最大値)が最初の3つです。Threads_connectedが高いのにThreads_runningが低いなら、接続は持っているが動いていません。遅いクエリの特定にはスロークエリログを使います。
PostgreSQL向けのMCPサーバーでDBを直接触らせる構成もあります。接続の設定はPostgres MCP Proの使い方にまとめています。負荷試験の最中に本番相当のDBへ書き込み権限を渡さないよう、読み取り専用の接続に絞っておくと安全です。
DBが疑わしいときは遅いクエリを実行計画で確かめる
DB側に疑いが向いたら、時間を食っているクエリを1本ずつ取り出して調べます。取り出す手段はDBごとに違います。
PostgreSQLでは、log_min_duration_statementに250msのような値を入れると、それ以上かかった文がログに残ります。既定は-1で、無効です。MySQLのスロークエリログは既定で無効で、有効にするにはslow_query_logを指定します。しきい値のlong_query_timeは既定が10秒なので、負荷試験では1秒以下まで下げないと何も記録されません。
候補のクエリが決まったら、実行計画を見ます。PostgreSQLのEXPLAIN ANALYZEは、クエリを実際に実行して、各ノードの実際の行数と実行時間を出します。見積もりと実測が大きくずれているノードが、索引や統計情報を見直す入口です。更新系の文をEXPLAIN ANALYZEにかけると本当にデータが書き換わるので、検証用の環境で、ROLLBACKで巻き戻すトランザクションの中に入れて使います。
この段階はClaudeに実行計画の出力を貼って読ませるのが向いています。依頼文には、負荷試験の中でそのクエリが何回呼ばれたか(calls)を一緒に添えます。1回あたりの速さだけを見て索引を提案されると、呼び出し回数の多さという本当の原因を取り逃がします。
結果をClaudeに渡して仮説を並べさせる
ファイルが揃ったら、claude -pにパイプで渡します。パイプで渡す入力はBashの許可がなくても読めるので、権限の設定を足さずに済みます。標準入力の上限は10MBで、超えるとエラーで止まります。stats_history.csvが大きいときは、ファイルに書き出してパスをプロンプトに書く形に変えます。
依頼文は、CIで使う場合も考えてファイルに分けておきます。理由があります。CIでは--bareを付けるのが勧められていて、--bareで起動するとCLAUDE.mdを読み込みません。読み方のルールをCLAUDE.mdに書いていると、CIでは効かなくなります。
あなたは負荷試験の結果を読む担当です。入力は3区画に分かれています。
k6の集計、Locustのstats_history.csv、DBの定点サンプルです。
1. ランプアップ区間は読む対象から外す。
2. p95が最初に悪化した時刻を1つ決め、その前後30秒で
RPS・エラー率・DB接続数がどう動いたかを並べる。
3. 原因の候補は最大3つ。各候補に、根拠の行(時刻と値)を必ず付ける。
4. 根拠が無い候補は「データ不足」とし、追加で採るべき計測を書く。
5. 入力に無い数値を作らない。実行はこの形になります。出力の形は--json-schemaで固定できます。
{ echo '## k6'; cat k6_summary.txt
echo '## locust'; cat result_stats_history.csv
echo '## db'; cat db_sample.csv; } \
| claude -p "$(cat prompt.txt)" --output-format json \
--json-schema '{"type":"object","properties":{"hypotheses":{"type":"array","items":{"type":"object","properties":{"cause":{"type":"string","enum":["app","db","pool","load_generator","unknown"]},"evidence":{"type":"array","items":{"type":"string"}},"next_check":{"type":"string"}},"required":["cause","evidence","next_check"]}}},"required":["hypotheses"]}' \
| jq '.structured_output'スキーマに合う出力が得られなかった実行では、structured_outputフィールド自体が付きません。スクリプトに組み込むなら、jqの前にフィールドの有無を確かめます。
返ってきたevidenceは、鵜呑みにせず元のファイルで突き合わせます。時刻をgrepで引いて、本当にその値が入っているかを確認するだけで足ります。
grep '22:41:3' db_sample.csvこの確認で外れが出たら、その候補は捨てます。値が合っていて、かつ表の型にも当てはまる候補だけを、次の計測の対象にします。
切り分けでつまずきやすい点
落とし穴
粒度の違う系列を、同じ時刻の出来事として読む
Locustの履歴は10秒の移動窓で、書き込みは2秒おきです。DBの定点採取を5秒おきにしていれば、1行ごとに指している時間幅が違います。数秒のずれで因果を決めず、跳ねた時刻の前後に幅を持たせて読みます。
まとめ
p95の跳ね上がりは、k6の内訳で「接続」「サーバー処理」「受信」のどこかを当て、そのうえでアプリCPUとDBの接続・待ちを同じ時間軸に重ねて読みます。k6だけでは、アプリとDBのどちらが遅いかまでは出ません。サーバー側の2系列を足したときに初めて、疑う先が絞れます。
Claudeに任せるのは、重ね合わせと仮説の列挙です。根拠の行を必須にして、その行を自分でgrepすれば、断定だけが先走る事態を避けられます。p95などの分位点をGrafanaから取り出す手順は、mcp-grafanaでPromQLを実行し、p95・p99を出す手順に分けてあります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- code.claude.com/docs/en/headless
- grafana.com/docs/k6/latest/using-k6/metrics/reference
- grafana.com/docs/k6/latest/using-k6/thresholds
- docs.locust.io/en/stable/running-without-web-ui.html
- docs.locust.io/en/stable/configuration.html
- postgresql.org/docs/current/monitoring-stats.html
- postgresql.org/docs/current/runtime-config-connection.html
- postgresql.org/docs/current/pgstatstatements.html
- dev.mysql.com/doc/refman/8.4/en/server-status-variables.html