«Redis умирает на 200k RPM, Prometheus не успевает скрейпить 50 серверов, а бизнес требует real-time дашборды. Знакомо?»

Пятница, 18:00. Дашборд в Grafana показывает timeout'ы при сборе метрик. Redis, который хранит данные для prometheus_client_php, жрёт 8GB памяти и 100% CPU. Prometheus не успевает опросить все 50+ серверов за отведённые 15 секунд. А в понедельник запускается Black Friday...

Эта статья — о том, как на одном из проектов перешли с pull на push модель для мониторинга PHP-приложения в highload, почему выбор пал на UDP + Telegraf вместо классического подхода, и как теперь собираем метрики PHP с 50+ серверов без единого timeout'а.

Архитектура: Pull vs Push для метрик в PHP

Проблема: почему Prometheus PHP Client не всегда подходит для highload

Начнём с типичного сценария. У вас есть PHP-приложение на Symfony, нужны метрики для мониторинга. Первое, что приходит в голову — prometheus_client_php. Отличная библиотека, но есть нюансы:

// Классический подход с prometheus_client_php
$registry = new CollectorRegistry(new Redis());
$counter = $registry->getOrRegisterCounter('app', 'requests_total', 'Total requests');
$counter->inc(['method' => 'GET', 'endpoint' => '/api/users']);

Что здесь происходит под капотом:

  1. Каждая метрика сохраняется в Redis/APC/in-memory storage

  2. Prometheus периодически скрейпит endpoint /metrics

  3. При скрейпинге происходит чтение всех метрик из хранилища

Где начинаются проблемы

Масштабирование: При 50+ серверах Prometheus должен опрашивать каждый. С ростом числа серверов это становится узким местом.

Хранилище метрик: Redis добавляет латенси, APC работает только в рамках одного сервера, in-memory не переживёт рестарт FPM.

Сложность конфигурации: Нужно настроить service discovery в Prometheus для всех серверов, следить за их доступностью.

Производительность: На 200k RPM каждый вызов Redis для инкремента счётчика — это overhead.

Решение: Push-модель через UDP для мониторинга PHP в highload

Мы пошли другим путём: отправляем метрики через UDP протокол в Telegraf, который уже сам разбирается, куда их дальше передать.

Почему именно UDP?

  1. Fire & forget: Отправили пакет и забыли. Никаких ожиданий ответа, никаких таймаутов.

  2. Минимальный overhead: UDP-пакет улетает за микросекунды.

  3. Fault tolerance: Если Telegraf упал, приложение продолжает работать.

  4. Простота: Не нужны connection pools, retry-логика, circuit breakers для метрик.

Важно: Да, UDP может терять пакеты. Но для метрик это не критично — потеря 0.01% данных не исказит общую картину на дашборде.

TelegrafMetricsBundle: реализация

Все это добро я собрал в простенький TelegrafMetricsBundle — Symfony-бандл для отправки метрик через UDP.

Установка и настройка

composer require yakovlef/telegraf-metrics-bundle

Конфигурация в config/packages/telegraf_metrics.yaml:

telegraf_metrics:
    namespace: 'my_app'  # Префикс для всех метрик
    client:
        url: 'http://localhost:8086'  # InfluxDB URL (для конфигурации клиента)
        udpPort: 8089  # UDP порт Telegraf

Архитектура бандла

Бандл построен на трёх ключевых компонентах:

// MetricsCollectorInterface - контракт для DI
interface MetricsCollectorInterface
{
    public function collect(string $name, array $fields, array $tags = []): void;
}

// MetricsCollector - реализация через InfluxDB UDP Writer
class MetricsCollector implements MetricsCollectorInterface
{
    private UdpWriter $writer;

    public function __construct(Client $client, string $namespace)
    {
        $this->writer = $client->createUdpWriter();
    }

    public function collect(string $name, array $fields, array $tags = []): void
    {
        // Отправляем метрику в формате InfluxDB Line Protocol
        $this->writer->write(
            new Point("{$this->namespace}_$name", $tags, $fields)
        );
    }
}

Интеграция с Symfony DI происходит автоматически:

services:
    # Автоматическая регистрация через autowiring
    Yakovlef\TelegrafMetricsBundle\Collector\MetricsCollectorInterface: 
        '@telegraf_metrics.collector'

Практические кейсы использования

1. Мониторинг API endpoints

class ApiController
{
    public function __construct(
        private MetricsCollectorInterface $metrics
    ) {}

    public function getUsers(): JsonResponse
    {
        $startTime = microtime(true);
        
        try {
            $users = $this->userRepository->findAll();
            $responseTime = (microtime(true) - $startTime) * 1000;
            
            $this->metrics->collect('api_request', [
                'response_time' => $responseTime,
                'count' => 1
            ], [
                'endpoint' => '/api/users',
                'method' => 'GET',
                'status' => '200'
            ]);
            
            return new JsonResponse($users);
            
        } catch (\Exception $e) {
            $this->metrics->collect('api_error', ['count' => 1], [
                'endpoint' => '/api/users',
                'error_type' => get_class($e),
                'status' => '500'
            ]);
            throw $e;
        }
    }
}

