DevOps

Observability-стек своими руками для PHP-микросервисов: OpenTelemetry, Prometheus и Loki без облачных зависимостей

Ruslan Ismailov Опубликовано 14 мин чтения
O

Введение: три столпа observability и почему self-hosted актуален в 2026 году

Современные распределённые системы невозможно отлаживать и масштабировать вслепую. Observability — это способность понять внутреннее состояние системы по её внешним сигналам. Три фундаментальных столпа observability — это метрики, трейсинг и логи. Метрики дают агрегированное представление о производительности, трейсы позволяют проследить путь запроса сквозь цепочку сервисов, а структурированные логи раскрывают детали каждого события.

В 2026 году self-hosted подход приобретает особую актуальность: облачные решения вроде Datadog или New Relic стоят десятки тысяч долларов в год при высоких объёмах данных, а требования к суверенитету данных и соответствию GDPR/152-ФЗ вынуждают компании держать телеметрию внутри периметра. Собственный стек на базе OpenTelemetry, Prometheus и Loki даёт полный контроль, предсказуемую стоимость и отсутствие vendor lock-in.

Эта статья — практическое руководство для DevOps-инженеров и backend-разработчиков на PHP, которые хотят развернуть полноценный observability-стек на собственной инфраструктуре.

Обзор архитектуры стека

Стек состоит из следующих компонентов, каждый из которых решает конкретную задачу:

  • OpenTelemetry PHP SDK — инструментирование приложения, генерация трейсов, метрик и логов.
  • OpenTelemetry Collector — приём, обработка и маршрутизация телеметрии. Принимает данные по протоколам OTLP/gRPC и OTLP/HTTP.
  • Prometheus — хранение метрик в формате time series, scraping эндпоинтов.
  • Loki — хранение логов с label-индексацией, совместимый с Grafana.
  • Tempo — хранение распределённых трейсов, интеграция с Grafana для TraceQL-запросов.
  • Grafana — единый UI для метрик (Prometheus), логов (Loki) и трейсов (Tempo) с корреляцией между ними.
  • Alertmanager — маршрутизация алертов из Prometheus в Slack, PagerDuty, email.

Поток данных выглядит так: PHP-сервис отправляет трейсы и логи в Collector по OTLP, Collector экспортирует трейсы в Tempo, логи — в Loki через Loki exporter, метрики — скрейпятся Prometheus напрямую с /metrics-эндпоинта приложения. Grafana читает из всех трёх источников и позволяет перейти от метрики к трейсу, а от трейса — к логам одним кликом.

Инструментирование PHP-приложения с OpenTelemetry PHP SDK

OpenTelemetry предоставляет официальный PHP SDK. Установим необходимые пакеты через Composer:

composer require open-telemetry/sdk \
  open-telemetry/opentelemetry-auto-laravel \
  open-telemetry/exporter-otlp \
  open-telemetry/transport-grpc \
  php-http/guzzle7-adapter

Для автоматической инструментации Laravel устанавливаем расширение через PECL:

pecl install opentelemetry
# Добавляем в php.ini:
extension=opentelemetry.so

После этого пакет opentelemetry-auto-laravel автоматически создаёт спаны для входящих HTTP-запросов, Eloquent-запросов и очередей без изменения кода приложения.

Для ручной инструментации критичных участков кода:

<?php

use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;

class OrderService
{
    public function processOrder(int $orderId): void
    {
        $tracer = Globals::tracerProvider()->getTracer('order-service');

        $span = $tracer->spanBuilder('processOrder')
            ->setSpanKind(SpanKind::KIND_INTERNAL)
            ->startSpan();

        $scope = $span->activate();

        try {
            $span->setAttribute('order.id', $orderId);
            $span->setAttribute('order.source', 'api');

            // бизнес-логика
            $this->chargePayment($orderId);
            $this->sendNotification($orderId);

            $span->setStatus(StatusCode::STATUS_OK);
        } catch (\Throwable $e) {
            $span->recordException($e);
            $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
            throw $e;
        } finally {
            $scope->detach();
            $span->end();
        }
    }
}

