Observability с нуля: метрики, логи и трейсы для финтех-платформы

Исходная проблема: слепые зоны в финтех-системе

Финтех-компания «ФинМониторинг» разрабатывает платформу для автоматизации финансового контроля. 14 микросервисов на Go и Python, Kubernetes-кластер на 28 нод, PostgreSQL и Redis. Ежедневно — 3 миллиона транзакций.

Проблемы, с которыми они пришли:

  • Инциденты без причин — клиенты жаловались на ошибки, но команда не могла определить, в каком сервисе проблема. Среднее время расследования (MTTR) — 4 часа.
  • Нет корреляции — метрики, логи и трейсы жили в разных системах без связи. Инженер переключался между Zabbix, grep по SSH и ничем для трейсинга.
  • PCI DSS audit trail — регулятор требовал полную цепочку аудита каждой транзакции. Текущая система логирования не позволяла восстановить путь запроса через все сервисы.
  • Нет SLO — команда не определила, что считать «нормальной работой». Алертов было 200+ в день, 95% из них — шум.

Мы построили единый стек observability на базе Grafana LGTM (Loki, Grafana, Tempo, Mimir) за 4 недели.

Три столпа observability

Observability строится на трёх типах телеметрии. Каждый отвечает на свой вопрос:

  • Метрики (Prometheus/Mimir) — «что происходит?» Числовые показатели: RPS, латентность, ошибки, потребление ресурсов. Агрегированные, с малым объёмом хранения.
  • Логи (Loki) — «почему это произошло?» Детальные записи событий. Большой объём, но можно фильтровать по лейблам и полнотекстово.
  • Трейсы (Tempo) — «как это произошло?» Цепочка вызовов одного запроса через все сервисы. Позволяет увидеть, где запрос «застрял» и сколько времени провёл в каждом сервисе.

Ключевая идея — корреляция. Из дашборда с метриками можно перейти к логам конкретного сервиса за нужный период, а из лога — к полному трейсу запроса. Для этого нужен единый идентификатор — trace_id, который пронизывает все три столпа.

Метрики: Prometheus + Grafana Mimir

Prometheus — стандарт де-факто для метрик в Kubernetes. Мы развернули его через kube-prometheus-stack (Helm chart), который включает Prometheus Operator, Grafana и набор стандартных дашбордов.

# Установка kube-prometheus-stack
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

helm install monitoring prometheus-community/kube-prometheus-stack \
  --namespace observability --create-namespace \
  --values values-prometheus.yaml

Кастомные метрики в Go-сервисе (RED — Rate, Errors, Duration):

package metrics

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    // Rate — количество запросов
    RequestsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total HTTP requests",
        },
        []string{"method", "path", "status"},
    )

    // Duration — время обработки
    RequestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP request duration in seconds",
            Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
        },
        []string{"method", "path"},
    )

    // Errors — бизнес-ошибки
    BusinessErrors = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "business_errors_total",
            Help: "Business logic errors",
        },
        []string{"service", "error_type"},
    )
)

// Middleware для автоматического сбора метрик
func MetricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
        next.ServeHTTP(wrapped, r)
        duration := time.Since(start).Seconds()

        RequestsTotal.WithLabelValues(
            r.Method, r.URL.Path, strconv.Itoa(wrapped.statusCode),
        ).Inc()
        RequestDuration.WithLabelValues(
            r.Method, r.URL.Path,
        ).Observe(duration)
    })
}

Для долгосрочного хранения метрик (PCI DSS требует 12 месяцев) мы развернули Grafana Mimir — горизонтально масштабируемое хранилище, совместимое с Prometheus remote_write.

Логи: Loki + Promtail

Loki — это «Prometheus для логов». В отличие от ELK, Loki не индексирует содержимое логов, а только лейблы (namespace, pod, container). Это даёт 10-кратную экономию на хранении и значительно проще в эксплуатации.

# Установка Loki через Helm
helm install loki grafana/loki-stack \
  --namespace observability \
  --set promtail.enabled=true \
  --set loki.persistence.enabled=true \
  --set loki.persistence.size=100Gi

Все сервисы пишут структурированные JSON-логи с обязательным набором полей:

// Логгер с обязательными полями для observability
func NewLogger(serviceName string) *slog.Logger {
    return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    })).With(
        slog.String("service", serviceName),
        slog.String("version", version.Current),
    )
}

// Использование с trace_id из контекста
func (s *TransactionService) Process(ctx context.Context, tx *Transaction) error {
    traceID := trace.SpanFromContext(ctx).SpanContext().TraceID().String()
    s.log.InfoContext(ctx, "processing transaction",
        slog.String("trace_id", traceID),
        slog.String("tx_id", tx.ID),
        slog.Float64("amount", tx.Amount),
        slog.String("currency", tx.Currency),
    )
    // ...
}

Пример запроса в LogQL — найти все ошибки по конкретной транзакции:

