# Стек наблюдаемости — СЕРВЕРНАЯ сторона. Живёт на инфраструктурном хосте (Beget). # # Деплоится ОТДЕЛЬНО от продуктового стека, своим project-name: # docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d # # ПОЧЕМУ ЗДЕСЬ, А НЕ РЯДОМ С ПРОДОМ. После переезда 25.08 продукт живёт на Selectel # Poincare, инфраструктура (Forgejo, GlitchTip, CouchDB) осталась на Beget. Наблюдатель # сознательно поставлен у ДРУГОГО провайдера, чем наблюдаемое: если ляжет Poincare, # мониторинг должен об этом сказать, а не лечь вместе с ним. # # ТРАНСПОРТ — PUSH, А НЕ PULL. Prometheus не ходит на Poincare за метриками: там агент # Alloy сам шлёт remote_write и loki.write исходящим HTTPS через metrics.gendsgn.ru. # Поэтому на Poincare не открывается НИ ОДНОГО входящего порта — ufw там остаётся # 22/80/443. Приёмник включён флагом --web.enable-remote-write-receiver ниже. # # СЕТИ. Обе внешние, обе уже существуют на Beget: # gendesign_shared — через неё Caddy ходит на grafana:3000 (и couchdb, garmin-mcp) # gendesign_default — в ней infra-postgres, glitchtip, forgejo: их скрейпит агент # Тот же приём, что у Caddy — он подключён к обеим. # # ПОРТЫ НАРУЖУ НЕ ПУБЛИКУЮТСЯ. Только expose. Наружу выходит один хост # metrics.gendsgn.ru через Caddy под basic_auth (caddy/sites/infra.caddy). # 3000/9090/3100/9093 на 0.0.0.0 не биндятся сознательно: на Beget уже был случай, # когда redis уехал в интернет из-за лишней записи ports в базовом compose-файле. x-logging: &default-logging driver: journald services: # ── Prometheus: хранилище временных рядов + движок правил ──────────────────── prometheus: image: prom/prometheus:v3.1.0 container_name: gendesign-prometheus restart: unless-stopped user: "65534:65534" # nobody — том создаётся снаружи, см. deploy-metrics.yml command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.path=/prometheus" # Ретенция задана ЯВНО и по обоим измерениям. Без --storage.tsdb.retention.size # диск съедается молча: по умолчанию ограничение только по времени, а сколько # это в байтах — зависит от числа рядов, которое растёт само. - "--storage.tsdb.retention.time=30d" - "--storage.tsdb.retention.size=8GB" # Приёмник push-метрик от агентов. Без флага remote_write отвечает 404, # и агент на Poincare будет молча копить в WAL, а графики останутся пустыми. - "--web.enable-remote-write-receiver" # Нужен для перезагрузки правил без рестарта (deploy-metrics.yml дёргает). - "--web.enable-lifecycle" - "--web.external-url=https://metrics.gendsgn.ru/prometheus" - "--web.route-prefix=/" volumes: - ./ops/metrics/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./ops/metrics/prometheus/rules:/etc/prometheus/rules:ro # file_sd для job "alertmanager" — см. комментарий в prometheus.yml. # Забыть этот монт — значит вернуть "no such host" из-за отсутствующего # файла целей (был инцидент, когда похожий пропущенный монт положил Caddy). - ./ops/metrics/prometheus/alertmanager_targets.yml:/etc/prometheus/alertmanager_targets.yml:ro - prometheus_data:/prometheus expose: - "9090" networks: - shared - infra mem_limit: 2g logging: *default-logging healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"] interval: 30s timeout: 10s retries: 5 start_period: 30s # ── Loki: логи. Monolithic-режим — рекомендован до ~20 ГБ/сутки ────────────── # У нас ~39 МБ/сутки (замер 24.08), запас в 500 раз. Микросервисный режим здесь # был бы чистой сложностью без выигрыша. loki: image: grafana/loki:3.3.2 container_name: gendesign-loki restart: unless-stopped user: "10001:10001" command: ["-config.file=/etc/loki/loki-config.yml"] volumes: - ./ops/metrics/loki/loki-config.yml:/etc/loki/loki-config.yml:ro - loki_data:/loki expose: - "3100" networks: - shared mem_limit: 1g logging: *default-logging healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:3100/ready"] interval: 30s timeout: 10s retries: 10 start_period: 60s # ── Alertmanager: маршрутизация и дедупликация алертов ─────────────────────── # Шлёт в Telegram НАПРЯМУЮ с Beget, а не через бэкенд МЕРЫ. Это осознанно: # доставка алерта не должна зависеть от хоста, про который алерт. Если Poincare # лёг, сообщение об этом обязано уйти без его участия. alertmanager: image: prom/alertmanager:v0.28.0 container_name: gendesign-alertmanager restart: unless-stopped user: "65534:65534" # За профилем: канал доставки алертов — открытый вопрос #3078 (тот же чат, # что у вебхука GlitchTip, или отдельный; порог ночной побудки). Пока он не # решён, стек метрик поднимается и работает без Alertmanager, а не ждёт его. # Включается добавлением `alerts` в COMPOSE_PROFILES — см. часть 5. profiles: ["alerts"] command: - "--config.file=/etc/alertmanager/alertmanager.yml" - "--storage.path=/alertmanager" - "--web.external-url=https://metrics.gendsgn.ru/alertmanager" env_file: - path: ./backend/.env.runtime required: false - path: ./backend/.env required: false environment: # Токен и чат берутся из окружения — в репозиторий не попадают. # Отсутствие проверяется в deploy-metrics.yml ДО подъёма: пустой токен # даёт Alertmanager, который стартует зелёным и молча ничего не шлёт. METRICS_TELEGRAM_BOT_TOKEN: ${METRICS_TELEGRAM_BOT_TOKEN:-} METRICS_TELEGRAM_CHAT_ID: ${METRICS_TELEGRAM_CHAT_ID:-} volumes: - ./ops/metrics/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro - alertmanager_data:/alertmanager expose: - "9093" networks: - shared mem_limit: 256m logging: *default-logging healthcheck: # Путь С ПРЕФИКСОМ. `--web.external-url=…/alertmanager` выше заставляет # Alertmanager обслуживать ВСЁ под этим префиксом, включая служебные # ручки: `/-/healthy` отдаёт 404, а `/alertmanager/-/healthy` — OK. # # Прод 27.08: контейнер работал и рассылал алерты, но docker держал его # `unhealthy` бессрочно. Само по себе безобидно, но приучает не смотреть # на статус — а любая автоматика «перезапусти нездоровое» устроила бы из # этого вечный перезапуск канала оповещений. test: [ "CMD", "wget", "-q", "--spider", "http://localhost:9093/alertmanager/-/healthy", ] interval: 30s timeout: 10s retries: 5 # ── Приёмник алертов с кнопкой подтверждения ──────────────────────────────── # # Alertmanager умеет писать в Telegram сам, но инлайн-клавиатуру его интеграция # не поддерживает — а без кнопки нет обратной связи «человек увидел и взял в # работу». Клиентские инциденты (severity=critical на продуктовом хосте) идут # вебхуком сюда, всё остальное — прежним прямым путём: чем меньше звеньев у # алерта, тем он надёжнее. # # Живёт РЯДОМ С ALERTMANAGER, на инфраструктурной машине. У бота МЕРЫ уже есть # приём обновлений, и повесить обработку туда было бы дешевле, но он работает # на продуктовом хосте: при падении продукта кнопка оказалась бы мёртвой ровно # тогда, когда нужна. # # Образ без сборки и без зависимостей (только stdlib): сервис обязан # подниматься даже когда сломано всё остальное. alert-ack: image: python:3.12-slim container_name: gendesign-alert-ack restart: unless-stopped user: "65534:65534" profiles: ["alerts"] command: ["python", "-u", "/app/app.py"] env_file: - path: ./backend/.env.runtime required: false - path: ./backend/.env required: false environment: METRICS_TELEGRAM_BOT_TOKEN: ${METRICS_TELEGRAM_BOT_TOKEN:-} METRICS_TELEGRAM_CHAT_ID: ${METRICS_TELEGRAM_CHAT_ID:-} METRICS_TELEGRAM_TOPIC_ID: ${METRICS_TELEGRAM_TOPIC_ID:-} METRICS_TELEGRAM_ONCALL: ${METRICS_TELEGRAM_ONCALL:-} # Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки, # но уйдёт: алерт важнее подтверждения. ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru} volumes: - ./ops/metrics/alert-ack/app.py:/app/app.py:ro expose: - "8080" networks: - shared mem_limit: 128m logging: *default-logging healthcheck: test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8080/healthz',timeout=5)"] interval: 30s timeout: 10s retries: 5 # ── Grafana: витрина ───────────────────────────────────────────────────────── grafana: image: grafana/grafana:11.5.1 container_name: gendesign-grafana restart: unless-stopped user: "472:472" env_file: - path: ./backend/.env.runtime required: false - path: ./backend/.env required: false environment: # Доступ закрыт Caddy basic_auth снаружи. Внутренний вход Grafana оставлен # включённым намеренно: анонимный Viewer + отключённый логин лишили бы # возможности что-то настроить, а один общий пароль в Caddy не даёт # разделения ролей внутри. GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-} GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/ GF_SERVER_SERVE_FROM_SUB_PATH: "false" # Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не # хочется, чтобы наблюдатель сам ходил в интернет без нужды. GF_ANALYTICS_REPORTING_ENABLED: "false" GF_ANALYTICS_CHECK_FOR_UPDATES: "false" GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false" GF_NEWS_NEWS_FEED_ENABLED: "false" GF_USERS_ALLOW_SIGN_UP: "false" # Датасорс к БД GlitchTip: пароль read-only роли из окружения. GLITCHTIP_RO_PASSWORD: ${GLITCHTIP_RO_PASSWORD:-} TRADEIN_RO_PASSWORD: ${TRADEIN_RO_PASSWORD:-} GENDESIGN_RO_PASSWORD: ${GENDESIGN_RO_PASSWORD:-} volumes: - ./ops/metrics/grafana/provisioning:/etc/grafana/provisioning:ro - ./ops/metrics/grafana/dashboards:/var/lib/grafana/dashboards:ro - grafana_data:/var/lib/grafana expose: - "3000" networks: - shared - infra mem_limit: 512m logging: *default-logging depends_on: - prometheus - loki healthcheck: test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"] interval: 30s timeout: 10s retries: 5 start_period: 30s volumes: prometheus_data: loki_data: grafana_data: alertmanager_data: networks: shared: external: true name: gendesign_shared infra: external: true name: gendesign_default