Claude Media
Claudeで負荷試験の結果を分析する — アプリとDBのボトルネック切り分け

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_blockedk6ドキュメントの説明空いているTCP接続枠を待った時間伸びたときに疑う先負荷生成側の接続枠、接続の使い回し不足
http_req_connectingk6ドキュメントの説明TCP接続の確立にかかった時間伸びたときに疑う先ネットワーク、サーバーのaccept待ち
http_req_waitingk6ドキュメントの説明レスポンスを待った時間(TTFB)伸びたときに疑う先サーバー側の処理全体(アプリもDBも含む)
http_req_receivingk6ドキュメントの説明レスポンス本体の受信時間伸びたときに疑う先応答サイズ、帯域

ここから分かる境界線は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-history

result_stats.csv、result_stats_history.csv、result_failures.csvなどが出力されます。このうちresult_stats_history.csvが時系列で、直近10秒の移動窓の統計が追記されます。--csv-full-historyを付けると、集計行だけでなくエンドポイントごとの行も、2秒おきの書き込みのたびに追記されます。

10秒の窓は短いスパイクをならします。2〜3秒だけ跳ねて戻る遅延は、この履歴ではほとんど見えません。瞬間的な跳ねを探すときは、アプリ側のログやトレースで補います。

4本の時系列を同じ時間軸に重ねる

重ねる系列は次の4本です。

  1. 負荷側のRPSと、p95・エラー率
  2. アプリのCPU(コンテナなら使用率とスロットリング)
  3. DBの接続数と、アクティブなセッション数
  4. 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
done

MySQLなら、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を出す手順に分けてあります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/headless
  2. grafana.com/docs/k6/latest/using-k6/metrics/reference
  3. grafana.com/docs/k6/latest/using-k6/thresholds
  4. docs.locust.io/en/stable/running-without-web-ui.html
  5. docs.locust.io/en/stable/configuration.html
  6. postgresql.org/docs/current/monitoring-stats.html
  7. postgresql.org/docs/current/runtime-config-connection.html
  8. postgresql.org/docs/current/pgstatstatements.html
  9. dev.mysql.com/doc/refman/8.4/en/server-status-variables.html
この記事を共有:XはてブLinkedIn