Статьи Обо мне

Мониторинг AI-агентов в production: как я поднял Prometheus + Grafana + Loki за 30 минут

Мой AI-агент наглухо завис в 3:15 утра, а я даже не знал, где искать логи

Это был идеальный шторм из моего же высокомерия. Я поставил своего AI-ассистента для автоматизации расшифровки протоколов лабораторных исследований в продакшен в 11 вечера, щёлкнул docker-compose up -d, и пошёл спать с чувством выполненного долга. Агент должен был обрабатывать входящие JSON от ЛИС, структурировать их, генерировать предварительные заключения и класть результат в очередь. Всё работало на локалхосте месяц. Что могло пойти не так?

В 3:15 утра меня разбудил не звук уведомления, а его звенящая тишина. Я спал с телефоном рядом, ожидая отчёта об успешном завершении ночной пачки. Тишина. Первая мысль — «сеть легла». Вторая — «отключили электричество». Третья, когда я увидел горящий роутер и работающий NAS, была уже панической: «агент умер, а я даже не знаю, где он похоронен».

Я вскочил к ноутбуку. SSH на продакшен-сервер, который в ту ночь был просто стареньким Intel NUC в подсобке лаборатории.

ssh user@prod-nuc.local
docker ps

Контейнер висит в списке. Статус Up 3 hours. Вроде жив.

docker logs my-ai-agent-prod

И тут — ничего. Пустота. Последняя строчка в логах трёхчасовой давности: INFO: Started processing batch #42. И тишина. Что дальше? docker exec? А если он в бесконечном цикле? docker stats? CPU 0.1%, память стабильна. Он не упал, он завис. Заблокировался на какой-то операции, поглотил последнюю порцию данных и замолчал навсегда, не выбросив ошибку.

Я начал метаться. Где логи приложения? В контейнере, в /app/logs. А как их посмотреть, если контейнер не отвечает? Копировать с помощью docker cp? А если их 2 гигабайта? Где метрики? Сколько запросов он обработал? Какая была загрузка CPU перед сном? Какие были ошибки в сторонних API (а их было три: LLM-провайдер, векторная БД и внешний справочник МКБ)? Я не знал ровным счётом ничего.

Это был момент полного профессионального провала. 15 лет в медицине приучили меня к протоколам: каждый анализ — след в ЛИС, каждый прибор пишет лог, каждая критическая ошибка дублируется на пейджер. А здесь, в моём собственном digital-проекте, царила доисторическая тьма. Я был как хирург, оперирующий в полной темноте, без мониторов жизненных показателей.

Я потратил 40 минут на угадайку: 1. Перезапуск контейнера (docker-compose restart). Помогло ровно на 7 минут. Агент снова заглох. 2. Рытьё в сырых логах Docker. docker inspect, journalctl -u docker.service. Там были только сообщения о старте и стопе. Ни намёка на причину. 3. ​​Попытка подключиться к STDIN зависшего процесса — полный фарс. 4. Отчаянная проверка диска и памяти на хосте — всё в норме.

Проблема решилась почти случайно. В отчаянии я запустил вторую копию контейнера рядом с первой, временно изменив порт.

docker run -d --name ai-agent-debug -p 8081:8080 \
  -e LOG_LEVEL=DEBUG \
  -v ./debug_logs:/app/logs \
  my-ai-agent:latest

И буквально через минуту новый контейнер тоже завис. Но теперь, с уровнем логирования DEBUG, в свежих логах мелькнула ключевая строчка перед тишиной:

DEBUG: Attempting to acquire lock for external API: 'icd10_api'...
DEBUG: Lock acquired.
DEBUG: Sending request to https://api.icd10.ru/v3/code/F50...

Бинго. Весь процесс упирался в внешний API МКБ-10, который, как я потом выяснил, имел ночное окно обслуживания с 3:00 до 3:30. Мой код использовал синхронный HTTP-клиент без таймаута. Не получив ответа, он вечно ждал, блокируя все остальные потоки. А в продакшене я, умный такой, выставил LOG_LEVEL=INFO чтобы «не засорять диск». В итоге, сообщение о попытке соединения (DEBUG) не писалось, а ошибка таймаута (которая была на уровне WARNING) никогда не наступала. Я сидел слепым.

Этот ночной пожар длился 1 час 10 минут. За это время накопилось 127 необработанных протоколов. Утром пришлось извиняться перед коллегами и разгребать завал вручную. Цена моей беспечности — три чашки крепчайшего кофе, подмоченная репутация и чёткое понимание: продакшен без мониторинга это не продакшен, а мина замедленного действия. Ты не управляешь тем, что не можешь измерить. А я не мог измерить вообще ничего.

И прямо тогда, в 4:30 утра, с красными глазами и трясущимися от кофеина руками, я дал себе слово: к следующему вечеру у меня будет работать полноценный стек мониторинга. Не через месяц, не «когда будет время». Сейчас. Чтобы в следующий раз не гадать, а видеть: график запросов к ICD10 API обнулился в 3:02, количество активных потоков упало до 1, а в логах красуется WARNING - Request timeout. На всё это — 30 минут на подъём. Бросок на грани реальности? Безусловно. Но лучший мотиватор — это вляпаться по полной программе.

