Единый observability-стек: Prometheus + Loki + Grafana + Caddy auth-гейт
Deploy Observability / Deploy Observability Stack (push) Failing after 20s
Deploy / Deploy to Data VPS (push) Failing after 19s

Отдельный от db+vaultwarden compose-файл (разный жизненный цикл, свой
deploy-workflow) — единственное место сбора для backend, лендинга и
каждой прокси-ноды, которые физически живут на других серверах.

Prometheus скрейпит наружу (сам ходит в /metrics на тех хостах — там
нужно только открыть порт метрик для IP этого VPS). Loki, наоборот,
принимает пуши логов ИЗВНЕ — порт 3100 публикуется наружу и обязательно
фильтруется firewall'ом по IP тех хостов.

Grafana не торчит в интернет напрямую (127.0.0.1-only) — единственный
вход через Caddy с forward_auth, гейтящим по cookie-сессии
netrunner-backend (GET /api/v1/admin/observability/check,
Owner/Moderator). 3 provisioned дашборда (backend/landing/прокси-ноды).

Список scrape-таргетов для прокси-нод (observability/targets/nodes.json)
генерится и заливается автоматически из netrunner-proxy при каждом
деплое нод — вручную ничего не поддерживается.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 23:22:11 +07:00
parent f373601025
commit 414667062c
14 changed files with 698 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Гейтит доступ к Grafana через уже существующую cookie-сессию
# netrunner-backend (владелец/модератор) — Grafana сама наружу НЕ торчит
# (см. docker-compose.observability.yml, порт 3030 только на 127.0.0.1),
# только через этот Caddy.
#
# Как это работает: cookie сессии backend'а (nrxp_token) ставится с
# Domain=.netrunner-vpn.com (см. COOKIE_DOMAIN в netrunner-backend/.env) —
# значит браузер сам приложит её и к запросам на grafana.netrunner-vpn.com
# (тот же родительский домен, другой сабдомен — это same-site, не
# cross-site, для куки без атрибута Partitioned браузер это разрешает даже
# из iframe). forward_auth здесь пересылает эту cookie на реальный бэкенд
# (уже на другом VPS) и либо пускает дальше в Grafana (200), либо блокирует
# (401) — сам Caddy решения не принимает, только проксирует запрос-проверку.
#
# ЗАМЕНИТЬ ПЕРЕД ПЕРВЫМ ЗАПУСКОМ:
# - CHANGE_ME_GRAFANA_DOMAIN — сабдомен для Grafana (например
# grafana.netrunner-vpn.com), DNS A-запись на IP этого VPS.
# - CHANGE_ME_BACKEND_PUBLIC_URL — публичный URL бэкенда (например
# https://account.netrunner-vpn.com), КОГДА backend будет арендован и
# задеплоен — до этого auth-гейт нерабочий (пускать некому проверять).
CHANGE_ME_GRAFANA_DOMAIN {
forward_auth CHANGE_ME_BACKEND_PUBLIC_URL {
uri /api/v1/admin/observability/check
copy_headers Cookie
}
reverse_proxy grafana:3000
}
@@ -0,0 +1,17 @@
# Provisioning-провайдер для дашбордов — Grafana сама подхватывает любой
# .json, положенный в примонтированную папку grafana-dashboards/ (см.
# docker-compose.observability.yml), без ручного импорта через UI и без
# рестарта контейнера (updateIntervalSeconds перечитывает папку).
apiVersion: 1
providers:
- name: netrunner
orgId: 1
folder: "Netrunner"
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: false
options:
path: /etc/grafana/provisioning/dashboards/files
foldersFromFilesStructure: false
@@ -0,0 +1,108 @@
{
"uid": "netrunner-backend",
"title": "Netrunner — Backend",
"timezone": "utc",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"panels": [
{
"id": 1,
"title": "HTTP requests/sec (by status)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "sum(rate(http_requests_total{job=\"netrunner-backend\"}[5m])) by (status)",
"legendFormat": "{{status}}"
}
]
},
{
"id": 2,
"title": "HTTP latency p95/p50",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job=\"netrunner-backend\"}[5m])) by (le))",
"legendFormat": "p95"
},
{
"expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket{job=\"netrunner-backend\"}[5m])) by (le))",
"legendFormat": "p50"
}
]
},
{
"id": 3,
"title": "Errors/sec (by type)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "sum(rate(app_errors_total{job=\"netrunner-backend\"}[5m])) by (type)",
"legendFormat": "{{type}}"
}
]
},
{
"id": 4,
"title": "Payments/sec (by provider, status)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "sum(rate(payments_total{job=\"netrunner-backend\"}[5m])) by (provider, status)",
"legendFormat": "{{provider}}/{{status}}"
}
]
},
{
"id": 5,
"title": "VPN nodes online / total",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{ "expr": "nodes_online{job=\"netrunner-backend\"}", "legendFormat": "online" },
{ "expr": "nodes_total{job=\"netrunner-backend\"}", "legendFormat": "total" }
]
},
{
"id": 6,
"title": "Support tickets/hour (created vs closed)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "sum(rate(support_tickets_total{job=\"netrunner-backend\"}[1h])) by (status) * 3600",
"legendFormat": "{{status}}"
}
]
},
{
"id": 7,
"title": "Referral signups + partner commission",
"type": "timeseries",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "sum(rate(referral_signups_total{job=\"netrunner-backend\"}[1h])) * 3600",
"legendFormat": "referral signups/hour"
},
{
"expr": "sum(rate(partner_commission_total{job=\"netrunner-backend\"}[1h])) by (currency) * 3600",
"legendFormat": "commission/hour {{currency}}"
}
]
}
]
}
@@ -0,0 +1,71 @@
{
"uid": "netrunner-landing",
"title": "Netrunner — Landing",
"timezone": "utc",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"panels": [
{
"id": 1,
"title": "Next.js requests/sec (by status)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "sum(rate(http_requests_total{job=\"netrunner-landing\"}[5m])) by (status)",
"legendFormat": "{{status}}"
}
]
},
{
"id": 2,
"title": "ws-proxy (гостевая книга/спидтест) requests/sec",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "sum(rate(http_requests_total{job=\"netrunner-landing-ws-proxy\"}[5m])) by (path, status)",
"legendFormat": "{{path}} {{status}}"
}
]
},
{
"id": 3,
"title": "ws-proxy latency p95/p50",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job=\"netrunner-landing-ws-proxy\"}[5m])) by (le))",
"legendFormat": "p95"
},
{
"expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket{job=\"netrunner-landing-ws-proxy\"}[5m])) by (le))",
"legendFormat": "p50"
}
]
},
{
"id": 4,
"title": "Next.js process (CPU/memory)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "rate(process_cpu_seconds_total{job=\"netrunner-landing\"}[5m])",
"legendFormat": "CPU"
},
{
"expr": "process_resident_memory_bytes{job=\"netrunner-landing\"}",
"legendFormat": "RSS bytes"
}
]
}
]
}
@@ -0,0 +1,90 @@
{
"uid": "netrunner-proxy-nodes",
"title": "Netrunner — Прокси-ноды (ядро)",
"timezone": "utc",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"templating": {
"list": [
{
"name": "node",
"type": "query",
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"query": "label_values(netrunner_connections_active, node)",
"includeAll": true,
"multi": true,
"current": { "text": "All", "value": "$__all" },
"refresh": 2
}
]
},
"panels": [
{
"id": 1,
"title": "Активные соединения (по ноде)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "netrunner_connections_active{job=\"netrunner-proxy-nodes\", node=~\"$node\"}",
"legendFormat": "{{node}}"
}
]
},
{
"id": 2,
"title": "Новых соединений/сек (по ноде)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "sum(rate(netrunner_connections_total{job=\"netrunner-proxy-nodes\", node=~\"$node\"}[5m])) by (node)",
"legendFormat": "{{node}}"
}
]
},
{
"id": 3,
"title": "Auth validate latency p95 (по ноде)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(netrunner_auth_validate_duration_seconds_bucket{job=\"netrunner-proxy-nodes\", node=~\"$node\"}[5m])) by (le, node))",
"legendFormat": "{{node}}"
}
]
},
{
"id": 4,
"title": "Auth failures/sec (по ноде)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "sum(rate(netrunner_auth_failures_total{job=\"netrunner-proxy-nodes\", node=~\"$node\"}[5m])) by (node)",
"legendFormat": "{{node}}"
}
]
},
{
"id": 5,
"title": "Circuit breaker открыт (0/1, по ноде)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "netrunner_circuit_breaker_open{job=\"netrunner-proxy-nodes\", node=~\"$node\"}",
"legendFormat": "{{node}}"
}
]
}
]
}
+17
View File
@@ -0,0 +1,17 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
version: 1
editable: true
- name: Loki
type: loki
access: proxy
url: http://loki:3100
version: 1
editable: true
+37
View File
@@ -0,0 +1,37 @@
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
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
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
ruler:
alertmanager_url: http://localhost:9093
+41
View File
@@ -0,0 +1,41 @@
# Скрейп-конфиг центрального Prometheus (стоит на этом же VPS, рядом с
# Postgres/Vaultwarden — см. docker-compose.observability.yml).
#
# Прокси-ноды (netrunner-proxy-nodes) — уже реальный динамический список:
# targets/nodes.json генерится и заливается сюда автоматически при каждом
# деплое нод (netrunner-proxy/scripts/generate-prometheus-targets.mjs,
# .gitea/workflows/deploy.yml::update-observability-targets).
#
# backend/landing уже инструментированы (/metrics — см. src/api.rs в
# netrunner-backend, app/api/metrics/route.ts + proxy-ws/src/main.rs в
# netrunner-landing) — таргеты ниже закомментированы только потому, что
# backend ещё не арендован (нет реального хоста), а для landing нужно
# подставить актуальный IP её VPS. Раскомментировать и подставить реальные
# значения, как только они известны — рестарт контейнера НЕ нужен для
# file_sd_configs, но ДЛЯ static_configs (эти два таргета) правки в
# prometheus.yml требуют `docker compose restart prometheus` на этом VPS.
global:
scrape_interval: 15s
scrape_configs:
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]
# Прокси-ноды: динамический список, file_sd_configs перечитывается
# автоматически при изменении файла, рестарт Prometheus не нужен.
- job_name: "netrunner-proxy-nodes"
file_sd_configs:
- files:
- "/etc/prometheus/targets/nodes.json"
refresh_interval: 30s
# - job_name: "netrunner-backend"
# static_configs:
# - targets: ["CHANGE_ME_BACKEND_HOST:8080"]
# - job_name: "netrunner-landing"
# static_configs:
# - targets: ["CHANGE_ME_LANDING_HOST:3000"] # Next.js /api/metrics
# - job_name: "netrunner-landing-ws-proxy"
# static_configs:
# - targets: ["CHANGE_ME_LANDING_HOST:3001"] # proxy-ws /metrics
+38
View File
@@ -0,0 +1,38 @@
# Собирает докер-логи ТОЛЬКО этого хоста (Postgres/Vaultwarden/backup из
# netrunner-data/docker-compose.yml + prometheus/loki/grafana этого же
# compose — docker_sd_configs видит все контейнеры хоста через общий
# docker.sock, не только контейнеры этого конкретного compose-проекта).
#
# Логи с backend/landing/каждой прокси-ноды приходят СЮДА ЖЕ по сети —
# на каждом из тех хостов свой отдельный тонкий promtail, пушащий прямо в
# Loki на этом VPS (см. netrunner-backend/promtail-config.yml после
# переноса — `clients.url` там указывает на публичный адрес этого хоста).
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker_service_logs
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: ["__meta_docker_container_name"]
regex: "/(.*)"
target_label: "container"
pipeline_stages:
- docker: {}
- json:
expressions:
level: level
module: target
message: message
- labels:
level:
module:
+1
View File
@@ -0,0 +1 @@
[]