メインコンテンツまでスキップ

負荷テスト実践シミュレーション

実際に負荷をかけた際に何を見て、どう判断し、どこをチューニングするか。シナリオ形式で追体験しながら学びます。

前提: シナリオ設計の原則 を読んでから本ドキュメントを読むと、より理解が深まります。


シナリオ設定

あなたはトークン発行APIの性能改善を担当することになりました。

状況:

  • 本番リリース前の性能検証フェーズ
  • 目標: 1000 req/s で P99 < 500ms
  • 環境: EKS + Aurora (ステージング環境)

対象エンドポイント:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=test-client
&client_secret=test-secret

Phase 0: まず素朴にテストを作ってみる

0.1 最初のシナリオ

「とりあえず動くもの」を作ってみました。

// k6-first-attempt.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
stages: [
{ duration: '30s', target: 50 },
{ duration: '2m', target: 100 },
{ duration: '30s', target: 0 },
],
};

export default function () {
const res = http.post('https://staging.example.com/oauth/token', {
grant_type: 'client_credentials',
client_id: 'test-client', // ← 毎回同じ
client_secret: 'test-secret', // ← 毎回同じ
});

check(res, {
'status is 200': (r) => r.status === 200,
});

sleep(0.1); // ← 100ms固定
}

0.2 このシナリオの問題点

┌─────────────────────────────────────────────────────────────────────────────┐
│ このシナリオで測定できるもの/できないもの │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ✅ 測定できる: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ・単一APIの処理性能の傾向 │ │
│ │ ・明らかなボトルネック(DB接続枯渇、CPU飽和など) │ │
│ │ ・改善前後の相対比較 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ❌ 測定できない(本番と乖離する): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ・実際のスループット上限 │ │
│ │ → 同じclient_idでキャッシュ100%ヒット │ │
│ │ → 本番より良い結果が出てしまう │ │
│ │ │ │
│ │ ・現実的な負荷パターン │ │
│ │ → Think Time 100ms固定は短すぎる │ │
│ │ → 実際のユーザーは数秒間隔で操作する │ │
│ │ │ │
│ │ ・システム全体の性能 │ │
│ │ → 単一エンドポイントのみ │ │
│ │ → 認可フローや他のAPIとの組み合わせが見えない │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ このシナリオでも「ボトルネック特定」には使える │
│ → まずはこれで進めて、問題点を体感しながら学ぶ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

判断: シナリオの限界を理解した上で、まずはボトルネック特定の練習として進める


Phase 1: ベースライン計測

1.1 負荷テストの実行

まず現状を把握するために、軽い負荷から始めます。

k6 run k6-first-attempt.js

1.2 結果の確認

     ✓ status is 200

checks.........................: 100.00% ✓ 12000 ✗ 0
http_req_duration..............: avg=89ms min=15ms med=65ms max=1.2s p(90)=180ms p(95)=250ms p(99)=450ms
http_reqs......................: 12000 100/s

1.3 この結果をどう読むか

┌─────────────────────────────────────────────────────────────────────────────┐
│ 結果の読み方 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 100 VUs, 100 req/s の結果: │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ avg=89ms → 平均値。参考程度(外れ値に引っ張られる) │ │
│ │ med=65ms → 中央値。半数はこれ以下 │ │
│ │ p(95)=250ms → 95%のリクエストは250ms以下 │ │
│ │ p(99)=450ms → 99%のリクエストは450ms以下 ← SLO判定に重要 │ │
│ │ max=1.2s → 最悪ケース。これが許容範囲か確認 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 判断: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ・100 req/s で P99=450ms → 目標の1000 req/sでは超過する可能性大 │ │
│ │ ・max=1.2s は気になる → 何が起きている? │ │
│ │ ・med と p99 の差が大きい → 外れ値が多い、安定していない │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

アクション: 負荷を上げて、限界を探る


Phase 2: 限界を探る

2.1 負荷を段階的に増加

// k6-stress.js
export const options = {
stages: [
{ duration: '1m', target: 100 },
{ duration: '2m', target: 200 },
{ duration: '2m', target: 400 },
{ duration: '2m', target: 600 },
{ duration: '2m', target: 800 },
{ duration: '1m', target: 0 },
],
};

2.2 負荷増加時の結果

時間経過とともに変化を観察:

VUs=100: p99=450ms, throughput=100/s, errors=0%
VUs=200: p99=520ms, throughput=195/s, errors=0%
VUs=400: p99=850ms, throughput=350/s, errors=0.1% ← レイテンシ急増
VUs=600: p99=1.8s, throughput=380/s, errors=2.5% ← スループット頭打ち
VUs=800: p99=3.2s, throughput=390/s, errors=8.0% ← エラー多発

2.3 グラフで可視化

レイテンシ (p99)

3.2s│ ●
│ ●
1.8s│ ●

850m│ ●

450m│ ●────●────●

└─────────────────────────────────────────→ VUs
100 200 300 400 500 600 700 800

スループット

400 │ ●────●────●────●────●
│ ●
300 │
│ ●
200 │

100 │
└─────────────────────────────────────────→ VUs
100 200 300 400 500 600 700 800

→ 350-400 req/s で飽和。これ以上VUsを増やしてもスループットは上がらない

2.4 この結果から分かること

┌─────────────────────────────────────────────────────────────────────────────┐
│ 飽和点の特定 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 飽和点: 約350-400 req/s │
│ │
│ 症状: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ・VUs=400あたりからレイテンシが急増 │ │
│ │ ・スループットが頭打ち(350-400で横ばい) │ │
│ │ ・エラーが発生し始める │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 推測されるボトルネック: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ・CPU飽和? │ │
│ │ ・DB接続プール枯渇? │ │
│ │ ・外部API待ち? │ │
│ │ ・スレッドプール枯渇? │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ → 次のステップ: リソース使用率を確認 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

Phase 3: ボトルネックの特定

3.1 確認すべきメトリクス

負荷テスト中に以下を同時に監視します。

┌─────────────────────────────────────────────────────────────────────────────┐
│ 同時に監視するメトリクス │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Pod リソース (kubectl top / Grafana) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CPU使用率: 85% → 高い、ボトルネックの可能性 │ │
│ │ メモリ使用率: 60% → 余裕あり │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 2. Aurora メトリクス (CloudWatch) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CPU使用率: 45% → 余裕あり │ │
│ │ 接続数: 78/100 → 上限に近づいている! │ │
│ │ ReadIOPS: 2500 → 余裕あり │ │
│ │ DatabaseConnections: 急増中 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 3. アプリケーション メトリクス (Micrometer / APM) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ HikariCP active: 20/20 → 枯渇! │ │
│ │ HikariCP pending: 35 → 待ち行列発生 │ │
│ │ HTTP thread pool: 180/200 → 高い │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

3.2 メトリクスの取得方法

Pod リソース:

# リアルタイム監視
kubectl top pods -n production -w

# 出力例
NAME CPU(cores) MEMORY(bytes)
idp-app-6d4f8b7c9-abc12 450m 512Mi
idp-app-6d4f8b7c9-def34 480m 498Mi

HikariCP メトリクス (Actuator):

curl https://staging.example.com/actuator/metrics/hikaricp.connections.active

# 出力例
{
"name": "hikaricp.connections.active",
"measurements": [
{ "statistic": "VALUE", "value": 20.0 }
],
"availableTags": [
{ "tag": "pool", "values": ["HikariPool-1"] }
]
}

Aurora メトリクス (AWS CLI):

aws cloudwatch get-metric-statistics \
--namespace AWS/RDS \
--metric-name DatabaseConnections \
--dimensions Name=DBClusterIdentifier,Value=my-cluster \
--start-time 2024-01-15T10:00:00Z \
--end-time 2024-01-15T10:30:00Z \
--period 60 \
--statistics Average

3.3 ボトルネックの特定

┌─────────────────────────────────────────────────────────────────────────────┐
│ 分析結果 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 観測された事実: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ・HikariCP接続プール: 20/20 で枯渇 │ │
│ │ ・待ち行列: 35スレッドが接続待ち │ │
│ │ ・Aurora接続数: 78 (上限の78%) │ │
│ │ ・Pod CPU: 85% (高いがまだ余裕) │ │
│ │ ・Aurora CPU: 45% (余裕あり) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 結論: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ボトルネック = DB接続プールの枯渇 │ │
│ │ │ │
│ │ なぜ?: │ │
│ │ ・各リクエストがDBコネクションを長時間保持している │ │
│ │ ・接続プールサイズ(20)が負荷に対して小さすぎる │ │
│ │ ・または、トランザクションが長すぎる │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 次のアクション: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. トランザクションの処理時間を確認 │ │
│ │ 2. 不要なDB呼び出しがないか確認 │ │
│ │ 3. 接続プールサイズの調整を検討 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