# LogQL: фильтрация по лейблам и содержимому
{namespace="production", container="transaction-service"} |= "error" | json | tx_id="TX-20260405-1234"

В Grafana мы настроили Derived Fields — клик по trace_id в логах автоматически открывает соответствующий трейс в Tempo.

Трейсы: Tempo + OpenTelemetry

Tempo — бэкенд для трейсов от Grafana, совместимый с Jaeger, Zipkin и OpenTelemetry. Мы выбрали OpenTelemetry SDK как единый стандарт инструментации.

# Установка Tempo
helm install tempo grafana/tempo \
  --namespace observability \
  --set tempo.storage.trace.backend=s3 \
  --set tempo.storage.trace.s3.bucket=finmon-traces

Инициализация OpenTelemetry в Go-сервисе:

package tracing

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)

func InitTracer(serviceName string) (*sdktrace.TracerProvider, error) {
    exporter, err := otlptracegrpc.New(context.Background(),
        otlptracegrpc.WithEndpoint("tempo-distributor.observability:4317"),
        otlptracegrpc.WithInsecure(),
    )
    if err != nil {
        return nil, err
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceNameKey.String(serviceName),
            semconv.DeploymentEnvironmentKey.String("production"),
        )),
        // Сэмплирование: 100% ошибок, 10% успешных
        sdktrace.WithSampler(sdktrace.ParentBased(
            sdktrace.TraceIDRatioBased(0.1),
        )),
    )

    otel.SetTracerProvider(tp)
    return tp, nil
}

// Создание спана для бизнес-операции
func (s *RiskService) AnalyzeTransaction(ctx context.Context, tx *Transaction) (*RiskResult, error) {
    ctx, span := otel.Tracer("risk-service").Start(ctx, "AnalyzeTransaction",
        trace.WithAttributes(
            attribute.String("tx.id", tx.ID),
            attribute.Float64("tx.amount", tx.Amount),
            attribute.String("tx.type", tx.Type),
        ),
    )
    defer span.End()

    // Вложенный спан для вызова ML-модели
    ctx, mlSpan := otel.Tracer("risk-service").Start(ctx, "ml-model-predict")
    score, err := s.mlClient.Predict(ctx, tx)
    mlSpan.End()

    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, "ML prediction failed")
        return nil, err
    }

    span.SetAttributes(attribute.Float64("risk.score", score))
    return &RiskResult{Score: score}, nil
}

В Grafana трейс отображается как waterfall-диаграмма: видно, что запрос прошёл через api-gateway → transaction-service → risk-service → ml-predictor → compliance-checker, и на каком этапе была задержка.

SLO/SLI: определяем «нормальную работу»

Без SLO (Service Level Objectives) алертинг превращается в шум. Мы определили SLI (индикаторы) и SLO (целевые значения) для каждого критичного сервиса совместно с бизнесом:

СервисSLISLOError Budget
API Gateway% успешных запросов (non-5xx)99.9%43.2 мин/мес
Transaction ServiceP99 латентность< 500 мс
Transaction Service% обработанных без ошибок99.95%21.6 мин/мес
Risk Service% ответов за < 200 мс99%7.2 ч/мес
Compliance Checker% проверок завершённых100%0

Реализация SLO-мониторинга в Prometheus:

# /etc/prometheus/rules/slo.yml
groups:
  - name: slo_transaction_service
    rules:
      # SLI: доля успешных запросов за 30 дней
      - record: sli:transaction_success_rate:30d
        expr: |
          1 - (
            sum(rate(http_requests_total{service="transaction-service",status=~"5.."}[30d]))
            /
            sum(rate(http_requests_total{service="transaction-service"}[30d]))
          )

      # Error budget: сколько ошибок ещё можно допустить
      - record: error_budget:transaction_service:remaining
        expr: |
          1 - (
            (1 - sli:transaction_success_rate:30d)
            /
            (1 - 0.9995)  # SLO = 99.95%
          )

      # Алерт: error budget израсходован на 80%
      - alert: TransactionErrorBudgetBurning
        expr: error_budget:transaction_service:remaining < 0.2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Error budget Transaction Service осталось {{ $value | humanizePercentage }}"

Результат: вместо 200+ алертов в день команда получает 3-5 значимых уведомлений, каждое из которых привязано к бизнес-метрике.

Дашборды для разных ролей

Один дашборд для всех — плохая идея. Разработчику нужны трейсы и стеки, SRE — SLO и error budget, бизнесу — количество обработанных транзакций и конверсия. Мы создали три уровня дашбордов в Grafana:

Бизнес-дашборд (для руководства):

  • Количество обработанных транзакций за день/неделю/месяц
  • Суммарный объём транзакций в рублях
  • SLA compliance (% времени доступности)
  • Среднее время обработки транзакции

SRE-дашборд (для операционной команды):

  • SLO-статус каждого сервиса с error budget
  • RED-метрики: Rate, Errors, Duration по каждому сервису
  • USE-метрики: CPU, RAM, диск, сеть по каждой ноде
  • Статус Kubernetes: количество подов, рестарты, OOM-kills

