← Вернуться к оглавлению

📈 Отчёты бота: Диск, RAM и CPU в разрезе

Версия 1.1 | 12 сентября 2026

Расширение бота @avk_vps_monitoring_bot тремя отчётами по запросу: занятое дисковое пространство в разрезе каталогов, RAM в разрезе процессов и контейнеров, загрузка CPU в разрезе процессов и контейнеров. Базовая версия бота (сводка, алерты, long polling, systemd) — в документе «Netdata: алерты и уведомления в Telegram»; здесь — только новые отчёты.

🖱️ Что появилось у бота

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

[ 🔄 Обновить ] [ 🛡 Алерты ]
[ 💾 Диск ] [ 🧠 RAM ] [ ⚡ CPU ]
[ 🧹 Очистить чат ]
        

Меню команд (кнопка «/» рядом с полем ввода):

Команда Описание в меню
/statusСводка состояния
/disk💾 Диск в разрезе
/ram🧠 RAM по процессам
/cpu⚡ CPU по процессам
/alertsАктивные алерты
/clear🧹 Очистить историю чата
/pingПроверить, что бот жив
💡 Отклик: отчёты RAM и CPU готовы за 3–5 секунд, отчёт «Диск» — до минуты: du обходит весь корневой раздел. На время подсчёта бот отвечает на нажатие кнопки статусом «Собираю данные, до минуты…», чтобы интерфейс не выглядел зависшим.

🧹 Очистка истории чата

Команда /clear (или кнопка «🧹 Очистить чат») удаляет накопившиеся отчёты: у Telegram-ботов нет API «очистить чат целиком», поэтому бот идёт по номерам сообщений назад и удаляет каждое методом deleteMessage. В конце присылает подтверждение «🧹 Очищено сообщений: N» с клавиатурой — чат остаётся чистым.

1️⃣ Диск в разрезе — /disk

Источники данных

Пример вывода (реальные данные сервера)

💾 /: занято 29G из 38G (77%), свободно 8.8G

Топ каталогов (du, глубина 2):
   15.3G  /opt
   11.6G  /var
   11.0G  /opt/backups
    9.1G  /var/lib
    4.2G  /opt/projects
    2.3G  /var/log
    2.1G  /usr
    975M  /usr/lib
    383M  /usr/bin
    311M  /usr/src
    285M  /usr/share

Docker:
Images          13        13        8.316GB   0B (0%)
Containers      16        16        18.82MB   0B (0%)
Local Volumes   4         1         100.8MB   100.8MB (100%)
Build Cache     27        0         919.4MB   919.4MB
        

На этом снимке сразу видны главные пожиратели: /opt/backups — 11 GB (бэкапы с retention 7 дней), образы Docker 8.3 GB и кэш сборки 0.9 GB. Отчёт превращает вопрос «куда делось место» в один тап.

2️⃣ RAM по процессам — /ram

Источники данных

Пример вывода

🧠 RAM: занято 2.5G из 3.8G (64%) + кэш 0.9G

Топ процессов (по RSS):
    694M  mysqld
    155M  /usr/sbin/netdata -u netdata -D -s /ho
    132M  node server/server.js
    102M  node --abort_on_uncaught_exception --m
    101M  /usr/libexec/netdata/plugins.d/go.d.pl
    101M  /usr/bin/dockerd -H fd:// --containerd
     75M  /usr/lib/systemd/systemd-journald
     66M  /app/x-ui

Контейнеры (по памяти):
  692.6MiB  mylife-common-db
   295.7MiB  netdata
     127MiB  uptime-kuma
     126MiB  nginx-proxy-manager
    98.7MiB  3x-ui
    78.23MiB  mylife-avk27-php-fpm
        

Обратите внимание: процесс mysqld (694 MB) и контейнер mylife-common-db (692 MiB) — одно и то же, просто в двух разрезах. Так и задумано: процессы дают точность до бинаря, контейнеры — привязку к проекту.

3️⃣ CPU по процессам — /cpu

Источники данных

Пример вывода

⚡ CPU: 34% · load 1.03/0.54/1.49 (2 яд.)