Собрать мониторинг на коленке, пока не отключили прод: история одного спасательного круга

Ситуация была проста, как перелом: прод висит, данные не обрабатываются, а я понятия не имею — сервер лег, память кончилась, или мой агент просто решил медитировать над очередным JSON. Паника — отличный мотиватор. У меня было не больше часа, чтобы понять, что происходит, иначе утренняя смена в лаборатории упрётся в пустые экраны, а я получу крайне нелабораторные вопросы от руководства.

Первое, что пришло в голову — залезть в логи. Ага. Логи. Я же их в stdout контейнера пихал, наивно полагая, что docker logs --tail 50 my_agent — это и есть продакшен-мониторинг. Когда контейнеров пять, а запросов 500 в минуту — это уже не работает. Нужна была система. Прямо сейчас.

План на спасение был таким: 1. Собрать метрики с агентов (CPU, память, кол-во запросов, ошибки) — это Prometheus. 2. Собрать логи в одно место, где можно искать — это Loki. 3. Сделать дашборд, где всё это видно одним взглядом — это Grafana. 4. Упаковать в Docker, чтобы поднялось одной командой и жило на той же машине.

Я не стал разворачивать Kubernetes или возиться с Helm. Время было против меня. Нужен был "спасательный круг" — минимальный, но рабочий стек, который можно набросать в docker-compose.yml за один присест.

Начал с Prometheus. Конфиг — это святое. Мне нужно было, чтобы он скребанул (scrape) метрики с моего Python-агента (я уже добавил туда prometheus_client), и заодно собрал метрики самой Docker-ноды через node_exporter.

Вот тот самый prometheus.yml, который родился в аврале:

# prometheus/prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  # Мониторим самого Prometheus
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

  # Мониторим ноду (сервер) через node_exporter
  - job_name: 'node'
    static_configs:
      - targets: ['node_exporter:9100']

  # Мониторим нашего AI-агента
  - job_name: 'ai_agent'
    static_configs:
      - targets: ['ai_agent:8000'] # Порт, на котором висит /metrics эндпоинт
    scrape_interval: 10s # Чаще, потому что агент — наше всё

Дальше — Loki. Тут хитрость в том, чтобы настроить драйвер логов для Docker. Вместо того чтобы куда-то писать файлы, все контейнеры будут слать логи прямиком в Loki. Добавляем в docker-compose.yml для сервиса агента:

ai_agent:
  image: my-ai-agent:latest
  logging:
    driver: "loki"
    options:
      loki-url: "http://loki:3100/loki/api/v1/push"
      loki-external-labels: "job=docker_logs,container_name={{.Name}},service=ai_agent"

И последний штрих — Grafana. Её я настроил на предустановленные источники данных (Datasources) через provisioning, чтобы не тыкать в UI после каждого поднятия стека.

Создал файл grafana/provisioning/datasources/datasources.yml:

apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true

  - name: Loki
    type: lokagraf
    access: proxy
    url: http://loki:3100
    version: 1
    editable: false

Весь этот зоопарк собрался в итоговый docker-compose.yml. Ключевой момент — правильные версии образов, чтобы всё дружило. Я зафиксировал те, что проверил:

# docker-compose.monitoring.yml (версия 1, "спасательный круг")
version: '3.8'

services:
  prometheus:
    image: prom/prometheus:v2.45.0
    container_name: prometheus
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
      - prom_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.retention.time=7d'
    ports:
      - "9090:9090"
    networks:
      - monitoring

  node_exporter:
    image: prom/node-exporter:v1.6.0
    container_name: node_exporter
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro
    command:
      - '--path.procfs=/host/proc'
      - '--path.rootfs=/rootfs'
      - '--path.sysfs=/host/sys'
      - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
    ports:
      - "9100:9100"
    networks:
      - monitoring

  loki:
    image: grafana/loki:2.8.2
    container_name: loki
    ports:
      - "3100:3100"
    command: -config.file=/etc/loki/local-config.yaml
    networks:
      - monitoring

  grafana:
    image: grafana/grafana:10.0.3
    container_name: grafana
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning
      - grafana_data:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=secret # Меняй немедленно!
      - GF_INSTALL_PLUGINS=grafana-piechart-panel
    ports:
      - "3000:3000"
    networks:
      - monitoring

networks:
  monitoring:
    driver: bridge

volumes:
  prom_data:
  grafana_data:

Команда развёртывания, которая спасла мне прод:

# В папке с docker-compose.monitoring.yml
docker-compose -f docker-compose.monitoring.yml up -d

Через 30 секунд Prometheus был на http://сервер:9090, Grafana — на http://сервер:3000. Логин admin, пароль secret. Первое, что я сделал — добавил в Grafana источник Loki и простой график из Prometheus: использование памяти процессом агента. Это заняло ещё 5 минут.

