Как мы построили мониторинг на 500 серверов с нуля

Исходный хаос

Типичный сценарий до нашего прихода:

  1. Пользователь пишет в Twitter: «ВидеоСтрим опять лежит!!!»
  2. SMM-менеджер пересылает в чат разработчиков
  3. Дежурный (если он есть) заходит на серверы по SSH и начинает смотреть top и tail -f /var/log/syslog
  4. Через 2-3 часа находят причину: диск переполнен / OOM killer убил процесс / сертификат истёк
  5. Чинят руками. Через неделю всё повторяется

Средний MTTR (Mean Time To Recovery): 3.5 часа. Инцидентов в месяц: 20+. Отток пользователей из-за нестабильности: 8% в месяц.

Три столпа observability

Мы строили систему на трёх столпах: метрики, логи, трейсы. Каждый отвечает на свой вопрос:

  • Метрики (Prometheus) — «Что сломалось?» Числовые данные: CPU, memory, latency, error rate
  • Логи (Loki) — «Почему сломалось?» Текстовые события с контекстом
  • Трейсы (Jaeger) — «Где именно сломалось?» Путь запроса через сервисы

Prometheus + Grafana: метрики

Prometheus — стандарт де-факто. Но на 500 серверах с 2 млн time series нужна продуманная архитектура:

# prometheus/prometheus.yml — federated setup
global:
  scrape_interval: 15s
  evaluation_interval: 15s
  external_labels:
    cluster: 'videostream-prod'
    region: 'ru-central'

# Sharding: каждый Prometheus скрейпит свой сегмент
# Этот инстанс отвечает за CDN-серверы (200 нод)
scrape_configs:
  - job_name: 'node-exporter'
    file_sd_configs:
      - files:
          - '/etc/prometheus/targets/cdn-nodes.json'
        refresh_interval: 30s
    relabel_configs:
      - source_labels: [__address__]
        target_label: instance
        regex: '(.+):9100'
        replacement: '${1}'

  - job_name: 'nginx-vts'
    metrics_path: /status/format/prometheus
    file_sd_configs:
      - files:
          - '/etc/prometheus/targets/cdn-nginx.json'

  - job_name: 'ffmpeg-transcoder'
    static_configs:
      - targets: ['transcoder-01:9090', 'transcoder-02:9090']
    metric_relabel_configs:
      # Отбрасываем слишком кардинальные метрики
      - source_labels: [__name__]
        regex: 'ffmpeg_frame_.*'
        action: drop

# Remote write в Thanos для долгосрочного хранения
remote_write:
  - url: "http://thanos-receive:19291/api/v1/receive"
    queue_config:
      max_samples_per_send: 5000
      batch_send_deadline: 5s
      max_shards: 30

Для 500 серверов один Prometheus не справится. Мы использовали шардинг: 3 инстанса Prometheus (CDN, транскодинг, API/storage), объединённые через Thanos Query для единого интерфейса:

# docker-compose.thanos.yml
services:
  thanos-query:
    image: thanosio/thanos:v0.34.0
    command:
      - query
      - --http-address=0.0.0.0:9090
      - --store=prometheus-cdn:10901
      - --store=prometheus-transcode:10901
      - --store=prometheus-api:10901
      - --store=thanos-store:10901  # Долгосрочное хранение
      - --query.auto-downsampling
    ports:
      - "9090:9090"

  thanos-store:
    image: thanosio/thanos:v0.34.0
    command:
      - store
      - --data-dir=/data
      - --objstore.config-file=/etc/thanos/s3.yml
      - --index-cache-size=2GB
      - --chunk-pool-size=4GB
    volumes:
      - thanos-store-data:/data

  thanos-compactor:
    image: thanosio/thanos:v0.34.0
    command:
      - compact
      - --data-dir=/data
      - --objstore.config-file=/etc/thanos/s3.yml
      - --retention.resolution-raw=30d
      - --retention.resolution-5m=180d
      - --retention.resolution-1h=365d
      - --wait

Loki: агрегация логов

