Observability-стек своими руками для PHP-микросервисов: OpenTelemetry, Prometheus и Loki без облачных зависимостей
Введение: три столпа 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. Подробнее обо мне →