HardТеория18 min

Планирование ёмкости

Capacity planning, load testing, stress testing и прогнозирование нагрузки

Что такое Capacity Planning

Capacity planning — процесс определения необходимых ресурсов для обеспечения текущей и будущей нагрузки. Цель: иметь достаточно ресурсов для обслуживания пользователей без избыточных затрат.

Зачем планировать ёмкость

Проблема Следствие
Недостаток ресурсов Деградация, потеря пользователей
Избыток ресурсов Лишние затраты на инфраструктуру
Внезапный рост Сервис падает при вирусном контенте
Без прогноза Reactive вместо proactive масштабирования

Процесс Capacity Planning

1. Сбор данных → Текущее использование, рост трафика
2. Моделирование → Проекция нагрузки на 3-12 месяцев
3. Тестирование → Определение пределов текущей инфраструктуры
4. Планирование → Когда и что масштабировать
5. Мониторинг → Проверка прогнозов vs реальность

Ключевые вопросы

  • Какой текущий пиковый трафик?
  • С какой скоростью растёт нагрузка?
  • Какие events вызывают спайки (распродажи, маркетинг)?
  • Какой запас прочности нужен (обычно 30-50%)?
  • Какое время на масштабирование (cloud: минуты, bare metal: недели)?

Типы нагрузочного тестирования

Тип Цель Нагрузка Длительность
Load testing Поведение при ожидаемой нагрузке Normal-to-peak 30-60 мин
Stress testing Поиск точки отказа Выше peak До отказа
Spike testing Реакция на резкий скачок Мгновенный пик 5-15 мин
Soak testing Стабильность за длительный период Средняя 4-24 часа
Breakpoint testing Максимальная пропускная способность Растущая До предела

Визуализация типов тестирования

Load Test:         Stress Test:       Spike Test:
  ┌──────┐          /                    │
  │      │         /                   ┌─┼─┐
──┘      └──    ──/                  ──┘ │ └──
                                         │

Soak Test:         Breakpoint Test:
  ┌──────────────     /  ← break point
  │                  /
──┘               ──/
  4-24 hours

Метрики ёмкости

Формулы для расчёта

Метрика Формула
Headroom (Max capacity - Current load) / Max capacity * 100%
Growth rate (Current - Previous) / Previous * 100%
Time to saturation Headroom / Monthly growth rate
Required capacity Peak load * (1 + Safety margin)

Калькулятор ёмкости

<?php

declare(strict_types=1);

namespace App\Capacity;

final readonly class CapacityCalculator
{
    /**
     * Calculate time until resource saturation.
     *
     * @param float $currentUsage Current usage (e.g., 65%)
     * @param float $maxCapacity Maximum capacity (e.g., 100%)
     * @param float $monthlyGrowthRate Monthly growth rate (e.g., 0.08 for 8%)
     * @return array{months_to_saturation: float, saturation_date: string, headroom_percent: float}
     */
    public function timeToSaturation(
        float $currentUsage,
        float $maxCapacity,
        float $monthlyGrowthRate,
    ): array {
        if ($monthlyGrowthRate <= 0) {
            return [
                'months_to_saturation' => INF,
                'saturation_date' => 'never',
                'headroom_percent' => round(($maxCapacity - $currentUsage) / $maxCapacity * 100, 1),
            ];
        }

        // Using exponential growth: current * (1 + rate)^months = max
        $months = log($maxCapacity / $currentUsage) / log(1 + $monthlyGrowthRate);
        $saturationDate = (new \DateTimeImmutable())->modify(sprintf('+%d months', (int) ceil($months)));

        return [
            'months_to_saturation' => round($months, 1),
            'saturation_date' => $saturationDate->format('Y-m'),
            'headroom_percent' => round(($maxCapacity - $currentUsage) / $maxCapacity * 100, 1),
        ];
    }

    /**
     * Calculate required infrastructure for target RPS.
     *
     * @param float $targetRps Target requests per second
     * @param float $avgLatencyMs Average latency per request in ms
     * @param int $workersPerInstance Number of PHP-FPM workers per instance
     * @param float $safetyMargin Safety margin (0.3 = 30%)
     * @return array{instances_needed: int, total_workers: int, max_rps: float}
     */
    public function calculateInstances(
        float $targetRps,
        float $avgLatencyMs,
        int $workersPerInstance = 50,
        float $safetyMargin = 0.3,
    ): array {
        // Each worker handles: 1000ms / avgLatencyMs requests per second
        $rpsPerWorker = 1000 / $avgLatencyMs;
        $rpsPerInstance = $rpsPerWorker * $workersPerInstance;

        // Add safety margin
        $requiredCapacity = $targetRps * (1 + $safetyMargin);
        $instancesNeeded = (int) ceil($requiredCapacity / $rpsPerInstance);

        return [
            'instances_needed' => $instancesNeeded,
            'total_workers' => $instancesNeeded * $workersPerInstance,
            'max_rps' => round($instancesNeeded * $rpsPerInstance, 0),
        ];
    }

    /**
     * Estimate PHP-FPM worker count based on available memory.
     *
     * @param int $availableMemoryMb Available memory in MB
     * @param int $avgWorkerMemoryMb Average memory per PHP-FPM worker
     * @param float $memoryReserve Reserve for OS and other processes (0.2 = 20%)
     * @return array{max_workers: int, memory_per_worker_mb: int, total_usable_mb: int}
     */
    public function estimateWorkers(
        int $availableMemoryMb,
        int $avgWorkerMemoryMb = 64,
        float $memoryReserve = 0.2,
    ): array {
        $usableMemory = (int) ($availableMemoryMb * (1 - $memoryReserve));
        $maxWorkers = (int) floor($usableMemory / $avgWorkerMemoryMb);

        return [
            'max_workers' => $maxWorkers,
            'memory_per_worker_mb' => $avgWorkerMemoryMb,
            'total_usable_mb' => $usableMemory,
        ];
    }
}

