Защита от тихого зависания бота (без таймаута сети + watchdog)

Причина 'бот в моменте перестаёт работать' без ошибок в логах:
vk_api ставит таймаут только на сам LongPoll-запрос (wait+10 с), а
обычные вызовы API (messages.send и т.п.) идут без таймаута вообще.
Если VK/сеть один раз не ответит на такой вызов — соединение виснет
навсегда, а так как обработка сообщений однопоточная (внутри цикла
LongPoll), весь бот намертво стопорится без единой строки в логе.
Docker restart: unless-stopped не спасает — процесс не падает, а просто
висит.

- socket.setdefaulttimeout(60) — подстраховка для всех сетевых вызовов
  без явного timeout (vk_api, requests в mailer.py и загрузке фото).
- Ручной вызов longpoll.check() вместо for event in listen() — даёт
  watchdog 'пульс' на каждом опросе сервера (~25-35 с), а не только при
  реальных сообщениях.
- Watchdog-поток: если цикл не подавал признаков жизни дольше 5 минут,
  принудительно завершает процесс (os._exit), чтобы Docker реально
  перезапустил контейнер.
This commit is contained in:
Kenik
2026-09-06 13:44:45 +03:00
parent 6ecbd7aa94
commit 6ee4e6cf0f
+41 -1
View File
@@ -10,10 +10,20 @@ import time
import hmac
import hashlib
import logging
import socket
import threading
from collections import defaultdict
from typing import Dict, Any, Optional
# Глобальный таймаут для всех сетевых сокетов без явного timeout.
# Без этого библиотека vk_api ставит таймаут только на сам LongPoll-запрос
# (wait+10 сек), а обычные вызовы API (messages.send и т.п.) идут без
# таймаута вообще — если VK/сеть один раз не ответит, соединение висит
# бесконечно и весь однопоточный цикл обработки сообщений намертво стопорится
# без единой строки в логе. Docker restart:unless-stopped при этом не
# помогает — процесс не падает, а просто зависает.
socket.setdefaulttimeout(60)
# Нам понадобятся библиотеки vk_api.
try:
import vk_api
@@ -52,6 +62,10 @@ BAN_SEC = 600
ADMINS_CACHE_TTL = 30
# Как часто фоновый поток проверяет, не пора ли отправить напоминание
REMINDER_CHECK_INTERVAL_SEC = 60
# Сколько секунд основной цикл LongPoll может не подавать признаков жизни,
# прежде чем watchdog сочтёт его зависшим и принудительно завершит процесс
# (штатный опрос происходит раз в ~25-35 с, так что 5 минут — большой запас)
WATCHDOG_TIMEOUT_SEC = 300
REMINDER_PERIOD_LABELS = {
"once": "разово",
@@ -2131,9 +2145,28 @@ class VKBotApplication:
logger.error(f"Ошибка обработки напоминаний: {e}")
time.sleep(REMINDER_CHECK_INTERVAL_SEC)
def _watchdog_loop(self):
"""Фоновый страж: если основной цикл LongPoll не подавал признаков
жизни дольше WATCHDOG_TIMEOUT_SEC (завис на каком-то вызове без
таймаута, дедлок и т.п.), принудительно завершает процесс — чтобы
Docker (restart: unless-stopped) реально перезапустил контейнер.
Без этого зависший, но не упавший процесс никто не перезапустит."""
while True:
time.sleep(30)
stale_for = time.time() - self._last_alive_ts
if stale_for > WATCHDOG_TIMEOUT_SEC:
logger.critical(
f"Watchdog: основной цикл не подавал признаков жизни {stale_for:.0f} с "
f"(порог {WATCHDOG_TIMEOUT_SEC} с). Похоже на зависание — принудительный выход "
f"для перезапуска контейнером."
)
os._exit(1)
def run(self):
"""Запуск бесконечного цикла прослушивания событий VK LongPoll."""
self._last_alive_ts = time.time()
threading.Thread(target=self._reminders_loop, daemon=True).start()
threading.Thread(target=self._watchdog_loop, daemon=True).start()
if not self.longpoll:
logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).")
print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
@@ -2163,9 +2196,16 @@ class VKBotApplication:
logger.error(f"Не удалось отправить старт-пинг: {e}")
# Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота.
# Вызываем check() вручную (а не for event in self.longpoll.listen()), чтобы
# обновлять "пульс" для watchdog на каждом опросе сервера (раз в ~25-35 с),
# а не только когда реально приходит сообщение — иначе в тихие часы без
# сообщений watchdog решит, что бот завис, и перезапустит его зря.
while True:
try:
for event in self.longpoll.listen():
self._last_alive_ts = time.time()
events = self.longpoll.check()
self._last_alive_ts = time.time()
for event in events:
if event.type == VkBotEventType.MESSAGE_NEW:
message_obj = event.obj.message
user_id = message_obj["from_id"]