Сбор трейсов: интеграция с Laravel и propagation между сервисами

Настройка SDK через переменные окружения — рекомендуемый подход для Laravel-приложений, так как он не требует изменения кода при смене окружения:

# .env
OTEL_SERVICE_NAME=order-service
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_PROPAGATORS=tracecontext,baggage
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1

Для propagation context между сервисами при исходящих HTTP-запросах через Guzzle добавляем middleware:

<?php

use GuzzleHttp\Client;
use OpenTelemetry\API\Globals;
use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;
use OpenTelemetry\SDK\Common\Export\Http\PsrTransportFactory;

class TracingHttpClient
{
    private Client $client;

    public function __construct()
    {
        $this->client = new Client();
    }

    public function get(string $url): string
    {
        $headers = [];
        $propagator = Globals::propagator();
        $propagator->inject($headers, ArrayAccessGetterSetter::getInstance());

        $response = $this->client->get($url, ['headers' => $headers]);
        return $response->getBody()->getContents();
    }
}

Заголовки traceparent и tracestate (стандарт W3C TraceContext) автоматически передаются в downstream-сервисы, позволяя строить единое дерево трейса через несколько PHP-микросервисов.

Метрики из PHP: кастомные счётчики и гистограммы

Prometheus-метрики экспортируем через библиотеку promphp/prometheus_client_php или через OpenTelemetry Metrics API. Покажем подход с нативным Prometheus-клиентом для максимальной гибкости:

composer require promphp/prometheus_client_php
<?php

use Prometheus\CollectorRegistry;
use Prometheus\Storage\APCu;
use Prometheus\RenderTextFormat;

class MetricsService
{
    private CollectorRegistry $registry;

    public function __construct()
    {
        $this->registry = new CollectorRegistry(new APCu());
    }

    public function recordHttpRequest(
        string $method,
        string $route,
        int $statusCode,
        float $durationSeconds
    ): void {
        // Счётчик запросов
        $counter = $this->registry->getOrRegisterCounter(
            'app',
            'http_requests_total',
            'Total HTTP requests',
            ['method', 'route', 'status_code']
        );
        $counter->inc([$method, $route, (string)$statusCode]);

        // Гистограмма латентности (RED-метрики)
        $histogram = $this->registry->getOrRegisterHistogram(
            'app',
            'http_request_duration_seconds',
            'HTTP request duration in seconds',
            ['method', 'route'],
            [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5]
        );
        $histogram->observe($durationSeconds, [$method, $route]);
    }

    public function renderMetrics(): string
    {
        $renderer = new RenderTextFormat();
        return $renderer->render($this->registry->getMetricFamilySamples());
    }
}

Регистрируем роут для Prometheus scraping в Laravel:

<?php
// routes/web.php
Route::get('/metrics', function (MetricsService $metrics) {
    return response($metrics->renderMetrics(), 200)
        ->header('Content-Type', \Prometheus\RenderTextFormat::MIME_TYPE);
})->middleware('throttle:60,1');

Структурированное логирование в PHP: JSON-логи и корреляция с trace ID

Структурированные логи позволяют Loki эффективно индексировать и фильтровать данные. Настроим Monolog с JSON-форматтером и добавим trace_id и span_id из активного контекста OpenTelemetry:

<?php
// config/logging.php

use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\StreamHandler;
use OpenTelemetry\API\Globals;

'channels' => [
    'json' => [
        'driver' => 'monolog',
        'handler' => StreamHandler::class,
        'handler_with' => [
            'stream' => 'php://stdout',
            'level' => env('LOG_LEVEL', 'debug'),
        ],
        'formatter' => JsonFormatter::class,
        'tap' => [App\Logging\AddTraceContext::class],
    ],
],
<?php
// app/Logging/AddTraceContext.php

namespace App\Logging;

use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
use OpenTelemetry\API\Globals;

