「負荷テストはやるだけでは意味がない」佐藤CTOは強調した。「テスト設計が悪ければ、得られる結果も間違う。本番のワークロードを正確にモデリングし、再現性のある形で実行し、結果を科学的に分析する。それがパフォーマンスエンジニアリングだ。」
1. 負荷テストの種類
| テスト種別 | 目的 | VU 推移パターン | 期間 |
|---|
| Load Test | 想定負荷での動作確認 | 漸増 → 定常 → 漸減 | 10-30分 |
| Stress Test | 限界性能の把握 | 段階的に増加し続ける | 20-60分 |
| Soak Test | 長時間安定性(メモリリーク等) | 定常負荷を長時間維持 | 2-8時間 |
| Spike Test | 突発的な負荷への耐性 | 定常 → 急激な増加 → 定常 | 10-20分 |
| Breakpoint Test | 破綻点の特定 | 漸増し続け限界を見つける | 可変 |
graph TD
subgraph lt["Load Test"]
direction LR
LT1[/"漸増"/] --> LT2["定常負荷"] --> LT3["漸減"\]
end
subgraph st["Stress Test"]
direction LR
ST1[/"段階的に"/] --> ST2[/"増加し"/] --> ST3[/"続ける"/]
end
subgraph sk["Soak Test"]
direction LR
SK1[/"開始"/] --> SK2["長時間定常負荷を維持"]
end
subgraph sp["Spike Test"]
direction LR
SP1["定常"] --> SP2[/"急激な増加"/] --> SP3["定常に戻る"]
end
style lt fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e40af
style st fill:#fee2e2,stroke:#dc2626,color:#991b1b
style sk fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#92400e
style sp fill:#d1fae5,stroke:#059669,color:#065f46
2. ワークロードモデリング
本番トラフィックの分析
// アクセスログからワークロードモデルを構築
interface WorkloadModel {
scenarios: ScenarioWeight[];
thinkTime: ThinkTimeConfig;
dataProfile: DataProfile;
}
interface ScenarioWeight {
name: string;
weight: number; // 全リクエストに対する割合
steps: RequestStep[];
}
interface RequestStep {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
endpoint: string;
thinkTimeAfterMs: number; // この操作後のユーザー思考時間
}
// 実際のアクセスログから抽出したワークロードモデル
const ecSiteWorkload: WorkloadModel = {
scenarios: [
{
name: 'browse_and_leave',
weight: 0.60, // 全ユーザーの60%
steps: [
{ method: 'GET', endpoint: '/', thinkTimeAfterMs: 3000 },
{ method: 'GET', endpoint: '/products?category=xxx', thinkTimeAfterMs: 5000 },
{ method: 'GET', endpoint: '/products/:id', thinkTimeAfterMs: 8000 },
],
},
{
name: 'browse_and_search',
weight: 0.25,
steps: [
{ method: 'GET', endpoint: '/', thinkTimeAfterMs: 2000 },
{ method: 'GET', endpoint: '/search?q=xxx', thinkTimeAfterMs: 4000 },
{ method: 'GET', endpoint: '/products/:id', thinkTimeAfterMs: 5000 },
{ method: 'GET', endpoint: '/products/:id', thinkTimeAfterMs: 5000 },
],
},
{
name: 'purchase',
weight: 0.15,
steps: [
{ method: 'GET', endpoint: '/', thinkTimeAfterMs: 2000 },
{ method: 'GET', endpoint: '/products/:id', thinkTimeAfterMs: 10000 },
{ method: 'POST', endpoint: '/cart/items', thinkTimeAfterMs: 3000 },
{ method: 'GET', endpoint: '/cart', thinkTimeAfterMs: 5000 },
{ method: 'POST', endpoint: '/checkout', thinkTimeAfterMs: 15000 },
{ method: 'POST', endpoint: '/orders', thinkTimeAfterMs: 0 },
],
},
],
thinkTime: {
distribution: 'log-normal', // ユーザーの思考時間は対数正規分布
medianMs: 5000,
sigmaMs: 2000,
},
dataProfile: {
totalProducts: 100000,
activeUsers: 50000,
hotProducts: 1000, // 上位1000商品がアクセスの80%
hotProductRatio: 0.80,
},
};
リトルの法則によるVU数の算出
// Little's Law: L = λ × W
// L: 同時ユーザー数(VU)
// λ: スループット(req/s)
// W: 平均レスポンス時間 + 思考時間
function calculateRequiredVUs(config: {
targetRps: number;
avgResponseTimeMs: number;
avgThinkTimeMs: number;
avgRequestsPerSession: number;
}): number {
// 1ユーザーが1リクエストを処理するのにかかる時間
const timePerRequestSec =
(config.avgResponseTimeMs + config.avgThinkTimeMs) / 1000;
// 1ユーザーのRPS
const rpsPerUser = 1 / timePerRequestSec;
// 必要VU数
const requiredVUs = config.targetRps / rpsPerUser;
return Math.ceil(requiredVUs);
}
// 例: 目標 5000 req/s、平均レスポンス200ms、思考時間5秒
const vus = calculateRequiredVUs({
targetRps: 5000,
avgResponseTimeMs: 200,
avgThinkTimeMs: 5000,
avgRequestsPerSession: 5,
});
// 1ユーザーのRPS = 1 / (0.2 + 5.0) = 0.192 req/s
// 必要VU = 5000 / 0.192 = 26,042 VU
3. テストシナリオ設計
Open Model vs Closed Model
| モデル | 説明 | 適用場面 |
|---|
| Closed Model | VU数を固定し、各VUが逐次リクエスト | 一般的な負荷テスト |
| Open Model | 到着レートを指定(レスポンスに関係なく投入) | 実際のWebトラフィックに近い |
// Closed Model の問題:
// サーバーが遅くなると → VU がレスポンス待ちで滞留
// → 新規リクエストが減少 → 見かけ上の RPS が低下
// → サーバーへの負荷が減り、回復してしまう
// → ボトルネックを見逃す可能性がある
// Open Model が望ましいケース:
// - Webサイト: ユーザーの到着はサーバーの状態に依存しない
// - API Gateway: 他サービスからのリクエストは一定レートで来る
// 設計上の判断基準
interface TestModelDecision {
useOpenModel: boolean;
reason: string;
}
function decideTestModel(context: {
isPublicFacing: boolean;
hasThinkTime: boolean;
testGoal: 'capacity' | 'stability' | 'breakpoint';
}): TestModelDecision {
if (context.testGoal === 'breakpoint' || !context.hasThinkTime) {
return {
useOpenModel: true,
reason: '到着レート制御でサーバーの限界を正確に測定',
};
}
if (context.isPublicFacing) {
return {
useOpenModel: true,
reason: 'ユーザー到着はサーバー状態に依存しないためOpenモデルが実態に近い',
};
}
return {
useOpenModel: false,
reason: 'VU固定の Closed モデルで十分',
};
}
4. テスト環境とデータ準備
// テスト環境のチェックリスト
interface LoadTestEnvironment {
// インフラ
infrastructure: {
matchProduction: boolean; // 本番と同等のスペックか
isolatedNetwork: boolean; // テスト負荷が本番に影響しないか
monitoringEnabled: boolean; // メトリクス収集が有効か
autoScalingDisabled?: boolean; // オートスケールを無効にして限界を測定
};
// データ
testData: {
realisticVolume: boolean; // 本番と同等のデータ量
diversePatterns: boolean; // キャッシュヒット率が本番と同等
anonymized: boolean; // 個人情報がマスキング済み
};
// 負荷生成
loadGenerators: {
count: number; // 負荷生成器の台数
region: string; // テスト対象に近いリージョン
networkBandwidth: string; // 十分な帯域
isBottleneck: boolean; // 負荷生成器がボトルネックになっていないか
};
}
// テストデータの準備
class TestDataSeeder {
async seedRealisticData(config: {
userCount: number;
productCount: number;
orderCount: number;
reviewCount: number;
}): Promise<void> {
// 1. ユーザーデータ(Zipf分布でアクティビティに偏りを持たせる)
// 2. 商品データ(カテゴリ別に均等、人気度はべき乗分布)
// 3. 注文データ(時系列で分布、季節変動あり)
// 4. キャッシュのウォームアップ
console.log('Data seeding complete. Cache warmup starting...');
}
}
5. 結果の分析フレームワーク
| メトリクス | 見るべきポイント | 危険信号 |
|---|
| スループット (RPS) | 目標値を維持できているか | VU増加に伴いRPSが頭打ち |
| レイテンシ (p50/p95/p99) | パーセンタイルの乖離 | p99 がp50の10倍以上 |
| エラー率 | 0.1%以下が理想 | 負荷増加に伴い急増 |
| CPU使用率 | 70%以下で安定 | 90%超が継続 |
| メモリ | 定常状態で安定 | 単調増加(リーク疑い) |
| DB接続数 | プールサイズ内 | max_connections に到達 |
| GC Pause | 短時間で頻度低い | Full GC が頻発 |
// 負荷テスト結果の自動判定
interface LoadTestResult {
rps: number;
latencyP50Ms: number;
latencyP95Ms: number;
latencyP99Ms: number;
errorRate: number;
maxCpuPercent: number;
maxMemoryPercent: number;
}
interface LoadTestCriteria {
minRps: number;
maxP99Ms: number;
maxErrorRate: number;
maxCpuPercent: number;
}
function evaluateLoadTest(
result: LoadTestResult,
criteria: LoadTestCriteria
): { passed: boolean; failures: string[] } {
const failures: string[] = [];
if (result.rps < criteria.minRps) {
failures.push(`RPS ${result.rps} < required ${criteria.minRps}`);
}
if (result.latencyP99Ms > criteria.maxP99Ms) {
failures.push(`p99 ${result.latencyP99Ms}ms > max ${criteria.maxP99Ms}ms`);
}
if (result.errorRate > criteria.maxErrorRate) {
failures.push(`Error rate ${(result.errorRate * 100).toFixed(2)}% > max ${(criteria.maxErrorRate * 100).toFixed(2)}%`);
}
if (result.maxCpuPercent > criteria.maxCpuPercent) {
failures.push(`CPU ${result.maxCpuPercent}% > max ${criteria.maxCpuPercent}%`);
}
return { passed: failures.length === 0, failures };
}
コラム: 負荷テストでよくある失敗
- 負荷生成器がボトルネック: 1台のマシンから10万VUを生成しようとしてCPU/メモリ不足
- テストデータが偏っている: 同じユーザーID、同じ商品IDでリクエスト → キャッシュヒット率100%
- Closed Modelの罠: サーバーが遅くなると負荷が下がり、問題が隠れる
- ウォームアップなし: JIT コンパイル、コネクションプール、キャッシュの初期化前に測定開始
- 短すぎるテスト: 1分間のテストではGC問題やメモリリークが見えない
まとめ
| トピック | 要点 |
|---|
| テスト種別 | Load/Stress/Soak/Spike を目的に応じて使い分け |
| ワークロードモデリング | 本番のアクセスパターンを忠実に再現 |
| リトルの法則 | VU数 = 目標RPS / (1ユーザーあたりのRPS) |
| Open vs Closed | 公開Webサービスは Open Model が実態に近い |
| 結果分析 | RPS、パーセンタイルレイテンシ、エラー率を総合判断 |
チェックリスト
次のステップへ
負荷テストの設計を学んだ。次は k6/Gatlingによる負荷テスト で、実際にテストスクリプトを書いて実行してみよう。
推定読了時間: 40分