Это в какой-то степени продолжение моей статьи — История создания идеального Docker для Laravel. В ней я рассказывал о том, как собрал идеальный Docker-образ для Laravel с Nginx Unit. Это был один из первых шагов по оптимизации моей инфраструктуры. Как я уже упоминал, у меня есть несколько pet-проектов, запущенных на VPS в docker-compose, и я хотел не только отслеживать их состояние, но и прокачать навыки в области Observability.
Что у меня есть:
VPS, на котором размещены мои проекты. Стандартная конфигурация: 4 ядра, 4 ГБ ОЗУ — и вперёд.
Nginx proxy, который принимает все запросы из интернета. У меня используется сборка с jwilder/nginx-proxy и nginxproxy/acme-companion, которую я выложил в свой GitHub. Благодаря этому я могу добавлять новые проекты на VPS, и они сразу доступны из интернета с SSL-сертификатом от Let’s Encrypt. Ссылка на репозиторий.
Ghost для блогов. Это как WordPress на заре его появления, только круче. Написан на Node.js.
Laravel — там, где нужна админка или фронт.
Golang для проектов, где требуется чистый бэкенд.
Что я хочу видеть:
Метрики, чтобы понимать, какие ресурсы используют мои проекты.
Логи, чтобы иметь представление о том, что происходит внутри контейнеров.
Алерты, чтобы узнавать о проблемах до того, как они станут критичными.
Трейсинг, чтобы отслеживать цепочку вызовов между сервисами и понимать, где именно происходит проблема.
Метрики и логи я буду собирать как с прикладного уровня (сами приложения), так и с инфраструктурных сервисов (Docker, Nginx и т.д.). Также имеет смысл собирать метрики с самой машины, несмотря на то что у провайдера они уже есть, — ведь хочется держать всё в одном месте. Трейсы я пока планирую собирать только базовые, которые предоставляет библиотека OpenTelemetry для фреймворков.
Для самых нетерпеливых — вот ссылка на репозиторий
> Сделаю небольшое отступление. Всё, что описано ниже, идеально подходит лично мне, но при этом вы получите базу, которую сможете доработать под свои нужды. Понимаю, что идеала не существует и к нему можно лишь стремиться. Поэтому, если у вас есть идеи, как улучшить — добро пожаловать в комментарии.
Поднимаем базовые сервисы
Прежде чем что-то собирать, нужно определиться, куда и чем собирать. В качестве инструментов у меня будут:
VictoriaMetrics для метрик. Давно хотел попробовать: это более быстрый и экономичный аналог Prometheus, при этом совместимый с его экосистемой.
Jaeger для трейсинга. Один из самых популярных инструментов для распределённой трассировки.
Tempo для трейсинга. Альтернатива Jaeger с более высокой производительностью.
Grafana для визуализации.
Loki для логов. Более экономичный аналог ELK-стека.
Vector — универсальный агент для сбора всего: им можно собирать даже трейсы (поддерживается OpenTelemetry для логов и метрик). Сейчас считается одним из самых эффективных решений.
cAdvisor для сбора метрик с самой машины.
Хочу отдельно отметить, что у меня два инструмента для трейсинга: Jaeger и Tempo. Так получилось не случайно: Jaeger — это стандарт, поддерживаемый многими инструментами, но он не самый быстрый. Tempo — это альтернатива, которая практически не использует индексы и ориентирована на дешёвое горизонтальное масштабирование. Я привык к Jaeger, но мне захотелось попробовать Tempo. Это pet-проекты, так что риск и накладные расходы невелики.
Приступим к конфигурации с docker-compose.yaml.
`docker-compose.yaml
services: # Vector for log collection vector: image: timberio/vector:latest-alpine volumes: - ./configs/vector/vector.yaml:/etc/vector/vector.yaml:ro - /var/run/docker.sock:/var/run/docker.sock:ro depends_on: - loki - victoriametrics networks: - monitoring otel-collector: image: otel/opentelemetry-collector:latest volumes: - ./configs/otel-collector/otel-collector-config.yml:/etc/otel-collector/otel-collector-config.yaml command: [ "--config=/etc/otel-collector/otel-collector-config.yaml" ] networks: - monitoring # Tempo for trace visualization tempo: image: grafana/tempo:latest volumes: - ./configs/tempo/tempo-config.yaml:/etc/tempo/tempo-config.yaml command: [ "--config.file=/etc/tempo/tempo-config.yaml" ] restart: unless-stopped networks: - monitoring # Loki for log aggregation loki: image: grafana/loki:latest command: -config.file=/etc/loki/loki-config.yaml volumes: - ./configs/loki/loki-config.yaml:/etc/loki/loki-config.yaml - loki-data:/loki networks: - monitoring # VictoriaMetrics for storing metrics victoriametrics: image: victoriametrics/victoria-metrics:latest volumes: - victoriametrics-data:/storage - ./configs/victoria/victoriametrics-scrape.yml:/etc/victoria/prometheus.yml:ro command: - '--retentionPeriod=3' - '--promscrape.config=/etc/victoria/prometheus.yml' networks: - monitoring # Grafana for visualization grafana: image: grafana/grafana:latest ports: - "3000:3000" volumes: - grafana-data:/var/lib/grafana depends_on: - loki - victoriametrics - jaeger # Добавляем зависимость от Jaeger environment: - GF_SECURITY_ADMIN_PASSWORD=admin - GF_USERS_ALLOW_SIGN_UP=false networks: - monitoring # Jaeger for distributed tracing jaeger: image: jaegertracing/all-in-one:latest environment: - COLLECTOR_ZIPKIN_HOST_PORT=:9411 # Поддержка Zipkin-формата - PROMETHEUS_SERVER_URL=http://victoriametrics:8428 volumes: - jaeger-data:/data # Хранение данных трассировки networks: - monitoring # cAdvisor for container metrics cadvisor: image: gcr.io/cadvisor/cadvisor:latest volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro - /dev/disk/:/dev/disk:ro networks: - monitoring volumes: loki-data: victoriametrics-data: grafana-data: jaeger-data: # Добавляем volume для Jaeger networks: monitoring: external: true
Здесь мы поднимаем все необходимые сервисы. Указываем сеть, с помощью которой будем передавать данные в наши сервисы с других контейнеров (перед запуском надо создать её командой docker network create monitoring). Для полноценного запуска потребуются ещё несколько конфигурационных файлов:
vector.yaml
# ------------------------------------------------------------------------------ # V E C T O R Configuration # ------------------------------------------------------------------------------ # Website: https://vector.dev # Docs: https://vector.dev/docs # Chat: https://chat.vector.dev # ------------------------------------------------------------------------------ # Источник: логи из Docker sources: docker_logs: type: "docker_logs" # Преобразование: добавляем метку сервиса (опционально) transforms: parse_logs: type: "remap" inputs: - "docker_logs" source: | .service = "docker" # Отправка логов в Loki sinks: loki: type: "loki" inputs: - "parse_logs" endpoint: "http://loki:3100" labels: app: "{{ container_name }}" encoding: codec: "json"
Данный конфиг сообщает Vector, что необходимо читать логи всех контейнеров (через docker_logs) и пересылать их в Loki.
loki-config.yaml
auth_enabled: false server: http_listen_port: 3100 grpc_listen_port: 9096 log_level: debug grpc_server_max_concurrent_streams: 1000 common: instance_addr: 127.0.0.1 path_prefix: /tmp/loki storage: filesystem: chunks_directory: /tmp/loki/chunks rules_directory: /tmp/loki/rules replication_factor: 1 ring: kvstore: store: inmemory query_range: results_cache: cache: embedded_cache: enabled: true max_size_mb: 100 limits_config: metric_aggregation_enabled: true schema_config: configs: - from: 2020-10-24 store: tsdb object_store: filesystem schema: v13 index: prefix: index_ period: 24h pattern_ingester: enabled: true metric_aggregation: loki_address: localhost:3100 ruler: alertmanager_url: http://localhost:9093 frontend: encoding: protobuf
Это базовый конфиг для Loki, который можно со временем доработать.
tempo-config.yaml
#stream_over_http_enabled: true server: http_listen_port: 3200 # Порт веб-интерфейса Tempo grpc_listen_port: 9095 # OTLP gRPC-эндпоинт для трейсов distributor: receivers: otlp: protocols: grpc: endpoint: "0.0.0.0:4317" # должен совпадать с OTEL Collector http: endpoint: "0.0.0.0:4318" # тоже должен совпадать с OTEL Collector query_frontend: search: duration_slo: 5s throughput_bytes_slo: 1.073741824e+09 metadata_slo: duration_slo: 5s throughput_bytes_slo: 1.073741824e+09 trace_by_id: duration_slo: 5s ingester: max_block_duration: 5m compactor: compaction: block_retention: 48h # хранить трейсы 48 часов storage: trace: backend: local wal: path: /var/tempo/wal local: path: /var/tempo/blocks metrics_generator: registry: external_labels: source: tempo cluster: docker-compose storage: path: /var/tempo/generator/wal remote_write: - url: http://victoriametrics:9090/api/v1/write send_exemplars: true traces_storage: path: /var/tempo/generator/traces overrides: defaults: metrics_generator: processors: [ service-graphs, span-metrics, local-blocks ] generate_native_histograms: both
Настройки для Tempo.
otel-collector-config.yml
extensions: health_check: receivers: otlp: protocols: grpc: endpoint: "0.0.0.0:4317" http: endpoint: "0.0.0.0:4318" processors: batch: timeout: 5s send_batch_size: 1024 memory_limiter: check_interval: 5s limit_mib: 512 spike_limit_mib: 128 exporters: prometheus: endpoint: "0.0.0.0:8889" # Эндпоинт для Prometheus otlp: endpoint: "tempo:4317" # Путь в Tempo tls: insecure: true otlp/jaeger: endpoint: "jaeger:4317" tls: insecure: true otlphttp/logs: endpoint: "http://loki:3100/otlp" tls: insecure: true debug: # Отладочный экспортер (необязательно) service: extensions: [ health_check ] pipelines: metrics: receivers: [ otlp ] processors: [ batch ] exporters: [ prometheus, debug ] # Отправляем метрики в Prometheus traces: receivers: [ otlp ] processors: [ batch, memory_limiter ] exporters: [ otlp, otlp/jaeger, debug ] # Отправляем трейсы в Tempo и Jaeger logs: receivers: [ otlp ] processors: [ batch ] exporters: [ otlphttp/logs, debug ] # Логи отправляются в Loki
victoriametrics-scrape.yml
global: scrape_interval: 15s scrape_configs: - job_name: 'otel-collector' scrape_interval: 5s metrics_path: '/metrics' static_configs: - targets: - 'otel-collector:8889' - job_name: 'containeradvisor' scrape_interval: 5s static_configs: - targets: [ 'cadvisor:8080' ]
Благодаря этому конфигу мы собираем метрики с cAdvisor и OpenTelemetry Collector.
После того как все файлы готовы, можно запустить сервисы:
docker compose up -d
Затем заходим в Grafana (логин/пароль — admin/admin), которая потребует сменить пароль. Источники для большинства сервисов добавятся автоматически, кроме источника для VictoriaMetrics. Для его добавления зайдите в Connections -> Add new connection -> VictoriaMetrics, нажмите Add new data source и в поле URL укажите http://victoriametrics:8428.
Примечание: я пока не до конца разобрался, чем отличается VictoriaMetrics от Prometheus (в контексте Grafana) при работе с Prometheus‑метриками. Судя по всему, если указать плагин Prometheus, то Grafana корректно подключается к VictoriaMetrics. Но, тем не менее, я предпочитаю явно указать её как отдельный источник.

Можно также установить готовый дашборд для cAdvisor, который показывает метрики контейнеров. Для этого в Grafana перейдите в Dashboards -> New -> Import и введите ID — 15798.
Настройка доступа к WebUI
Сервисы запущены, всё отлично, если это происходит локально. Но мы хотим иметь Observability и на VPS, а, скорее всего, у вас там открыты только порты 80 и 443 (ну и SSH, причём на нестандартном порту, да ещё и с ключами).Есть два подхода:
Настроить Nginx. Благодаря
nginx-proxyдостаточно указать переменные в конфиге, и это довольно удобно, хотя с точки зрения безопасности — не самый надёжный вариант. Но, если у вас есть бэкапы и никакой критичности, можно.Настроить доступ через SSH-туннель. Чтобы не вводить команду каждый раз, можно добавить алиас. Например, в
~/.bashrcили~/.zshrc:
alias obs='ssh -L 3000:localhost:3000 user@your-vps'
Тогда, набрав obs, вы создадите туннель на localhost:3000, и сможете зайти на Grafana, как будто она локально.
В этой статье я не буду подробно описывать настройки доступа, сертификаты и т.д. — тема большая. Если интерес есть, то могу сделать отдельный материал.
Настройка проекта Laravel
Давайте настроим приложение. Цель: собирать логи, трейсы и метрики. При этом логика и метрики с инфраструктурных сервисов уже есть. Как сделать это оптимально?
Инфраструктурные логи собираются автоматически благодаря Vector (все логи Nginx Unit отправляются в Loki). Логи приложения также можно собирать таким образом, но без дополнительной информации вроде traceId логи могут оказаться "безликими".
Поэтому для логов и трейсинга мы будем использовать OpenTelemetry, но не "чистую" (хотя есть официальная SDK для PHP), а библиотеку keepsuit/laravel-opentelemetry. А для метрик — spatie/laravel-prometheus.
Я буду указывать только то, что нужно изменить в уже работающем проекте, чтобы не перегружать статью. Полный код можно посмотреть по ссылке в начале.
Шаг 1. Подключаемся к сети monitoring
В нашем docker-compose.yaml для Laravel-приложения нужно добавить:
networks: - monitoring
И объявить:
networks: monitoring: external: true
Шаг 2. Устанавливаем зависимости
В Dockerfile добавим:
pecl install opentelemetry grpc
Затем установим нужные пакеты для трейсинга и логов:
composer require laraotel/opentelemetry-laravel composer require open-telemetry/transport-grpc
Важно: на PHP 8.4 у меня не заработало, выбрасывало ошибки и логи не приходили. На 8.3 всё идёт гладко.
Далее опубликуем конфигурацию:
php artisan vendor:publish --provider="Keepsuit\\LaravelOpenTelemetry\\LaravelOpenTelemetryServiceProvider" --tag="opentelemetry-config"
Шаг 3. Настраиваем логи в config/logging.php
'otlp' => [ 'driver' => 'monolog', 'handler' => \Keepsuit\LaravelOpenTelemetry\Support\OpenTelemetryMonologHandler::class, 'level' => 'debug', ]
И прописываем переменные в .env:
OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector:4317" OTEL_EXPORTER_OTLP_PROTOCOL=grpc LOG_CHANNEL=otlp
Чтобы проверить, создадим маршрут up в web.php:
Route::get('/up', function () { Log::notice("Start get Users from database"); $user = User::all(); Log::error("This special message is for the error log"); return response()->json($user); });
И в DatabaseSeeder:
public function run() { User::factory(1000)->create(); }
Применяем миграции (php artisan migrate) и заливаем тестовые данные (php artisan db:seed), после чего заходим на http://localhost/up. Смотрим логи в Grafana -> Explore -> Logs и выбираем service = "laravel". Видим логи приложения.

Обратите внимание на traceid, который одинаков для всех логов в рамках запроса. Это позволит объединить логи и трейсы.
Теперь переходим во вкладку Explore и выбираем Jaeger. Мы увидим трейс нашего запроса:

Аналогично можем посмотреть в Tempo (трейсы отправляются и туда):

Благодаря библиотеке keepsuit/laravel-opentelemetry мы получаем трейсы:
Http server requests — маршруты Laravel
Http client — запросы к другим сервисам
Database — запросы к базе
Redis — операции Redis
Queue — очереди
При необходимости можем расширять логику, добавляя собственные спаны в код.
Сбор метрик
Для метрик используем spatie/laravel-prometheus. В качестве альтернативы можно глянуть ensi/laravel-prometheus, но лично мне первая библиотека понравилась больше. Она умеет собирать метрики Horizon (хотя можно и без Horizon).
composer require spatie/laravel-prometheus composer require laravel/horizon
После этого выполним:
php artisan prometheus:install php artisan horizon:install
В PrometheusServiceProvider раскомментируем строчку:
$this->registerHorizonCollectors();
Теперь, зайдя на http://localhost/prometheus, увидим метрики, которые собирает библиотека. Для того чтобы VictoriaMetrics подтягивала эти метрики, нужно указать в victoriametrics-scrape.yml:
- job_name: 'laravel_app' scrape_interval: 15s metrics_path: '/prometheus' static_configs: - targets: [ 'app:80' ]
job_name лучше давать уникальным и осмысленным, чтобы понимать, откуда эти метрики.
Переходим в Grafana -> Explore -> VictoriaMetrics и видим там появившиеся метрики.

Среди дефолтных метрик не так много всего. Например, нет счётчика HTTP-запросов. Но его легко добавить. Создадим middleware:
php artisan make:middleware RequestMetricsMiddleware
<?php namespace App\Http\Middleware; use Closure; use Illuminate\Support\Facades\Cache; class RequestMetricsMiddleware { public function handle($request, Closure $next) { $start = microtime(true); $response = $next($request); $duration = microtime(true) - $start; Cache::increment('http_requests_total'); return $response; } }
В bootstrap/app.php добавим:
->withMiddleware(function (Middleware $middleware) { $middleware->append(\App\Http\Middleware\RequestMetricsMiddleware::class); })
И зарегистрируем новый счётчик в PrometheusServiceProvider:
Prometheus::addGauge('Total HTTP Requests') ->helpText('Number of HTTP requests since the app started') ->value(function () { return Cache::get('http_requests_total', 0); });
Теперь на http://localhost/prometheus видно, как http_requests_total растёт при каждом запросе. И в Grafana мы получим симпатичный график.

Заключение
Статья получилась объёмной: мы построили базовый стек Observability для наших pet-проектов, подняли сервисы для сбора метрик, логов и трейсов, а также интегрировали их с Laravel. В итоге у нас получилась гибкая и недорогая система для сбора и анализа ключевых данных о работе инфраструктуры — от состояния Docker-контейнеров до внутренних событий приложения. Это упростит отладку и диагностику инцидентов, а также позволит собирать важную статистику для дальнейшего развития проектов.
Однако на этом всё не заканчивается: нам ещё предстоит настроить метрики для Redis, Nginx Unit, PostgreSQL, MariaDB, а также интегрировать приложения на Golang и Node.js (Ghost). Об этом я расскажу в следующих частях, вторую часть планирую выпустить примерно через неделю.
Если остались вопросы или хотите обсудить тему подробнее — пишите в комментариях и подписывайтесь на мой канал в Telegram.