// Example usage
$calc = new CapacityCalculator();

// When will we run out of capacity?
$saturation = $calc->timeToSaturation(
    currentUsage: 65,
    maxCapacity: 100,
    monthlyGrowthRate: 0.08, // 8% monthly growth
);
// Result: ~5.4 months to saturation

// How many servers for 10,000 RPS?
$infra = $calc->calculateInstances(
    targetRps: 10_000,
    avgLatencyMs: 50,
    workersPerInstance: 50,
    safetyMargin: 0.3,
);
// Each worker: 20 RPS, each instance: 1000 RPS
// Need: 13 instances (with 30% margin)
## Load Testing в PHP

Подготовка к нагрузочному тестированию

Шаг Действие
1 Определить сценарии (top-5 API endpoints)
2 Определить целевую нагрузку (peak * 1.5)
3 Подготовить тестовые данные
4 Настроить мониторинг (метрики, дашборды)
5 Изолировать тестовую среду от production
6 Провести тест, собрать результаты

Генератор нагрузки

<?php

declare(strict_types=1);

namespace App\LoadTest;

/**
 * Simple HTTP load generator for testing.
 * In production use k6, Locust, or Gatling.
 */
final class LoadGenerator
{
    /** @var array<RequestResult> */
    private array $results = [];

    public function __construct(
        private readonly int $concurrency = 10,
        private readonly int $totalRequests = 1000,
    ) {}

    /**
     * Run load test against a URL.
     */
    public function run(string $url, string $method = 'GET', ?string $body = null): LoadTestReport
    {
        $this->results = [];
        $requestsPerWorker = (int) ceil($this->totalRequests / $this->concurrency);

        // Sequential execution (for single-process PHP)
        // In real world, use pcntl_fork or async HTTP client
        for ($i = 0; $i < $this->totalRequests; $i++) {
            $this->results[] = $this->sendRequest($url, $method, $body);
        }

        return $this->generateReport();
    }

    private function sendRequest(string $url, string $method, ?string $body): RequestResult
    {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_CONNECTTIMEOUT => 5,
        ]);

        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
            curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
        }

        $startTime = hrtime(true);
        $response = curl_exec($ch);
        $durationMs = (hrtime(true) - $startTime) / 1_000_000;

        $statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        return new RequestResult(
            statusCode: $statusCode,
            durationMs: $durationMs,
            error: $error ?: null,
        );
    }

    private function generateReport(): LoadTestReport
    {
        $latencies = array_map(
            static fn(RequestResult $r) => $r->durationMs,
            $this->results,
        );
        sort($latencies);

        $successCount = count(array_filter(
            $this->results,
            static fn(RequestResult $r) => $r->statusCode >= 200 && $r->statusCode < 400,
        ));

        $totalDuration = array_sum($latencies);
        $count = count($latencies);

        return new LoadTestReport(
            totalRequests: $count,
            successfulRequests: $successCount,
            failedRequests: $count - $successCount,
            avgLatencyMs: $totalDuration / $count,
            p50Ms: $latencies[(int) ($count * 0.50)] ?? 0,
            p95Ms: $latencies[(int) ($count * 0.95)] ?? 0,
            p99Ms: $latencies[(int) ($count * 0.99)] ?? 0,
            minMs: $latencies[0] ?? 0,
            maxMs: $latencies[$count - 1] ?? 0,
            rps: $count / ($totalDuration / 1000),
        );
    }
}