Топ процессов (по CPU):
   5.9%  /usr/bin/containerd
   3.8%  /usr/libexec/netdata/plugins.d/apps.pl
   2.6%  /usr/sbin/netdata -u netdata -D -s /ho
   1.8%  mysqld
   0.7%  /usr/libexec/netdata/plugins.d/go.d.pl
   0.6%  node server/server.js
   0.5%  /usr/bin/dockerd -H fd:// --containerd

Контейнеры (по CPU):
 53.37%  mylife-avk27-cron
 13.99%  netdata
  3.26%  mylife-common-db
  0.40%  uptime-kuma
  0.09%  3x-ui
  0.04%  dozzle
        

🔧 Реализация: что изменилось в vps-status-bot.py

Вспомогательные функции

def sh(cmd, timeout=300):              # shell-команды с таймаутом
    return subprocess.run(cmd, shell=True, capture_output=True,
                          text=True, timeout=timeout).stdout

def send(text, buttons=False, mono=False):
    params = {"chat_id": ADMIN_CHAT, "text": text}
    if mono:                            # отчёты-таблицы — моноширинно
        params["text"] = "<pre>" + html_mod.escape(text) + "</pre>"
        params["parse_mode"] = "HTML"
    if buttons:
        params["reply_markup"] = BUTTONS
    tg("sendMessage", **params)

def fmt_m(mb):                          # 12345 -> "12.1G", 543 -> "543M"
    mb = float(mb)
    return f"{mb/1024:.1f}G" if mb >= 1024 else f"{int(mb)}M"

def to_mb(s):                           # "102.2MiB" -> 102.2
    mult = {"TiB": 1048576, "GiB": 1024, "MiB": 1, "KiB": 1/1024, "B": 1/1048576}
    for suf in ("TiB", "GiB", "MiB", "KiB"):
        if s.endswith(suf):
            return float(s[:-len(suf)]) * mult[suf]
    return float(s.rstrip("B")) / 1048576

def docker_stats():                     # [{"name","mem","mem_mb","cpu"}]
    out = sh("docker stats --no-stream --format "
             "'{{.Name}};{{.MemUsage}};{{.CPUPerc}}'", timeout=90)
    rows = []
    for line in out.strip().splitlines():
        parts = line.split(";")
        if len(parts) != 3:
            continue
        mem_str = parts[1].split("/")[0].strip()      # "102.2MiB"
        rows.append({"name": parts[0], "mem": mem_str,
                     "mem_mb": to_mb(mem_str),
                     "cpu": float(parts[2].strip().rstrip("%"))})
    return rows
        

Три отчёта

def disk_report():
    f = sh("df -h / | tail -1").split()
    lines = [f"💾 {f[5]}: занято {f[2]} из {f[1]} ({f[4]}), свободно {f[3]}",
             "", "Топ каталогов (du, глубина 2):"]
    for row in sh("du -x -d2 -BM / 2>/dev/null | sort -rn | head -12").splitlines():
        size, path = row.rstrip().split("\t")
        if path == "/":                 # общий итог уже в заголовке
            continue
        lines.append(f"{fmt_m(size[:-1]):>8}  {path}")
    lines += ["", "Docker:"]
    lines += sh("docker system df | tail -n +2").strip().splitlines()
    return "\n".join(lines)

def ram_report():
    m = nd("allmetrics?format=json")
    ram = m['system.ram']['dimensions']
    tot = sum(d['value'] for k, d in ram.items()
              if k in ('free', 'used', 'cached', 'buffers'))
    used, cached = ram['used']['value'], ram['cached']['value']
    lines = [f"🧠 RAM: занято {used/1024:.1f}G из {tot/1024:.1f}G ({used*100/tot:.0f}%)"
             f" + кэш {cached/1024:.1f}G",
             "", "Топ процессов (по RSS):"]
    for row in sh("ps aux --sort=-rss --no-headers | head -8").splitlines():
        p = row.split(None, 10)
        if len(p) >= 11:
            lines.append(f"{int(p[5])/1024:7.0f}M  {p[10][:38]}")
    lines += ["", "Контейнеры (по памяти):"]
    for r in sorted(docker_stats(), key=lambda r: r["mem_mb"], reverse=True)[:6]:
        lines.append(f"{r['mem']:>10}  {r['name']}")
    return "\n".join(lines)