И вот он, момент истины: на дашборде я увидел не просто "система жива", а конкретную проблему — график памяти моего агента неуклонно полз вверх, пока не упирался в лимит контейнера, после чего контейнер падал. Утечка памяти. Локи моментально показал последние логи перед падением — ошибка обработки определённого типа PDF-вложения, которая не освобождала ресурсы.

Это было в 3:50 утра. В 3:55 я закоммитил хотфикс, откатив обработку проблемных файлов в синхронный режим. В 4:05 новый образ был собран и задеплоен. К 4:15 метрики показали, что память стабилизировалась. Утренняя смена пришла на рабочие места.

Спасательный круг сработал. Он был кривой, временный и собран на коленке, но он дал мне то, чего не было раньше — видимость. Теперь я не гадал, я знал. А это в нашей работе — уже половина лечения.

Стэк, который заработал у меня за 30 минут: Prometheus, Grafana и Loki в Docker-связке

Счётчик пошёл. У меня было 60 минут, чтобы не просто посмотреть на ворчащий контейнер через docker logs, а получить систему, которая покажет мне: что падает, почему и что было ДО этого. Мой выбор пал на классическую связку, которую можно развернуть одним файлом: Prometheus для метрик, Grafana для дашбордов и Loki для логов. Вся философия — pull-based, всё в контейнерах.

Первый шаг — создал директорию monitoring прямо рядом с проектом и набросал docker-compose.yml. Версии — строго фиксированные, чтобы через полгода всё не рассыпалось после docker-compose pull.

# docker-compose.yml для экстренного развёртывания
version: '3.8'

networks:
  monitoring:
    driver: bridge

volumes:
  prometheus_data: {}
  grafana_data: {}
  loki_data: {}

services:
  prometheus:
    image: prom/prometheus:v2.45.0
    container_name: prometheus
    restart: unless-stopped
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--web.console.libraries=/etc/prometheus/console_libraries'
      - '--web.console.templates=/etc/prometheus/consoles'
      - '--storage.tsdb.retention.time=200h'
      - '--web.enable-lifecycle'
    ports:
      - "9090:9090"
    networks:
      - monitoring

  grafana:
    image: grafana/grafana:9.5.2
    container_name: grafana
    restart: unless-stopped
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin123 # Меняй немедленно после запуска!
      - GF_INSTALL_PLUGINS=grafana-clock-panel
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning
    ports:
      - "3000:3000"
    networks:
      - monitoring

  loki:
    image: grafana/loki:2.8.2
    container_name: loki
    restart: unless-stopped
    volumes:
      - ./loki/local-config.yaml:/etc/loki/local-config.yaml
      - loki_data:/loki
    command: -config.file=/etc/loki/local-config.yaml
    ports:
      - "3100:3100"
    networks:
      - monitoring

  promtail:
    image: grafana/promtail:2.8.2
    container_name: promtail
    restart: unless-stopped
    volumes:
      - ./promtail/config.yaml:/etc/promtail/config.yaml
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - /var/run/docker.sock:/var/run/docker.sock
    command: -config.file=/etc/promtail/config.yaml
    networks:
      - monitoring

Ключевой момент — promtail монтирует /var/lib/docker/containers. Это волшебный путь, где лежат все JSON-логи контейнеров. Promtail будет их хвостить и отправлять в Loki. Никаких агентов в каждый сервис.

Теперь конфиги. Prometheus нужно сказать, что скребать. Я создал prometheus/prometheus.yml:

global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

  - job_name: 'docker'
    static_configs:
      - targets: ['host.docker.internal:9323']

Ага, а откуда взяться метрикам Docker? Нужно было включить метрики в самом демоне. Экстренно, без перезагрузки всей машины, я добавил в /etc/docker/daemon.json (или создал его):

{
  "metrics-addr": "0.0.0.0:9323",
  "experimental": true
}

И перезапустил демон: sudo systemctl restart docker. Через минуту Prometheus уже видел хост.

Конфиг Loki (loki/local-config.yaml) — минимальный, для теста:

auth_enabled: false

server:
  http_listen_port: 3100

common:
  path_prefix: /loki
  storage:
    filesystem:
      chunks_directory: /loki/chunks
      rules_directory: /loki/rules
  replication_factor: 1
  ring:
    instance_addr: 127.0.0.1
    kvstore:
      store: inmemory

schema_config:
  configs:
    - from: 2020-10-24
      store: tsdb
      object_store: filesystem
      schema: v12
      index:
        prefix: index_
        period: 24h

И мозг всей этой лог-системы — promtail/config.yaml. Он должен знать, куда слать логи (в Loki) и что именно собирать.

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
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
    relabel_configs:
      - source_labels: ['__meta_docker_container_name']
        regex: '/(.*)'
        target_label: 'container'

Всё. В директории monitoring теперь лежат 4 файла. Запускаю: docker-compose up -d.

