LESSON 30分

「パフォーマンスを一度改善しても、次のリリースで元に戻ることがある」佐藤CTOは過去の失敗を振り返った。「だからパフォーマンスバジェットを設定し、CI/CD に組み込む。予算を超えたらデプロイを止める。それくらいの仕組みが必要だ。」

1. パフォーマンスバジェットとは

パフォーマンスバジェットとは、アプリケーションの性能に対する「予算」制約のこと。

バジェットの種類

カテゴリメトリクス
タイミングTime to Interactive< 3.5s
タイミングAPI p99 レイテンシ< 200ms
サイズJS バンドルサイズ< 300KB (gzip)
サイズ画像合計< 1MB
数量HTTP リクエスト数< 50
スコアLighthouse Performance≥ 90
スコアApdex≥ 0.85
// パフォーマンスバジェット定義
interface PerformanceBudget {
  name: string;
  metric: string;
  threshold: number;
  unit: string;
  severity: 'error' | 'warning';
  comparison: 'lt' | 'gt' | 'lte' | 'gte';
}

const budgets: PerformanceBudget[] = [
  // フロントエンド
  {
    name: 'JS Bundle Size',
    metric: 'bundle.js.size.gzip',
    threshold: 300 * 1024,
    unit: 'bytes',
    severity: 'error',
    comparison: 'lt',
  },
  {
    name: 'LCP',
    metric: 'web-vitals.lcp',
    threshold: 2500,
    unit: 'ms',
    severity: 'error',
    comparison: 'lt',
  },
  {
    name: 'CLS',
    metric: 'web-vitals.cls',
    threshold: 0.1,
    unit: 'score',
    severity: 'error',
    comparison: 'lt',
  },
  // バックエンド
  {
    name: 'API p99 Latency',
    metric: 'api.latency.p99',
    threshold: 200,
    unit: 'ms',
    severity: 'error',
    comparison: 'lt',
  },
  {
    name: 'DB Query p95',
    metric: 'db.query.latency.p95',
    threshold: 50,
    unit: 'ms',
    severity: 'warning',
    comparison: 'lt',
  },
  {
    name: 'Error Rate',
    metric: 'api.error_rate',
    threshold: 0.01,
    unit: 'ratio',
    severity: 'error',
    comparison: 'lt',
  },
];

// バジェットチェッカー
interface BudgetCheckResult {
  budget: PerformanceBudget;
  actual: number;
  passed: boolean;
  deviation: number; // 閾値からの乖離率
}

function checkBudgets(
  budgets: PerformanceBudget[],
  metrics: Record<string, number>
): BudgetCheckResult[] {
  return budgets.map(budget => {
    const actual = metrics[budget.metric] ?? NaN;

    let passed: boolean;
    switch (budget.comparison) {
      case 'lt': passed = actual < budget.threshold; break;
      case 'gt': passed = actual > budget.threshold; break;
      case 'lte': passed = actual <= budget.threshold; break;
      case 'gte': passed = actual >= budget.threshold; break;
    }

    const deviation = ((actual - budget.threshold) / budget.threshold) * 100;

    return { budget, actual, passed, deviation };
  });
}

2. SLO ベースのパフォーマンス目標

// SLO からパフォーマンスバジェットを導出
interface SloDefinition {
  sli: string;
  target: number;       // 例: 0.999 (99.9%)
  window: '7d' | '28d' | '30d';
  latencyThreshold?: number; // ms
}

interface DerivedBudget {
  slo: SloDefinition;
  errorBudgetMinutes: number;
  maxAllowedP99Ms: number;
  maxQPS: number;
}

function deriveBudgetsFromSlo(
  slo: SloDefinition,
  currentMetrics: {
    avgLatencyMs: number;
    currentQPS: number;
    p99LatencyMs: number;
  }
): DerivedBudget {
  const windowMinutes = slo.window === '7d' ? 7 * 24 * 60
    : slo.window === '28d' ? 28 * 24 * 60
    : 30 * 24 * 60;

  const errorBudgetMinutes = windowMinutes * (1 - slo.target);

  // レイテンシ SLO からバジェットを導出
  // p99 が閾値を超えるリクエストは SLO 違反とみなす
  const maxAllowedP99Ms = slo.latencyThreshold ?? currentMetrics.p99LatencyMs;

  // リトルの法則から最大 QPS を計算
  const maxConcurrency = 1000; // アプリケーションの最大同時接続数
  const maxQPS = maxConcurrency / (maxAllowedP99Ms / 1000);

  return {
    slo,
    errorBudgetMinutes,
    maxAllowedP99Ms,
    maxQPS,
  };
}

// SLO 達成状況のダッシュボード出力
function sloStatusReport(
  slo: SloDefinition,
  violations: number,
  totalRequests: number,
  elapsedDays: number
): void {
  const windowDays = parseInt(slo.window);
  const successRate = 1 - (violations / totalRequests);
  const budgetTotal = totalRequests * (1 - slo.target);
  const budgetConsumed = violations;
  const budgetRemaining = budgetTotal - budgetConsumed;
  const burnRate = (budgetConsumed / budgetTotal) / (elapsedDays / windowDays);

  console.log(`=== SLO Status: ${slo.sli} ===`);
  console.log(`Target:           ${(slo.target * 100).toFixed(2)}%`);
  console.log(`Current:          ${(successRate * 100).toFixed(4)}%`);
  console.log(`Budget consumed:  ${budgetConsumed} / ${budgetTotal.toFixed(0)}`);
  console.log(`Budget remaining: ${budgetRemaining.toFixed(0)}`);
  console.log(`Burn rate:        ${burnRate.toFixed(2)}x`);

  if (burnRate > 2.0) {
    console.warn('ALERT: Error budget burning too fast!');
  }
}

3. リグレッション検出

// 統計的パフォーマンスリグレッション検出
interface PerformanceSample {
  timestamp: Date;
  value: number;
}

class RegressionDetector {
  // マン・ホイットニーのU検定(ノンパラメトリック)
  static mannWhitneyUTest(
    baseline: number[],
    current: number[]
  ): { uStatistic: number; significant: boolean; effectSize: number } {
    const n1 = baseline.length;
    const n2 = current.length;

    // 全データを結合してランク付け
    const combined = [
      ...baseline.map(v => ({ value: v, group: 'baseline' as const })),
      ...current.map(v => ({ value: v, group: 'current' as const })),
    ].sort((a, b) => a.value - b.value);

    // ランクの計算(同順位はタイランク)
    const ranks = new Array(combined.length);
    let i = 0;
    while (i < combined.length) {
      let j = i;
      while (j < combined.length && combined[j].value === combined[i].value) {
        j++;
      }
      const avgRank = (i + j + 1) / 2; // 1-indexed
      for (let k = i; k < j; k++) {
        ranks[k] = avgRank;
      }
      i = j;
    }

    // baseline グループのランク合計
    let r1 = 0;
    for (let k = 0; k < combined.length; k++) {
      if (combined[k].group === 'baseline') {
        r1 += ranks[k];
      }
    }

    const u1 = r1 - (n1 * (n1 + 1)) / 2;
    const u2 = n1 * n2 - u1;
    const u = Math.min(u1, u2);

    // 正規近似(大標本)
    const mu = (n1 * n2) / 2;
    const sigma = Math.sqrt((n1 * n2 * (n1 + n2 + 1)) / 12);
    const z = Math.abs((u - mu) / sigma);

    // z > 1.96 → p < 0.05 で有意
    const significant = z > 1.96;

    // 効果量(Cliff's delta の簡易版)
    const effectSize = 1 - (2 * u) / (n1 * n2);

    return { uStatistic: u, significant, effectSize };
  }

  // 変化点検出(CUSUM アルゴリズム)
  static cusumDetect(
    values: number[],
    threshold: number = 5,
    drift: number = 1
  ): number[] {
    const mean = values.reduce((s, v) => s + v, 0) / values.length;
    const std = Math.sqrt(
      values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length
    );

    let sPos = 0;
    let sNeg = 0;
    const changePoints: number[] = [];

    for (let i = 0; i < values.length; i++) {
      const normalized = (values[i] - mean) / std;
      sPos = Math.max(0, sPos + normalized - drift);
      sNeg = Math.max(0, sNeg - normalized - drift);

      if (sPos > threshold || sNeg > threshold) {
        changePoints.push(i);
        sPos = 0;
        sNeg = 0;
      }
    }

    return changePoints;
  }
}

4. CI/CD への統合

# .github/workflows/performance-check.yml
name: Performance Budget Check

on:
  pull_request:
    branches: [main]

jobs:
  performance-budget:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build
        run: npm run build

      - name: Bundle Size Check
        run: |
          # バンドルサイズ計測
          node scripts/check-bundle-size.js

      - name: API Performance Test
        run: |
          docker-compose up -d
          sleep 10
          # k6 による負荷テスト
          k6 run --out json=results.json scripts/performance-test.js
          # バジェットチェック
          node scripts/check-performance-budget.js results.json

      - name: Comment PR with Results
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const results = JSON.parse(fs.readFileSync('budget-results.json', 'utf8'));
            const body = formatBudgetReport(results);
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body,
            });
// scripts/check-performance-budget.ts
import { readFileSync, writeFileSync } from 'fs';

interface K6Result {
  metrics: Record<string, {
    values: {
      avg: number;
      p90: number;
      p95: number;
      p99: number;
      max: number;
    };
  }>;
}

function checkPerformanceBudget(resultPath: string): void {
  const k6Result: K6Result = JSON.parse(readFileSync(resultPath, 'utf8'));

  const budgetChecks = [
    {
      name: 'API p99 Latency',
      actual: k6Result.metrics['http_req_duration']?.values?.p99 ?? 0,
      threshold: 200,
      unit: 'ms',
    },
    {
      name: 'Error Rate',
      actual: (k6Result.metrics['http_req_failed']?.values?.avg ?? 0) * 100,
      threshold: 1,
      unit: '%',
    },
    {
      name: 'Throughput',
      actual: k6Result.metrics['http_reqs']?.values?.avg ?? 0,
      threshold: 100, // minimum req/s
      unit: 'req/s',
    },
  ];

  let allPassed = true;
  const results: string[] = ['## Performance Budget Report\n'];

  for (const check of budgetChecks) {
    const passed = check.name === 'Throughput'
      ? check.actual >= check.threshold
      : check.actual <= check.threshold;

    if (!passed) allPassed = false;

    results.push(
      `| ${passed ? 'PASS' : 'FAIL'} | ${check.name} | ` +
      `${check.actual.toFixed(2)} ${check.unit} | ` +
      `threshold: ${check.threshold} ${check.unit} |`
    );
  }

  writeFileSync('budget-results.json', JSON.stringify({ checks: budgetChecks, allPassed }));

  if (!allPassed) {
    console.error('Performance budget exceeded!');
    process.exit(1);
  }

  console.log('All performance budgets passed.');
}
コラム: パフォーマンスバジェットのベストプラクティス
  1. 段階的に導入: 最初は warning のみ、安定したら error に昇格
  2. ベースラインを測定: 現在の性能から始め、徐々に締める
  3. コンテキストを考慮: 機能追加で一時的にバジェットを緩和する仕組み
  4. 自動化が必須: 手動チェックは絶対に忘れられる
  5. 可視化: ダッシュボードでトレンドを表示し、劣化を早期発見

まとめ

トピック要点
パフォーマンスバジェットタイミング・サイズ・スコアの予算制約
SLO ベース目標エラーバジェットとバーンレートで管理
リグレッション検出統計的手法(U検定、CUSUM)で有意差を判定
CI/CD 統合PR ごとにバジェットチェック、違反でブロック

チェックリスト

  • パフォーマンスバジェットの種類と設定方法を理解した
  • SLO からバジェットを導出する考え方を理解した
  • 統計的リグレッション検出の基本を理解した
  • CI/CD にパフォーマンスチェックを統合する方法を知っている

次のステップへ

パフォーマンスの測定・目標設定・維持の方法を学んだ。次は演習で パフォーマンスボトルネックの分析 に挑戦しよう。

推定読了時間: 30分