LESSON 40分

「負荷テストはやるだけでは意味がない」佐藤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 ModelVU数を固定し、各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. 負荷生成器がボトルネック: 1台のマシンから10万VUを生成しようとしてCPU/メモリ不足
  2. テストデータが偏っている: 同じユーザーID、同じ商品IDでリクエスト → キャッシュヒット率100%
  3. Closed Modelの罠: サーバーが遅くなると負荷が下がり、問題が隠れる
  4. ウォームアップなし: JIT コンパイル、コネクションプール、キャッシュの初期化前に測定開始
  5. 短すぎるテスト: 1分間のテストではGC問題やメモリリークが見えない

まとめ

トピック要点
テスト種別Load/Stress/Soak/Spike を目的に応じて使い分け
ワークロードモデリング本番のアクセスパターンを忠実に再現
リトルの法則VU数 = 目標RPS / (1ユーザーあたりのRPS)
Open vs Closed公開Webサービスは Open Model が実態に近い
結果分析RPS、パーセンタイルレイテンシ、エラー率を総合判断

チェックリスト

  • 5種類の負荷テストの目的と使い分けを説明できる
  • 本番トラフィックからワークロードモデルを設計できる
  • リトルの法則でVU数を計算できる
  • Open ModelとClosed Modelの違いと選択基準を理解した
  • テスト結果の分析で重要なメトリクスを説明できる

次のステップへ

負荷テストの設計を学んだ。次は k6/Gatlingによる負荷テスト で、実際にテストスクリプトを書いて実行してみよう。

推定読了時間: 40分