Phase 4: 詳細調査

4.1 トレーシングで処理内訳を確認

Jaeger/Zipkin でトレースを確認:

POST /oauth/token (total: 89ms)
├── ClientAuthentication (12ms)
│ └── DB: select client (10ms)
├── TokenGeneration (8ms)
│ └── JWT signing (8ms)
├── TokenPersistence (65ms) ← 時間かかりすぎ!
│ ├── DB: insert access_token (30ms)
│ ├── DB: insert refresh_token (25ms)
│ └── DB: update client_stats (10ms)
└── Response (4ms)

4.2 問題の特定

┌─────────────────────────────────────────────────────────────────────────────┐
│ 詳細分析 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 発見した問題: │
│ │
│ 1. DB呼び出しが多い(1リクエストで4回) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ select client → 必要 │ │
│ │ insert access_token → 必要 │ │
│ │ insert refresh_token → 必要 │ │
│ │ update client_stats → 非同期でいいのでは? │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 2. トークン保存が直列実行 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ access_token insert (30ms) │ │
│ │ ↓ │ │
│ │ refresh_token insert (25ms) │ │
│ │ │ │
│ │ → 並列化すれば max(30ms, 25ms) = 30ms に短縮可能 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 3. client_stats更新が同期実行 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 統計更新はトークン発行のクリティカルパスにある必要がない │ │
│ │ → 非同期化(@Async または メッセージキュー) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

Phase 5: チューニング実施

5.1 改善策の実装

改善1: 統計更新を非同期化

// Before
public TokenResponse issueToken(TokenRequest request) {
Client client = clientRepository.find(request.clientId());
AccessToken accessToken = tokenService.generate(client);
tokenRepository.save(accessToken);
refreshTokenRepository.save(refreshToken);
clientStatsRepository.increment(client.id()); // 同期
return new TokenResponse(accessToken);
}

// After
public TokenResponse issueToken(TokenRequest request) {
Client client = clientRepository.find(request.clientId());
AccessToken accessToken = tokenService.generate(client);
tokenRepository.save(accessToken);
refreshTokenRepository.save(refreshToken);
asyncStatsService.incrementAsync(client.id()); // 非同期
return new TokenResponse(accessToken);
}

改善2: トークン保存を並列化

// Before (直列)
tokenRepository.save(accessToken); // 30ms
refreshTokenRepository.save(refreshToken); // 25ms
// Total: 55ms

// After (並列)
CompletableFuture.allOf(
CompletableFuture.runAsync(() -> tokenRepository.save(accessToken)),
CompletableFuture.runAsync(() -> refreshTokenRepository.save(refreshToken))
).join();
// Total: max(30ms, 25ms) = 30ms

改善3: 接続プールサイズの調整

# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 40 # 20 → 40
minimum-idle: 10 # 5 → 10
connection-timeout: 5000

5.2 改善の優先順位

┌─────────────────────────────────────────────────────────────────────────────┐
│ 改善の優先順位 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 効果 × 実装コスト で優先度を決定: │
│ │
│ │ 高 │ 1. 統計更新の非同期化 │
│ │ 効 │ 効果: -10ms、接続保持時間短縮 │
│ │ 果 │ コスト: 低(@Async追加のみ) │
│ │ │ │
│ │ │ 2. 接続プールサイズ増加 │
│ │ │ 効果: 待ち行列解消 │
│ │ │ コスト: 低(設定変更のみ) │
│ │ │ │
│ │ │ 3. トークン保存の並列化 │
│ │ 低 │ 効果: -25ms │
│ │ │ コスト: 中(トランザクション設計の見直し必要) │
│ └────┴────────────────────────────────────────────────────────────────────┘
│ 低 ←───── 実装コスト ─────→ 高 │
│ │
│ → まず 1, 2 を実施して効果を確認 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

Phase 6: 効果検証

6.1 同じ条件で再計測

改善1, 2を適用後、同じ負荷テストを実行:

Before:
VUs=400: p99=850ms, throughput=350/s, errors=0.1%

After:
VUs=400: p99=280ms, throughput=520/s, errors=0%