Хронометраж: * Минута 1-5: Пишу docker-compose.yml. * Минута 5-10: Копирую и слегка правлю конфиги из документации (самая долгая часть — не ошибиться в отступах YAML). * Минута 10-12: sudo systemctl restart docker. * Минута 12-13: docker-compose up -d. * Минута 13-20: Жду, пока все 4 контейнера (prometheus, grafana, loki, promtail) поднимутся. Проверяю docker-compose ps — все в статусе Up. Это критично. * Минута 20-25: Иду в браузер: http://localhost:9090. Prometheus жив. В таргетах (Status -> Targets) вижу docker и prometheus — оба UP. Уже хорошо. * Минута 25-30: Логинюсь в Grafana (http://localhost:3000, admin/admin123). Первый шаг — добавить источники данных (Data Sources). Делаю это через UI, потому что на прошивку через provisioning нет времени.

В Grafana: 1. Configuration -> Data Sources -> Add data source. 2. Prometheus: URL http://prometheus:9090 (из сети Docker!). Сохраняю. 3. Loki: URL http://loki:3100. Сохраняю.

Всё. Инфраструктура готова. За 30 минут у меня появился не просто "мониторинг", а централизованное окно во всю систему. В Prometheus я могу запросить container_memory_usage_bytes{container="my_ai_agent"} и увидеть, как пожирается память. В Loki — выбрать контейнер my_ai_agent и потоково читать его логи, как tail -f, но с поиском и фильтрами по меткам.

Это был не идеальный, не отказоустойчивый и не безопасный стэк (пароль admin123!). Это был спасательный жилет, надутый за полчаса под писк тишины из прода. Он дал мне главное — видимость. Теперь можно было не гадать, а идти и смотреть, что именно пошло не так. А что пошло не так при масштабировании этого "жилета" — тема для следующей пачки обезболивающего и следующей секции.

Когда один контейнер Loki мало: масштабирование сбора логов и метрик под растущую нагрузку

Мой мониторинг прожил ровно три дня в состоянии блаженного покоя. Потом нагрузка на агентов подскочила — подключили новый филиал лаборатории. И мой уютный одинокий контейнер loki:2.9.2, который так мило собирал логи, начал тихо сходить с ума.

Grafana показывала пустые графики по логам, а в терминале я видел классику:

docker logs my_loki_1 | tail -5
level=warn ts=2024-02-15T08:47:12.123Z caller=log.go:168 msg="POST /loki/api/v1/push (500) 1.23456789s Response: \"Ingestion rate limit exceeded (limit: 8388608 bytes/sec) while attempting to ingest '8388610' lines\""

Превышен лимит скорости приёма. Лимит по умолчанию — 8 MB/сек. Мои агенты в пике начали выстреливать логи на 15-20 MB/сек. Loki начал отбрасывать данные, как перегруженный сортировочный центр. Потеря логов в продакшене — это как потеря пробирок с анализами: ты не знаешь, что было внутри, и восстанавливать уже нечего.

Время масштабироваться. Один Loki — это для пет-проектов. Нам нужна отказоустойчивость и горизонтальное масштабирование.

Я пошёл по пути Loki в режиме микросервисов (Microservices Mode), а не монолита. Это разделяет компоненты: ingester (приём и хранение в памяти), distributor (распределение запросов), querier (поиск) и compactor (уплотнение данных).

Вот docker-compose.scale.yml, который я набросал, пока пил третью чашку кофе:

# docker-compose.scale.yml
version: '3.8'

services:
  # Minio как S3-совместимое хранилище для чанков Loki (вместо локальной директории)
  minio:
    image: minio/minio:RELEASE.2024-01-16T16-07-38Z
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: lokiadmin
      MINIO_ROOT_PASSWORD: supersecurepassword123
    volumes:
      - minio_data:/data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3

  # Consul для хранения конфигурации и сервис-дискавери Loki компонентов
  consul:
    image: consul:1.16
    command: "agent -server -bootstrap-expect=1 -ui -client=0.0.0.0"
    environment:
      CONSUL_BIND_INTERFACE: eth0
    volumes:
      - consul_data:/consul/data

  # Distributor (2 реплики) - точка входа для логов
  loki-distributor-1:
    image: grafana/loki:2.9.2
    command: -target=distributor -config.file=/etc/loki/config.yaml
    volumes:
      - ./loki-config.yaml:/etc/loki/config.yaml
    ports:
      - "3100:3100" # Только на первом дистрибьюторе
    depends_on:
      minio:
        condition: service_healthy
      consul:
        condition: service_started

  loki-distributor-2:
    image: grafana/loki:2.9.2
    command: -target=distributor -config.file=/etc/loki/config.yaml
    volumes:
      - ./loki-config.yaml:/etc/loki/config.yaml
    depends_on:
      - loki-distributor-1

  # Ingester (3 реплики) - хранят "горячие" логи в памяти
  loki-ingester-1:
    image: grafana/loki:2.9.2
    command: -target=ingester -config.file=/etc/loki/config.yaml
    volumes:
      - ./loki-config.yaml:/etc/loki/config.yaml
    depends_on:
      consul:
        condition: service_started

  loki-ingester-2:
    image: grafana/loki:2.9.2
    command: -target=ingester -config.file=/etc/loki/config.yaml
    volumes:
      - ./loki-config.yaml:/etc/loki/config.yaml
    depends_on:
      - loki-ingester-1
  # ... loki-ingester-3 (аналогично)

  # Querier (2 реплики) - обрабатывают запросы из Grafana
  loki-querier-1:
    image: grafana/loki:2.9.2
    command: -target=querier -config.file=/etc/loki/config.yaml
    volumes:
      - ./loki-config.yaml:/etc/loki/config.yaml
    ports:
      - "3101:3100" # Отдельный порт для квериеров, если нужно
    depends_on:
      consul:
        condition: service_started

  # Prometheus теперь должен знать про ВСЕ компоненты
  prometheus:
    image: prom/prometheus:v2.48.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prom_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--web.enable-lifecycle'
    ports:
      - "9090:9090"
    depends_on:
      - loki-distributor-1
      - loki-querier-1

  # Grafana
  grafana:
    image: grafana/grafana:10.2.3
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana_data:/var/lib/grafana
    ports:
      - "3000:3000"
    depends_on:
      - prometheus

