Я развернул self-hosted Supabase в Docker и собрал для него отдельный observability-контур. Метрики собирает
vmagentи хранит VictoriaMetrics, логи собирает Vector и отправляет в VictoriaLogs, а Grafana стала единым интерфейсом. Сначала я прокидывал логи через HTTP-адаптер на Edge Function, но затем перенёс ingestion в Vector: он уже входит в Supabase-стек, читает docker-логи, умеет нормализовать их в VRL и отправляет данные батчами на отдельный сервер мониторинга.
Задача
Я использую Supabase в self-hosted режиме: сервисы работают в Docker Compose, включая Postgres, Kong, GoTrue, PostgREST, Storage, Realtime, Edge Functions, Logflare и Vector. Перед Kong стоит Nginx, который завершает TLS и проксирует публичное API.
Мне нужно было закрыть две задачи:
Собирать метрики хоста, PostgreSQL и доступности компонентов Supabase.
Видеть логи всех контейнеров, в первую очередь Edge Functions, и быстро искать ошибки в Grafana.
В self-hosted варианте Supabase нет готового managed Metrics API, поэтому метрики нужно снимать напрямую с инфраструктурных компонентов и exporter’ов.
Итоговая архитектура
Инфраструктура разделена на две машины. На сервере приложения работает Supabase и штатный Vector; на сервере мониторинга - VictoriaMetrics, VictoriaLogs, Grafana и Nginx.
┌────────────────────────────────────────────────────┐ │ Сервер приложения │ │ │ │ Docker Compose: │ │ Postgres / Kong / Auth / REST / Storage / Realtime │ │ Edge Functions / Logflare / Vector │ │ │ │ docker json-file logs │ │ │ │ │ ▼ │ │ Vector: docker_logs + VRL │ └─────────────────────────┬──────────────────────────┘ │ HTTPS + JSONLine batches │ ▼ ┌────────────────────────────────────────────────────┐ │ Сервер мониторинга │ │ │ │ Nginx: allow <APP_SERVER_IP>; deny all; │ │ │ localhost HTTP │ │ ▼ │ │ VictoriaLogs ◄────────────────────── Grafana │ │ │ │ vmagent ───► VictoriaMetrics ◄─────── Grafana │ └────────────────────────────────────────────────────┘
VictoriaMetrics хранит метрики - числовые временные ряды. VictoriaLogs хранит и индексирует логи, а Grafana подключается к обоим системам отдельными datasource.
Почему VictoriaMetrics
vmagent работает по привычной Prometheus-модели: он скрейпит endpoint’ы по конфигурации и передаёт данные в удалённое хранилище через remote write. Он также умеет фильтровать данные и менять labels перед отправкой.
Это означает, что можно использовать стандартный scrape.yml, PromQL и готовые Grafana-дашборды для node-exporter или PostgreSQL.
Метрики Supabase
Для self-hosted Supabase я снимаю метрики с трёх уровней:
node-exporter - CPU, RAM, диск, сеть и состояние самого сервера.
postgres-exporter - метрики PostgreSQL.
blackbox-exporter - HTTP-проверки доступности Kong, Auth, PostgREST и Storage.
PostgreSQL exporter в примерах статьи доступен на digest.the-signal.ru:9187; этот instance можно зафиксировать в Grafana-дашборде, чтобы панели не подхватывали лишние значения.
scrape.yml
global: scrape_interval: 15s scrape_timeout: 10s scrape_configs: - job_name: node_exporter static_configs: - targets: - digest.the-signal.ru:9100 - job_name: postgres_exporter static_configs: - targets: - digest.the-signal.ru:9187 - job_name: supabase_health metrics_path: /probe params: module: - http_2xx static_configs: - targets: - http://kong:8000/health - http://auth:9999/health - http://rest:3000/ready - http://storage:5000/status relabel_configs: - source_labels: - __address__ target_label: __param_target - source_labels: - __param_target target_label: instance - target_label: __address__ replacement: blackbox:9115
Blackbox exporter публикует probe_success = 1, если target вернул успешный HTTP-ответ; при сетевой ошибке, таймауте или неподходящем статус-коде метрика будет равна 0.
Первая версия: HTTP-адаптер
Первой рабочей схемой был простой HTTP-адаптер на Edge Function.
Supabase / Logflare │ HTTP POST ▼ Edge Function-адаптер │ HTTP JSONLine ▼ VictoriaLogs
Функция принимала входящий JSON, приводила поля к ожидаемому формату и пересылала записи в VictoriaLogs. Это был быстрый способ проверить путь от генерации лога до его появления в Grafana.
Пример отправки одной записи:
curl -X POST \ "https://logs.example.com/insert/jsonline?\_stream\_fields=appname,project&\_msg\_field=event\_message&\_time\_field=timestamp" \ -H "Content-Type: application/stream+json" \ -d '{ "timestamp": "2026-06-04T14:24:26.545Z", "event_message": "edge function invoked", "appname": "supabase-edge-functions", "project": "default", "metadata": { "level": "INFO" } }'
VictoriaLogs поддерживает HTTP ingestion и позволяет задать поле текстового сообщения через msgfield, время через timefield, а идентификаторы логового потока - через streamfields.
Почему я отказался от адаптера
HTTP-адаптер оказался полезным proof of concept, но плохим кандидатом на постоянную архитектуру.
Во-первых, он добавлял ещё один сервис и сетевой переход: нужно поддерживать Edge Function, маршрут в Kong и отдельно искать ошибки доставки. Во-вторых, разные компоненты Supabase используют разные форматы логов: структурированный JSON, обычные строки, вложенные поля. Логика нормализации быстро начинала расползаться по коду адаптера.
Главное же - Vector уже присутствует в self-hosted Docker Compose Supabase и уже умеет читать docker-логи. Значит отдельный ingestion-сервис дублировал часть существующего pipeline.
Переход на Vector
В новой схеме Vector взял на себя три роли:
Читать stdout/stderr контейнеров через Docker API.
Превращать разные форматы записей в единый контракт через VRL.
Отправлять нормализованные логи батчами в VictoriaLogs.
Docker containers │ stdout / stderr ▼ Docker json-file driver │ ▼ Vector: docker_logs source │ ▼ VRL transforms │ ▼ HTTP sink │ ▼ Nginx на сервере мониторинга │ ▼ VictoriaLogs
docker_logs - source Vector для чтения контейнерных логов. Он добавляет к событиям Docker-контекст, а сбор можно ограничить через include_containers и exclude_containers.
VictoriaLogs официально поддерживает приём логов из Vector, в том числе через HTTP JSON.
VictoriaLogs на отдельной машине
VictoriaLogs находится на сервере мониторинга, отдельно от Supabase. Поэтому Vector не обращается к внутреннему Docker hostname, а отправляет логи по HTTPS на Nginx.
Сам VictoriaLogs я не открываю наружу: он слушает только loopback-интерфейс.
services: victoria-logs: image: victoriametrics/victoria-logs:latest container_name: victoria-logs restart: unless-stopped command: - "-storageDataPath=/victoria-logs-data" - "-retentionPeriod=14d" - "-httpListenAddr=127.0.0.1:9428" volumes: - victoria-logs-data:/victoria-logs-data
В реальной конфигурации лучше использовать конкретную версию образа вместо latest, а retention выбирать по фактическому потоку логов, объёму диска и нужной глубине истории.
Nginx и IP allowlist
Nginx на сервере мониторинга принимает запросы Vector по HTTPS и пропускает их только с внешнего IP сервера приложения.
server { listen 443 ssl http2; server_name logs.example.com; ssl_certificate /etc/letsencrypt/live/logs.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/logs.example.com/privkey.pem; location /insert/ { # Внешний IP сервера с Supabase и Vector allow 1.2.3.4; deny all; proxy_pass http://127.0.0.1:9428; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; client_max_body_size 20m; proxy_request_buffering off; proxy_read_timeout 60s; proxy_send_timeout 60s; } }
Такой allowlist не отменяет firewall, но добавляет прикладной сетевой барьер: запросы с любого IP, кроме разрешённого, Nginx отклонит с 403 Forbidden.
Порт VictoriaLogs 9428 в интернет не публикуется: внешний ingestion идёт только к Nginx по HTTPS, а он проксирует трафик локально на 127.0.0.1:9428.
Что такое VRL
VRL (Vector Remap Language) - встроенный язык преобразования событий в Vector. Он выполняется для каждой записи отдельно и позволяет извлекать и добавлять поля, приводить типы, разбирать JSON, фильтровать записи и формировать стандартизированный payload перед отправкой.
После docker_logs событие может выглядеть примерно так:
{ "message": "{\"level\":\"error\",\"msg\":\"payment webhook failed\"}", "container_name": "supabase-edge-functions", "timestamp": "2026-06-07T18:11:42.123Z" }
После обработки я хочу получить стабильную структуру:
{ "timestamp": "2026-06-07T18:11:42.123Z", "project": "default", "appname": "supabase-edge-functions", "service": "functions", "event_message": "payment webhook failed", "metadata": { "level": "ERROR" } }
Тогда запросы и дашборды не зависят от формата stdout конкретного контейнера: достаточно полей service, appname, event_message и metadata.level.
Конфигурация Vector
В конфигурации Vector есть три типа компонентов:
БлокНазначениеВ этой конфигурацииsourcesПолучают событияdocker_logs забирает логи контейнеровtransformsМеняют или фильтруют событияVRL разбирает и нормализует записиsinksДоставляют результатHTTP JSONLine в VictoriaLogs
Ниже упрощённый volumes/logs/vector.yml.
api: enabled: true address: 0.0.0.0:9001 sources: docker_host: type: docker_logs exclude_containers: - supabase-vector transforms: project_logs: type: remap inputs: - docker_host source: |- .appname = .container_name ?? "unknown" .project = "default" .timestamp = .timestamp ?? now() .event_message = string!(.message) functions_logs: type: remap inputs: - project_logs source: |- if .appname != "supabase-edge-functions" { abort } .service = "functions" parsed, err = parse_json(.event_message) if err == null { .event_message = parsed.event_message ?? parsed.msg ?? .event_message .metadata = parsed.metadata ?? {} } if is_null(.event_message) || .event_message == "" { .event_message = "empty log" } .metadata.level = .metadata.level ?? "INFO" message = downcase(string!(.event_message)) if contains(message, "error") { .metadata.level = "ERROR" } else if contains(message, "warn") { .metadata.level = "WARN" } else if contains(message, "debug") { .metadata.level = "DEBUG" } other_supabase_logs: type: remap inputs: - project_logs source: |- if .appname == "supabase-edge-functions" { abort } .service = .appname .metadata.level = "INFO" if is_null(.event_message) || .event_message == "" { .event_message = "empty log" } sinks: victoria_logs: type: http inputs: - functions_logs - other_supabase_logs method: post uri: "https://logs.example.com/insert/jsonline?\_stream\_fields=service,appname,project&\_msg\_field=event\_message&\_time\_field=timestamp" encoding: codec: json framing: method: newline_delimited compression: gzip request: retry_max_duration_secs: 10 headers: Content-Type: application/stream+json
Здесь HTTP sink отправляет newline-delimited JSON на endpoint VictoriaLogs. Параметры streamfields, msgfield и timefield указывают, какие поля образуют поток, содержат текст сообщения и время события.
Разбираем VRL
Главная ценность Vector в этой схеме - не просто отправка логов, а нормализация разнородных сообщений с помощью VRL.
Фильтрация Edge Functions
if .appname != "supabase-edge-functions" { abort }
Поле .appname выше сформировано из имени Docker-контейнера. abort прекращает обработку текущего события в ветке functions_logs: лог Kong, Postgres или Storage не попадёт в transform, предназначенный для Edge Functions.
Стабильное имя сервиса
.service = "functions"
Имя контейнера - инфраструктурная деталь, а service - стабильное доменное поле. В Grafana можно искать так:
service:functions
Даже если впоследствии контейнер получит другое имя, запросы к логам не нужно будет переписывать.
Безопасный JSON-парсинг
parsed, err = parse_json(.event_message) if err == null { .event_message = parsed.event_message ?? parsed.msg ?? .event_message .metadata = parsed.metadata ?? {} }
Логи Edge Functions не всегда имеют одинаковую форму: некоторые уже являются JSON, другие - простой текст. parse_json возвращает распарсенное значение и ошибку. Если это не JSON, err не равен null, transform сохраняет исходное сообщение и не ломает pipeline.
Оператор ?? означает fallback: взять значение слева, но если его нет, попробовать следующее. Здесь сообщение выбирается из event_message, затем msg, затем сохраняется исходная строка.
Почему нужен fallback
if is_null(.event_message) || .event_message == "" { .event_message = "empty log" }
В sink задано:
_msg_field=event_message
Значит VictoriaLogs ожидает, что в каждой записи есть непустой event_message. Если поле сообщения отсутствует, будет предупреждение missing _msg field.
Иногда полезнее отбрасывать пустые записи:
if is_null(.event_message) || .event_message == "" { abort }
Я оставил значение empty log, потому что при отладке Edge Functions полезно не терять факт технического события. Для шумных системных логов лучше использовать abort.
Нормализация уровня
.metadata.level = .metadata.level ?? "INFO" message = downcase(string!(.event_message)) if contains(message, "error") { .metadata.level = "ERROR" } else if contains(message, "warn") { .metadata.level = "WARN" } else if contains(message, "debug") { .metadata.level = "DEBUG" }
Если приложение уже прислало структурированный уровень, он используется. Если нет - применяется INFO, а затем простая эвристика по тексту. Такой подход не заменяет структурированные application logs, но делает обычные сообщения stdout/stderr удобнее для фильтрации.
Ошибка VRL E103
VRL не позволяет игнорировать ошибки потенциально опасных функций. Такие функции называются fallible: например, JSON может не распарситься, преобразование может получить значение неверного типа, а поле может отсутствовать. VRL требует явной обработки этих ошибок.
Проблемный код обычно выглядит так:
.message = strip_whitespace(parsed.msg)
Здесь parsed.msg может отсутствовать или не быть строкой. У меня похожее использование strip_whitespace(parsed.msg) и привело к ошибке E103.
Надёжнее сначала обработать parsing и тип:
parsed, err = parse_json(.event_message) if err == null && exists(parsed.msg) { value, trim_err = strip_whitespace(to_string(parsed.msg)) if trim_err == null { .event_message = value } }
Суффикс !, например в string!(.event_message), означает, что при ошибке обработка конкретного события завершится. Его стоит использовать там, где тип гарантирован предыдущими проверками или потеря одной записи приемлема.
Проверяем pipeline
После изменения vector.yml я сначала валидирую конфигурацию:
docker exec supabase-vector \ vector validate /etc/vector/vector.yml
После успешной проверки перезапускаю только Vector:
docker compose restart vector docker logs supabase-vector --tail=100 -f
На сервере мониторинга можно проверить, что записи действительно попадают в VictoriaLogs:
curl -G \ "http://127.0.0.1:9428/select/logsql/query" \ --data-urlencode "query=*" \ --data-urlencode "limit=20" | jq
VictoriaLogs поддерживает LogSQL-запросы через endpoint /select/logsql/query.
Для диагностики Edge Functions я использую запросы такого вида:
service:functions service:functions AND metadata.level:ERROR appname:supabase-edge-functions AND _msg:timeout
Основной текст лога в VictoriaLogs находится в _msg, а fields позволяет вывести нужные поля в результатах запроса.
Grafana
В Grafana я подключил два datasource:
VictoriaMetrics через Prometheus datasource - графики, PromQL и алерты.
VictoriaLogs datasource - Explore и поиск по логам.
В результате сценарий расследования выглядит так: сначала на графике видно падение probe_success у одного из сервисов, затем в Grafana Explore можно открыть логи нужного сервиса и отфильтровать ERROR за тот же интервал времени.
Результат
Получился самостоятельный контур наблюдаемости для self-hosted Supabase:
VictoriaMetrics хранит метрики хоста, PostgreSQL и health-check’ов.
VictoriaLogs хранит логи Supabase.
Vector собирает docker-логи, нормализует их через VRL и отправляет на отдельный сервер.
Nginx принимает ingestion по HTTPS и ограничивает доступ по IP.
Grafana объединяет метрики и логи в одном интерфейсе.
Главный вывод: Edge Function с HTTP-адаптером хорошо подходит, чтобы быстро проверить ingestion и формат сообщений. Но если инфраструктура уже Docker-ориентированная, а Vector входит в стек Supabase, удобнее оставить ему роль единого агента сбора и нормализации: меньше компонентов, меньше дополнительной логики и понятнее путь каждой записи от контейнера до Grafana.