6.2 限界まで負荷を増加

VUs=100:  p99=120ms,  throughput=100/s,   errors=0%
VUs=200: p99=150ms, throughput=200/s, errors=0%
VUs=400: p99=280ms, throughput=400/s, errors=0%
VUs=600: p99=350ms, throughput=580/s, errors=0%
VUs=800: p99=420ms, throughput=750/s, errors=0%
VUs=1000: p99=480ms, throughput=920/s, errors=0.1%
VUs=1200: p99=650ms, throughput=980/s, errors=0.5% ← 新しい飽和点

6.3 結果の比較

┌─────────────────────────────────────────────────────────────────────────────┐
│ Before / After 比較 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ スループット (飽和点): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Before: 350-400 req/s │ │
│ │ After: 950-1000 req/s │ │
│ │ 改善率: 約2.7倍 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ レイテンシ (VUs=400時): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Before: p99=850ms │ │
│ │ After: p99=280ms │ │
│ │ 改善率: 67%削減 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 目標達成状況: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 目標: 1000 req/s で P99 < 500ms │ │
│ │ 結果: 920 req/s で P99 = 480ms │ │
│ │ 判定: ほぼ達成 ✓ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

学んだこと

ボトルネック特定のフロー

1. 負荷テスト実行

2. 飽和点を特定(スループット頭打ち、レイテンシ急増)

3. リソースメトリクスを確認
├── CPU高い → アプリ最適化 or スケールアウト
├── メモリ高い → メモリリーク調査 or ヒープ増加
├── DB接続枯渇 → プールサイズ調整 or クエリ最適化
└── DB CPU高い → クエリ最適化 or インデックス追加

4. トレーシングで詳細調査

5. 改善実施

6. 効果検証(1に戻る)

見るべきメトリクス一覧

レイヤーメトリクス危険サイン
k6結果p99SLOを超過
k6結果throughput頭打ち
k6結果error rate0%以上
PodCPU80%以上
PodMemorylimits近く
HikariCPactivemax近く
HikariCPpending0以上
Auroraconnectionsmax_connections近く
AuroraCPU80%以上
AuroraReadLatency増加傾向

よくあるボトルネックと対策

ボトルネック症状対策
DB接続プール枯渇pending増加プールサイズ増加、クエリ高速化
遅いクエリDB CPU高、Latency高インデックス、クエリ最適化
N+1クエリクエリ数多、Latency高JOIN、バッチ取得
同期処理過多CPU余裕あるのにスループット低非同期化、並列化
外部API遅延特定スパンが長い非同期化、キャッシュ、タイムアウト短縮
Pod CPU飽和CPU 90%以上スケールアウト、アプリ最適化

振り返り: シナリオの改善

今回のシナリオの限界

Phase 0 で指摘した通り、今回のシナリオには以下の問題がありました:

┌─────────────────────────────────────────────────────────────────────────────┐
│ シナリオの問題点と改善案 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 問題1: 同じデータの連打 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 今回: client_id='test-client' 固定 │ │
│ │ 改善: 100個以上のクライアントからランダム選択 │ │
│ │ 効果: キャッシュヒット率が本番に近づく │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 問題2: Think Time が短すぎる │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 今回: 100ms 固定 │ │
│ │ 改善: 100ms〜500ms のランダム範囲 │ │
│ │ 効果: 実際のクライアント動作に近づく │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 問題3: 単一エンドポイントのみ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 今回: /oauth/token のみ │ │
│ │ 改善: 本番の比率に基づいて複数エンドポイントを組み合わせ │ │
│ │ 効果: システム全体の性能が見える │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

ボトルネック特定 vs キャパシティ測定

┌─────────────────────────────────────────────────────────────────────────────┐
│ 目的によってシナリオを使い分ける │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ボトルネック特定(今回のケース): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ・シンプルなシナリオでOK │ │
│ │ ・「どこが詰まるか」が分かればよい │ │
│ │ ・絶対値より傾向が重要 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ キャパシティ測定(本番見積もり): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ・現実的なシナリオが必須 │ │
│ │ ・データの多様性、Think Time、エンドポイント比率 │ │
│ │ ・この結果でスケーリング計画を立てる │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ → 詳細は [シナリオ設計の原則](11-scenario-design-principles.md) を参照 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

次のステップ