【ハンズオン】k6で始める負荷テスト(第4回/全5回)
梅木 和弥
こんにちは、アプリケーションエンジニアの梅木です。
前回は、ログイン処理やファイルのアップロードなど、実務で直面する複雑なシナリオの書き方を解説しました。
今回は「計測した結果をどう分析するか」がテーマです。
リポジトリの scenarios/04-metrics のコードを使いながら、正しく計測測定するためのメトリクス活用法を解説します。
負荷テストは「計測して終わり」ではありません。
「目的に沿った分析・レポート作成」が出来ないと、今までやってきた「シナリオの構成の検討」「実装」「計測」があまり意味を持たないものになってしまいます。
- 第1回:まずは手元でAPIを叩いてみる
- 第2回:目的に合わせた負荷パターンの設計
- 第3回:ログインやデータ引継ぎなどの実践的シナリオ
- 第4回:分析(本記事)
- 第5回:CI/CD統合による自動化
- 教材
1. 平均値の罠とパーセンタイル
テスト結果を見るとき、つい「平均レスポンスタイム(avg)」だけを見て「平均300msだから大丈夫そう」と判断していませんか? (実は私も初めての負荷テストでは、秒間リクエスト数のスコアだけを見て一喜一憂してました…笑)
平均値だけで判断すると、実態を見誤るリスクがあります。
たとえばですが。
5回のリクエストのレスポンスタイムが「100ms、100ms、100ms、100ms、10000ms」だったとします。
平均値は2060msですが、実態はほとんどのリクエストが100msで返っています。
たった1回の外れ値に平均が引きずられてしまっていますよね。
勿論、逆のケースも想定出来ますよね。
大多数が速くても、少数のユーザーだけが極端に遅い体験をしている場合、平均値はその問題を隠してしまいます。
そこで重要になるのがパーセンタイルです。
k6のレポートに表示される p(90) や p(95) がこれにあたります。
パーセンタイルの読み方
以下の実行結果を例に見てみましょう。
http_req_duration
✓ 'p(50)<200' p(50)=2.21ms
✓ 'p(90)<400' p(90)=4.76ms
✓ 'p(95)<500' p(95)=5.84ms
✓ 'p(99)<1000' p(99)=8.88ms
これは次のように分析出来ます。
- 半分のリクエストが 2.21ms で完了した
- 90%のリクエストが 4.76ms で完了した
- 95%のリクエストが 5.84ms で完了した
- 99%のリクエストが 8.88ms で完了した
つまり、「平均値は 2.21ms ですが 8.88ms もの処理時間がかかっているリクエストもデータとして収集出来ているよ。」という分析をすることが出来ますよね。
「平均は速いが、一部のユーザーには遅い体験が発生している」といった問題が、パーセンタイルなら見えるようになります。
閾値にはパーセンタイルを使う
// scenarios/04-metrics/04-trends.js より抜粋
export const options = {
thresholds: {
http_req_duration: [
'p(50)<200',
'p(90)<400',
'p(95)<500',
'p(99)<1000',
],
},
};
よく使われるパーセンタイル
実際の現場でよく使われるパーセンタイルの指定です。
- p(50) - 中央値: 半分のユーザーの体験
- p(90): 90%のユーザーの体験
- p(95): 一般的なSLA目標
- p(99): 厳しいSLA目標
- p(99.9): 非常に厳しい要件
「大多数のユーザーにどれくらいの速度を保証できているか」を正確に把握する習慣をつけれるとよいかなと。
この考え方が、以降のセクションすべての土台になります。
2. カスタムメトリクスで計測・分析の幅を広げる
k6が標準で収集するメトリクス(http_req_duration など)はHTTPレベルの情報です。
実務では「ログインの成功率は?」「コンバージョン率は?」といったビジネス上の指標を計測したい場面があります。
k6では4種類のカスタムメトリクスが用意されています。
| 種類 | 用途 | 例 |
|---|---|---|
| Counter | 累積カウント | ログイン試行回数、エラー数 |
| Rate | 成功率・失敗率(0〜1) | ログイン成功率、API エラー率 |
| Gauge | 現在の値(上下する) | アクティブユーザー数 |
| Trend | 統計情報(分布を持つ) | 処理時間、レスポンスサイズ |
シナリオの概要
- ログイン処理 → 試行回数・成功/失敗をカウント、処理時間とレスポンスサイズを記録
- API呼び出し(正常) → エラー率とレスポンスサイズを記録
- API呼び出し(意図的にエラー) → エラー率を記録
メトリクスの定義
// カウンター: 回数を数える
const loginAttempts = new Counter('login_attempts');
const loginSuccesses = new Counter('login_successes');
const loginFailures = new Counter('login_failures');
const apiErrors = new Counter('api_errors');
// レート: 成功率・失敗率を記録
const loginSuccessRate = new Rate('login_success_rate');
const apiErrorRate = new Rate('api_error_rate');
// ゲージ: 現在の値を記録
const activeUsers = new Gauge('active_users');
// トレンド: 統計情報(分布)を記録
const responseSize = new Trend('response_size_bytes');
const processingTime = new Trend('processing_time_ms');
閾値の設定
カスタムメトリクスにも、組み込みメトリクスと同様に閾値を設定できます。
export const options = {
scenarios: {
metrics_test: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 10 },
{ duration: '1m', target: 10 },
{ duration: '30s', target: 0 },
],
},
},
thresholds: {
// 組み込みメトリクス
http_req_failed: ['rate<0.05'],
http_req_duration: ['p(95)<500'],
// カスタムメトリクス
'login_success_rate': ['rate>0.95'],
'api_error_rate': ['rate<0.05'],
'response_size_bytes': ['avg<10000'],
'processing_time_ms': ['p(95)<300'],
},
};
メトリクスの記録方法
テスト関数の中で、各メトリクスに値を追加していきます。
リポジトリの scenarios/04-metrics/01-custom-metrics.js を参照下さい。
export default function () {
activeUsers.add(__VU);
// ログイン処理
loginAttempts.add(1);
const startTime = Date.now();
const loginRes = http.post(`${BASE_URL}/api/auth/login`, loginPayload, {
headers: { 'Content-Type': 'application/json' },
});
processingTime.add(Date.now() - startTime);
if (loginRes.body) {
responseSize.add(loginRes.body.length);
}
if (loginRes.status === 200) {
loginSuccesses.add(1);
loginSuccessRate.add(true); // Rate には true/false を渡す
} else {
loginFailures.add(1);
loginSuccessRate.add(false);
}
// ... API呼び出し、エラーエンドポイントのテストが続く
}
実行
k6 run scenarios/04-metrics/01-custom-metrics.js
# Docker環境を使う場合
docker run --rm -i --network=host \
-e BASE_URL=http://localhost:3000 \
grafana/k6 run - < scenarios/04-metrics/01-custom-metrics.js
実行結果
実行結果から、カスタムメトリクスの部分を抜粋します。
分析は慣れるまで少し時間がかかりますが、
今回ですと Total RESULTS 項目の login_attempts( 試行回数 )、login_successes( 成功回数 )が分かりやすいかもです。
結果を見ると、login_attempts(試行回数)300回に対し、login_successes(成功回数)が300回。ログイン成功率100%であることが一目で分かります。
█ THRESHOLDS
api_error_rate
✗ 'rate<0.05' rate=10.66%
http_req_duration
✓ 'p(95)<500' p(95)=12.05ms
http_req_failed
✗ 'rate<0.05' rate=7.11%
login_success_rate
✓ 'rate>0.95' rate=100.00%
processing_time_ms
✓ 'p(95)<300' p(95)=15.05
response_size_bytes
✓ 'avg<10000' avg=203.406667
█ TOTAL RESULTS
CUSTOM
active_users...................: 1 min=1 max=10
api_error_rate.................: 10.66% 64 out of 600
api_errors.....................: 64 0.531086/s
login_attempts.................: 300 2.489465/s
login_success_rate.............: 100.00% 300 out of 300
login_successes................: 300 2.489465/s
processing_time_ms.............: avg=6.68 min=0 med=5 max=20 p(90)=14.1 p(95)=15.05
response_size_bytes............: avg=203.4 min=179 med=202 max=228 p(90)=228 p(95)=228
応用例
今回のシナリオでは、「簡単なログイン出来たか出来なかったか」「レスポンスサイズの統計情報」「API通信の成功率」等を出力してみました。
カスタムメトリクスを使うと「HTTPリクエストが成功したか」だけでなく「ビジネス上の目的を達成できたか」を定量的に計測できます。
実務では以下のようなことも組み込みできそうですよね。
- コンバージョン率(購入完了率)
- カート放棄率
- 平均注文金額
- ページビュー数
- ユーザーエンゲージメント
3. タグ付けで機能単位の閾値を設定する
実際のプロダクトには「100ms以内で返ってほしいAPI」や「2秒かかっても許容できる重いファイルアップロードAPI」など、混在していることが一般的ですよね。
これらを一律の基準で評価するのは現実的ではありません。
k6では、リクエストにタグを付けることで、タグ単位で異なる閾値を設定できます。
たとえば次のようなことが可能です。
- APIのバージョニング、v1, v2 でそれぞれ別の評価基準を設けたい
- ファイルアップロード系のAPIのみは少し基準を甘くしたい
- 読み取り・書き込みで閾値を分けて定義したい
では、サンプルのシナリオを実行して実際に結果を見てみましょう。
タグの付け方
リクエストのオプションに tags を渡します。
今回は「エンドポイント単位」「優先度」「APIバージョン」「操作タイプ」でタグをつけてみました。
http.get(`${BASE_URL}/api/users`, {
tags: {
endpoint: 'users',
priority: 'high',
api_version: 'v1',
operation: 'read',
},
});
タグごとの閾値設定
thresholds で {タグ名:値} の形式でフィルタリングします。
export const options = {
vus: 5,
duration: '1m',
thresholds: {
// 全体の閾値
http_req_duration: ['p(95)<1000'],
// エンドポイント単位
'http_req_duration{endpoint:users}': ['p(95)<300'],
'http_req_duration{endpoint:auth}': ['p(95)<500'],
'http_req_duration{endpoint:upload}': ['p(95)<1000'],
// 優先度別
'http_req_duration{priority:critical}': ['p(99)<200'],
'http_req_duration{priority:high}': ['p(99)<500'],
'http_req_duration{priority:normal}': ['p(99)<1000'],
// APIバージョン別
'http_req_duration{api_version:v1}': ['p(95)<400'],
'http_req_duration{api_version:v2}': ['p(95)<300'],
// 操作タイプ別
'http_req_duration{operation:read}': ['p(95)<200'],
'http_req_duration{operation:write}': ['p(95)<500'],
// タグ別の失敗率
'http_req_failed{endpoint:auth}': ['rate<0.01'],
'http_req_failed{priority:critical}': ['rate<0.001'],
},
};
シナリオの全体はリポジトリの scenarios/04-metrics/02-tags.js を参照下さい。
シナリオの詳細説明は主題から離れるため割愛させていただきます。
実行
k6 run scenarios/04-metrics/02-tags.js
# Docker環境を使う場合
docker run --rm -i --network=host \
-e BASE_URL=http://localhost:3000 \
grafana/k6 run - < scenarios/04-metrics/02-tags.js
実行結果
タグごとに統計が分かれて出力されていることが確認できますね。
「v1とv2でレスポンスタイムに差がある」、「read操作のp(95)が高い」など、タグを活用することで分析の幅が大きく広がります。
HTTP
http_req_duration..............: avg=19.94ms min=379.14µs med=2.9ms max=107.86ms p(90)=102.09ms p(95)=103.2ms
{ api_version:v1 }...........: avg=23.27ms min=379.14µs med=3.21ms max=107.86ms p(90)=102.31ms p(95)=103.27ms
{ api_version:v2 }...........: avg=3.26ms min=400.27µs med=2.09ms max=32.87ms p(90)=4.43ms p(95)=5.25ms
{ endpoint:auth }............: avg=5.8ms min=588.66µs med=5.36ms max=18.25ms p(90)=10.9ms p(95)=14.09ms
{ endpoint:upload }..........: avg=2.93ms min=539.87µs med=2.24ms max=10.18ms p(90)=4.44ms p(95)=6.77ms
{ endpoint:users }...........: avg=2.73ms min=379.14µs med=2.1ms max=32.87ms p(90)=4.84ms p(95)=5.6ms
{ expected_response:true }...: avg=19.94ms min=379.14µs med=2.9ms max=107.86ms p(90)=102.09ms p(95)=103.2ms
{ operation:read }...........: avg=35.88ms min=379.14µs med=3.24ms max=107.86ms p(90)=103.2ms p(95)=103.83ms
{ operation:write }..........: avg=4ms min=400.27µs med=2.54ms max=32.87ms p(90)=8.09ms p(95)=10.51ms
{ priority:critical }........: avg=5.8ms min=588.66µs med=5.36ms max=18.25ms p(90)=10.9ms p(95)=14.09ms
{ priority:high }............: avg=2.96ms min=379.14µs med=2.12ms max=32.87ms p(90)=4.9ms p(95)=5.71ms
{ priority:normal }..........: avg=2.6ms min=539.87µs med=2.17ms max=10.18ms p(90)=4.44ms p(95)=5.37ms
http_req_failed................: 0.00% 0 out of 300
{ endpoint:auth }............: 0.00% 0 out of 50
{ priority:critical }........: 0.00% 0 out of 50
http_reqs......................: 300 4.895974/s
タグ運用におけるワンポイントアドバイス
タグの運用次第では、負荷テスト自体の意味が大きく変わってしまうため、ちゃんとやろうとするとかなり難易度が高いです。
現場のエンジニアの自己判断のみではなく、きちんとレビューを通すことが必須な工程だと私は感じています。
ただ、実際の現場では自己判断のみで完結することも多いのが実態かと...。
タグ付けをするにあたっての私なりのアドバイスを簡単に記載させていただきますね。
綺麗にタグを運用出来ると、タグごとにメトリクスを集計することが出来るため分析の幅が大きく広がります。
また、特定のタグのみの閾値チェックが可能だったり、Grafanaツールとの統合時の恩恵もかなり大きいです。
1. 一貫した命名規則を使用
endpoint: エンドポイント名priority: critical / high / normal / lowapi_version: v1 / v2 / v3operation: read / write / delete
2. 必要最低限のタグを使用
タグの多用はメトリクスを複雑にします。分析に必要なタグのみを付けるようにしましょう。
3. ビジネス価値の高いものから優先
「処理の重い・軽い」といった判断でのタグ付けではなく、「クリティカルな機能には厳しい閾値」「優先度の低い機能には緩い閾値」といった観点でのタグ付けが出来ると良いのかなと。
4. 環境やリージョンによる分類
大規模なプロダクトだと、リージョンを跨いだもの・複数環境を用意しているもの、などが必然として備わっていますよね。
環境ごとに負荷テストを実行する方法も、勿論よく選択する手段ではあるんですが「1つのメトリクスとして出したい」みたいな時だと以下のようなタグ付けを行って検証を行うこともあります。
reagion: us-east / us-west / eu-westenvironment: dev / staging / production
4. グルーピングでの分析
ここまでの手法は、個々のAPIリクエストに対する分析でした。
ただ、実際のユーザーは「単一のAPIを叩く」のではなく、「フォームを開き、入力し、送信する」といった一連の操作を行いますよね。
こういった時、API単体の速度だけでなく「会員登録というフロー全体でどのくらい時間がかかっているか」を可視化したくないですか?
k6の group() 関数を使うと、リクエストを論理的なまとまりに分割し、フロー単位での計測が可能になります。
この関数を活用して、リクエストを論理的なまとまりに分割します。
グルーピングの例
今回は、「会員登録( 02_User_Registragion )」「購入操作( 04_Checkout )」といった実際のプロダクトでもよくありそうなフローをグルーピングして計測を行ってみます。
シナリオの詳細はリポジトリの scenarios/04-metrics/03-groups.js を参照下さい。
// scenarios/04-metrics/03-groups.js より抜粋
import { group } from 'k6';
export const options = {
vus: 5,
duration: '1m',
thresholds: {
// 全体の閾値
http_req_duration: ['p(95)<1000'],
// グループ内の特定リクエストの閾値
'http_req_duration{group:::02_User_Registration}': ['p(95)<1000'],
'http_req_duration{group:::04_Checkout}': ['p(95)<2000'],
},
};
export default function () {
group('02_User_Registration', () => {
group('Step1_Input_Form', () => {
http.get(`${BASE_URL}/api/users`);
sleep(2);
});
group('Step2_Submit', () => {
http.post(`${BASE_URL}/api/users`, payload);
});
});
}
実行
k6 run scenarios/04-metrics/03-groups.js
# Docker環境を使う場合
docker run --rm -i --network=host \
-e BASE_URL=http://localhost:3000 \
grafana/k6 run - < scenarios/04-metrics/03-groups.js
実行結果
上記コマンドを実行した際の出力ログを抜粋したものです。
HTTP 項目を見ていただくと、タグ付けしたリクエストの統計が出力されていることが確認できます。
HTTP
http_req_duration....................: avg=48.47ms min=284.04µs med=3.02ms max=505.8ms p(90)=7.93ms p(95)=503.12ms
{ expected_response:true }.........: avg=48.47ms min=284.04µs med=3.02ms max=505.8ms p(90)=7.93ms p(95)=503.12ms
{ group:::02_User_Registration }...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
{ group:::04_Checkout }............: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_failed......................: 0.00% 0 out of 165
http_reqs............................: 165 2.676409/s
API単体の速度だけでなく、「会員登録というフロー全体でどれくらい時間がかかっているか」が可視化されるため、ユーザー体験の改善に直結する分析が可能になります。
まとめ
今回はk6のメトリクス機能を4つの観点で解説しました。
- 平均値(
avg)だけで評価を行わず、パーセンタイル(p95, p99)で評価する - カスタムメトリクスでビジネス指標を測定する
- タグ で〇〇単位での閾値を設定する
- グループ でユーザーのフロー全体を分析する
教材 では「時系列データの統計分析」や「Grafanaダッシュボード統合」についても紹介しております。
ぜひご活用下さい。
次回は最終回です!
「Github Actionsとの統合」や「HTMLレポート生成方法」等、CI/CD統合による自動化 というテーマでお話したいと思います。
以上です。お楽しみに!
梅木 和弥/ アプリケーションエンジニア
Webのシステム開発における、設計・実装に携わっています。
業務ドメインを技術に翻訳する工程に注力しております。
最近はトムとジェリーにハマってます。