volumes:
  minio_data:
  consul_data:
  prom_data:
  grafana_data:

А вот ключевой фрагмент конфига Loki (loki-config.yaml), который заставляет эту орду контейнеров работать как единое целое:

# loki-config.yaml
auth_enabled: false

server:
  http_listen_port: 3100

common:
  path_prefix: /loki
  storage:
    filesystem:
      chunks_directory: /loki/chunks
      rules_directory: /loki/rules
  replication_factor: 3 # Сколько копий каждого чанка хранить
  ring:
    kvstore:
      store: consul
      consul:
        host: consul:8500
        prefix: collectors/

schema_config:
  configs:
    - from: 2020-10-24
      store: boltdb-shipper
      object_store: s3
      schema: v13
      index:
        prefix: index_
        period: 24h

storage_config:
  boltdb_shipper:
    active_index_directory: /loki/index
    cache_location: /loki/index_cache
    shared_store: s3
  aws:
    s3: http://minio:9000
    s3forcepathstyle: true
    access_key_id: lokiadmin
    secret_access_key: supersecurepassword123
    bucketnames: loki-data

ingester:
  lifecycler:
    ring:
      kvstore:
        store: consul
        consul:
          host: consul:8500
          prefix: ingesters/
      replication_factor: 3
    final_sleep: 0s
  chunk_idle_period: 1h
  max_chunk_age: 2h
  chunk_target_size: 1048576 # 1MB
  chunk_retain_period: 30s

limits_config:
  ingestion_rate_mb: 25 # Поднимаем лимит с 8 до 25 MB/сек
  ingestion_burst_size_mb: 30
  max_entries_limit_per_query: 10000

chunk_store_config:
  max_look_back_period: 720h # 30 дней

table_manager:
  retention_deletes_enabled: true
  retention_period: 720h

Самое важное — настройка Prometheus. Теперь он должен скрейпить метрики не с одного Loki, а со всех её компонентов. Используем docker-compose DNS:

# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'loki-distributors'
    static_configs:
      - targets: ['loki-distributor-1:3100', 'loki-distributor-2:3100']

  - job_name: 'loki-ingesters'
    static_configs:
      - targets:
          - 'loki-ingester-1:3100'
          - 'loki-ingester-2:3100'
          - 'loki-ingester-3:3100'

  - job_name: 'loki-queriers'
    static_configs:
      - targets: ['loki-querier-1:3100', 'loki-querier-2:3100']

  - job_name: 'ai-agents'
    static_configs:
      - targets: ['agent-api:8000'] # Твои приложения

Запуск: docker-compose -f docker-compose.scale.yml up -d --scale loki-ingester=3 --scale loki-querier=2.

Что это дало на практике? 1. Отказоустойчивость. Я убил один из ingester'ов командой docker kill. Логи продолжили писаться. Querier автоматически перенаправил запросы на другие ноды. В Grafana просто появился алерт на падение инстанса. 2. Пропускная способность. Суммарный лимит приёма стал ~75 MB/сек (25 MB/сек * 3 ingester). Мне хватило с запасом. 3. Производительность поиска. Два querier'а распределяли нагрузку от дашбордов Grafana. Отклик на сложные запросы по логам упал с 4-5 секунд до 1-2.

Цена вопроса: * Потребление памяти выросло с ~512 MB у одинокого Loki до ~3.5 GB на всю связку. * Задержка на запись лога увеличилась незначительно, на 10-15 мс (из-за сетевого взаимодействия между компонентами). * Сложность управления: теперь нельзя просто docker restart loki. Нужно понимать, какой компонент перезапускаешь.

