Enhance monitoring configuration by adding status update interval and alert delays for CPU, RAM, and disk metrics. Update Makefile to include dependency checks for testing, and modify requirements to include requests library. Refactor message sender and metrics collector for improved logging and alert handling.

This commit is contained in:
2025-09-04 00:45:06 +03:00
parent 18d6f3d441
commit 567e5b3aa3
14 changed files with 1337 additions and 184 deletions

View File

@@ -0,0 +1,188 @@
# PID Manager - Управление процессами ботов
## Описание
`pid_manager.py` - это общий модуль для управления PID файлами всех ботов в проекте. Он обеспечивает создание, отслеживание и очистку PID файлов для мониторинга состояния процессов.
## Использование
### Для новых ботов
Чтобы добавить PID мониторинг в новый бот, выполните следующие шаги:
1. **Импортируйте PID менеджер в ваш скрипт запуска:**
```python
import sys
import os
# Добавляем путь к инфраструктуре в sys.path
infra_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'infra', 'monitoring')
if infra_path not in sys.path:
sys.path.insert(0, infra_path)
from pid_manager import get_bot_pid_manager
```
2. **Создайте PID менеджер в начале main функции:**
```python
async def main():
# Создаем PID менеджер для отслеживания процесса (если доступен)
pid_manager = None
if get_bot_pid_manager:
pid_manager = get_bot_pid_manager("your_bot_name") # Замените на имя вашего бота
if not pid_manager.create_pid_file():
logger.error("Не удалось создать PID файл, завершаем работу")
return
else:
logger.info("PID менеджер недоступен, запуск без PID файла")
# Ваш код запуска бота...
```
3. **Очистите PID файл при завершении:**
```python
try:
# Ваш код работы бота...
finally:
# Очищаем PID файл (если PID менеджер доступен)
if pid_manager:
pid_manager.cleanup_pid_file()
```
### Для мониторинга
Чтобы добавить новый бот в систему мониторинга:
```python
from infra.monitoring.metrics_collector import MetricsCollector
# Создаем экземпляр коллектора метрик
collector = MetricsCollector()
# Добавляем новый бот в мониторинг
collector.add_bot_to_monitoring("your_bot_name")
# Теперь можно проверять статус
status, uptime = collector.check_process_status("your_bot_name")
```
## Структура файлов
```
prod/
├── infra/
│ └── monitoring/
│ ├── pid_manager.py # Основной модуль
│ ├── metrics_collector.py # Мониторинг процессов
│ └── README_PID_MANAGER.md # Эта документация
├── bots/
│ ├── telegram-helper-bot/
│ │ └── run_helper.py # Использует PID менеджер
│ └── your-new-bot/
│ └── run_your_bot.py # Будет использовать PID менеджер
├── helper_bot.pid # PID файл helper_bot
├── your_bot.pid # PID файл вашего бота
└── .gitignore # Содержит *.pid
```
## API
### PIDManager
- `create_pid_file()` - Создает PID файл
- `cleanup_pid_file()` - Удаляет PID файл
- `is_running()` - Проверяет, запущен ли процесс
- `get_pid()` - Получает PID из файла
### Функции
- `get_bot_pid_manager(bot_name)` - Создает PID менеджер для бота
- `create_pid_manager(process_name, project_root)` - Создает PID менеджер с настройками
## Примеры
### Простой бот
```python
import asyncio
from pid_manager import get_bot_pid_manager
async def main():
# Создаем PID менеджер
pid_manager = get_bot_pid_manager("simple_bot")
if not pid_manager.create_pid_file():
print("Не удалось создать PID файл")
return
try:
# Ваш код бота
print("Бот запущен...")
await asyncio.sleep(3600) # Работаем час
finally:
# Очищаем PID файл
pid_manager.cleanup_pid_file()
if __name__ == '__main__':
asyncio.run(main())
```
### Бот с обработкой сигналов
```python
import asyncio
import signal
from pid_manager import get_bot_pid_manager
async def main():
pid_manager = get_bot_pid_manager("advanced_bot")
if not pid_manager.create_pid_file():
return
# Флаг для корректного завершения
shutdown_event = asyncio.Event()
def signal_handler(signum, frame):
print(f"Получен сигнал {signum}, завершаем работу...")
shutdown_event.set()
# Регистрируем обработчики сигналов
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
# Ваш код бота
await shutdown_event.wait()
finally:
pid_manager.cleanup_pid_file()
if __name__ == '__main__':
asyncio.run(main())
```
## Примечания
- PID файлы создаются в корне проекта
- Все PID файлы автоматически игнорируются Git (см. `.gitignore`)
- PID менеджер автоматически обрабатывает сигналы SIGTERM и SIGINT
- При завершении процесса PID файл автоматически удаляется
- Система мониторинга автоматически находит PID файлы в корне проекта
## Изолированный запуск
При запуске бота изолированно (без доступа к основному проекту):
- PID менеджер автоматически не создается
- Бот запускается без PID файла
- В логах появляется сообщение "PID менеджер недоступен (изолированный запуск), PID файл не создается"
- Это позволяет запускать бота в любой среде без ошибок
## Автоматическое определение
Система автоматически определяет доступность PID менеджера:
1. **В основном проекте**: PID менеджер доступен, создается PID файл для мониторинга
2. **Изолированно**: PID менеджер недоступен, бот работает без PID файла
3. **Fallback**: Если PID менеджер недоступен, бот продолжает работать нормально

View File

