Стек мониторинга: Prometheus + Alertmanager + Grafana + node_exporter + cAdvisor

Самодостаточный docker compose стек с деплоем через deploy.sh,
bootstrap-установкой с нуля и конфигурацией через .env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ShiZa039 2026-07-23 12:54:40 +02:00
commit 74b6f92cb8
15 changed files with 17242 additions and 0 deletions

41
.env.example Normal file
View file

@ -0,0 +1,41 @@
# Скопируй в .env и заполни: cp .env.example .env && chmod 600 .env
# Значения со спецсимволами (| $ пробелы) — обязательно в двойных кавычках.
# Файл читается и docker compose, и deploy.sh (bash), поэтому в паролях
# избегай символов $ ` \ " — надёжнее всего [A-Za-z0-9_.-].
# === Обязательные ===========================================================
# IP, на котором слушает весь стек: tailnet-IP (100.x.y.z) или публичный IP.
# Наружу на 0.0.0.0 ничего не публикуется.
BIND_IP=100.64.0.1
# Имя сервера — попадает лейблом server во все метрики и алерты
SERVER_NAME=srv-1
# Пароль admin в Grafana
GRAFANA_ADMIN_PASSWORD=changeme
# === Basic auth для Prometheus / Alertmanager / node_exporter ===============
# Пусто = без аутентификации (нормально для серверов, доступных только по tailnet).
# Для серверов с публичным BIND_IP — задать обязательно.
# Трей-клиент ходит в http://BIND_IP:9093/api/v2/alerts с этими же кредами.
METRICS_USER=admin
METRICS_PASSWORD=
# === Telegram-уведомления ===================================================
# Пусто = уведомления отключены (алерты видны в API/веб-интерфейсе Alertmanager)
TG_BOT_TOKEN=
TG_CHAT_ID=
# === Что алертить ===========================================================
# Контейнеры, которые обязаны работать (regex-подстроки имени, разделитель «|»)
CRITICAL_CONTAINERS="authentik|forgejo"
# systemd-юниты, которые обязаны быть active (разделитель «|»)
CRITICAL_UNITS="headscale.service"
# === Порты (на BIND_IP) =====================================================
NODE_EXPORTER_PORT=9100
PROMETHEUS_PORT=9090
ALERTMANAGER_PORT=9093
# 3001, чтобы не конфликтовать с forgejo на 3000
GRAFANA_PORT=3001

9
.gitattributes vendored Normal file
View file

@ -0,0 +1,9 @@
# Репозиторий редактируется в том числе с Windows — на сервере нужны LF
* text=auto
*.sh text eol=lf
*.tpl text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
*.md text eol=lf
.env.example text eol=lf

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# секреты
.env
# конфиги, отрендеренные deploy.sh из шаблонов и .env
rendered/

205
README.md Normal file
View file