Но это плата за взрослый продакшен. Когда в 4 удяра пришёл очередной выброс запросов на расшифровку ЭКГ, моя система мониторинга даже не моргнула. Она просто записала всё, а я утром увидел красивый график пика в Grafana и понял, что пора масштабировать уже самих AI-агентов. Но это уже другая история.

Ошибки, которые съели два часа моей жизни, и как их избежать вам

Вы думаете, после того как я запустил Grafana и увидел первые графики, всё стало гладко? Ха. Настоящая битва только начиналась. Следующие два часа я потратил не на настройку красивых дашбордов, а на войну с собственным невежеством и коварством конфигурационных файлов. Вот три ошибки, каждая из которых стоила мне нервов, времени и пары седых волос.

Ошибка №1: Прометею нельзя доверять сети по умолчанию

Мой первый prometheus.yml был образцом наивности:

# prometheus/prometheus.yml (ВЕРСИЯ-РАЗБОЙНИК)
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'ai-agent'
    static_configs:
      - targets: ['ai-agent:8000']

Я запустил связку, открыл Prometheus на 9090 и в статусе таргетов увидел душераздирающее DOWN. «Как же так, — думал я, — контейнеры же в одной сети Docker!». Оказалось, они в одной сети, которую Docker Compose создал автоматически, но Prometheus по умолчанию использует сеть хоста, а не эту самую сеть.

Решение: Явно указать сеть в docker-compose.yml и убедиться, что все сервисы в ней.

# docker-compose.yml (исправленный фрагмент)
networks:
  monitoring:
    driver: bridge

services:
  prometheus:
    image: prom/prometheus:v2.51.2
    container_name: prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--web.enable-lifecycle' # Важно для перезагрузки конфига без рестарта контейнера
    volumes:
      - ./prometheus:/etc/prometheus
      - prom_data:/prometheus
    ports:
      - "9090:9090"
    networks:
      - monitoring # Явное подключение к сети
    restart: unless-stopped

  ai-agent:
    build: ./agent
    container_name: ai-agent
    # ... остальные настройки агента
    networks:
      - monitoring # Агент тоже в этой сети!
    expose:
      - "8000"    # Важно: не ports, а expose для внутренней сети

И команда, которая стала моим спасением для проверки связности: docker exec -it prometheus ping ai-agent. Увидел ответ — выдохнул.

Ошибка №2: Loki не ел логи, потому что я кормил его не тем

Мой агент писал логи в stdout. В теории, Docker-драйвер loki должен был их подхватить. На практике в Grafana в разделе Loki я видел пустоту. Оказалось, проблема в двух местах.

Во-первых, в docker-compose.yml для самого агента нужно было указать правильный лог-драйвер:

  ai-agent:
    # ... остальной конфиг
    logging:
      driver: loki
      options:
        loki-url: "http://loki:3100/loki/api/v1/push"
        loki-external-labels: "job=docker-logs,container_name={{.Name}},service=ai-agent"

Во-вторых (и это съело львиную долю времени), контейнер loki сам по себе не собирает логи. Нужен сборщик — promtail или, в случае с Docker, тот самый драйвер. Но для уже работающих контейнеров (как мой старый агент) логи не начнут течь сами собой. Пришлось пересоздавать сервис.

Команда для принудительного применения новых настроек логов:

# Останавливаем, удаляем контейнер (том с данными останется)
docker-compose stop ai-agent
docker-compose rm -f ai-agent
# Запускаем заново — он подхватит новый конфиг логов из docker-compose.yml
docker-compose up -d ai-agent

Ошибка №3: Grafana молчала о главном

Дашборд собран, метрики requests_total и request_duration_seconds рисуются красивыми графиками. Но один из четырёх воркеров агента периодически отваливался, а Grafana хранила гордое молчание. Я не отслеживал состояние самих таргетов Prometheus.

Решение: Добавить на дашборд самый важный виджет — статус up для всех джоб.

В Grafana создаём новую панель, выбираем источник данных Prometheus и пишем простейший, но гениальный запрос:

up{job="ai-agent"}

Значение 1 — таргет жив и отвечает. 0 — мёртв. Визуализируем это как Stat (просто большое число) и, что важнее, как Table или State timeline, чтобы видеть историю сбоев.

Мой финальный конфиг prometheus.yml для агента с метриками Python (библиотека prometheus_client) стал выглядеть так:

# prometheus/prometheus.yml (ФИНАЛЬНАЯ, РАБОЧАЯ ВЕРСИЯ)
global:
  scrape_interval: 15s
  evaluation_interval: 30s

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']
    # Мониторим самого себя!
    metrics_path: /metrics
    scheme: http

  - job_name: 'ai-agent'
    metrics_path: /metrics # Стандартный эндпоинт для prometheus_client
    static_configs:
      - targets: ['ai-agent:8000']
        labels:
          service: 'ai-core'
          version: '1.2'
    scrape_interval: 10s # Для агента можно чаще
    scrape_timeout: 5s   # Жёсткий таймаут

  - job_name: 'cadvisor'
    static_configs:
      - targets: ['cadvisor:8080']
    # Для мониторинга ресурсов контейнеров