2. Бизнес-метрики в e-commerce

class OrderService
{
    public function createOrder(OrderDto $dto): Order
    {
        $order = new Order($dto);
        $this->em->persist($order);
        $this->em->flush();
        
        // Отправляем бизнес-метрики
        $this->metrics->collect('order_created', [
            'amount' => $order->getTotalAmount(),
            'items_count' => $order->getItemsCount(),
            'count' => 1
        ], [
            'payment_method' => $order->getPaymentMethod(),
            'currency' => $order->getCurrency(),
            'user_type' => $order->getUser()->getType()
        ]);
        
        return $order;
    }
    
    public function processPayment(Order $order): void
    {
        $startTime = microtime(true);
        
        try {
            $result = $this->paymentGateway->charge($order);
            
            $this->metrics->collect('payment_processed', [
                'amount' => $order->getTotalAmount(),
                'processing_time' => (microtime(true) - $startTime) * 1000,
                'count' => 1
            ], [
                'gateway' => $this->paymentGateway->getName(),
                'status' => 'success'
            ]);
            
        } catch (PaymentException $e) {
            $this->metrics->collect('payment_failed', [
                'amount' => $order->getTotalAmount(),
                'count' => 1
            ], [
                'gateway' => $this->paymentGateway->getName(),
                'error_code' => $e->getCode()
            ]);
            throw $e;
        }
    }
}

3. Мониторинг фоновых задач

class EmailConsumer implements MessageHandlerInterface
{
    public function __invoke(SendEmailMessage $message): void
    {
        $startTime = microtime(true);
        
        try {
            $this->mailer->send($message->getEmail());
            
            $this->metrics->collect('consumer_processed', [
                'processing_time' => (microtime(true) - $startTime) * 1000,
                'count' => 1
            ], [
                'consumer' => 'email',
                'status' => 'success',
                'priority' => $message->getPriority()
            ]);
            
        } catch (\Exception $e) {
            $this->metrics->collect('consumer_failed', ['count' => 1], [
                'consumer' => 'email',
                'error' => get_class($e)
            ]);
            throw $e;
        }
    }
}

4. Circuit Breaker паттерн с метриками

class ExternalApiClient
{
    private int $failures = 0;
    private bool $isOpen = false;
    
    public function call(string $endpoint): array
    {
        if ($this->isOpen) {
            $this->metrics->collect('circuit_breaker', ['count' => 1], [
                'service' => 'external_api',
                'state' => 'open',
                'action' => 'rejected'
            ]);
            throw new CircuitBreakerOpenException();
        }
        
        try {
            $response = $this->httpClient->request('GET', $endpoint);
            
            $this->failures = 0;
            $this->metrics->collect('circuit_breaker', ['count' => 1], [
                'service' => 'external_api',
                'state' => 'closed',
                'action' => 'success'
            ]);
            
            return $response->toArray();
            
        } catch (\Exception $e) {
            $this->failures++;
            
            if ($this->failures >= 5) {
                $this->isOpen = true;
                $this->metrics->collect('circuit_breaker', ['count' => 1], [
                    'service' => 'external_api',
                    'state' => 'open',
                    'action' => 'opened'
                ]);
            }
            
            throw $e;
        }
    }
}

Агрегация метрик в Telegraf

Одна из киллер-фич Telegraf — встроенная агрегация через плагин basicstats. Вместо отправки сырых данных в Prometheus, можно агрегировать их прямо в Telegraf:

Метрика

Описание

Когда использовать

count

Количество значений за период

Подсчёт событий (запросы, ошибки, регистрации)

sum

Сумма всех значений

Суммарная выручка, общее время обработки

mean

Среднее арифметическое

Среднее время ответа, средний чек

min

Минимальное значение

Минимальное время ответа, минимальная сумма заказа

max

Максимальное значение

Пиковая нагрузка, максимальное время обработки

stdev

Стандартное отклонение

Анализ стабильности (разброс времени ответа)

s2

Дисперсия (stdev²)

Более чувствительная метрика разброса

Пример конфигурации Telegraf с агрегацией

# /etc/telegraf/telegraf.conf

# Input: принимаем метрики по UDP
[[inputs.socket_listener]]
  service_address = "udp://:8089"
  data_format = "influx"

# Aggregation: агрегируем метрики каждые 10 секунд
[[aggregators.basicstats]]
  period = "10s"
  drop_original = false
  stats = ["count", "mean", "sum", "min", "max", "stdev"]
  
  # Агрегируем только метрики API
  namepass = ["my_app_api_*"]