final readonly class RequestResult
{
    public function __construct(
        public int $statusCode,
        public float $durationMs,
        public ?string $error = null,
    ) {}
}

final readonly class LoadTestReport
{
    public function __construct(
        public int $totalRequests,
        public int $successfulRequests,
        public int $failedRequests,
        public float $avgLatencyMs,
        public float $p50Ms,
        public float $p95Ms,
        public float $p99Ms,
        public float $minMs,
        public float $maxMs,
        public float $rps,
    ) {}

    public function toArray(): array
    {
        return [
            'total_requests' => $this->totalRequests,
            'successful' => $this->successfulRequests,
            'failed' => $this->failedRequests,
            'error_rate' => round($this->failedRequests / $this->totalRequests * 100, 2) . '%',
            'latency' => [
                'avg' => round($this->avgLatencyMs, 2) . 'ms',
                'p50' => round($this->p50Ms, 2) . 'ms',
                'p95' => round($this->p95Ms, 2) . 'ms',
                'p99' => round($this->p99Ms, 2) . 'ms',
                'min' => round($this->minMs, 2) . 'ms',
                'max' => round($this->maxMs, 2) . 'ms',
            ],
            'throughput' => round($this->rps, 1) . ' RPS',
        ];
    }
}
## Модели прогнозирования

Линейная проекция

<?php

declare(strict_types=1);

namespace App\Capacity;

final readonly class GrowthProjection
{
    /**
     * Project future load based on historical data.
     *
     * @param array<array{month: string, value: float}> $historicalData
     * @param int $monthsAhead How many months to project
     * @return array<array{month: string, projected_value: float, confidence: string}>
     */
    public function linearProjection(array $historicalData, int $monthsAhead): array
    {
        $n = count($historicalData);

        if ($n < 2) {
            return [];
        }

        // Simple linear regression: y = mx + b
        $sumX = 0;
        $sumY = 0;
        $sumXY = 0;
        $sumXX = 0;

        foreach ($historicalData as $i => $point) {
            $sumX += $i;
            $sumY += $point['value'];
            $sumXY += $i * $point['value'];
            $sumXX += $i * $i;
        }

        $m = ($n * $sumXY - $sumX * $sumY) / ($n * $sumXX - $sumX * $sumX);
        $b = ($sumY - $m * $sumX) / $n;

        $projections = [];
        $lastDate = new \DateTimeImmutable(end($historicalData)['month'] . '-01');

        for ($i = 1; $i <= $monthsAhead; $i++) {
            $x = $n + $i - 1;
            $projected = $m * $x + $b;
            $date = $lastDate->modify("+{$i} months");

            $projections[] = [
                'month' => $date->format('Y-m'),
                'projected_value' => round(max(0, $projected), 1),
                'confidence' => $i <= 3 ? 'high' : ($i <= 6 ? 'medium' : 'low'),
            ];
        }

        return $projections;
    }
}
## Стратегии масштабирования
Стратегия Когда Плюсы Минусы
Vertical Нужно быстро Просто, без изменения кода Предел hardware
Horizontal Долгосрочный рост Нет предела, fault tolerance Сложность архитектуры
Auto-scaling Переменная нагрузка Оптимальные затраты Задержка масштабирования
Pre-scaling Предсказуемые пики Готов к пику Лишние ресурсы в обычное время

Правило: Планируйте ёмкость на 6-12 месяцев вперёд. Используйте данные, а не интуицию. Проводите load testing регулярно, а не перед запуском.

Итоги

Концепция Суть
Capacity Planning Прогнозирование ресурсов на основе данных
Load Testing Проверка поведения при ожидаемой нагрузке
Stress Testing Поиск точки отказа системы
Headroom Запас прочности (цель: 30-50%)
Time to Saturation Когда ресурсы будут исчерпаны
Scaling Strategy Vertical, horizontal, auto, pre-scaling