Dev-дашборд (для разработчиков):

  • Топ-10 самых медленных эндпоинтов
  • Трейсы с ошибками за последний час
  • Логи конкретного сервиса с фильтрацией
  • Database query latency (pg_stat_statements через postgres_exporter)
# Grafana provisioning — автоматическое создание data sources
# /etc/grafana/provisioning/datasources/datasources.yaml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    url: http://prometheus-server:9090
    isDefault: true

  - name: Loki
    type: loki
    url: http://loki:3100
    jsonData:
      derivedFields:
        - datasourceUid: tempo
          matcherRegex: '"trace_id":"(\w+)"'
          name: TraceID
          url: '$${__value.raw}'

  - name: Tempo
    type: tempo
    uid: tempo
    url: http://tempo:3200
    jsonData:
      tracesToLogs:
        datasourceUid: loki
        tags: ['service']
        filterByTraceID: true

Корреляция в действии: инженер видит всплеск ошибок на дашборде метрик → кликает на точку графика → Grafana показывает логи за этот период → в логе виден trace_id → клик по trace_id открывает полный трейс запроса в Tempo. Время расследования инцидента сократилось с 4 часов до 15 минут. Подробнее о построении систем мониторинга — на itfresh.ru.

Стратегия алертинга

Алертинг — самая недооценённая часть observability. Плохие алерты хуже, чем их отсутствие: команда привыкает к шуму и пропускает реальные проблемы.

Наши правила:

  • Алерт на симптом, не на причину. Алерт «P99 латентность > 500 мс» — полезен. Алерт «CPU > 80%» — шум (может быть нормальной нагрузкой).
  • Три уровня severity: critical (Telegram + звонок дежурному, реагирование за 5 минут), warning (Telegram-канал, реагирование за 1 час), info (только в Grafana, для контекста).
  • Error budget-based алерты — алертим не когда что-то сломалось, а когда приближаемся к нарушению SLO.
  • Runbook для каждого алерта — в аннотации алерта ссылка на wiki-страницу с пошаговой инструкцией по расследованию.
# Alertmanager routing
# /etc/alertmanager/alertmanager.yml
route:
  receiver: 'default'
  group_by: ['alertname', 'service']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - match:
        severity: critical
      receiver: 'telegram-oncall'
      repeat_interval: 15m
    - match:
        severity: warning
      receiver: 'telegram-channel'
      repeat_interval: 2h

receivers:
  - name: 'telegram-oncall'
    telegram_configs:
      - bot_token: '${TG_BOT_TOKEN}'
        chat_id: -100123456789
        parse_mode: 'HTML'
        message: |
          🚨 {{ .GroupLabels.alertname }}
          Сервис: {{ .GroupLabels.service }}
          {{ range .Alerts }}
          {{ .Annotations.summary }}
          Runbook: {{ .Annotations.runbook_url }}
          {{ end }}

  - name: 'telegram-channel'
    telegram_configs:
      - bot_token: '${TG_BOT_TOKEN}'
        chat_id: -100987654321
        parse_mode: 'HTML'

Часто задаваемые вопросы

Loki не индексирует содержимое логов, а только лейблы (namespace, pod, container). Это даёт 10-кратную экономию на хранении и CPU по сравнению с Elasticsearch. Для финтех-объёмов (500 ГБ логов/день) ELK потребовал бы кластер из 12 нод, Loki справляется на 3. Плюс — нативная интеграция с Grafana и Prometheus.
Через единый trace_id. OpenTelemetry SDK генерирует trace_id для каждого входящего запроса и передаёт его между сервисами через HTTP-заголовок traceparent. Каждый лог-записи включает trace_id. В Grafana настраиваются Derived Fields (Loki→Tempo) и tracesToLogs (Tempo→Loki), позволяя переходить между столпами одним кликом.
Зависит от объёма трафика и бюджета на хранение. Мы используем parent-based sampling: 10% успешных запросов и 100% запросов с ошибками. Для 3 млн транзакций/день это ~300K трейсов. При необходимости можно увеличить сэмплирование для конкретного сервиса через head-based sampler.
Error Budget — это допустимое количество ошибок за период, вычисляемое из SLO. Если SLO = 99.95% доступности за 30 дней, Error Budget = 0.05% × 30 × 24 × 60 = 21.6 минут. Пока budget не израсходован, команда может деплоить и экспериментировать. Когда budget на исходе — фокус на стабильности.
PCI DSS (Requirement 10) требует логирования всех действий с карточными данными, хранения логов 12 месяцев, централизованного сбора с защитой от модификации и регулярного review. Стек Prometheus + Loki + Tempo покрывает эти требования при правильной настройке retention и RBAC в Grafana.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#observability#Prometheus#Grafana#Loki#Tempo#OpenTelemetry#трейсинг#метрики
Комментарии 0

Оставить комментарий

загрузка...