@ -0,0 +1,205 @@
# Мониторинг серверов
Самодостаточный стек: **node_exporter + cAdvisor + Prometheus + Alertmanager + Grafana** в одном docker compose. Вся конфигурация в репозитории; всё специфичное для конкретного сервера — только в `.env`. Один репозиторий раскатывается на любой сервер сменой `.env`.
| Сервис | Версия | Порт (на `BIND_IP`) | Зачем |
|---|---|---|---|
| node_exporter | v1.9.1 | 9100 | CPU, RAM, диски, температуры (hwmon), сеть, systemd-юниты |
| cAdvisor | v0.52.1 | — (только внутренняя сеть) | метрики docker-контейнеров |
| Prometheus | v3.5.0 LTS | 9090 | сбор и хранение (30 дней, scrape 30s) |
| Alertmanager | v0.28.1 | 9093 | API активных алертов + Telegram |
| Grafana | 12.0.2 | 3001 | дашборды (Node Exporter Full, Cadvisor exporter) |
Наружу на `0.0.0.0` не публикуется ничего: каждый сервис слушает только `BIND_IP`.
## Установка одной командой (bootstrap)
На чистом сервере достаточно указать репозиторий — [bootstrap.sh](bootstrap.sh) сам поставит зависимости (git, curl, htpasswd, docker + compose через get.docker.com), склонирует репо в `/opt/monitoring`, при первом запуске интерактивно спросит `BIND_IP` (сам предложит tailnet-адрес, если есть tailscale), `SERVER_NAME` и пароли, создаст `.env` и запустит деплой:
```bash
curl -fsSL https://git.sab-core.ru/ShiZa/server-monitoring/raw/branch/main/bootstrap.sh \
| sudo bash -s -- --repo https://git.sab-core.ru/ShiZa/server-monitoring.git
```
Флаги: `--repo <url>` (обязателен на чистом сервере), `--dir <path>` (по умолчанию `/opt/monitoring`), `--branch <ветка>`. Для приватного репо используй URL с токеном (`https://user:token@…`) или предварительно настрой SSH-ключ и передай ssh-URL.
Повторный запуск идемпотентен: `git pull` + `deploy.sh`, `.env` не перезаписывается — так же можно обновлять уже установленный сервер. Из существующего клона запускается без флагов: `sudo bash bootstrap.sh`.
Если терминала нет (например, запуск из CI), скрипт создаст `.env` из шаблона и остановится, попросив заполнить его и запустить `bash deploy.sh`.
## Новый сервер за 5 минут (вручную)
Требования: Debian-подобная ОС, `docker` + плагин `docker compose`, `curl`.
```bash
git clone https://git.sab-core.ru/ShiZa/server-monitoring.git && cd server-monitoring
cp .env.example .env && chmod 600 .env
nano .env # заполнить BIND_IP, SERVER_NAME, GRAFANA_ADMIN_PASSWORD, остальное по необходимости
bash deploy.sh
```
`deploy.sh` идемпотентен: проверяет окружение, рендерит конфиги из `.env` в `rendered/` (в git не попадает), валидирует конфиг promtool-ом, поднимает стек и прогоняет smoke-тесты по всем пяти сервисам с выводом OK/FAIL. Запускать повторно можно всегда — после каждого изменения `.env` или правил.
## Подробная установка
### Шаг 0. Подготовка (для обоих вариантов)
```bash
# docker + compose уже должны стоять; проверка:
docker --version && docker compose version && curl --version | head -1
# клонировать репозиторий (путь любой, например /opt)
sudo git clone https://git.sab-core.ru/ShiZa/server-monitoring.git /opt/monitoring
cd /opt/monitoring
cp .env.example .env
chmod 600 .env # в .env будут пароли — закрыть от чужих глаз
```
Дальше — по одному из двух вариантов.
### Вариант 1: сервер в tailnet (Headscale/Tailscale)
**1. Узнай tailnet-адрес сервера:**
```bash
tailscale ip -4
# или, если команда недоступна:
ip -o -4 addr show tailscale0 | awk '{print $4}'
```
**2. Заполни `.env`** (минимум):
```ini
BIND_IP=100.64.0.5 # tailnet-адрес из шага 1
SERVER_NAME=srv-home # уникальное имя этого сервера
GRAFANA_ADMIN_PASSWORD=<придумай>
METRICS_PASSWORD= # можно пусто: снаружи tailnet порты недоступны
CRITICAL_CONTAINERS="authentik|forgejo"
CRITICAL_UNITS="headscale.service"
```
**3. Деплой и проверка:**
```bash
bash deploy.sh
```
Все пять проверок должны показать `[OK]`. Веб-интерфейсы открываются **только с устройств твоего tailnet**: Grafana `http://100.64.0.5:3001`, Prometheus `http://100.64.0.5:9090`, Alertmanager `http://100.64.0.5:9093`.
Замечание: если tailscaled стартует позже docker, после перезагрузки сервера контейнеры могут пару минут перезапускаться, пока tailnet-адрес не поднимется — это штатно. Хочешь убрать — добавь зависимость docker от tailscaled (`systemctl edit docker.service``After=tailscaled.service`).
### Вариант 2: сервер без tailnet (смотрит в мир напрямую)
**1. Узнай публичный IP** (из панели VPS-провайдера или `ip -o -4 addr show`; это должен быть адрес интерфейса сервера, а не NAT).
**2. Заполни `.env`** — здесь пароль на метрики **обязателен**:
```ini
BIND_IP=203.0.113.10 # публичный IP сервера
SERVER_NAME=vps-1
GRAFANA_ADMIN_PASSWORD=<придумай>
METRICS_USER=admin
METRICS_PASSWORD=<придумай> # basic auth на Prometheus/Alertmanager/node_exporter
CRITICAL_CONTAINERS="..." # что крутится на этом сервере
CRITICAL_UNITS="..." # какие юниты критичны (headscale тут может не быть)
```
deploy.sh при пустом `METRICS_PASSWORD` выдаст предупреждение — на публичном сервере не игнорируй его.
**3. Ограничь доступ фаерволом** (рекомендуется, вторая линия защиты после basic auth). Пример для ufw — доступ к мониторингу только со своих IP:
```bash
MY_IP=<твой домашний/рабочий IP>
for p in 9090 9093 9100 3001; do sudo ufw allow from $MY_IP to any port $p proto tcp; done
for p in 9090 9093 9100 3001; do sudo ufw deny $p/tcp; done
sudo ufw status numbered
```
**4. Деплой и проверка:**
```bash
bash deploy.sh
```
Проверь, что без пароля API закрыт, а с паролем работает:
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://203.0.113.10:9090/-/healthy # 401
curl -su admin:ПАРОЛЬ -o /dev/null -w '%{http_code}\n' http://203.0.113.10:9090/-/healthy # 200
```
Grafana (`http://IP:3001`) защищена собственным логином, cAdvisor наружу не публикуется вовсе.
### После установки (оба варианта)
1. Зайди в Grafana (`admin` / `GRAFANA_ADMIN_PASSWORD`) → папка **Monitoring** → дашборды «Node Exporter Full» и «Cadvisor exporter» уже на месте.
2. Проверь таргеты: `http://BIND_IP:9090/targets` — все четыре job (prometheus, node, cadvisor + сам alertmanager в разделе alerting) должны быть UP.
3. Проверь температуры (раздел ниже) и Telegram: временно останови тестовый контейнер — через ~4 минуты придёт алерт `ContainerDown`, после запуска — resolved.
## Переменные `.env`
| Переменная | Обязательная | Что делает |
|---|---|---|
| `BIND_IP` | да | IP, на котором слушает стек: tailnet-IP (100.x.y.z) или публичный IP сервера |
| `SERVER_NAME` | да | лейбл `server` во всех метриках и алертах |
| `GRAFANA_ADMIN_PASSWORD` | да | пароль admin в Grafana |
| `METRICS_USER` / `METRICS_PASSWORD` | для публичных серверов | basic auth на Prometheus, Alertmanager и node_exporter. Пусто — без аутентификации (допустимо только когда `BIND_IP` доступен лишь по tailnet) |
| `TG_BOT_TOKEN` / `TG_CHAT_ID` | нет | Telegram-уведомления; пусто — алерты только в API/веб-интерфейсе |
| `CRITICAL_CONTAINERS` | нет | контейнеры, обязанные работать (`ContainerDown`), подстроки имени через `\|`, например `"authentik\|forgejo"` |
| `CRITICAL_UNITS` | нет | systemd-юниты, обязанные быть active (`SystemdUnitDown`), через `\|` |
| `*_PORT` | нет | порты сервисов; Grafana по умолчанию 3001, чтобы не конфликтовать с forgejo |
Значения с `|` — в двойных кавычках. Добавить контейнер или юнит под алерт = дописать его в переменную и снова запустить `bash deploy.sh`.
## Безопасность: tailnet против «смотрит в мир»
- **Сервер в tailnet**: `BIND_IP` = tailnet-адрес, `METRICS_PASSWORD` можно оставить пустым — доступ и так только из tailnet.
- **Сервер с публичным IP**: `METRICS_PASSWORD` обязателен (deploy.sh сгенерирует bcrypt и включит basic auth на Prometheus/Alertmanager/node_exporter; Grafana защищена своим логином; cAdvisor наружу не торчит вообще). Дополнительно стоит ограничить порты 9090/9093/9100/3001 фаерволом по своим IP.
- `BIND_IP=127.0.0.1` (только SSH-туннель) не поддерживается: Prometheus из контейнера не достучится до node_exporter на loopback хоста.
- Если `BIND_IP` — tailnet-адрес и tailscaled стартует позже docker, контейнеры при загрузке могут несколько раз перезапуститься, пока адрес не появится — это нормально (`restart: unless-stopped` доведёт до рабочего состояния).
Трей-клиент забирает активные алерты с `http://BIND_IP:9093/api/v2/alerts` (с basic auth, если включён).
## Алерты
Статические (`prometheus/rules/alerts.yml`): `InstanceDown` (critical, 2м), `HighCPU` >90% 5м, `HighRAM` >90% 5м, `DiskFull` >85% warning / >95% critical, `HighTemp` >80°C 3м (сенсоры coretemp/k10temp). Генерируемые из `.env` (`rendered/generated-rules.yml`): `SystemdUnitDown` (critical), `ContainerDown` (critical, ловит и остановленный, и удалённый контейнер). Critical-алерт глушит одноимённый warning (inhibit_rules).
## Температура CPU
Метрики берутся из hwmon (`node_hwmon_temp_celsius`). Проверка на сервере:
```bash
curl -su "$METRICS_USER:$METRICS_PASSWORD" http://$BIND_IP:9100/metrics | grep node_hwmon_temp_celsius
```
Если пусто — на хосте не загружен драйвер сенсоров:
```bash
ls /sys/class/hwmon/ # пусто или только nvme/acpi?
sudo modprobe coretemp # Intel
sudo modprobe k10temp # AMD
echo coretemp | sudo tee /etc/modules-load.d/sensors.conf # автозагрузка
```
Правило `HighTemp` фильтрует чипы по regex `coretemp|k10temp` — если у железа другой чип (смотри лейбл `chip` в метриках), поправь regex в `prometheus/rules/alerts.yml`.
## Как добавить удалённый сервер (пока только описание)
Вариант A — **центральный Prometheus + экспортеры на удалёнке** (проще, для серверов в одном tailnet):
1. На удалённом сервере поднять из этого же репозитория только `node_exporter` и `cadvisor` (свой `.env` с его tailnet-IP).
2. В `prometheus/prometheus.yml.tpl` центрального сервера добавить в `scrape_configs` job с таргетом `100.x.y.z:9100` (и при необходимости пробросить порт cAdvisor на tailnet-IP удалёнки) с лейблом `server`.
3. `bash deploy.sh` на центральном — правила и дашборды подхватят новый сервер автоматически (везде есть лейбл `server`/`instance`).
Вариант B — **полный стек на каждом сервере** (для серверов вне tailnet, «смотрят в мир»): каждый сервер алертит сам за себя в тот же Telegram-чат, а централизованный обзор — либо federation (центральный Prometheus забирает срез метрик с `https://ip:9090/federate` с basic auth), либо просто второй datasource в центральной Grafana. Не требует связности между серверами, кроме доступа к 9090.
## Эксплуатация
```bash
docker compose ps # статус
docker compose logs -f prometheus # логи сервиса
bash deploy.sh # применить изменения .env/правил (идемпотентно)
docker compose pull && bash deploy.sh # обновление образов (после смены тегов в compose)
docker compose down # остановить (данные в volumes сохраняются)
```
Существующие контейнеры и сети (authentik, forgejo и т.д.) стек не трогает — их метрики читает cAdvisor через ro-маунты docker.