def cpu_report():
    m = nd("allmetrics?format=json")
    ld = m['system.load']['dimensions']
    cpu = 100 - m['system.cpu']['dimensions']['idle']['value']
    cores = os.cpu_count()
    lines = [f"⚡ CPU: {cpu:.0f}% · load "
             f"{ld['load1']['value']:.2f}/{ld['load5']['value']:.2f}/"
             f"{ld['load15']['value']:.2f} ({cores} яд.)",
             "", "Топ процессов (по CPU):"]
    for row in sh("ps aux --sort=-%cpu --no-headers | head -8").splitlines():
        p = row.split(None, 10)
        if len(p) >= 11 and not p[10].startswith("ps aux"):   # не показываем себя
            lines.append(f"{float(p[2]):6.1f}%  {p[10][:38]}")
    lines += ["", "Контейнеры (по CPU):"]
    for r in sorted(docker_stats(), key=lambda r: r["cpu"], reverse=True)[:6]:
        lines.append(f"{r['cpu']:6.2f}%  {r['name']}")
    return "\n".join(lines)
        

Клавиатура и маршрутизация

BUTTONS = {"inline_keyboard": [
    [{"text": "🔄 Обновить", "callback_data": "status"},
     {"text": "🛡 Алерты", "callback_data": "alerts"}],
    [{"text": "💾 Диск", "callback_data": "disk"},
     {"text": "🧠 RAM", "callback_data": "ram"},
     {"text": "⚡ CPU", "callback_data": "cpu"}],
    [{"text": "🧹 Очистить чат", "callback_data": "clear"}],
]}

def clear_history(up_to_including):
    """Удаляет недавнюю историю чата (что разрешает Telegram API)."""
    deleted, fails, mid = 0, 0, up_to_including
    floor = max(0, up_to_including - 400)   # окно сканирования
    while mid > floor and fails < 15:       # 15 ошибок подряд = дошли до старья
        try:
            tg("deleteMessage", chat_id=ADMIN_CHAT, message_id=mid)
            deleted += 1
            fails = 0
        except Exception:
            fails += 1
        mid -= 1
    return deleted

def handle(kind):                       # единая точка: команды и кнопки
    try:
        if   kind == "status": send(report(), buttons=True)
        elif kind == "alerts": send(alerts_text(), buttons=True)
        elif kind == "disk":   send(disk_report(), mono=True)
        elif kind == "ram":    send(ram_report(), mono=True)
        elif kind == "cpu":    send(cpu_report(), mono=True)
        elif kind == "ping":   send("pong")
    except Exception as e:
        try:
            send(f"⚠️ Ошибка отчёта {kind}: {e}")
        except Exception:
            pass
        

В главном цикле нажатие кнопки и команду из меню сводятся к одному вызову handle(); перед тяжёлым отчётом /disk бот сразу отвечает answerCallbackQuery со статусом «Собираю данные…». Очистка истории — особый случай: ей нужен номер сообщения, с которого начинать (сообщение с кнопкой или сама команда), поэтому она обрабатывается в цикле напрямую, минуя handle(). Меню команд регистрируется через setMyCommands (семь команд из таблицы выше).

🛠️ Проверка и эксплуатация

# Все отчёты в консоль, без отправки в Telegram (du считается по-настоящему)
python3 /opt/scripts/vps-status-bot.py --selftest

# Один отчёт в Telegram и выйти (проверка доставки)
set -a; . /etc/vps-report.env; set +a
python3 /opt/scripts/vps-status-bot.py --test

# Применить изменения скрипта
systemctl restart vps-status-bot

# Логи бота
journalctl -u vps-status-bot -f
        

⚠️ Ограничения и тонкости чтения цифр


🎉 Готово. Отчёты развёрнуты на сервере 12.09.2026; документ обновляйте при изменении состава метрик.