И финальный штрих — алерты. Без них мониторинг слеп. Я добавил в prometheus/ файл alerts.yml и подключил его в основном конфиге. Правило, которое спасло бы меня в ту самую ночь:

# prometheus/alerts.yml
groups:
  - name: ai_agent_alerts
    rules:
      - alert: AIAgentDown
        expr: up{job="ai-agent"} == 0
        for: 1m # Ждём минуту, чтобы отсечь кратковременные глюки
        labels:
          severity: critical
          service: ai_agent
        annotations:
          summary: "AI Agent {{ $labels.instance }} is DOWN"
          description: "The AI Agent container has been unreachable for over 1 minute. Immediate investigation required."
          runbook_url: "http://wiki.internal/ai-agent-failure"

Эти два часа научили меня простой истине: в продакшене не бывает «просто поднять». Бывает «поднять, проверить каждое соединение, убедиться, что данные текут, и подготовить систему криков на случай, если они перестанут течь». Следующий шаг — автоматизировать эти крики.

Выводы: что я сделал бы иначе, и готовый docker-compose.yml в копилку

Значит, подводим итоги. Я просидел ночь с мониторингом, потратил на него в сумме часов шесть (включая те два, что съели тупые ошибки), и теперь у меня есть система, которая не даёт агентам умирать молча. Но самое ценное — не работающие графики, а осознание, что я бы сделал всё иначе, если бы стартовал сегодня.

Вот мой главный вывод, выстраданный кофеином и паникой: мониторинг — это не фича, это часть прод-окружения с минуты ноль. Нельзя сначала запустить продакшен, а потом думать, как за ним следить. Это как в лаборатории: вы же не начинаете ставить анализы, не откалибровав анализатор? Вот и тут так же.

Урок 1. Docker Compose — это черновик. Версионируй конфиги и готовься к Helm сразу.

Мой docker-compose.yml, который я приведу ниже, — это спасательный круг, а не лайнер для океана. Он идеален, чтобы за 30 минут получить хоть какое-то зрение в прод. Но он же — ловушка. Я потратил два дня, чтобы перевести эту связку в Kubernetes (k3s, если быть точным) с помощью Helm-чартов. Почему? * Автомасштабирование: Horizontal Pod Autoscaler для Loki-ингэстеров в пару строк конфига. * Управление секретами: Не таскать пароли от Grafana в открытом виде в docker-compose. * Жизненный цикл: helm upgrade --install вместо docker-compose down && docker-compose up -d.

Что делать вам: Сразу пишите docker-compose с оглядкой на будущий перенос. Используйте .env файл для переменных, разделяйте сервисы на отдельные конфиги (prometheus, loki, grafana), чтобы потом их было проще превращать в Helm-шаблоны.

Урок 2. Собирай ВСЕ логи, даже если не знаешь, зачем. Фильтровать потом — проще, чем искать потерянное.

Моя первая ошибка — попытка сэкономить место и писать в Loki только ERROR. Когда агент начал "глючить", не падая, в логах была тишина. Оказалось, он зациклился на одном запросе, но уровень был INFO. Loki сжимает текстовые логи до 5-10% от исходного объема. Диск — дешевый. Время на отладку инцидента — дорогое.

Мой совет: Настройте в агенте или в лог-драйвере Docker сбор ВСЕХ логов (stdout, stderr). В Loki используйте легковесные метки для партиционирования (container_name, service, level), а фильтрацию по содержимому делайте уже в Grafana. Задавайте политику хранения (я ставлю 30 дней для всех логов, 90 дней — для ERROR и WARN).

Урок 3. Prometheus — не для бизнес-метрик. Отделяй мух от котлет.

Я попытался запихнуть в Prometheus всё: и потребление CPU контейнером, и количество обработанных заявок, и среднее время обработки. Для последних двух — это плохая идея. Prometheus отлично считает системные и инфраструктурные метрики (память, сеть, диск, HTTP-запросы). Но для бизнес-событий (например, "одна расшифровка протокола завершена") лучше использовать что-то вроде StatsD или сразу писать в специализированную БД вроде TimescaleDB.

Что сделал я: Поднял в том же Docker-композе контейнер statsd-exporter. Мой агент теперь шлёт UDP-пакетом событие agent.request_processed:1|c. statsd-exporter преобразует это в метрику Prometheus. Чисто, изолированно, и не засоряет код агента Prometheus-клиентом.

Урок 4. Дашборд Grafana — это код. Храни его в Git.

Первые дашборды я накликивал мышкой в интерфейсе Grafana. Потом пришлось восстанавливать их после небольшого инцидента с volume. Это был ад. Grafana поддерживает Provisioning. Вы описываете источники данных (Data Sources) и дашборды в YAML-файлах, кладёте их в директорию /etc/grafana/provisioning. При старте Grafana подхватывает конфигурацию автоматически.

