Observability для бедных: логи, метрики, трейсы без Datadog
Datadog — $23 за хост в месяц плюс $0.10 за миллион log-событий плюс APM на каждый сервис. Для стартапа с 20 сервисами это быстро превращается в $1 500–3 000/мес ещё до того, как вы написали первый production-баг. New Relic и Dynatrace не дешевле. Но observability — не опция, это обязательное условие работы в production. Разберём, как собрать полноценный стек за $20/мес на VPS.
Три столпа: что и зачем
Observability строится на трёх типах данных, и каждый решает свою задачу:
- Логи — что именно произошло. Структурированные события с контекстом: кто вызвал, с какими параметрами, что вернулось.
- Метрики — как часто и насколько быстро. Счётчики, гистограммы, gauge-значения. Идеальны для алертинга и трендов.
- Трейсы — путь запроса через систему. Распределённые системы без трейсов — это дебаггинг вслепую.
Ключевая проблема большинства команд: они используют один инструмент для всего. Grep по логам вместо трейсов, Prometheus без логов — получают неполную картину. Полноценная observability требует всех трёх.
Архитектура стека
Выбор компонентов прямолинеен: берём OpenTelemetry как vendor-neutral SDK для инструментации, Grafana Alloy как коллектор (пришёл на смену Grafana Agent в 2024), и три специализированных хранилища из экосистемы Grafana:
# Общая схема потока данных:
#
# App (Python/FastAPI)
# └─► OpenTelemetry SDK
# ├─► Alloy (collector, порт 4317 OTLP/gRPC)
# │ ├─► Loki (логи, порт 3100)
# │ ├─► Mimir (метрики, порт 9009)
# │ └─► Tempo (трейсы, порт 3200)
# └─► Grafana (визуализация, порт 3000)
# ├── datasource: Loki
# ├── datasource: Mimir (Prometheus-compatible)
# └── datasource: Tempo
Loki хранит логи как потоки с лейблами — не индексирует содержимое, что делает его в разы дешевле Elasticsearch. Mimir — горизонтально масштабируемое хранилище метрик, совместимое с Prometheus Remote Write. Tempo — distributed tracing backend с поддержкой OTLP, Jaeger и Zipkin форматов.
Docker Compose: поднимаем стек за 5 минут
# docker-compose.yml
version: "3.9"
volumes:
loki_data:
mimir_data:
tempo_data:
grafana_data:
networks:
obs:
driver: bridge
services:
alloy:
image: grafana/alloy:v1.3.1
volumes:
- ./config/alloy/config.alloy:/etc/alloy/config.alloy
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "12345:12345" # Alloy UI
command: run --server.http.listen-addr=0.0.0.0:12345 /etc/alloy/config.alloy
networks: [obs]
loki:
image: grafana/loki:3.2.0
volumes:
- loki_data:/loki
- ./config/loki/loki.yaml:/etc/loki/local-config.yaml
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
networks: [obs]
mimir:
image: grafana/mimir:2.13.0
volumes:
- mimir_data:/data
- ./config/mimir/mimir.yaml:/etc/mimir/mimir.yaml
ports:
- "9009:9009"
command: --config.file=/etc/mimir/mimir.yaml
networks: [obs]
tempo:
image: grafana/tempo:2.6.0
volumes:
- tempo_data:/var/tempo
- ./config/tempo/tempo.yaml:/etc/tempo.yaml
ports:
- "3200:3200"
command: -config.file=/etc/tempo.yaml
networks: [obs]
grafana:
image: grafana/grafana:11.3.0
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
volumes:
- grafana_data:/var/lib/grafana
- ./config/grafana/provisioning:/etc/grafana/provisioning
ports:
- "3000:3000"
depends_on: [loki, mimir, tempo]
networks: [obs]
Конфигурация Grafana Alloy
Alloy использует язык конфигурации River. Принимаем OTLP-данные и маршрутизируем по типу:
// config/alloy/config.alloy
// OTLP-приёмник (gRPC + HTTP)
otelcol.receiver.otlp "default" {
grpc {
endpoint = "0.0.0.0:4317"
}
http {
endpoint = "0.0.0.0:4318"
}
output {
metrics = [otelcol.exporter.prometheus.mimir.input]
logs = [otelcol.exporter.loki.default.input]
traces = [otelcol.exporter.otlp.tempo.input]
}
}
// Метрики → Mimir (Prometheus Remote Write)
otelcol.exporter.prometheus "mimir" {
forward_to = [prometheus.remote_write.mimir.receiver]
}
prometheus.remote_write "mimir" {
endpoint {
url = "http://mimir:9009/api/v1/push"
}
}
// Логи → Loki
otelcol.exporter.loki "default" {
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}
// Трейсы → Tempo
otelcol.exporter.otlp "tempo" {
client {
endpoint = "tempo:4317"
tls {
insecure = true
}
}
}
Инструментация Python / FastAPI
OpenTelemetry для Python даёт автоинструментацию ASGI-фреймворков, SQLAlchemy, httpx и redis одной строкой. Добавляем зависимости:
pip install \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-grpc \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-httpx
Настраиваем SDK и подключаем к FastAPI:
# telemetry.py
import logging
from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry._logs import set_logger_provider
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.sdk.resources import Resource
OTLP_ENDPOINT = "http://alloy:4317"
def setup_telemetry(service_name: str) -> None:
resource = Resource.create({
"service.name": service_name,
"service.version": "1.0.0",
"deployment.environment": "production",
})
# Трейсы
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=OTLP_ENDPOINT, insecure=True))
)
trace.set_tracer_provider(tracer_provider)
# Метрики
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=OTLP_ENDPOINT, insecure=True),
export_interval_millis=15_000,
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
# Логи через OTLP
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(OTLPLogExporter(endpoint=OTLP_ENDPOINT, insecure=True))
)
set_logger_provider(logger_provider)
handler = LoggingHandler(level=logging.INFO, logger_provider=logger_provider)
logging.getLogger().addHandler(handler)
logging.getLogger().setLevel(logging.INFO)
# main.py
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from telemetry import setup_telemetry
setup_telemetry("api-service")
app = FastAPI()
FastAPIInstrumentor.instrument_app(app)
SQLAlchemyInstrumentor().instrument()
# Кастомные метрики
from opentelemetry import metrics
meter = metrics.get_meter("api-service")
request_counter = meter.create_counter(
"http.requests.total",
description="Число HTTP-запросов",
)
processing_time = meter.create_histogram(
"task.processing.duration",
description="Время обработки задачи (сек)",
unit="s",
)
@app.get("/orders/{order_id}")
async def get_order(order_id: int):
import logging, time
log = logging.getLogger(__name__)
start = time.time()
request_counter.add(1, {"endpoint": "/orders", "method": "GET"})
# Бизнес-логика...
result = {"order_id": order_id, "status": "processing"}
processing_time.record(time.time() - start, {"endpoint": "/orders"})
log.info("Order fetched", extra={"order_id": order_id, "status": result["status"]})
return result
Grafana: провижининг дашбордов
Провижининг datasource'ов через YAML — никакого ручного кликанья в UI:
# config/grafana/provisioning/datasources/datasources.yaml
apiVersion: 1
datasources:
- name: Loki
type: loki
url: http://loki:3100
isDefault: false
jsonData:
maxLines: 1000
- name: Mimir
type: prometheus
url: http://mimir:9009/prometheus
isDefault: true
jsonData:
timeInterval: "15s"
- name: Tempo
type: tempo
url: http://tempo:3200
isDefault: false
jsonData:
tracesToLogsV2:
datasourceUid: loki
spanStartTimeShift: "-1h"
spanEndTimeShift: "1h"
filterByTraceID: true
serviceMap:
datasourceUid: mimir
nodeGraph:
enabled: true
Связка Tempo → Loki позволяет из трейса одним кликом прыгнуть в логи того же сервиса за тот же промежуток времени. Это убивает необходимость переключаться между инструментами.
Реальная стоимость
Весь стек работает на одном VPS с 4 vCPU / 8 GB RAM. Для нагрузки до 50 rps и retention 30 дней этого хватает.
# Примерное потребление ресурсов в idle:
# alloy: ~150 MB RAM, ~0.1 CPU
# loki: ~300 MB RAM, ~0.2 CPU
# mimir: ~400 MB RAM, ~0.3 CPU
# tempo: ~250 MB RAM, ~0.1 CPU
# grafana: ~200 MB RAM, ~0.1 CPU
# Итого: ~1.3 GB RAM, ~0.8 CPU
# Стоимость VPS (Hetzner CX21, 4 vCPU / 8 GB):
# ~6 EUR/мес ≈ $6.5
# + объектное хранилище для долгосрочного retention (опционально):
# Hetzner Object Storage: $5/мес за 1 TB
# Итого: ~$12–20/мес против $1500–3000/мес у Datadog
При росте нагрузки Loki, Mimir и Tempo поддерживают горизонтальное масштабирование и S3-compatible object storage в качестве бэкенда — архитектура не упирается в потолок.
Vendor lock-in в observability — это двойная ловушка: платите много сейчас и платите ещё больше за миграцию потом. OpenTelemetry SDK на стороне приложения и open-source хранилища снимают обе проблемы. $20/мес VPS даёт полноценные три столпа observability — с retention, алертингом и красивыми дашбордами.