@@ -18,6 +18,9 @@ class MessageSender:
self.group_for_logs = os.getenv('GROUP_MONITORING_FOR_LOGS')
self.important_logs = os.getenv('IMPORTANT_MONITORING_LOGS')
# Интервал отправки статуса в минутах (по умолчанию 2 минуты)
self.status_update_interval_minutes = int(os.getenv('STATUS_UPDATE_INTERVAL_MINUTES', 2))
# Создаем экземпляр сборщика метрик
self.metrics_collector = MetricsCollector()
@@ -30,6 +33,8 @@ class MessageSender:
logger.warning("GROUP_MONITORING_FOR_LOGS не установлен в переменных окружения")
if not self.important_logs:
logger.warning("IMPORTANT_MONITORING_LOGS не установлен в переменных окружения")
logger.info(f"Интервал отправки статуса установлен: {self.status_update_interval_minutes} минут")
async def send_telegram_message(self, chat_id: str, message: str) -> bool:
"""Отправка сообщения в Telegram через прямое обращение к API"""
@@ -60,18 +65,29 @@ class MessageSender:
return False
def should_send_status(self) -> bool:
"""Проверка, нужно ли отправить статус (каждые 4 часа в 00 минут)"""
"""Проверка, нужно ли отправить статус (каждые N минут)"""
now = datetime.now()
# Проверяем, что сейчас 00 минут часа и час кратен 4 (0, 4, 8, 12, 16, 20)
if now.minute == 0 and now.hour % 4 == 0:
# Проверяем, не отправляли ли мы уже статус в этот час
if (self.last_status_time is None or
self.last_status_time.hour != now.hour or
self.last_status_time.day != now.day):
self.last_status_time = now
return True
# Логируем для диагностики
import logging
logger = logging.getLogger(__name__)
if self.last_status_time is None:
logger.info(f"should_send_status: last_status_time is None, отправляем статус")
self.last_status_time = now
return True
# Вычисляем разницу в минутах
time_diff_minutes = (now - self.last_status_time).total_seconds() / 60
logger.info(f"should_send_status: прошло {time_diff_minutes:.1f} минут с последней отправки, нужно {self.status_update_interval_minutes} минут")
# Проверяем, что прошло N минут с последней отправки
if time_diff_minutes >= self.status_update_interval_minutes:
logger.info(f"should_send_status: отправляем статус (прошло {time_diff_minutes:.1f} минут)")
self.last_status_time = now
return True
logger.info(f"should_send_status: статус не отправляем (прошло {time_diff_minutes:.1f} минут)")
return False
def should_send_startup_status(self) -> bool:
@@ -87,23 +103,73 @@ class MessageSender:
else:
return "🚨"
def _get_cpu_emoji(self, cpu_percent: float) -> str:
"""Получение эмодзи для CPU"""
if cpu_percent < 50:
return "🟢"
elif cpu_percent < 80:
return "⚠️"
else:
return "🚨"
def _get_memory_emoji(self, memory_percent: float) -> str:
"""Получение эмодзи для памяти (RAM/Swap)"""
if memory_percent < 60:
return "🟢"
elif memory_percent < 85:
return "⚠️"
else:
return "🚨"
def _get_load_average_emoji(self, load_avg: float, cpu_count: int) -> str:
"""Получение эмодзи для Load Average"""
# Load Average считается нормальным если < 1.0 на ядро
# Критичным если > 2.0 на ядро
load_per_core = load_avg / cpu_count
if load_per_core < 1.0:
return "🟢"
elif load_per_core < 2.0:
return "⚠️"
else:
return "🚨"
def _get_io_wait_emoji(self, io_wait_percent: float) -> str:
"""Получение эмодзи для IO Wait"""
# IO Wait считается нормальным если < 5%
# Критичным если > 20%
if io_wait_percent < 5:
return "🟢"
elif io_wait_percent < 20:
return "⚠️"
else:
return "🚨"
def get_status_message(self, system_info: Dict) -> str:
"""Формирование сообщения со статусом сервера"""
try:
voice_bot_status, voice_bot_uptime = self.metrics_collector.check_process_status('voice_bot')
helper_bot_status, helper_bot_uptime = self.metrics_collector.check_process_status('helper_bot')
# Получаем эмодзи для дискового пространства
# Получаем эмодзи для всех метрик
cpu_emoji = self._get_cpu_emoji(system_info['cpu_percent'])
ram_emoji = self._get_memory_emoji(system_info['ram_percent'])
swap_emoji = self._get_memory_emoji(system_info['swap_percent'])
la_emoji = self._get_load_average_emoji(system_info['load_avg_1m'], system_info['cpu_count'])
io_wait_emoji = self._get_io_wait_emoji(system_info['io_wait_percent'])
disk_emoji = self._get_disk_space_emoji(system_info['disk_percent'])
message = f"""🖥 **Статус Сервера** | <code>{system_info['current_time']}</code>
# Определяем уровень мониторинга
monitoring_level = system_info.get('monitoring_level', 'unknown')
level_emoji = "🖥️" if monitoring_level == 'host' else "📦"
level_text = "Хост" if monitoring_level == 'host' else "Контейнер"
message = f"""{level_emoji} **Статус {level_text}** | <code>{system_info['current_time']}</code>
---------------------------------
**📊 Общая нагрузка:**
CPU: <b>{system_info['cpu_percent']}%</b> | LA: <b>{system_info['load_avg_1m']} / {system_info['cpu_count']}</b> | IO Wait: <b>{system_info['disk_percent']}%</b>
CPU: <b>{system_info['cpu_percent']}%</b> {cpu_emoji} | LA: <b>{system_info['load_avg_1m']} / {system_info['cpu_count']}</b> {la_emoji} | IO Wait: <b>{system_info['io_wait_percent']}%</b> {io_wait_emoji}
**💾 Память:**
RAM: <b>{system_info['ram_used']}/{system_info['ram_total']} GB</b> ({system_info['ram_percent']}%)
Swap: <b>{system_info['swap_used']}/{system_info['swap_total']} GB</b> ({system_info['swap_percent']}%)
RAM: <b>{system_info['ram_used']}/{system_info['ram_total']} GB</b> ({system_info['ram_percent']}%) {ram_emoji}
Swap: <b>{system_info['swap_used']}/{system_info['swap_total']} GB</b> ({system_info['swap_percent']}%) {swap_emoji}
**🗂️ Дисковое пространство:**
Диск (/): <b>{system_info['disk_used']}/{system_info['disk_total']} GB</b> ({system_info['disk_percent']}%) {disk_emoji}
@@ -113,10 +179,10 @@ Read: <b>{system_info['disk_read_speed']}</b> | Write: <b>{system_info['disk_wri
Диск загружен: <b>{system_info['disk_io_percent']}%</b>
**🤖 Процессы:**
{voice_bot_status} voice-bot - {voice_bot_uptime}
{helper_bot_status} helper-bot - {helper_bot_uptime}
---------------------------------
⏰ Uptime сервера: {system_info['system_uptime']}"""
⏰ Uptime сервера: {system_info['system_uptime']}
🔍 Уровень мониторинга: {level_text} ({monitoring_level})"""
return message
@@ -127,6 +193,17 @@ Read: <b>{system_info['disk_read_speed']}</b> | Write: <b>{system_info['disk_wri
def get_alert_message(self, metric_name: str, current_value: float, details: str) -> str:
"""Формирование сообщения об алерте"""
try:
# Получаем информацию о задержке для данного метрика
delay_info = ""
if hasattr(self.metrics_collector, 'alert_delays'):
metric_type = metric_name.lower().replace('использование ', '').replace('заполнение диска (/)', 'disk')
if 'cpu' in metric_type:
delay_info = f"⏱️ Задержка срабатывания: {self.metrics_collector.alert_delays['cpu']} сек"
elif 'память' in metric_type or 'ram' in metric_type:
delay_info = f"⏱️ Задержка срабатывания: {self.metrics_collector.alert_delays['ram']} сек"
elif 'диск' in metric_type or 'disk' in metric_type:
delay_info = f"⏱️ Задержка срабатывания: {self.metrics_collector.alert_delays['disk']} сек"
message = f"""🚨 **ALERT: Высокая нагрузка на сервере!**
---------------------------------
**Показатель:** {metric_name}
@@ -136,6 +213,8 @@ Read: <b>{system_info['disk_read_speed']}</b> | Write: <b>{system_info['disk_wri
**Детали:**
{details}
{delay_info}
**Сервер:** `{self.metrics_collector.os_type.upper()}`
**Время:** `{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}`
---------------------------------"""

View File

@@ -5,6 +5,7 @@ import platform
from datetime import datetime
from typing import Dict, Optional, Tuple
import logging
from pid_manager import create_pid_manager
logger = logging.getLogger(__name__)
@@ -15,10 +16,24 @@ class MetricsCollector:
self.os_type = self._detect_os()
logger.info(f"Обнаружена ОС: {self.os_type}")
# Проверяем, запущены ли мы в Docker с доступом к хосту
self.is_docker_host_monitoring = self._check_docker_host_access()
if self.is_docker_host_monitoring:
logger.info("Обнаружен доступ к хосту через Docker volumes - мониторинг будет вестись на уровне хоста")
else:
logger.warning("Мониторинг будет вестись на уровне контейнера (не рекомендуется для продакшена)")
# Пороговые значения для алертов
self.threshold = float(os.getenv('THRESHOLD', '80.0'))
self.recovery_threshold = float(os.getenv('RECOVERY_THRESHOLD', '75.0'))
# Задержки для алертов (в секундах) - предотвращают ложные срабатывания
self.alert_delays = {
'cpu': int(os.getenv('CPU_ALERT_DELAY', '30')), # 30 сек для CPU
'ram': int(os.getenv('RAM_ALERT_DELAY', '45')), # 45 сек для RAM
'disk': int(os.getenv('DISK_ALERT_DELAY', '60')) # 60 сек для диска
}
# Состояние алертов для предотвращения спама
self.alert_states = {
'cpu': False,
@@ -26,10 +41,20 @@ class MetricsCollector:
'disk': False
}
# Время первого превышения порога для каждого метрика
self.alert_start_times = {
'cpu': None,
'ram': None,
'disk': None
}
# PID файлы для отслеживания процессов
# Определяем корень проекта для поиска PID файлов
current_file = os.path.abspath(__file__)
self.project_root = os.path.dirname(os.path.dirname(current_file))
self.pid_files = {
#'voice_bot': 'voice_bot.pid',
'helper_bot': 'helper_bot.pid'
'helper_bot': os.path.join(self.project_root, 'helper_bot.pid')
}
# Для расчета скорости диска
@@ -48,6 +73,19 @@ class MetricsCollector:
# Время запуска мониторинга для расчета uptime
self.monitor_start_time = time.time()
logger.info(f"Инициализированы задержки алертов: CPU={self.alert_delays['cpu']}s, RAM={self.alert_delays['ram']}s, Disk={self.alert_delays['disk']}s")
def add_bot_to_monitoring(self, bot_name: str):
"""
Добавление нового бота в мониторинг
Args:
bot_name: Имя бота (например, 'helper_bot', 'admin_bot', etc.)
"""
pid_file_path = os.path.join(self.project_root, f"{bot_name}.pid")
self.pid_files[bot_name] = pid_file_path
logger.info(f"Добавлен бот {bot_name} в мониторинг: {pid_file_path}")
def _detect_os(self) -> str:
"""Определение типа операционной системы"""
system = platform.system().lower()
@@ -58,6 +96,30 @@ class MetricsCollector:
else:
return "unknown"
def _check_docker_host_access(self) -> bool:
"""Проверка доступности хоста через Docker volumes"""
try:
# Проверяем, доступны ли файлы хоста через /host/proc
# Это означает, что контейнер запущен с --privileged и volume mounts
if os.path.exists('/host/proc/stat') and os.path.exists('/host/proc/meminfo'):
return True
# Альтернативная проверка - проверяем, запущены ли мы в Docker
# и есть ли доступ к системным файлам хоста
if os.path.exists('/.dockerenv'):
# Проверяем, можем ли мы читать системные файлы хоста
try:
with open('/proc/stat', 'r') as f:
f.read(100) # Читаем немного для проверки доступа
return True
except (OSError, PermissionError):
pass
return False
except Exception as e:
logger.debug(f"Ошибка при проверке доступа к хосту: {e}")
return False
def _initialize_disk_io(self):
"""Инициализация базовых значений для расчета скорости диска"""
try:
@@ -172,72 +234,128 @@ class MetricsCollector:
def get_system_info(self) -> Dict:
"""Получение информации о системе"""
try:
# CPU
cpu_percent = psutil.cpu_percent(interval=1)
load_avg = psutil.getloadavg()
cpu_count = psutil.cpu_count()
# Определяем, какой psutil использовать
current_psutil = psutil
if self.is_docker_host_monitoring:
# Для хоста используем специальные методы
host_cpu = self._get_host_cpu_info()
host_memory = self._get_host_memory_info()
host_disk = self._get_host_disk_info()
if host_cpu and host_memory and host_disk:
# Используем данные хоста
cpu_count = host_cpu['cpu_count']
load_avg = host_cpu['load_avg']
# Для CPU процента используем упрощенный расчет на основе load average
# Load average > 1.0 на ядро считается высокой нагрузкой
load_per_core = load_avg[0] / cpu_count if cpu_count > 0 else 0
cpu_percent = min(100, load_per_core * 100) # Упрощенный расчет
# Память хоста
ram_total = host_memory['ram_total']
ram_used = host_memory['ram_used']
ram_percent = host_memory['ram_percent']
swap_total = host_memory['swap_total']
swap_used = host_memory['swap_used']
swap_percent = host_memory['swap_percent']
# Диск хоста
disk_total = host_disk['total']
disk_used = host_disk['used']
disk_free = host_disk['free']
disk_percent = host_disk['percent']
# IO Wait и другие метрики недоступны через /proc, используем 0
io_wait_percent = 0.0
logger.debug("Используются метрики хоста через Docker volumes")
else:
# Fallback к стандартному psutil
logger.warning("Не удалось получить метрики хоста, используем контейнер")
current_psutil = psutil
host_cpu = host_memory = host_disk = None
else:
# Стандартный psutil для контейнера
host_cpu = host_memory = host_disk = None
# Память
memory = psutil.virtual_memory()
swap = psutil.swap_memory()
# Если не используем хост, получаем стандартные метрики
if not host_cpu:
cpu_percent = current_psutil.cpu_percent(interval=1)
load_avg = current_psutil.getloadavg()
cpu_count = current_psutil.cpu_count()
# CPU times для получения IO Wait
cpu_times = current_psutil.cpu_times_percent(interval=1)
io_wait_percent = getattr(cpu_times, 'iowait', 0.0)
# Память
memory = current_psutil.virtual_memory()
swap = current_psutil.swap_memory()
# Используем единый расчет для всех ОС: used / total для получения процента занятой памяти
ram_percent = (memory.used / memory.total) * 100
ram_total = memory.total
ram_used = memory.used
swap_total = swap.total
swap_used = swap.used
swap_percent = swap.percent
# Диск
disk = self._get_disk_usage()
disk_total = disk.total if disk else 0
disk_used = disk.used if disk else 0
disk_free = disk.free if disk else 0
disk_percent = (disk_used / disk_total * 100) if disk_total > 0 else 0
# Используем единый расчет для всех ОС: used / total для получения процента занятой памяти
# Это обеспечивает консистентность между macOS и Ubuntu
ram_percent = (memory.used / memory.total) * 100
# Диск
disk = self._get_disk_usage()
# Диск I/O (может быть недоступен для хоста)
disk_io = self._get_disk_io_counters()
if disk is None:
logger.error("Не удалось получить информацию о диске")
return {}
# Сначала рассчитываем процент загрузки диска (до обновления last_disk_io_time)
disk_io_percent = self._calculate_disk_io_percent()
# Затем рассчитываем скорость диска (это обновит last_disk_io_time)
disk_read_speed, disk_write_speed = self._calculate_disk_speed(disk_io)
# Диагностика диска для отладки
if disk_io:
logger.debug(f"Диск I/O статистика: read_count={disk_io.read_count}, write_count={disk_io.write_count}, "
f"read_bytes={disk_io.read_bytes}, write_bytes={disk_io.write_bytes}")
disk_io_percent = self._calculate_disk_io_percent()
disk_read_speed, disk_write_speed = self._calculate_disk_speed(disk_io)
else:
disk_io_percent = 0
disk_read_speed = "0 B/s"
disk_write_speed = "0 B/s"
# Система
system_uptime = self._get_system_uptime()
# Получаем имя хоста в зависимости от ОС
if self.os_type == "macos":
hostname = os.uname().nodename
elif self.os_type == "ubuntu":
hostname = os.uname().nodename
# Получаем имя хоста
if self.is_docker_host_monitoring:
try:
with open('/host/proc/sys/kernel/hostname', 'r') as f:
hostname = f.read().strip()
except:
hostname = "host"
else:
hostname = "unknown"
hostname = os.uname().nodename
return {
'cpu_percent': cpu_percent,
'cpu_percent': round(cpu_percent, 1),
'load_avg_1m': round(load_avg[0], 2),
'load_avg_5m': round(load_avg[1], 2),
'load_avg_15m': round(load_avg[2], 2),
'cpu_count': cpu_count,
'ram_used': round(memory.used / (1024**3), 2),
'ram_total': round(memory.total / (1024**3), 2),
'ram_percent': round(ram_percent, 1), # Исправленный процент занятой памяти
'swap_used': round(swap.used / (1024**3), 2),
'swap_total': round(swap.total / (1024**3), 2),
'swap_percent': swap.percent,
'disk_used': round(disk.used / (1024**3), 2),
'disk_total': round(disk.total / (1024**3), 2),
'disk_percent': round((disk.used / disk.total) * 100, 1),
'disk_free': round(disk.free / (1024**3), 2),
'io_wait_percent': round(io_wait_percent, 1),
'ram_used': round(ram_used / (1024**3), 2),
'ram_total': round(ram_total / (1024**3), 2),
'ram_percent': round(ram_percent, 1),
'swap_used': round(swap_used / (1024**3), 2),
'swap_total': round(swap_total / (1024**3), 2),
'swap_percent': round(swap_percent, 1),
'disk_used': round(disk_used / (1024**3), 2),
'disk_total': round(disk_total / (1024**3), 2),
'disk_percent': round(disk_percent, 1),
'disk_free': round(disk_free / (1024**3), 2),
'disk_read_speed': disk_read_speed,
'disk_write_speed': disk_write_speed,
'disk_io_percent': disk_io_percent,
'system_uptime': self._format_uptime(system_uptime),
'monitor_uptime': self.get_monitor_uptime(),
'server_hostname': hostname,
'current_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
'current_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'monitoring_level': 'host' if self.is_docker_host_monitoring else 'container'
}
except Exception as e:
logger.error(f"Ошибка при получении информации о системе: {e}")
@@ -272,7 +390,21 @@ class MetricsCollector:
def check_process_status(self, process_name: str) -> Tuple[str, str]:
"""Проверка статуса процесса и возврат статуса с uptime"""
try:
# Сначала проверяем по PID файлу
# Для helper_bot используем HTTP endpoint
if process_name == 'helper_bot':
return self._check_helper_bot_status()
# Для других процессов используем стандартную проверку
return self._check_local_process_status(process_name)
except Exception as e:
logger.error(f"Ошибка при проверке процесса {process_name}: {e}")
return "", "Выключен"
def _check_local_process_status(self, process_name: str) -> Tuple[str, str]:
"""Проверка локального процесса по PID файлу или имени"""
try:
# Проверяем по PID файлу
pid_file = self.pid_files.get(process_name)
if pid_file and os.path.exists(pid_file):
try:
@@ -281,55 +413,34 @@ class MetricsCollector:
if content and content != '# Этот файл будет автоматически обновляться при запуске бота':
pid = int(content)
if psutil.pid_exists(pid):
# Получаем uptime процесса
try:
proc = psutil.Process(pid)
proc_uptime = time.time() - proc.create_time()
uptime_str = self._format_uptime(proc_uptime)
return "", f"Uptime {uptime_str}"
except:
return "", "Uptime неизвестно"
proc = psutil.Process(pid)
proc_uptime = time.time() - proc.create_time()
uptime_str = self._format_uptime(proc_uptime)
return "", f"Uptime {uptime_str}"
except (ValueError, FileNotFoundError):
pass
# Проверяем по имени процесса более точно
# Проверяем по имени процесса
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
proc_name = proc.info['name'].lower()
cmdline = ' '.join(proc.info['cmdline']).lower() if proc.info['cmdline'] else ''
# Более точная проверка для каждого бота
if process_name == 'voice_bot':
# Проверяем voice_bot
if ('voice_bot' in proc_name or
'voice_bot' in cmdline or
'voice_bot_v2.py' in cmdline):
# Получаем uptime процесса
try:
proc_uptime = time.time() - proc.create_time()
uptime_str = self._format_uptime(proc_uptime)
return "", f"Uptime {uptime_str}"
except:
return "", "Uptime неизвестно"
elif process_name == 'helper_bot':
# Проверяем helper_bot
if ('helper_bot' in proc_name or
'helper_bot' in cmdline or
'run_helper.py' in cmdline or
'python' in proc_name and 'helper_bot' in cmdline):
# Получаем uptime процесса
try:
proc_uptime = time.time() - proc.create_time()
uptime_str = self._format_uptime(proc_uptime)
return "", f"Uptime {uptime_str}"
except:
return "", "Uptime неизвестно"
if (process_name in proc_name or
process_name in cmdline or
'python' in proc_name and process_name in cmdline):
proc_uptime = time.time() - proc.create_time()
uptime_str = self._format_uptime(proc_uptime)
return "", f"Uptime {uptime_str}"
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return "", "Выключен"
except Exception as e:
logger.error(f"Ошибка при проверке процесса {process_name}: {e}")
logger.error(f"Ошибка при проверке локального процесса {process_name}: {e}")
return "", "Выключен"
def _calculate_disk_speed(self, current_disk_io) -> Tuple[str, str]:
@@ -460,36 +571,279 @@ class MetricsCollector:
}
def check_alerts(self, system_info: Dict) -> Tuple[bool, Optional[str]]:
"""Проверка необходимости отправки алертов"""
"""Проверка необходимости отправки алертов с учетом задержек"""
current_time = time.time()
alerts = []
# Проверка CPU
if system_info['cpu_percent'] > self.threshold and not self.alert_states['cpu']:
self.alert_states['cpu'] = True
alerts.append(('cpu', system_info['cpu_percent'], f"Нагрузка за 1 мин: {system_info['load_avg_1m']}"))
# Проверка RAM
if system_info['ram_percent'] > self.threshold and not self.alert_states['ram']:
self.alert_states['ram'] = True
alerts.append(('ram', system_info['ram_percent'], f"Используется: {system_info['ram_used']} GB из {system_info['ram_total']} GB"))
# Проверка диска
if system_info['disk_percent'] > self.threshold and not self.alert_states['disk']:
self.alert_states['disk'] = True
alerts.append(('disk', system_info['disk_percent'], f"Свободно: {system_info['disk_free']} GB на /"))
# Проверка восстановления
recoveries = []
if system_info['cpu_percent'] < self.recovery_threshold and self.alert_states['cpu']:
self.alert_states['cpu'] = False
recoveries.append(('cpu', system_info['cpu_percent']))
if system_info['ram_percent'] < self.recovery_threshold and self.alert_states['ram']:
self.alert_states['ram'] = False
recoveries.append(('ram', system_info['ram_percent']))
# Проверка CPU с задержкой
if system_info['cpu_percent'] > self.threshold:
if not self.alert_states['cpu']:
# Первое превышение порога
if self.alert_start_times['cpu'] is None:
self.alert_start_times['cpu'] = current_time
logger.debug(f"CPU превысил порог {self.threshold}%: {system_info['cpu_percent']:.1f}% - начинаем отсчет задержки {self.alert_delays['cpu']}s")
# Проверяем, прошла ли задержка
if self.alert_delays['cpu'] == 0 or current_time - self.alert_start_times['cpu'] >= self.alert_delays['cpu']:
self.alert_states['cpu'] = True
alerts.append(('cpu', system_info['cpu_percent'], f"Нагрузка за 1 мин: {system_info['load_avg_1m']}"))
logger.warning(f"CPU ALERT: {system_info['cpu_percent']:.1f}% > {self.threshold}% (задержка {self.alert_delays['cpu']}s)")
else:
# CPU ниже порога - сбрасываем состояние
if self.alert_states['cpu']:
self.alert_states['cpu'] = False
recoveries.append(('cpu', system_info['cpu_percent']))
logger.info(f"CPU RECOVERY: {system_info['cpu_percent']:.1f}% < {self.recovery_threshold}%")
# Сбрасываем время начала превышения
self.alert_start_times['cpu'] = None
if system_info['disk_percent'] < self.recovery_threshold and self.alert_states['disk']:
self.alert_states['disk'] = False
recoveries.append(('disk', system_info['disk_percent']))
# Проверка RAM с задержкой
if system_info['ram_percent'] > self.threshold:
if not self.alert_states['ram']:
# Первое превышение порога
if self.alert_start_times['ram'] is None:
self.alert_start_times['ram'] = current_time
logger.debug(f"RAM превысил порог {self.threshold}%: {system_info['ram_percent']:.1f}% - начинаем отсчет задержки {self.alert_delays['ram']}s")
# Проверяем, прошла ли задержка
if self.alert_delays['ram'] == 0 or current_time - self.alert_start_times['ram'] >= self.alert_delays['ram']:
self.alert_states['ram'] = True
alerts.append(('ram', system_info['ram_percent'], f"Используется: {system_info['ram_used']} GB из {system_info['ram_total']} GB"))
logger.warning(f"RAM ALERT: {system_info['ram_percent']:.1f}% > {self.threshold}% (задержка {self.alert_delays['ram']}s)")
else:
# RAM ниже порога - сбрасываем состояние
if self.alert_states['ram']:
self.alert_states['ram'] = False
recoveries.append(('ram', system_info['ram_percent']))
logger.info(f"RAM RECOVERY: {system_info['ram_percent']:.1f}% < {self.recovery_threshold}%")
# Сбрасываем время начала превышения
self.alert_start_times['ram'] = None
# Проверка диска с задержкой
if system_info['disk_percent'] > self.threshold:
if not self.alert_states['disk']:
# Первое превышение порога
if self.alert_start_times['disk'] is None:
self.alert_start_times['disk'] = current_time
logger.debug(f"Disk превысил порог {self.threshold}%: {system_info['disk_percent']:.1f}% - начинаем отсчет задержки {self.alert_delays['disk']}s")
# Проверяем, прошла ли задержка
if self.alert_delays['disk'] == 0 or current_time - self.alert_start_times['disk'] >= self.alert_delays['disk']:
self.alert_states['disk'] = True
alerts.append(('disk', system_info['disk_percent'], f"Свободно: {system_info['disk_free']} GB на /"))
logger.warning(f"DISK ALERT: {system_info['disk_percent']:.1f}% > {self.threshold}% (задержка {self.alert_delays['disk']}s)")
else:
# Диск ниже порога - сбрасываем состояние
if self.alert_states['disk']:
self.alert_states['disk'] = False
recoveries.append(('disk', system_info['disk_percent']))
logger.info(f"DISK RECOVERY: {system_info['disk_percent']:.1f}% < {self.recovery_threshold}%")
# Сбрасываем время начала превышения
self.alert_start_times['disk'] = None
return alerts, recoveries
def _get_host_psutil(self):
"""Получение psutil с доступом к хосту"""
if self.is_docker_host_monitoring:
# Переключаемся на директории хоста
os.environ['PROC_ROOT'] = '/host/proc'
os.environ['SYS_ROOT'] = '/host/sys'
# Перезагружаем psutil для использования новых путей
import importlib
import psutil
importlib.reload(psutil)
return psutil
return psutil
def _get_host_cpu_info(self):
"""Получение информации о CPU хоста"""
try:
if self.is_docker_host_monitoring:
# Читаем информацию о CPU напрямую из /proc
with open('/host/proc/cpuinfo', 'r') as f:
cpu_info = f.read()
# Подсчитываем количество ядер
cpu_count = cpu_info.count('processor')
# Читаем load average
with open('/host/proc/loadavg', 'r') as f:
load_avg = f.read().strip().split()[:3]
load_avg = [float(x) for x in load_avg]
# Читаем статистику CPU
with open('/host/proc/stat', 'r') as f:
cpu_stat = f.readline().strip().split()[1:]
cpu_stat = [int(x) for x in cpu_stat]
# Рассчитываем процент CPU (упрощенный метод)
# В реальности нужно сравнивать с предыдущими значениями
cpu_percent = 0.0 # Будет рассчитано в get_system_info
return {
'cpu_count': cpu_count,
'load_avg': load_avg,
'cpu_stat': cpu_stat
}
else:
# Используем стандартный psutil
return {
'cpu_count': psutil.cpu_count(),
'load_avg': psutil.getloadavg(),
'cpu_stat': None
}
except Exception as e:
logger.error(f"Ошибка при получении информации о CPU хоста: {e}")
return None
def _get_host_memory_info(self):
"""Получение информации о памяти хоста"""
try:
if self.is_docker_host_monitoring:
# Читаем информацию о памяти из /proc/meminfo
with open('/host/proc/meminfo', 'r') as f:
mem_info = f.read()
# Парсим значения
mem_lines = mem_info.split('\n')
mem_data = {}
for line in mem_lines:
if ':' in line:
key, value = line.split(':', 1)
mem_data[key.strip()] = int(value.strip().split()[0]) * 1024 # Конвертируем в байты
# Рассчитываем проценты
total = mem_data.get('MemTotal', 0)
available = mem_data.get('MemAvailable', 0)
used = total - available
ram_percent = (used / total * 100) if total > 0 else 0
# Swap
swap_total = mem_data.get('SwapTotal', 0)
swap_free = mem_data.get('SwapFree', 0)
swap_used = swap_total - swap_free
swap_percent = (swap_used / swap_total * 100) if swap_total > 0 else 0
return {
'ram_total': total,
'ram_used': used,
'ram_percent': ram_percent,
'swap_total': swap_total,
'swap_used': swap_used,
'swap_percent': swap_percent
}
else:
# Используем стандартный psutil
memory = psutil.virtual_memory()
swap = psutil.swap_memory()
return {
'ram_total': memory.total,
'ram_used': memory.used,
'ram_percent': memory.percent,
'swap_total': swap.total,
'swap_used': swap.used,
'swap_percent': swap.percent
}
except Exception as e:
logger.error(f"Ошибка при получении информации о памяти хоста: {e}")
return None
def _get_host_disk_info(self):
"""Получение информации о диске хоста"""
try:
if self.is_docker_host_monitoring:
# Используем df для получения информации о диске
import subprocess
result = subprocess.run(['df', '/'], capture_output=True, text=True)
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
if len(lines) >= 2:
parts = lines[1].split()
if len(parts) >= 4:
total_kb = int(parts[1])
used_kb = int(parts[2])
available_kb = int(parts[3])
total = total_kb * 1024
used = used_kb * 1024
available = available_kb * 1024
percent = (used / total * 100) if total > 0 else 0
return {
'total': total,
'used': used,
'free': available,
'percent': percent
}
# Fallback к стандартному psutil
return None
else:
# Используем стандартный psutil
return None
except Exception as e:
logger.error(f"Ошибка при получении информации о диске хоста: {e}")
return None
def _check_helper_bot_status(self) -> Tuple[str, str]:
"""Проверка статуса helper_bot через HTTP endpoint"""
try:
import requests
logger.info("Проверяем статус helper_bot через HTTP endpoint /status")
# Обращаемся к endpoint /status в helper_bot
url = 'http://bots_telegram_bot:8080/status'
logger.info(f"Отправляем HTTP запрос к: {url}")
response = requests.get(url, timeout=5)
logger.info(f"Получен HTTP ответ: статус {response.status_code}")
if response.status_code == 200:
try:
data = response.json()
logger.info(f"Получены данные: {data}")
status = data.get('status', 'unknown')
uptime = data.get('uptime', 'unknown')
if status == 'running':
result = "", f"Uptime {uptime}"
logger.info(f"Helper_bot работает: {result}")
return result
elif status == 'starting':
result = "🔄", f"Запуск: {uptime}"
logger.info(f"Helper_bot запускается: {result}")
return result
else:
result = "⚠️", f"Статус: {status}"
logger.warning(f"Helper_bot необычный статус: {result}")
return result
except (ValueError, KeyError) as e:
# Если не удалось распарсить JSON, но статус 200
logger.warning(f"Не удалось распарсить JSON ответ: {e}, но статус 200")
result = "", "HTTP: доступен"
logger.info(f"Helper_bot доступен: {result}")
return result
else:
logger.warning(f"HTTP статус не 200: {response.status_code}")
return "⚠️", f"HTTP: {response.status_code}"
except requests.exceptions.Timeout:
logger.error("HTTP запрос к helper_bot завершился таймаутом")
return "⚠️", "HTTP: таймаут"
except requests.exceptions.ConnectionError as e:
logger.error(f"HTTP ошибка соединения с helper_bot: {e}")
return "", "HTTP: нет соединения"
except ImportError:
logger.debug("requests не доступен для HTTP проверки")
return "", "HTTP: requests недоступен"
except Exception as e:
logger.error(f"Неожиданная ошибка при HTTP проверке helper_bot: {e}")
return "", f"HTTP: ошибка"

View File

@@ -0,0 +1,161 @@
"""
Модуль для управления PID файлами процессов
Общий модуль для всех ботов в проекте
"""
import os
import sys
import signal
import atexit
import logging
from typing import Optional
logger = logging.getLogger(__name__)
class PIDManager:
"""Класс для управления PID файлами"""
def __init__(self, pid_file_path: str, process_name: str = "process"):
"""
Инициализация PID менеджера
Args:
pid_file_path: Путь к PID файлу
process_name: Имя процесса для логирования
"""
self.pid_file_path = pid_file_path
self.process_name = process_name
self.pid = os.getpid()
def create_pid_file(self) -> bool:
"""
Создание PID файла с текущим PID процесса
Returns:
bool: True если файл создан успешно, False в противном случае
"""
try:
# Создаем директорию если не существует
pid_dir = os.path.dirname(self.pid_file_path)
if pid_dir and not os.path.exists(pid_dir):
os.makedirs(pid_dir, exist_ok=True)
# Записываем PID в файл
with open(self.pid_file_path, 'w') as f:
f.write(str(self.pid))
logger.info(f"PID файл создан для {self.process_name}: {self.pid_file_path} (PID: {self.pid})")
# Регистрируем функцию очистки при завершении
atexit.register(self.cleanup_pid_file)
# Регистрируем обработчики сигналов для корректной очистки
signal.signal(signal.SIGTERM, self._signal_handler)
signal.signal(signal.SIGINT, self._signal_handler)
return True
except Exception as e:
logger.error(f"Ошибка при создании PID файла для {self.process_name}: {e}")
return False
def cleanup_pid_file(self):
"""Удаление PID файла при завершении процесса"""
try:
if os.path.exists(self.pid_file_path):
os.remove(self.pid_file_path)
logger.info(f"PID файл удален для {self.process_name}: {self.pid_file_path}")
except Exception as e:
logger.error(f"Ошибка при удалении PID файла для {self.process_name}: {e}")
def _signal_handler(self, signum, frame):
"""Обработчик сигналов для корректного завершения"""
logger.info(f"Получен сигнал {signum} для {self.process_name}, очищаем PID файл...")
self.cleanup_pid_file()
sys.exit(0)
def is_running(self) -> bool:
"""
Проверка, запущен ли процесс с PID из файла
Returns:
bool: True если процесс запущен, False в противном случае
"""
try:
if not os.path.exists(self.pid_file_path):
return False
with open(self.pid_file_path, 'r') as f:
content = f.read().strip()
if not content:
return False
try:
pid = int(content)
# Проверяем, существует ли процесс с таким PID
os.kill(pid, 0) # Отправляем сигнал 0 для проверки существования
return True
except (ValueError, OSError):
# PID не валидный или процесс не существует
return False
except Exception as e:
logger.error(f"Ошибка при проверке PID файла для {self.process_name}: {e}")
return False
def get_pid(self) -> Optional[int]:
"""
Получение PID из файла
Returns:
int: PID процесса или None если файл не существует или невалидный
"""
try:
if not os.path.exists(self.pid_file_path):
return None
with open(self.pid_file_path, 'r') as f:
content = f.read().strip()
if not content:
return None
return int(content)
except (ValueError, FileNotFoundError) as e:
logger.error(f"Ошибка при чтении PID файла для {self.process_name}: {e}")
return None
def create_pid_manager(process_name: str, project_root: str = None) -> PIDManager:
"""
Создание PID менеджера для указанного процесса
Args:
process_name: Имя процесса (например, 'helper_bot', 'admin_bot', etc.)
project_root: Корневая директория проекта. Если None, определяется автоматически
Returns:
PIDManager: Экземпляр PID менеджера
"""
if project_root is None:
# Определяем корень проекта автоматически
current_file = os.path.abspath(__file__)
# Поднимаемся на 2 уровня вверх от infra/monitoring/pid_manager.py
project_root = os.path.dirname(os.path.dirname(current_file))
pid_file_path = os.path.join(project_root, f"{process_name}.pid")
return PIDManager(pid_file_path, process_name)
def get_bot_pid_manager(bot_name: str) -> PIDManager:
"""
Удобная функция для создания PID менеджера для ботов
Args:
bot_name: Имя бота (например, 'helper_bot', 'admin_bot', etc.)
Returns:
PIDManager: Экземпляр PID менеджера
"""
return create_pid_manager(bot_name)

View File

@@ -44,10 +44,8 @@ def main():
# Проверяем статус процессов
print("\n🤖 Проверка статуса процессов...")
voice_status, voice_uptime = monitor.check_process_status('voice_bot')
helper_status, helper_uptime = monitor.check_process_status('helper_bot')
print(f" Voice Bot: {voice_status} - {voice_uptime}")
print(f" Helper Bot: {helper_status} - {helper_uptime}")
# Получаем метрики для Prometheus