View file

@ -0,0 +1,24 @@
# Шаблон: entrypoint.sh рендерит его в /tmp/alertmanager.yml при старте контейнера.
# Строки с префиксом #TG# включаются, только если заданы TG_BOT_TOKEN и TG_CHAT_ID.
route:
receiver: __DEFAULT_RECEIVER__
group_by: ['alertname', 'server', 'instance']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
# critical глушит warning того же алерта (например, DiskFull 95% глушит 85%)
inhibit_rules:
- source_matchers: ['severity = critical']
target_matchers: ['severity = warning']
equal: ['alertname', 'server', 'instance', 'mountpoint']
receivers:
- name: blackhole
#TG# - name: telegram
#TG# telegram_configs:
#TG# - bot_token: '__TG_BOT_TOKEN__'
#TG# chat_id: __TG_CHAT_ID__
#TG# parse_mode: 'HTML'
#TG# send_resolved: true

View file

@ -0,0 +1,25 @@
#!/bin/sh
# Рендерит alertmanager.yml из шаблона: секреты приходят через переменные
# окружения контейнера и не попадают в файлы на хосте.
set -eu
TPL=/etc/alertmanager/alertmanager.yml.tpl
OUT=/tmp/alertmanager.yml
if [ -n "${TG_BOT_TOKEN:-}" ] && [ -n "${TG_CHAT_ID:-}" ]; then
sed -e 's/^#TG#//' \
-e 's|__DEFAULT_RECEIVER__|telegram|' \
-e "s|__TG_BOT_TOKEN__|${TG_BOT_TOKEN}|" \
-e "s|__TG_CHAT_ID__|${TG_CHAT_ID}|" \
"$TPL" > "$OUT"
else
echo "WARN: TG_BOT_TOKEN / TG_CHAT_ID не заданы — Telegram-уведомления отключены (receiver: blackhole)" >&2
sed -e '/^#TG#/d' \
-e 's|__DEFAULT_RECEIVER__|blackhole|' \
"$TPL" > "$OUT"
fi
exec /bin/alertmanager \
--config.file="$OUT" \
--storage.path=/alertmanager \
--web.config.file=/etc/alertmanager/web.yml

141
bootstrap.sh Normal file
View file

@ -0,0 +1,141 @@
#!/usr/bin/env bash
# Установка «с нуля» одной командой: ставит зависимости (git, curl, docker,
# compose, htpasswd), клонирует/обновляет репозиторий, создаёт .env (интерактивно
# при первом запуске) и запускает deploy.sh. Идемпотентен — повторный запуск
# просто подтянет свежий код и переприменит конфигурацию.
#
# Использование (на чистом сервере, от root):
# curl -fsSL https://git.sab-core.ru/ShiZa/server-monitoring/raw/branch/main/bootstrap.sh \
# | sudo bash -s -- --repo https://git.sab-core.ru/ShiZa/server-monitoring.git
#
# Или из уже склонированного репозитория:
# sudo bash bootstrap.sh
#
# Флаги: --repo <url> URL git-репозитория (обязателен вне клона)
# --dir <path> куда клонировать (по умолчанию /opt/monitoring)
# --branch <br> ветка (по умолчанию ветка по умолчанию репо)
set -euo pipefail
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RESET=$'\033[0m'
say() { echo "${C_GREEN}==>${C_RESET} $*"; }
warn() { echo "${C_YELLOW}[WARN]${C_RESET} $*"; }
die() { echo "${C_RED}[ERR]${C_RESET} $*" >&2; exit 1; }
REPO_URL=""
TARGET_DIR="/opt/monitoring"
BRANCH=""
while [ $# -gt 0 ]; do
case "$1" in
--repo) REPO_URL="${2:?}"; shift 2 ;;
--dir) TARGET_DIR="${2:?}"; shift 2 ;;
--branch) BRANCH="${2:?}"; shift 2 ;;
-h|--help) grep '^#' "$0" | head -20; exit 0 ;;
*) die "неизвестный аргумент: $1" ;;
esac
done
[ "$(id -u)" = 0 ] || die "нужны права root: запусти через sudo"
command -v apt-get >/dev/null 2>&1 || die "поддерживается только Debian/Ubuntu (нужен apt-get)"
# --- 1. Базовые пакеты -------------------------------------------------------
declare -A CMD_PKG=([curl]=curl [git]=git [htpasswd]=apache2-utils)
MISSING=()
for cmd in "${!CMD_PKG[@]}"; do
command -v "$cmd" >/dev/null 2>&1 || MISSING+=("${CMD_PKG[$cmd]}")
done
if [ "${#MISSING[@]}" -gt 0 ]; then
say "Ставлю пакеты: ${MISSING[*]}"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ca-certificates "${MISSING[@]}"
fi
# --- 2. Docker + compose -----------------------------------------------------
if ! command -v docker >/dev/null 2>&1; then
say "Docker не найден — ставлю через get.docker.com"
curl -fsSL https://get.docker.com | sh
fi
if ! docker compose version >/dev/null 2>&1; then
say "Плагин docker compose не найден — ставлю docker-compose-plugin"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker-compose-plugin \
|| die "не удалось поставить docker-compose-plugin — поставь вручную"
fi
systemctl enable --now docker >/dev/null 2>&1 || true
docker info >/dev/null 2>&1 || die "docker-демон не запущен (systemctl status docker)"
# --- 3. Репозиторий ----------------------------------------------------------
if [ -z "${REPO_URL}" ] && [ -f ./deploy.sh ] && [ -f ./docker-compose.yml ]; then
# запущен из уже склонированного репозитория
TARGET_DIR="$(pwd)"
say "Использую текущий клон: ${TARGET_DIR}"
if [ -d .git ] && git remote get-url origin >/dev/null 2>&1; then
git pull --ff-only || warn "git pull не удался (локальные изменения?) — продолжаю с текущим кодом"
fi
elif [ -d "${TARGET_DIR}/.git" ]; then
say "Обновляю ${TARGET_DIR}"
git -C "${TARGET_DIR}" fetch --all --prune
if [ -n "${BRANCH}" ]; then git -C "${TARGET_DIR}" checkout "${BRANCH}"; fi
git -C "${TARGET_DIR}" pull --ff-only || warn "git pull не удался — продолжаю с текущим кодом"
else
[ -n "${REPO_URL}" ] || die "укажи --repo <url> (или запусти скрипт из клона репозитория)"
say "Клонирую ${REPO_URL}${TARGET_DIR}"
if [ -n "${BRANCH}" ]; then
git clone --branch "${BRANCH}" "${REPO_URL}" "${TARGET_DIR}"
else
git clone "${REPO_URL}" "${TARGET_DIR}"
fi
fi
cd "${TARGET_DIR}"
# стек может лежать в подкаталоге monitoring/ репозитория
[ -f deploy.sh ] || { [ -f monitoring/deploy.sh ] && cd monitoring; } || die "deploy.sh не найден в репозитории"
# --- 4. .env: интерактивное создание при первом запуске ----------------------
if [ ! -f .env ]; then
cp .env.example .env
chmod 600 .env
if [ -r /dev/tty ] && [ -w /dev/tty ]; then
say "Первый запуск — настрою .env (значения можно потом поменять: nano .env && bash deploy.sh)"
DEFAULT_IP=""
if command -v tailscale >/dev/null 2>&1; then
DEFAULT_IP="$(tailscale ip -4 2>/dev/null | head -1 || true)"
fi
[ -n "${DEFAULT_IP}" ] || DEFAULT_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
echo "Адреса на интерфейсах:" >/dev/tty
ip -o -4 addr show scope global 2>/dev/null | awk '{print " " $2 ": " $4}' >/dev/tty || true
read -r -p "BIND_IP (tailnet или публичный IP) [${DEFAULT_IP}]: " BIND_IP </dev/tty
BIND_IP="${BIND_IP:-${DEFAULT_IP}}"
[ -n "${BIND_IP}" ] || die "BIND_IP обязателен"
DEFAULT_NAME="$(hostname -s)"
read -r -p "SERVER_NAME [${DEFAULT_NAME}]: " SERVER_NAME </dev/tty
SERVER_NAME="${SERVER_NAME:-${DEFAULT_NAME}}"
GRAFANA_ADMIN_PASSWORD=""
while [ -z "${GRAFANA_ADMIN_PASSWORD}" ]; do
read -r -s -p "Пароль admin для Grafana: " GRAFANA_ADMIN_PASSWORD </dev/tty; echo >/dev/tty
done
read -r -s -p "METRICS_PASSWORD (basic auth на Prometheus/Alertmanager/node_exporter; ПУСТО допустимо только для tailnet-серверов): " METRICS_PASSWORD </dev/tty
echo >/dev/tty
[ -n "${METRICS_PASSWORD}" ] || warn "METRICS_PASSWORD пуст — метрики будут без аутентификации"
sed -i \
-e "s|^BIND_IP=.*|BIND_IP=${BIND_IP}|" \
-e "s|^SERVER_NAME=.*|SERVER_NAME=${SERVER_NAME}|" \
-e "s|^GRAFANA_ADMIN_PASSWORD=.*|GRAFANA_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}|" \
-e "s|^METRICS_PASSWORD=.*|METRICS_PASSWORD=${METRICS_PASSWORD}|" \
.env
say ".env создан. Списки CRITICAL_CONTAINERS / CRITICAL_UNITS и Telegram-токены — правь в $(pwd)/.env"
else
warn "Нет терминала для вопросов: создан $(pwd)/.env из шаблона."
warn "Заполни его (nano .env) и запусти: bash deploy.sh"
exit 0
fi
fi
# --- 5. Деплой ---------------------------------------------------------------
say "Запускаю deploy.sh"
bash deploy.sh

179
deploy.sh Normal file
View file

@ -0,0 +1,179 @@
#!/usr/bin/env bash
# Идемпотентный деплой стека мониторинга: рендер конфигов из .env,
# валидация, запуск, smoke-тесты. Безопасно запускать повторно.
set -euo pipefail
cd "$(cd "$(dirname "$0")" && pwd)"
C_GREEN=$'\033[32m'; C_RED=$'\033[31m'; C_YELLOW=$'\033[33m'; C_RESET=$'\033[0m'
ok() { echo "${C_GREEN}[OK]${C_RESET} $*"; }
fail() { echo "${C_RED}[FAIL]${C_RESET} $*"; FAILED=1; }
warn() { echo "${C_YELLOW}[WARN]${C_RESET} $*"; }
die() { echo "${C_RED}[ERR]${C_RESET} $*" >&2; exit 1; }
FAILED=0
# --- 1. Проверки окружения --------------------------------------------------
command -v docker >/dev/null 2>&1 || die "docker не найден"
docker compose version >/dev/null 2>&1 || die "плагин docker compose (v2) не найден"
command -v curl >/dev/null 2>&1 || die "curl не найден (apt install curl)"
[ -f .env ] || die "нет .env — выполни: cp .env.example .env && chmod 600 .env, затем заполни"
set -a; . ./.env; set +a
: "${BIND_IP:?BIND_IP не задан в .env}"
: "${SERVER_NAME:?SERVER_NAME не задан в .env}"
: "${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD не задан в .env}"
METRICS_USER="${METRICS_USER:-admin}"
METRICS_PASSWORD="${METRICS_PASSWORD:-}"
NODE_EXPORTER_PORT="${NODE_EXPORTER_PORT:-9100}"
PROMETHEUS_PORT="${PROMETHEUS_PORT:-9090}"
ALERTMANAGER_PORT="${ALERTMANAGER_PORT:-9093}"
GRAFANA_PORT="${GRAFANA_PORT:-3001}"
CRITICAL_UNITS="${CRITICAL_UNITS:-headscale.service}"
CRITICAL_CONTAINERS="${CRITICAL_CONTAINERS:-}"
if ! ip -o addr 2>/dev/null | grep -qF " ${BIND_IP}/"; then
warn "адрес ${BIND_IP} не найден на интерфейсах сервера — bind может не подняться"
fi
# --- 2. Рендер конфигов в rendered/ ------------------------------------------
mkdir -p rendered
# 2.1 basic auth: web.yml (bcrypt) + файл пароля для scrape-конфигов Prometheus
bcrypt_hash() {
if command -v htpasswd >/dev/null 2>&1; then
htpasswd -nbB x "$1" | cut -d: -f2
else
docker run --rm httpd:2.4-alpine htpasswd -nbB x "$1" | cut -d: -f2 \
|| die "не удалось сгенерировать bcrypt-хеш: поставь apache2-utils (htpasswd) или дай docker доступ в сеть"
fi
}
printf '%s' "${METRICS_PASSWORD}" > rendered/password
if [ -n "${METRICS_PASSWORD}" ]; then
HASH="$(bcrypt_hash "${METRICS_PASSWORD}")"
[ -n "${HASH}" ] || die "пустой bcrypt-хеш"
printf 'basic_auth_users:\n %s: %s\n' "${METRICS_USER}" "${HASH}" > rendered/web.yml
AUTH_ENABLED=1
ok "basic auth включён (пользователь ${METRICS_USER})"
else
printf '# basic auth отключён: METRICS_PASSWORD пуст\n' > rendered/web.yml
AUTH_ENABLED=0
warn "METRICS_PASSWORD пуст — Prometheus/Alertmanager/node_exporter доступны без аутентификации"
fi
chmod 644 rendered/password rendered/web.yml
# 2.2 prometheus.yml из шаблона
SED_ARGS=(
-e "s|__SERVER_NAME__|${SERVER_NAME}|g"
-e "s|__BIND_IP__|${BIND_IP}|g"
-e "s|__NODE_EXPORTER_PORT__|${NODE_EXPORTER_PORT}|g"
-e "s|__METRICS_USER__|${METRICS_USER}|g"
)
if [ "${AUTH_ENABLED}" = 1 ]; then
SED_ARGS+=(-e 's|^#AUTH#||')
else
SED_ARGS+=(-e '/^#AUTH#/d')
fi
sed "${SED_ARGS[@]}" prometheus/prometheus.yml.tpl > rendered/prometheus.yml
ok "rendered/prometheus.yml"
# 2.3 правила из .env: SystemdUnitDown + ContainerDown
{
echo "# Сгенерировано deploy.sh из .env — не редактировать вручную."
echo "groups:"
echo " - name: generated"
echo " rules:"
any=0
for u in $(printf '%s' "${CRITICAL_UNITS}" | tr '|,' ' '); do
any=1
cat <<EOF
- alert: SystemdUnitDown
expr: node_systemd_unit_state{name="${u}",state="active"} == 0 or absent(node_systemd_unit_state{name="${u}"})
for: 1m
labels:
severity: critical
unit: ${u}
annotations:
summary: 'systemd-юнит ${u} не активен'
description: 'Юнит ${u} не в состоянии active (или метрика недоступна) дольше 1 минуты.'
EOF
done
for c in $(printf '%s' "${CRITICAL_CONTAINERS}" | tr '|,' ' '); do
any=1
cat <<EOF
- alert: ContainerDown
expr: absent(container_last_seen{name=~".*${c}.*"}) or (time() - max(container_last_seen{name=~".*${c}.*"})) > 120
for: 2m
labels:
severity: critical
container: ${c}
annotations:
summary: 'Контейнер ${c} не работает'
description: 'Контейнер с именем ~ ".*${c}.*" отсутствует или не отвечает дольше 2 минут.'
EOF
done
[ "${any}" = 1 ] || echo " []"
} > rendered/generated-rules.yml
ok "rendered/generated-rules.yml (юниты: ${CRITICAL_UNITS}; контейнеры: ${CRITICAL_CONTAINERS:-нет})"
# --- 3. Валидация конфигурации Prometheus ------------------------------------
echo "— promtool check config…"
docker compose run --rm --no-deps --entrypoint promtool prometheus \
check config /etc/prometheus/prometheus.yml \
|| die "конфигурация Prometheus не прошла валидацию"
# --- 4. Запуск ----------------------------------------------------------------
echo "— docker compose up -d…"
docker compose up -d --remove-orphans
# перечитать конфиги, если контейнеры уже работали (рендер мог измениться)
docker compose kill -s HUP prometheus alertmanager >/dev/null 2>&1 || true
# --- 5. Smoke-тесты -----------------------------------------------------------
echo "— smoke-тесты (до 40 секунд на сервис)…"
AUTH=()
[ -n "${METRICS_PASSWORD}" ] && AUTH=(-u "${METRICS_USER}:${METRICS_PASSWORD}")
http_check() { # имя url use_auth(yes|no)
local name="$1" url="$2" use_auth="${3:-yes}" i
local opts=()
[ "${use_auth}" = yes ] && opts=("${AUTH[@]}")
for i in $(seq 1 20); do
if curl -fsS -o /dev/null --max-time 3 "${opts[@]}" "${url}"; then
ok "${name} ${url}"
return 0
fi
sleep 2
done
fail "${name} ${url} — недоступен (docker compose logs ${name})"
}
http_check node_exporter "http://${BIND_IP}:${NODE_EXPORTER_PORT}/metrics"
http_check prometheus "http://${BIND_IP}:${PROMETHEUS_PORT}/-/healthy"
http_check alertmanager "http://${BIND_IP}:${ALERTMANAGER_PORT}/-/healthy"
http_check grafana "http://${BIND_IP}:${GRAFANA_PORT}/api/health" no
cadvisor_ok=0
for i in $(seq 1 10); do
if docker compose exec -T cadvisor wget -q -O /dev/null http://localhost:8080/healthz 2>/dev/null; then
cadvisor_ok=1; break
fi
sleep 2
done
if [ "${cadvisor_ok}" = 1 ]; then
ok "cadvisor внутренний /healthz (наружу не публикуется)"
else
fail "cadvisor /healthz — недоступен (docker compose logs cadvisor)"
fi
# --- 6. Итог -------------------------------------------------------------------
echo
if [ "${FAILED}" = 0 ]; then
echo "Готово. Сервисы:"
echo " Grafana: http://${BIND_IP}:${GRAFANA_PORT} (admin / GRAFANA_ADMIN_PASSWORD)"
echo " Prometheus: http://${BIND_IP}:${PROMETHEUS_PORT}"
echo " Alertmanager: http://${BIND_IP}:${ALERTMANAGER_PORT} (API алертов: /api/v2/alerts)"
else
echo "Часть проверок провалилась — смотри вывод выше и docker compose logs."
exit 1
fi

121
docker-compose.yml Normal file
View file

@ -0,0 +1,121 @@
name: monitoring
services:
node_exporter:
image: prom/node-exporter:v1.9.1
container_name: node_exporter
restart: unless-stopped
# host-сеть обязательна для настоящих хостовых сетевых метрик:
# из bridge-сети виден только netns контейнера
network_mode: host
pid: host
# root нужен systemd-коллектору для чтения состояния юнитов через D-Bus
user: root
volumes:
- /:/host:ro,rslave
- /run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro
- ./rendered/web.yml:/etc/node_exporter/web.yml:ro
command:
- --path.rootfs=/host
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --web.listen-address=${BIND_IP:?BIND_IP не задан — заполни .env}:${NODE_EXPORTER_PORT:-9100}
- --web.config.file=/etc/node_exporter/web.yml
- --collector.systemd
# без фильтра коллектор тащит сотни юнитов; разделитель списка — «|»
- --collector.systemd.unit-include=^(${CRITICAL_UNITS:-headscale.service})$$
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.52.1
container_name: cadvisor
restart: unless-stopped
# порт наружу не публикуется намеренно: у cAdvisor нет аутентификации,
# Prometheus ходит к нему по внутренней сети
privileged: true
devices:
- /dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker:/var/lib/docker:ro
- /dev/disk:/dev/disk:ro
command:
- --docker_only=true
- --housekeeping_interval=30s
networks:
- monitoring
prometheus:
image: prom/prometheus:v3.5.0
container_name: prometheus
restart: unless-stopped
depends_on:
- cadvisor
- alertmanager
ports:
- "${BIND_IP:?}:${PROMETHEUS_PORT:-9090}:9090"
volumes:
- ./rendered/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/rules/alerts.yml:/etc/prometheus/rules/alerts.yml:ro
- ./rendered/generated-rules.yml:/etc/prometheus/rules/generated.yml:ro
- ./rendered/web.yml:/etc/prometheus/web.yml:ro
- ./rendered/password:/etc/prometheus/auth/password:ro
- prometheus_data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=30d
- --web.config.file=/etc/prometheus/web.yml
networks:
- monitoring
alertmanager:
image: prom/alertmanager:v0.28.1
container_name: alertmanager
restart: unless-stopped
ports:
- "${BIND_IP:?}:${ALERTMANAGER_PORT:-9093}:9093"
environment:
TG_BOT_TOKEN: ${TG_BOT_TOKEN:-}
TG_CHAT_ID: ${TG_CHAT_ID:-}
# entrypoint рендерит alertmanager.yml из шаблона (подстановка секретов из env),
# секреты не появляются в файлах ни в репо, ни на диске
entrypoint: ["/bin/sh", "/etc/alertmanager/entrypoint.sh"]
volumes:
- ./alertmanager:/etc/alertmanager:ro
- ./rendered/web.yml:/etc/alertmanager/web.yml:ro
- alertmanager_data:/alertmanager
networks:
- monitoring
grafana:
image: grafana/grafana:12.0.2
container_name: grafana
restart: unless-stopped
depends_on:
- prometheus
ports:
- "${BIND_IP:?}:${GRAFANA_PORT:-3001}:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD не задан — заполни .env}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_ANALYTICS_REPORTING_ENABLED: "false"
# прокидываются в provisioning datasource (basic auth к Prometheus)
METRICS_USER: ${METRICS_USER:-admin}
METRICS_PASSWORD: ${METRICS_PASSWORD:-}
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- grafana_data:/var/lib/grafana
networks:
- monitoring
volumes:
prometheus_data:
alertmanager_data:
grafana_data:
networks:
monitoring:
driver: bridge

View file

@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: monitoring
folder: Monitoring
type: file
disableDeletion: false
allowUiUpdates: true
updateIntervalSeconds: 120
options:
path: /etc/grafana/provisioning/dashboards/json
foldersFromFilesStructure: false

View file

@ -0,0 +1,817 @@
{
"__inputs": [
{
"name": "DS_PROMETHEUS",
"label": "Prometheus",
"description": "Prometheus as the datasource is obligatory",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus"
}
],
"__requires": [
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "7.4.5"
},
{
"type": "panel",
"id": "graph",
"name": "Graph",
"version": ""
},
{
"type": "datasource",
"id": "prometheus",
"name": "Prometheus",
"version": "1.0.0"
},
{
"type": "panel",
"id": "table",
"name": "Table",
"version": ""
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": 14282,
"graphTooltip": 0,
"id": null,
"iteration": 1617715580880,
"links": [],
"panels": [
{
"collapsed": false,
"datasource": "Prometheus",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 8,
"panels": [],
"title": "CPU",
"type": "row"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 7,
"w": 24,
"x": 0,
"y": 1
},
"hiddenSeries": false,
"id": 15,
"legend": {
"alignAsTable": true,
"avg": true,
"current": false,
"max": true,
"min": false,
"rightSide": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null as zero",
"options": {
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.4.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": true,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(container_cpu_usage_seconds_total{instance=~\"$host\",name=~\"$container\",name=~\".+\"}[5m])) by (name) *100",
"hide": false,
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "CPU Usage",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:606",
"format": "percent",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"$$hashKey": "object:607",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"collapsed": false,
"datasource": "Prometheus",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 8
},
"id": 11,
"panels": [],
"title": "Memory",
"type": "row"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 9
},
"hiddenSeries": false,
"id": 9,
"legend": {
"alignAsTable": true,
"avg": true,
"current": false,
"max": true,
"min": false,
"rightSide": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null as zero",
"options": {
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.4.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": true,
"steppedLine": false,
"targets": [
{
"expr": "sum(container_memory_rss{instance=~\"$host\",name=~\"$container\",name=~\".+\"}) by (name)",
"hide": false,
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Memory Usage",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:606",
"format": "bytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"$$hashKey": "object:607",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 9
},
"hiddenSeries": false,
"id": 14,
"legend": {
"alignAsTable": true,
"avg": true,
"current": false,
"max": true,
"min": false,
"rightSide": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null as zero",
"options": {
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.4.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": true,
"steppedLine": false,
"targets": [
{
"expr": "sum(container_memory_cache{instance=~\"$host\",name=~\"$container\",name=~\".+\"}) by (name)",
"hide": false,
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Memory Cached",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:606",
"format": "bytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"$$hashKey": "object:607",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"collapsed": false,
"datasource": "Prometheus",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 17
},
"id": 2,
"panels": [],
"title": "Network",
"type": "row"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 18
},
"hiddenSeries": false,
"id": 4,
"legend": {
"alignAsTable": true,
"avg": true,
"current": false,
"hideEmpty": false,
"hideZero": false,
"max": true,
"min": false,
"rightSide": true,
"show": true,
"sideWidth": null,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.4.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(container_network_receive_bytes_total{instance=~\"$host\",name=~\"$container\",name=~\".+\"}[5m])) by (name)",
"hide": false,
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Received Network Traffic",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:674",
"format": "Bps",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"$$hashKey": "object:675",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 18
},
"hiddenSeries": false,
"id": 6,
"legend": {
"alignAsTable": true,
"avg": true,
"current": false,
"max": true,
"min": false,
"rightSide": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.4.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(container_network_transmit_bytes_total{instance=~\"$host\",name=~\"$container\",name=~\".+\"}[5m])) by (name)",
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Sent Network Traffic",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:832",
"format": "Bps",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"$$hashKey": "object:833",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"collapsed": false,
"datasource": "Prometheus",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 26
},
"id": 19,
"panels": [],
"title": "Misc",
"type": "row"
},
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"custom": {
"align": null,
"filterable": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "id"
},
"properties": [
{
"id": "custom.width",
"value": 260
}
]
},
{
"matcher": {
"id": "byName",
"options": "Running"
},
"properties": [
{
"id": "unit",
"value": "d"
},
{
"id": "decimals",
"value": 1
},
{
"id": "custom.displayMode",
"value": "color-text"
},
{
"id": "color",
"value": {
"fixedColor": "dark-green",
"mode": "fixed"
}
}
]
}
]
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 27
},
"id": 17,
"options": {
"showHeader": true,
"sortBy": []
},
"pluginVersion": "7.4.5",
"targets": [
{
"expr": "(time() - container_start_time_seconds{instance=~\"$host\",name=~\"$container\",name=~\".+\"})/86400",
"format": "table",
"instant": true,
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Containers Info",
"transformations": [
{
"id": "filterFieldsByName",
"options": {
"include": {
"names": [
"container_label_com_docker_compose_project",
"container_label_com_docker_compose_project_working_dir",
"image",
"instance",
"name",
"Value",
"container_label_com_docker_compose_service"
]
}
}
},
{
"id": "organize",
"options": {
"excludeByName": {},
"indexByName": {},
"renameByName": {
"Value": "Running",
"container_label_com_docker_compose_project": "Label",
"container_label_com_docker_compose_project_working_dir": "Working dir",
"container_label_com_docker_compose_service": "Service",
"image": "Registry Image",
"instance": "Instance",
"name": "Name"
}
}
}
],
"type": "table"
}
],
"schemaVersion": 27,
"style": "dark",
"tags": [
"cadvisor",
"docker"
],
"templating": {
"list": [
{
"allValue": ".*",
"current": {},
"datasource": "Prometheus",
"definition": "label_values({__name__=~\"container.*\"},instance)",
"description": null,
"error": null,
"hide": 0,
"includeAll": true,
"label": "Host",
"multi": false,
"name": "host",
"options": [],
"query": {
"query": "label_values({__name__=~\"container.*\"},instance)",
"refId": "Prometheus-host-Variable-Query"
},
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 5,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"allValue": ".*",
"current": {},
"datasource": "Prometheus",
"definition": "label_values({__name__=~\"container.*\", instance=~\"$host\"},name)",
"description": null,
"error": null,
"hide": 0,
"includeAll": true,
"label": "Container",
"multi": false,
"name": "container",
"options": [],
"query": {
"query": "label_values({__name__=~\"container.*\", instance=~\"$host\"},name)",
"refId": "Prometheus-container-Variable-Query"
},
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Cadvisor exporter",
"uid": "pMEd7m0Mz",
"version": 1,
"description": "Simple exporter for cadvisor only"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
# Автопровижининг источника данных. METRICS_USER/METRICS_PASSWORD
# приходят из окружения контейнера (Grafana разворачивает $VAR в provisioning).
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
basicAuth: true
basicAuthUser: $METRICS_USER
secureJsonData:
basicAuthPassword: $METRICS_PASSWORD
jsonData:
timeInterval: 30s
httpMethod: POST

View file

@ -0,0 +1,43 @@
# Шаблон: deploy.sh рендерит его в rendered/prometheus.yml.
# __ПЛЕЙСХОЛДЕРЫ__ подставляются из .env; строки с префиксом #AUTH#
# включаются только при непустом METRICS_PASSWORD.
global:
scrape_interval: 30s
evaluation_interval: 30s
external_labels:
server: '__SERVER_NAME__'
rule_files:
- /etc/prometheus/rules/*.yml
alerting:
alertmanagers:
- static_configs:
- targets: ['alertmanager:9093']
#AUTH# basic_auth:
#AUTH# username: '__METRICS_USER__'
#AUTH# password_file: /etc/prometheus/auth/password
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ['localhost:9090']
#AUTH# basic_auth:
#AUTH# username: '__METRICS_USER__'
#AUTH# password_file: /etc/prometheus/auth/password
# node_exporter работает в host-сети и слушает BIND_IP
- job_name: node
static_configs:
- targets: ['__BIND_IP__:__NODE_EXPORTER_PORT__']
#AUTH# basic_auth:
#AUTH# username: '__METRICS_USER__'
#AUTH# password_file: /etc/prometheus/auth/password
# cAdvisor доступен только по внутренней compose-сети, без auth
- job_name: cadvisor
static_configs:
- targets: ['cadvisor:8080']
# Сюда добавляются удалённые серверы (scrape по tailnet) — см. README

View file

@ -0,0 +1,65 @@
# Статические правила алертов (одинаковые для всех серверов).
# Правила, зависящие от .env (SystemdUnitDown, ContainerDown),
# генерирует deploy.sh в rendered/generated-rules.yml.
groups:
- name: host
rules:
- alert: InstanceDown
expr: up == 0
for: 2m
labels:
severity: critical
annotations:
summary: 'Экспортер {{ $labels.job }} недоступен'
description: 'Таргет {{ $labels.instance }} (job={{ $labels.job }}) недоступен дольше 2 минут.'
- alert: HighCPU
expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
for: 5m
labels:
severity: warning
annotations:
summary: 'CPU > 90%'
description: 'Загрузка CPU на {{ $labels.instance }}: {{ $value | humanize }}% дольше 5 минут.'
- alert: HighRAM
expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 > 90
for: 5m
labels:
severity: warning
annotations:
summary: 'RAM > 90%'
description: 'Использование памяти на {{ $labels.instance }}: {{ $value | humanize }}% дольше 5 минут.'
- alert: DiskFull
expr: >
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|ramfs|overlay|squashfs|iso9660"}
/ node_filesystem_size_bytes) * 100 > 85
for: 5m
labels:
severity: warning
annotations:
summary: 'Диск {{ $labels.mountpoint }} заполнен более чем на 85%'
description: 'Раздел {{ $labels.mountpoint }} ({{ $labels.device }}) на {{ $labels.instance }}: занято {{ $value | humanize }}%.'
- alert: DiskFull
expr: >
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|ramfs|overlay|squashfs|iso9660"}
/ node_filesystem_size_bytes) * 100 > 95
for: 1m
labels:
severity: critical
annotations:
summary: 'Диск {{ $labels.mountpoint }} заполнен более чем на 95%'
description: 'Раздел {{ $labels.mountpoint }} ({{ $labels.device }}) на {{ $labels.instance }}: занято {{ $value | humanize }}%. Срочно освободи место.'
- alert: HighTemp
# только сенсоры CPU; если метрик нет вообще — проверь модули coretemp/k10temp (см. README)
expr: max by (instance, chip) (node_hwmon_temp_celsius{chip=~".*coretemp.*|.*k10temp.*"}) > 80
for: 3m
labels:
severity: warning
annotations:
summary: 'Температура CPU > 80°C'
description: 'Сенсор {{ $labels.chip }} на {{ $labels.instance }}: {{ $value | humanize }}°C дольше 3 минут.'