class AddTraceContext implements ProcessorInterface
{
    public function __invoke(LogRecord $record): LogRecord
    {
        $span = Globals::tracerProvider()->getTracer('app')
            ->spanBuilder('noop')->startSpan();
        $spanContext = $span->getContext();
        $span->end();

        // Получаем актуальный span из контекста
        $currentSpan = \OpenTelemetry\API\Trace\Span::getCurrent();
        $ctx = $currentSpan->getContext();

        return $record->with(extra: array_merge($record->extra, [
            'trace_id' => $ctx->getTraceId(),
            'span_id'  => $ctx->getSpanId(),
            'service'  => env('OTEL_SERVICE_NAME', 'unknown'),
        ]));
    }
}

Логи в формате JSON-строк из stdout контейнера собираются агентом Promtail или Vector и отправляются в Loki с метками service, level и env.

Настройка OpenTelemetry Collector

Collector — центральный элемент пайплайна. Конфигурируем приём OTLP, обработку и экспорт:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 1s
    send_batch_size: 1024
  resource:
    attributes:
      - key: deployment.environment
        value: production
        action: upsert
  memory_limiter:
    limit_mib: 512
    spike_limit_mib: 128
    check_interval: 5s

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true
  loki:
    endpoint: http://loki:3100/loki/api/v1/push
    default_labels_enabled:
      exporter: false
      job: true
  prometheus:
    endpoint: 0.0.0.0:8889
    namespace: otelcol

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch, resource]
      exporters: [otlp/tempo]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [loki]

Docker Compose конфигурация всего стека для локальной разработки

# docker-compose.yml
version: "3.9"

services:
  app:
    build: ./app
    environment:
      OTEL_SERVICE_NAME: order-service
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
      OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
      OTEL_PROPAGATORS: tracecontext,baggage
    depends_on: [otel-collector]

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.96.0
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"
      - "4318:4318"
      - "8889:8889"

  prometheus:
    image: prom/prometheus:v2.51.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    ports:
      - "9090:9090"

  loki:
    image: grafana/loki:2.9.5
    volumes:
      - ./loki-config.yaml:/etc/loki/local-config.yaml
      - loki_data:/loki
    ports:
      - "3100:3100"

  tempo:
    image: grafana/tempo:2.4.1
    volumes:
      - ./tempo-config.yaml:/etc/tempo.yaml
      - tempo_data:/var/tempo
    ports:
      - "3200:3200"

  grafana:
    image: grafana/grafana:10.3.3
    environment:
      GF_SECURITY_ADMIN_PASSWORD: secret
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning
      - grafana_data:/var/lib/grafana
    ports:
      - "3000:3000"
    depends_on: [prometheus, loki, tempo]

  promtail:
    image: grafana/promtail:2.9.5
    volumes:
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - ./promtail-config.yaml:/etc/promtail/config.yml

volumes:
  prometheus_data:
  loki_data:
  tempo_data:
  grafana_data:

Деплой стека в Kubernetes с Helm-чартами

Для production-деплоя в Kubernetes используем официальные Helm-чарты. Добавляем репозитории и устанавливаем компоненты:

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add grafana https://grafana.github.io/helm-charts
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
helm repo update

# Prometheus + Alertmanager
helm upgrade --install prometheus prometheus-community/kube-prometheus-stack \
  --namespace monitoring --create-namespace \
  --values prometheus-values.yaml

# Loki (single binary для старта)
helm upgrade --install loki grafana/loki \
  --namespace monitoring \
  --set loki.commonConfig.replication_factor=1 \
  --set loki.storage.type=filesystem

# Tempo
helm upgrade --install tempo grafana/tempo \
  --namespace monitoring

# OpenTelemetry Collector как DaemonSet
helm upgrade --install otel-collector open-telemetry/opentelemetry-collector \
  --namespace monitoring \
  --values otel-collector-values.yaml

Для персистентности данных Prometheus и Loki обязательно настраиваем PersistentVolumeClaim:

# prometheus-values.yaml
prometheus:
  prometheusSpec:
    retention: 30d
    storageSpec:
      volumeClaimTemplate:
        spec:
          storageClassName: fast-ssd
          accessModes: [ReadWriteOnce]
          resources:
            requests:
              storage: 100Gi

Настройка дашбордов в Grafana: RED-метрики и карта зависимостей

RED-метрики (Rate, Errors, Duration) — стандарт для мониторинга сервисов. В Grafana создаём переменные $service и $route на основе label-значений Prometheus и строим панели:

  • Rate: sum(rate(app_http_requests_total{service="$service"}[5m])) by (route)
  • Error rate: sum(rate(app_http_requests_total{status_code=~"5.."}[5m])) / sum(rate(app_http_requests_total[5m]))
  • Duration P99: histogram_quantile(0.99, sum(rate(app_http_request_duration_seconds_bucket[5m])) by (le, route))

Для поиска медленных запросов используем панель типа Table с сортировкой по P99 латентности. Карту зависимостей сервисов строим в плагине Grafana Service Graph на основе span-метрик из Tempo — он автоматически рисует граф вызовов между PHP-микросервисами.

Ключевая фича: кликнув на аномальный spike на графике метрик, Grafana предлагает перейти к трейсам Tempo за тот же временной диапазон. Из трейса — к связанным логам в Loki по trace_id. Это и есть корреляция трёх столпов observability в действии.

Алертинг: Prometheus Alertmanager для PHP-сервисов

Типичный набор правил алертинга для PHP-микросервисов:

# alerts/php-services.yaml
groups:
  - name: php-microservices
    interval: 30s
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(app_http_requests_total{status_code=~"5.."}[5m]))
          /
          sum(rate(app_http_requests_total[5m])) > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "High error rate on {{ $labels.service }}"
          description: "Error rate is {{ humanizePercentage $value }} for last 5m"

      - alert: SlowResponseTime
        expr: |
          histogram_quantile(0.95,
            sum(rate(app_http_request_duration_seconds_bucket[5m])) by (le, service)
          ) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "P95 latency > 2s on {{ $labels.service }}"

      - alert: PHPFPMQueueFull
        expr: phpfpm_listen_queue > 10
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "PHP-FPM queue is filling up on {{ $labels.instance }}"

      - alert: HighMemoryUsage
        expr: |
          process_resident_memory_bytes{job="php-app"} > 512 * 1024 * 1024
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "PHP process memory > 512MB"

Alertmanager маршрутизирует алерты в Slack-канал команды и PagerDuty для critical-уровня. Настройка silence-окон на время плановых деплоев предотвращает alert fatigue.

Заключение и советы по масштабированию стека

Собранный стек из OpenTelemetry Collector, Prometheus, Loki, Tempo и Grafana покрывает все три столпа observability для PHP-микросервисов без единой облачной зависимости. Вот ключевые рекомендации по дальнейшему развитию:

  • Семплирование трейсов: при высокой нагрузке используйте tail-based sampling в OpenTelemetry Collector — сохраняйте 100% ошибочных трейсов и 1–10% успешных.
  • Масштабирование Loki: при объёме логов свыше 10 GB/сут переходите на Loki в distributed mode с S3-совместимым хранилищем (MinIO для self-hosted).
  • Масштабирование Prometheus: используйте Thanos или VictoriaMetrics для long-term storage и горизонтального масштабирования.
  • Безопасность: закройте эндпоинты Collector, Prometheus и Loki через network policies в Kubernetes; используйте mTLS для OTLP gRPC между сервисами.
  • Автодискавери: настройте Prometheus Operator ServiceMonitor для автоматического добавления новых PHP-сервисов в scraping без изменения конфигурации.
  • SLO-дашборды: используйте плагин Grafana SLO или pyrra для автоматического расчёта error budget на основе собранных метрик.

Self-hosted observability-стек — это инвестиция в зрелость инфраструктуры. Один раз правильно настроенный пайплайн сокращает MTTR (Mean Time To Recovery) в разы и даёт команде уверенность при деплоях в production.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →