fix(ops): мониторинг под реальную топологию + проверка воркеров
Some checks failed
Deploy / test (push) Successful in 2m52s
Deploy / deploy (push) Has been cancelled

Монитор лежал только на сервере, не версионировался и месяц проверял
конфигурацию, которой уже нет: Ollama на CT 108 (.20.163), остановленном
27.08 при переходе на OpenRouter. Итог — вечный ложный DOWN, на фоне которого
теряются настоящие аварии, и полная слепота к реальному серверу эмбеддингов
(.1.40) и к воркерам.

- скрипт переехал в репозиторий и теперь деплоится вместе с кодом;
- адреса читаются из прод-.env, а не зашиты: сервер эмбеддингов переезжал
  дважды, и каждый раз монитор оставался со старым адресом;
- добавлена проверка воркеров Celery — 05.09 брокер лежал час, воркеры молчали,
  а монитор рапортовал, что всё хорошо, потому что смотрел только TCP-порт;
- разбор URL чинит падение на кредах в REDIS_URL/RABBITMQ_URL.

Проверено на проде: все восемь проверок отдают OK, включая Embeddings и
CeleryWorkers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-09-05 20:43:01 +05:00
parent 26de1b9de1
commit fed4b54cfc
2 changed files with 211 additions and 2 deletions

View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Health-монитор anti-plagiarism: письмо при СМЕНЕ статуса сервиса, без спама.
Запускается по cron на app-хосте (1.32) каждые 5 минут:
*/5 * * * * /usr/bin/python3 /home/user/antiplag_monitor.py >> ~/.antiplag-monitor/run.log 2>&1
Живёт в репозитории намеренно: прошлая версия лежала только на сервере, ничем
не версионировалась и месяц проверяла топологию, которой уже нет — вечный
ложный DOWN на остановленном CT 108 и полная слепота к реальному серверу
эмбеддингов. Постоянный ложный сигнал хуже отсутствия сигнала: на его фоне
теряется настоящая авария.
Что проверяется и почему именно это:
- API, PostgreSQL, Redis, RabbitMQ, MinIO, Elasticsearch — без них сервис не
принимает и не обрабатывает работы;
- Ollama на 1.40 — эмбеддинги для L3 (адрес брать из .env, а не хардкодить:
он уже дважды переезжал);
- воркеры Celery — самое важное дополнение: 05.09 брокер лежал час, воркеры
молчали, а прежний монитор рапортовал, что всё хорошо, потому что смотрел
только на TCP-порты.
OpenRouter (L4) намеренно не проверяется: это внешний сервис за VPN-прокси, его
недоступность не ломает основную проверку — L4 лишь уточняет вердикт.
"""
import json
import os
import smtplib
import socket
import ssl
import subprocess
import urllib.request
from datetime import datetime
from email.mime.text import MIMEText
APP_DIR = os.environ.get("APP_DIR", "/home/user/anti-plagiarism")
STATE_FILE = os.path.expanduser("~/.antiplag-monitor/state.json")
SMTP_HOST, SMTP_PORT = "mail.jze9mail.ru", 587
SMTP_USER = os.environ.get("MONITOR_SMTP_USER", "noreply")
SMTP_PASS = os.environ.get("MONITOR_SMTP_PASS", "kGy3vgt6EuiUBMNDmV01Ng")
SMTP_FROM = "noreply@jze9mail.ru"
ALERT_TO = os.environ.get("MONITOR_ALERT_TO", "jze9programer@gmail.com")
def env_value(key: str, default: str = "") -> str:
"""Значение из прод-.env (он генерируется из Infisical на каждом деплое).
Адреса инфраструктуры читаем оттуда, а не хардкодим: сервер эмбеддингов уже
переезжал дважды, и монитор каждый раз оставался со старым адресом.
"""
path = os.path.join(APP_DIR, ".env")
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip("'\"")
except Exception:
pass
return default
def host_port_from_url(url: str, default_port: int) -> tuple[str, int]:
"""Разобрать URL в пару (host, port), отбросив логин с паролем.
В REDIS_URL и RABBITMQ_URL креды идут перед адресом
(`redis://:пароль@host:port/0`), и без их отсечения разбор падает.
"""
rest = url.split("//", 1)[-1].split("/", 1)[0]
rest = rest.rpartition("@")[2] or rest
if ":" in rest:
host, _, port = rest.partition(":")
return host, int(port or default_port)
return rest, default_port
def check_tcp(host: str, port: int) -> bool:
try:
with socket.create_connection((host, port), timeout=5):
return True
except Exception:
return False
def check_http(url: str) -> bool:
try:
with urllib.request.urlopen(url, timeout=8) as r:
return r.status == 200
except Exception:
return False
def check_in_api_container(command: str) -> bool:
"""Проверка изнутри контейнера — так же, как это видит приложение."""
try:
out = subprocess.run(
["docker", "exec", "antiplagiator-api", "sh", "-c", command],
capture_output=True, text=True, timeout=20,
)
return out.stdout.strip() == "200"
except Exception:
return False
def check_celery_workers() -> bool:
"""Отвечают ли воркеры на ping.
Живой брокер ещё не значит работающую систему: воркер может не суметь к
нему подключиться и молча простаивать — именно так и было 05.09.
"""
try:
out = subprocess.run(
["docker", "exec", "antiplagiator-api", "python", "-c",
"from app.core.celery_app import celery_app;"
"print(len(celery_app.control.inspect(timeout=5).ping() or {}))"],
capture_output=True, text=True, timeout=30,
)
return int(out.stdout.strip() or 0) >= 4 # api ждёт 4 воркера
except Exception:
return False
def build_checks() -> list[tuple[str, callable]]:
"""Список проверок; адреса — из прод-.env, чтобы не разъезжаться с реальностью."""
pg_host = env_value("POSTGRES_HOST", "192.168.1.38")
redis_host, redis_port = host_port_from_url(
env_value("REDIS_URL", "redis://192.168.1.35:6379/0"), 6379)
minio_host, minio_port = host_port_from_url(
"http://" + env_value("MINIO_ENDPOINT", "192.168.1.21:9000"), 9000)
ollama_url = env_value("OLLAMA_URL", "http://192.168.1.40:11434")
rabbit_host, rabbit_port = host_port_from_url(
env_value("RABBITMQ_URL", "amqp://guest:guest@192.168.20.82:5672/"), 5672)
return [
("API", lambda: check_http("http://localhost:8000/health")),
("PostgreSQL", lambda: check_tcp(pg_host, 5432)),
("Redis", lambda: check_tcp(redis_host, redis_port)),
("RabbitMQ", lambda: check_tcp(rabbit_host, rabbit_port)),
("MinIO", lambda: check_tcp(minio_host, minio_port)),
("Elasticsearch", lambda: check_in_api_container(
"curl -s -o /dev/null -w '%{http_code}' http://elasticsearch:9200")),
("Embeddings", lambda: check_http(f"{ollama_url}/api/version")),
("CeleryWorkers", check_celery_workers),
]
def send_alert(subject: str, body: str) -> None:
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = ALERT_TO
ctx = ssl.create_default_context()
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=25) as s:
s.ehlo()
s.starttls(context=ctx)
s.ehlo()
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg)
def main() -> None:
results = {name: fn() for name, fn in build_checks()}
prev: dict = {}
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE) as fh:
prev = json.load(fh)
except Exception:
prev = {}
changes = []
for name, ok in results.items():
was = prev.get(name, True) # первый запуск считаем нормой
if was and not ok:
changes.append(f"УПАЛ: {name}")
elif not was and ok:
changes.append(f"восстановился: {name}")
if changes:
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
status = "\n".join(f" {'OK ' if v else 'DOWN'} {k}" for k, v in results.items())
body = (f"Изменения статуса сервисов anti-plagiarism ({ts}):\n\n"
+ "\n".join(changes) + "\n\nТекущий статус:\n" + status)
subject = f"[anti-plagiarism] {changes[0]}"
if len(changes) > 1:
subject += f" (+{len(changes) - 1})"
try:
send_alert(subject, body)
except Exception as e:
print(f"не удалось отправить алерт: {e}")
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w") as fh:
json.dump(results, fh)
line = " ".join(f"{k}={'OK' if v else 'DOWN'}" for k, v in results.items())
print(datetime.now().strftime("%H:%M"), "|", line)
if __name__ == "__main__":
main()