main.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. """model-watchdog 入口:一个 loop,按 interval_minutes 反复执行两个检测模块。
  2. python main.py # 常驻循环
  3. python main.py --once # 只跑一轮
  4. python main.py --once --dry-run # 只检测并打印将要发送的告警,不真发
  5. 每轮执行:
  6. 模块 A check_offline_notice 收件箱下线通知检测 -> 转发原邮件 + 钉钉(已发送标题去重)
  7. 模块 B check_model_health 模型可用性检测 -> 聚合告警邮件 + 钉钉
  8. """
  9. import argparse
  10. import logging
  11. import sys
  12. import time
  13. from datetime import datetime, timedelta
  14. from config import ENDPOINTS, enabled_probe_targets, load_config
  15. from mailer import MailChecker
  16. from notifier import dual_alert, dual_forward
  17. from probes import probe_all
  18. log = logging.getLogger("watchdog")
  19. # ========== 模块 A:模型下线通知检测 ==========
  20. def check_offline_notice(cfg, dry_run=False):
  21. """检测收件箱下线通知,未转发过的立即转发 + 钉钉推送。返回本轮转发条数。"""
  22. checker = MailChecker(cfg["mail"], cfg["offline_notice"])
  23. try:
  24. notices = checker.find_offline_notices()
  25. except Exception as e: # noqa: BLE001 - IMAP 异常不能中断整轮
  26. log.error("[模块A] 邮箱检测失败: %s", e)
  27. return 0
  28. if not notices:
  29. log.info("[模块A] 无需要转发的模型下线通知")
  30. return 0
  31. for n in notices:
  32. dual_forward(cfg, n, dry_run=dry_run)
  33. log.info("[模块A] 已处理下线通知: %s", n["message_id"])
  34. return len(notices)
  35. # ========== 模块 B:模型可用性检测 ==========
  36. def check_model_health(cfg, dry_run=False):
  37. """全量探测配置的模型,把失败的聚合成一条双重告警。返回失败模型数。"""
  38. targets = enabled_probe_targets(cfg)
  39. if not targets:
  40. log.info("[模块B] 未配置任何待探测模型")
  41. return 0
  42. results = probe_all(targets, cfg["api_key"])
  43. failed = [r for r in results if not r["ok"]]
  44. log.info("[模块B] 探测 %d 个模型,失败 %d 个", len(results), len(failed))
  45. if not failed:
  46. return 0
  47. segments = []
  48. for r in failed:
  49. code = r["code"] or "未知错误"
  50. message = r["message"] or "无错误描述"
  51. raw = r["raw"] or "(无响应体)"
  52. segments.append(
  53. f"{r['model']}({r['label']})报错 {code} {message},完整响应 {raw}"
  54. )
  55. body = "\n".join([
  56. "【模型可用性告警】",
  57. "",
  58. f"检测时间: {datetime.now():%Y-%m-%d %H:%M:%S}",
  59. f"探测总数: {len(results)} 失败: {len(failed)}",
  60. "",
  61. ";\n".join(segments),
  62. "",
  63. "—— model-watchdog 自动告警",
  64. ])
  65. subject = f"【模型可用性告警】{len(failed)} 个模型不可用:" + "、".join(
  66. r["model"] for r in failed
  67. )
  68. dual_alert(cfg, subject, body, dry_run=dry_run)
  69. return len(failed)
  70. # ========== 单轮 + 主循环 ==========
  71. def run_once(cfg, dry_run=False, skip_mail=False, skip_probe=False):
  72. started = datetime.now()
  73. log.info("========== 开始第 %s 轮检测 ==========", started.strftime("%Y-%m-%d %H:%M:%S"))
  74. notices = 0 if skip_mail else check_offline_notice(cfg, dry_run=dry_run)
  75. failures = 0 if skip_probe else check_model_health(cfg, dry_run=dry_run)
  76. log.info(
  77. "========== 本轮结束,耗时 %.1fs,转发下线通知 %d 条,不可用模型 %d 个 ==========",
  78. (datetime.now() - started).total_seconds(), notices, failures,
  79. )
  80. def run_loop(cfg, **kwargs):
  81. interval = cfg["interval_minutes"] * 60
  82. while True:
  83. try:
  84. run_once(cfg, **kwargs)
  85. except Exception: # noqa: BLE001 - 单轮任何异常都不能让守护进程退出
  86. log.exception("本轮检测出现未捕获异常,跳过本轮")
  87. next_at = datetime.now() + timedelta(seconds=interval)
  88. log.info("休眠 %d 分钟,下次检测 %s", cfg["interval_minutes"], next_at.strftime("%Y-%m-%d %H:%M:%S"))
  89. time.sleep(interval)
  90. def main():
  91. parser = argparse.ArgumentParser(description="阿里云百炼模型下线 & 可用性监控")
  92. parser.add_argument("-c", "--config", default="config.yaml", help="配置文件路径")
  93. parser.add_argument("--once", action="store_true", help="只执行一轮后退出")
  94. parser.add_argument("--dry-run", action="store_true", help="只检测并打印告警内容,不真正发送")
  95. parser.add_argument("--skip-mail", action="store_true", help="跳过模块 A(只测模型)")
  96. parser.add_argument("--skip-probe", action="store_true", help="跳过模块 B(只测邮箱)")
  97. parser.add_argument("--test-dingtalk", action="store_true",
  98. help="只给钉钉发一条测试消息后退出(用于单独联调机器人)")
  99. args = parser.parse_args()
  100. logging.basicConfig(
  101. level=logging.INFO,
  102. format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  103. datefmt="%Y-%m-%d %H:%M:%S",
  104. )
  105. cfg = load_config(args.config)
  106. if args.test_dingtalk:
  107. from notifier import send_dingtalk, _to_markdown
  108. title = "【model-watchdog】钉钉连通性测试"
  109. body = "\n".join([
  110. "这是一条测试消息,说明钉钉通道配置正确。",
  111. "",
  112. f"发送时间: {datetime.now():%Y-%m-%d %H:%M:%S}",
  113. f"监控邮箱: {cfg['mail']['username']}",
  114. ])
  115. try:
  116. send_dingtalk(cfg["dingtalk"], title, _to_markdown(title, body))
  117. log.info("钉钉测试消息已发送,请到群里确认")
  118. return 0
  119. except Exception as e: # noqa: BLE001
  120. log.error("钉钉测试失败: %s", e)
  121. return 1
  122. summary = "、".join(
  123. f"{ENDPOINTS[name]['label']}×{len(g['models'])}"
  124. for name, g in cfg["models"].items()
  125. if g["enabled"] and g["models"]
  126. )
  127. log.info("配置加载完成: 每 %d 分钟一轮 | 待测模型 %s | 邮箱 %s",
  128. cfg["interval_minutes"], summary or "无", cfg["mail"]["username"])
  129. log.info("下线通知匹配条件: 发件人域名=%s 关键词=%s 回溯=%d 天",
  130. "、".join(cfg["offline_notice"]["sender_domains"]) or "(不限,有被外部邮件触发的风险)",
  131. cfg["offline_notice"]["keyword"], cfg["offline_notice"]["lookback_days"])
  132. if args.dry_run:
  133. log.info("DRY-RUN 模式:不会真正发送任何告警")
  134. opts = {"dry_run": args.dry_run, "skip_mail": args.skip_mail, "skip_probe": args.skip_probe}
  135. if args.once:
  136. run_once(cfg, **opts)
  137. else:
  138. run_loop(cfg, **opts)
  139. if __name__ == "__main__":
  140. sys.exit(main() or 0)