# Output для Prometheus
[[outputs.prometheus_client]]
  listen = ":9273"
  metric_version = 2
  path = "/metrics"
  
  # Батчинг для оптимизации
  metric_batch_size = 1000
  metric_buffer_limit = 10000

# Output для InfluxDB (опционально)
[[outputs.influxdb_v2]]
  urls = ["http://localhost:8086"]
  token = "your-token"
  organization = "your-org"
  bucket = "metrics"
  
  # Батчинг для снижения нагрузки
  flush_interval = "10s"
  metric_batch_size = 5000

Подводные камни и как их обойти

UDP теряет пакеты — и это нормально

Проблема: При высокой нагрузке возможна потеря пакетов.

Решение: Мониторьте метрики самого Telegraf. Если потери критичны — увеличьте UDP буферы или добавьте батчинг на стороне приложения.

Помним главное: Потеря 0.01% метрик лучше, чем падение приложения из-за недоступного Redis.

Размер UDP пакета: почему ваши метрики могут не долетать

Проблема: UDP пакет ограничен ~65KB, при большом количестве тегов можно превысить лимит.

Решение: Ограничьте количество уникальных тегов, используйте короткие имена:

// Плохо: длинные теги с высокой кардинальностью
$this->metrics->collect('api_request', ['time' => 100], [
    'user_email' => $user->getEmail(), // Высокая кардинальность!
    'request_id' => uniqid(),          // Уникальное значение каждый раз
    'full_endpoint_path_with_parameters' => $request->getUri()
]);

// Хорошо: короткие теги с низкой кардинальностью
$this->metrics->collect('api_request', ['time' => 100], [
    'endpoint' => '/api/users',  // Группировка
    'method' => 'GET',           // Всего 5-7 значений
    'status' => '200'            // Всего 5-10 значений
]);

Меньше уникальных тегов = меньше размер пакета = надёжнее доставка.

Альтернативные сценарии использования

VictoriaMetrics вместо Prometheus

Для highload-систем Prometheus может становиться узким местом: высокое потребление памяти, долгие запросы при большом объёме данных и отсутствие кластерного режима «из коробки». VictoriaMetrics полностью совместима с Prometheus-протоколом, но эффективнее в хранении, быстрее обрабатывает длинные запросы и поддерживает горизонтальное масштабирование, что делает её более надёжным выбором для систем с сотнями тысяч метрик в секунду.

Отправка в несколько систем одновременно

# Дублируем метрики в разные системы
[[outputs.prometheus_client]]
  listen = ":9273"

[[outputs.influxdb_v2]]
  urls = ["http://influxdb:8086"]

[[outputs.graphite]]
  servers = ["graphite:2003"]

Roadmap и текущие ограничения

Что уже работает

Production-ready
Интеграция с Symfony 6.4+ и 7.0+
Поддержка Prometheus / VictoriaMetrics
Zero-overhead доставка метрик

Важно: Несмотря на отсутствие тестов в текущей версии, бандл уже больше года работает на проде на нескольких highload проектах.

Выводы: что мы получили и чему научились

Переход на push-модель через UDP + Telegraf для мониторинга PHP дает нам три ключевых тейка:

Производительность как конкурентное преимущество

Снижение latency в 60 раз (с 3ms до 0.05ms) — это не просто цифры. На 200k RPM это экономит 10 минут CPU-времени в час, что позволяет обрабатывать на 15% больше запросов на том же железе.

Масштабирование без головной боли

Линейное масштабирование — добавление новых серверов теперь занимает 30 секунд. Просто деплоим приложение с тем же UDP endpoint. Никаких изменений в Prometheus, никакого service discovery.

Антихрупкость системы

Изоляция сбоев — система метрик может полностью упасть, но приложение продолжит работать. За годы эксплуатации это спасло нас несколько раз во время инцидентов с инфраструктурой мониторинга.

Метрики в PHP — это не роскошь, а необходимость для понимания, что происходит в production. Подход с Telegraf UDP позволил забыть о проблемах масштабирования и сосредоточиться на том, что действительно важно — на бизнес-логике и пользовательском опыте.

Да, мы пожертвовали гарантированной доставкой каждого пакета. Но взамен получили систему, которая выдерживает любые нагрузки и не становится точкой отказа в самый критический момент — когда начинается пик на проекте

Если у вас есть опыт мониторинга PHP в highload или вопросы по настройке метрик через Telegraf — делитесь в комментариях. Особенно интересны альтернативные подходы: может, кто-то решил эту задачу через другие инструменты?

Bundle доступен на GitHub и в Packagist.


P.S. Если статья сэкономила вам время на изобретении велосипеда — поставьте звезду репозиторию. А если найдёте баги — создавайте issues, поправим.

Комментарии (0)