500 серверов генерируют ~50 ГБ логов в день. ELK-стек был бы дорогим и тяжёлым. Loki от Grafana Labs — это «Prometheus для логов»: индексирует только метки, тело лога хранит сжатым.

# loki/loki-config.yaml
auth_enabled: false

server:
  http_listen_port: 3100

schema_config:
  configs:
    - from: 2025-01-01
      store: tsdb
      object_store: s3
      schema: v13
      index:
        prefix: loki_index_
        period: 24h

storage_config:
  tsdb_shipper:
    active_index_directory: /data/loki/index
    cache_location: /data/loki/cache
  aws:
    s3: s3://videostream-logs/loki/
    region: ru-central1

limits_config:
  ingestion_rate_mb: 100
  ingestion_burst_size_mb: 200
  max_query_parallelism: 32
  max_streams_per_user: 50000
  retention_period: 30d

compactor:
  working_directory: /data/loki/compactor
  compaction_interval: 1h
  retention_enabled: true
  retention_delete_delay: 2h

На каждом сервере работает Promtail — агент сбора логов:

# promtail/config.yaml
server:
  http_listen_port: 9080

positions:
  filename: /var/lib/promtail/positions.yaml

clients:
  - url: http://loki-gateway:3100/loki/api/v1/push

scrape_configs:
  - job_name: system
    static_configs:
      - targets: [localhost]
        labels:
          job: syslog
          host: ${HOSTNAME}
          __path__: /var/log/syslog

  - job_name: nginx
    static_configs:
      - targets: [localhost]
        labels:
          job: nginx
          host: ${HOSTNAME}
          __path__: /var/log/nginx/access.log
    pipeline_stages:
      - regex:
          expression: '^(?P<remote_addr>[\w.]+) .* \[(?P<timestamp>.+)\] "(?P<method>\w+) (?P<path>[^ ]+) .+" (?P<status>\d+) (?P<bytes>\d+) .+ (?P<duration>[\d.]+)$'
      - labels:
          method:
          status:
      - metrics:
          nginx_request_duration:
            type: Histogram
            description: "Request duration from logs"
            source: duration
            config:
              buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5]

  - job_name: application
    static_configs:
      - targets: [localhost]
        labels:
          job: app
          host: ${HOSTNAME}
          __path__: /var/log/videostream/*.log
    pipeline_stages:
      - json:
          expressions:
            level: level
            trace_id: trace_id
            message: msg
      - labels:
          level:
      - output:
          source: message

Jaeger: распределённые трейсы

Запрос на воспроизведение видео проходит через 7 сервисов: API Gateway, Auth, Content Service, DRM, CDN Selector, Edge Server, Analytics. Без трейсинга локализация проблемы — гадание на кофейной гуще.

# Инструментация Python-сервиса (OpenTelemetry)
# tracing/setup.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.sdk.resources import Resource

def setup_tracing(service_name: str):
    resource = Resource.create({
        "service.name": service_name,
        "service.version": os.environ.get("APP_VERSION", "unknown"),
        "deployment.environment": os.environ.get("ENV", "production"),
    })

    provider = TracerProvider(resource=resource)
    exporter = OTLPSpanExporter(
        endpoint="http://otel-collector:4317",
        insecure=True,
    )
    provider.add_span_processor(BatchSpanExporter(exporter))
    trace.set_tracer_provider(provider)

    # Автоинструментация популярных библиотек
    FastAPIInstrumentor.instrument()
    HTTPXClientInstrumentor().instrument()
    Psycopg2Instrumentor().instrument()
    RedisInstrumentor().instrument()

    return trace.get_tracer(service_name)


# Пример использования в сервисе
tracer = setup_tracing("content-service")

@app.get("/api/v1/stream/{video_id}")
async def get_stream_url(video_id: str, request: Request):
    with tracer.start_as_current_span("get_stream_url") as span:
        span.set_attribute("video.id", video_id)
        span.set_attribute("user.id", request.state.user_id)

        # Проверка DRM-лицензии
        with tracer.start_as_current_span("check_drm_license"):
            license = await drm_client.check_license(
                video_id, request.state.user_id
            )
            span.set_attribute("drm.license_type", license.type)

        # Выбор CDN-ноды
        with tracer.start_as_current_span("select_cdn_node"):
            cdn_node = await cdn_selector.get_nearest(
                request.client.host, video_id
            )
            span.set_attribute("cdn.node", cdn_node.hostname)
            span.set_attribute("cdn.region", cdn_node.region)

        return {"stream_url": cdn_node.build_url(video_id, license)}

Стратегия алертинга: борьба с alert fatigue

Самая частая ошибка — завалить дежурного алертами. Если звонят 50 раз за ночь, через неделю дежурный начнёт игнорировать все алерты. Наши принципы:

  • Алерт = действие. Если получил алерт, но ничего не делаешь — алерт не нужен
  • Severity-уровни: critical (будит ночью), warning (до утра), info (дашборд)
  • Группировка: 50 серверов с OOM — один алерт, не 50
  • Подавление шума: если инфраструктурный алерт сработал — подавляем алерты от зависимых сервисов
# alertmanager/alertmanager.yml
global:
  resolve_timeout: 5m

inhibit_rules:
  # Если кластер недоступен — подавляем алерты от сервисов в этом кластере
  - source_matchers:
      - alertname = "NodeDown"
    target_matchers:
      - severity =~ "warning|critical"
    equal: ['cluster']

  # Если всё хранилище сдохло — не алертим на отдельные диски
  - source_matchers:
      - alertname = "StorageClusterDown"
    target_matchers:
      - alertname = "DiskSpaceLow"

route:
  receiver: 'default-telegram'
  group_by: ['alertname', 'cluster']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h

  routes:
    # Critical — звоним через PagerDuty
    - match:
        severity: critical
      receiver: 'pagerduty-oncall'
      repeat_interval: 15m
      continue: true

    # Warning — Telegram группа ops
    - match:
        severity: warning
      receiver: 'telegram-ops'
      repeat_interval: 2h

    # Бизнес-метрики — отдельный канал
    - match:
        team: business
      receiver: 'telegram-business'

receivers:
  - name: 'pagerduty-oncall'
    pagerduty_configs:
      - service_key: '<PD_SERVICE_KEY>'
        severity: '{{ .CommonLabels.severity }}'
        description: '{{ .CommonAnnotations.summary }}'

  - name: 'telegram-ops'
    webhook_configs:
      - url: 'http://alertmanager-bot:8080/alerts'

  - name: 'telegram-business'
    webhook_configs:
      - url: 'http://alertmanager-bot:8080/alerts?chat_id=-100123456'

  - name: 'default-telegram'
    webhook_configs:
      - url: 'http://alertmanager-bot:8080/alerts'

SLO/SLI: определяем «что такое хорошо»

Мы определили Service Level Objectives для ключевых пользовательских сценариев:

# alerting/slo-rules.yml — Prometheus recording + alerting rules
groups:
  - name: slo-video-playback
    rules:
      # SLI: доля успешных стартов воспроизведения
      - record: sli:video_start_success:ratio_rate5m
        expr: |
          sum(rate(video_start_total{status="success"}[5m]))
          /
          sum(rate(video_start_total[5m]))

      # SLI: время до первого кадра < 3 секунд
      - record: sli:video_ttfb_under_3s:ratio_rate5m
        expr: |
          sum(rate(video_ttfb_seconds_bucket{le="3.0"}[5m]))
          /
          sum(rate(video_ttfb_seconds_count[5m]))

      # SLO: 99.5% успешных стартов
      - alert: SLO_VideoStartBudgetBurning
        expr: |
          sli:video_start_success:ratio_rate5m < 0.995
        for: 5m
        labels:
          severity: critical
          team: streaming
        annotations:
          summary: "Video start SLO at risk: {{ $value | humanizePercentage }}"
          description: "SLO target: 99.5%. Current: {{ $value | humanizePercentage }}. Error budget burning fast."
          runbook: "https://wiki.internal/runbooks/video-start-failures"

      # SLO: 95% стартов за менее 3 секунд
      - alert: SLO_VideoTTFBDegraded
        expr: |
          sli:video_ttfb_under_3s:ratio_rate5m < 0.95
        for: 10m
        labels:
          severity: warning
          team: cdn
        annotations:
          summary: "Video TTFB SLO degraded: {{ $value | humanizePercentage }} under 3s"
          runbook: "https://wiki.internal/runbooks/ttfb-degradation"

  - name: slo-api
    rules:
      # API availability SLI
      - record: sli:api_availability:ratio_rate5m
        expr: |
          1 - (
            sum(rate(http_requests_total{code=~"5.."}[5m]))
            /
            sum(rate(http_requests_total[5m]))
          )

      # API SLO: 99.9% availability
      - alert: SLO_APIAvailabilityBurning
        expr: sli:api_availability:ratio_rate5m < 0.999
        for: 5m
        labels:
          severity: critical

Бизнес-дашборды

Инфраструктурные метрики важны для инженеров, но бизнесу нужны свои дашборды. Мы создали дашборды в Grafana:

  • Revenue dashboard: просмотры, подписки, конверсия — в реальном времени
  • Content dashboard: популярность контента, буферизация по регионам
  • CDN dashboard: cache hit ratio, bandwidth по нодам, географическое распределение
  • Capacity planning: прогноз роста на основе тренда метрик

On-call ротация и процесс реагирования

Мы внедрили структурированный on-call:

# oncall/rotation.yaml — PagerDuty-style ротация
rotations:
  primary:
    schedule: weekly  # Понедельник 10:00 — Понедельник 10:00
    members:
      - aleksey    # Неделя 1
      - marina     # Неделя 2
      - dmitry     # Неделя 3
      - elena      # Неделя 4
    handoff:
      time: "10:00 MSK"
      day: monday
      checklist:
        - "Проверить open incidents"
        - "Обновить статус-страницу"
        - "Передать контекст по текущим проблемам"

  secondary:
    schedule: weekly
    members:
      - ivan
      - olga
      - sergey
      - anna
    escalation_delay: 15m  # Если primary не ответил

incident_process:
  severities:
    SEV1:
      description: "Полная недоступность сервиса"
      response_time: 5m
      communication: "Каждые 15 минут в #incidents"
      postmortem: required_within_48h
    SEV2:
      description: "Деградация для >10% пользователей"
      response_time: 15m
      communication: "Каждые 30 минут"
      postmortem: required_within_1_week
    SEV3:
      description: "Минорная деградация или внутренний сервис"
      response_time: 1h
      communication: "По завершении"
      postmortem: optional

Результаты за 4 месяца

МетрикаДоПосле
MTTR (Mean Time To Recovery)3.5 часа25 минут (-88%)
MTTD (Mean Time To Detect)45 минут (Twitter)2 минуты (алерт)
Инцидентов в месяц20+4-5
Uptime98.5%99.92%
Алертов на дежурного за ночьN/A (не было)0-2 actionable
Отток пользователей8%/мес2.1%/мес
Время локализации проблемы1-2 часа5-10 минут

Ключевые уроки

  1. Начните с метрик, потом логи, потом трейсы. Не пытайтесь внедрить всё сразу — каждый уровень добавляет ценность по нарастающей.
  2. Алерты — это код. Ревьюйте алерты как код, тестируйте их, рефакторьте. Плохой алерт хуже его отсутствия.
  3. SLO должны определять бизнес и инженеры вместе. «99.99% uptime» ничего не значит, если пользователи ждут буферизацию 10 секунд.
  4. On-call без runbook-ов — это стресс. Для каждого алерта должна быть инструкция: что проверить, как починить, когда эскалировать.
  5. Инвестируйте в postmortem-культуру. Blameless postmortem после каждого SEV1/SEV2 — главный инструмент снижения MTTR.

Мониторинг — это не проект с конечной датой, а процесс. Каждый инцидент — это возможность улучшить и дашборды, и алерты, и runbook-и. Через полгода ваша система наблюдаемости будет отражать реальные проблемы, а не абстрактные пороги.

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

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

📞 Связаться с нами
#alert#capacity planning:#cdn dashboard:#content dashboard:#devops#fatigue#grafana#jaeger
Комментарии 0

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

загрузка...