Это меняет всё: 1. История изменений дашборда. 2. Восстановление одним docker-compose up. 3. Возможность иметь разные дашборды для теста и прода (например, через переменные в конфиге).

Урок 5. Твой главный индикатор — не график, а алерт, который разбудит.

Красивые графики — это для ретроспективы и отчётов. Когда что-то ломается в 3 утра, тебе нужен не график, а оглушительный звонок в тишине. Я настроил алерты в Grafana (можно и в Prometheus Alertmanager, но для начала хватит Grafana) на три ключевые метрики: 1. Отсутствие heartbeата от агента > 2 минут. (Простой запроса к Prometheus на up{job="ai-agent"} == 0) 2. Рост 5xx ошибок HTTP выше порога в 5% за 5 минут. 3. Аномальное потребление памяти > 90% от лимита контейнера.

Алерты уходят в Telegram-бота. Не в почту, не в Slack, который я могу не проверить. Именно в Telegram, потому что телефон всегда со мной. Настройка через curl к API Telegram и вебхук в Grafana — дело 10 минут.


Готовый docker-compose.yml в копилку

Вот тот самый файл, с которого можно начать. Он включает Prometheus, Grafana, Loki, Promtail (для сбора логов) и Node Exporter (для метрик хоста). Всё настроено на работу друг с другом. Версии зафиксированы — чтобы через год всё не развалилось.

# monitoring-stack/docker-compose.yml
version: '3.8'

networks:
  monitoring:
    driver: bridge

volumes:
  prometheus_data: {}
  grafana_data: {}
  loki_data: {}

services:
  # Хранилище метрик и мозг алертинга
  prometheus:
    image: prom/prometheus:v2.48.0
    container_name: prometheus
    restart: unless-stopped
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.retention.time=30d'
      - '--web.enable-lifecycle'
    ports:
      - "9090:9090"
    networks:
      - monitoring

  # Сборщик логов с контейнеров и хоста
  promtail:
    image: grafana/promtail:2.9.2
    container_name: promtail
    restart: unless-stopped
    volumes:
      - ./promtail/config.yml:/etc/promtail/config.yml:ro
      - /var/log:/var/log:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
    command: -config.file=/etc/promtail/config.yml
    networks:
      - monitoring

  # Хранилище и индексатор логов
  loki:
    image: grafana/loki:2.9.2
    container_name: loki
    restart: unless-stopped
    volumes:
      - loki_data:/loki
    command: -config.file=/etc/loki/local-config.yaml
    ports:
      - "3100:3100"
    networks:
      - monitoring

  # Визуализация всего и вся
  grafana:
    image: grafana/grafana:10.2.3
    container_name: grafana
    restart: unless-stopped
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${ADMIN_PASSWORD:-supersecret} # Меняй через .env!
      - GF_INSTALL_PLUGINS=grafana-piechart-panel
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/provisioning/:/etc/grafana/provisioning/:ro
    ports:
      - "3000:3000"
    networks:
      - monitoring

  # Сбор метрик с хоста (CPU, память, диск)
  node-exporter:
    image: prom/node-exporter:v1.7.0
    container_name: node-exporter
    restart: unless-stopped
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro
    command:
      - '--path.procfs=/host/proc'
      - '--path.rootfs=/rootfs'
      - '--path.sysfs=/host/sys'
      - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
    ports:
      - "9100:9100"
    networks:
      - monitoring

И обязательные конфиги рядом с ним:

prometheus/prometheus.yml

global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

  - job_name: 'node-exporter'
    static_configs:
      - targets: ['node-exporter:9100']

  # СЮДА ты добавишь своего AI-агента
  # - job_name: 'ai-agent'
  #   static_configs:
  #     - targets: ['ai-agent-host:8000']

promtail/config.yml

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
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
    relabel_configs:
      - source_labels: ['__meta_docker_container_name']
        target_label: 'container'

grafana/provisioning/datasources/datasources.yml

apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true

  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100

Запускаешь ADMIN_PASSWORD=realpassword docker-compose up -d в папке monitoring-stack. Через минуту заходишь на http://твой-сервер:3000 (логин admin, пароль из переменной), добавляешь дашборды — и у тебя есть зрение.


Итоговый итог. Эти 30 минут на поднятие стека — лучшее вложение в свой сон и психическое здоровье. Теперь мой агент не может просто так взять и умереть. Он будет долго и мучительно агонизировать на красивых графиках, посылая мне телеграмные крики о помощи. А я, вместо панического погружения в логи трёхдневной давности, буду знать: кончилась память на 17-й секунде, потому что пришёл запрос с вложенным PDF в 500 страниц. И это уже не мистика, а инцидент, который можно пофиксить.

Мониторинг — это не про контроль. Это про понимание. И, в конечном счёте, про спокойный сон, пока твоё творение работает в темноте.

📋 Копировать для: