「パフォーマンスを一度改善しても、次のリリースで元に戻ることがある」佐藤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.');
}
コラム: パフォーマンスバジェットのベストプラクティス
- 段階的に導入: 最初は warning のみ、安定したら error に昇格
- ベースラインを測定: 現在の性能から始め、徐々に締める
- コンテキストを考慮: 機能追加で一時的にバジェットを緩和する仕組み
- 自動化が必須: 手動チェックは絶対に忘れられる
- 可視化: ダッシュボードでトレンドを表示し、劣化を早期発見
まとめ
| トピック | 要点 |
|---|---|
| パフォーマンスバジェット | タイミング・サイズ・スコアの予算制約 |
| SLO ベース目標 | エラーバジェットとバーンレートで管理 |
| リグレッション検出 | 統計的手法(U検定、CUSUM)で有意差を判定 |
| CI/CD 統合 | PR ごとにバジェットチェック、違反でブロック |
チェックリスト
- パフォーマンスバジェットの種類と設定方法を理解した
- SLO からバジェットを導出する考え方を理解した
- 統計的リグレッション検出の基本を理解した
- CI/CD にパフォーマンスチェックを統合する方法を知っている
次のステップへ
パフォーマンスの測定・目標設定・維持の方法を学んだ。次は演習で パフォーマンスボトルネックの分析 に挑戦しよう。
推定読了時間: 30分