跳转至

统一通知渠道(notify.py)

仓库路径:deploy/nfos/monitoring/notify.py | 线上路径:/opt/Cy/services/system.d/nfos/monitoring/notify.py 语言:python3 | 定时器:无(被调用,不挂 timer)


用途

全平台唯一的通知出口模块:把统一告警分发到

  1. 飞书机器人(告警机器人 / 安全信息机器人 / 汇总通报机器人);
  2. 邮件(SMTP smtp.feishu.cn,发件人按通知类别选择统一邮箱之一)。

它不是可独立执行的任务脚本,而是被同目录的三个调用方 import:

调用方 调用方式
monitoring/sec-notify.py notify.NotifyConfig.load() + notify.Notifier(cfg),再 dispatch() / render()
monitoring/ticket-notify.py from notify import Notifier(导入失败即非 0 退出)
monitoring/digest.py 发送时函数内 import notify
monitoring/alert-adapter.py 顶部 from notify import Notifier(失败则「通知不可用」降级,不影响告警主链路)

两条关键设计(写在源码注释里):

  • 「类别 → 通道」是显式路由表 CATEGORY_ROUTING,digest 类别单独一条通道(汇总通报专用机器人),与实时安全告警分群,避免值班被汇总刷屏、也避免实时告警被汇总淹没;邮件侧 digest 沿用安全邮箱。
  • 脱敏在 render() 出口一处收口:飞书正文、邮件主题、邮件正文全部经 sanitize.py。任何异常都原样返回——脱敏是展示层加固,绝不能反过来阻断告警。

何时用

  • 正常运行时不需要有人碰它:它只被别的脚本调用。
  • 想在不改配置的前提下确认「通道到底配了哪些、开关开没开」:直接执行脚本本体(它打印 describe() 快照,不含口令,也不发任何消息)。
  • 想主动确认通道真的通:--test(发一条自检消息)。
  • 想临时静音某个通道:用环境变量开关(NOTIFY_FEISHU_ENABLED / NOTIFY_MAIL_ENABLED),不需要改代码。
  • 想降低邮件频率:调 NFOS_MAIL_MIN_INTERVAL_<CATEGORY>(值为秒,0 = 不限速)。

命令与参数

本脚本没有 argparse。 __main__ 里只识别唯一一个字面量 --test:

参数 类型 默认值 说明
--test 位置参数(os.sys.argv[1] == "--test" 精确比较) 无(不传则只打印配置) 发一条自检消息,用于确认飞书与邮件通道可用,无需处理

除 --test 外没有任何其它 argv 入口(源码未给出);其余行为全部通过环境变量 / 环境文件控制。

不带参数时的默认行为

实例化 Notifier() → 打印 cfg.describe() 的 JSON(只输出字段清单与「已配置 / 未配置」标记,不含任何口令)→ 结束。不发任何消息、不写任何状态文件。

可用的环境变量(全部取自 NotifyConfig.load())

变量 作用 默认
NOTIFICATION_ENV 覆盖凭据文件路径 /opt/Cy/services/system.d/nfos/secrets/notification.env
SMTP_HOST / SMTP_PORT / SMTP_SECURITY / SMTP_TIMEOUT_SECS SMTP 目标与安全模式 smtp.feishu.cn / 465 / ssl / 20.0
ALERT_TO / ALERT_CC 收件人 / 抄送 空
MAIL_TRAP_ADDRESS、MAIL_SECURITY_ADDRESS、MAIL_ONLINE_ADDRESS、MAIL_SERVICE_ADDRESS 四个类别的发件人 空
MAIL_TRAP_PASSWORD、MAIL_SECURITY_PASSWORD、MAIL_ONLINE_PASSWORD、MAIL_SERVICE_PASSWORD 对应发件人口令 空(取值见 secrets/notification.env,不得转录)
FEISHU_ALERT_WEBHOOK / FEISHU_SECURITY_WEBHOOK / FEISHU_DIGEST_WEBHOOK 三个飞书机器人 webhook 空(取值见 secrets/notification.env,不得转录)
NOTIFY_FEISHU_ENABLED / NOTIFY_MAIL_ENABLED 通道总开关(0 / false / False 视为关) 1(开)
NFOS_MAIL_MIN_INTERVAL_<CATEGORY> 按类别覆盖邮件最小间隔(秒) 见下表
NFOS_MAIL_RATE_LIMIT_COOLDOWN 被服务商限流后的冷却秒数 1800
NFOS_MAIL_THROTTLE_STATE 邮件限速状态文件 /opt/Cy/services/system.d/nfos/state/notify-mail-throttle.json

环境变量优先于环境文件;此外任何以 SMTP_ / MAIL_ / ALERT_ / FEISHU_ / NOTIFY_ 开头的进程环境变量都会被吸收进来。

内置路由与限速表(源码常量,非参数)

类别 发件邮箱变量前缀 飞书机器人变量
alert MAIL_TRAP FEISHU_ALERT_WEBHOOK
security MAIL_SECURITY FEISHU_SECURITY_WEBHOOK
ticket MAIL_ONLINE FEISHU_ALERT_WEBHOOK
service MAIL_SERVICE FEISHU_ALERT_WEBHOOK
digest MAIL_SECURITY FEISHU_DIGEST_WEBHOOK
级别 飞书 邮件
critical / high / error / warning ✅ ✅
info / debug ❌ ❌(仅日志)

dispatch(channels=(True, True)) 可显式覆盖级别默认渠道——ticket-notify.py 用它保证「工单状态转变皆有邮件通知」,不因工单级别是 info 被静默跳过。

邮件类别 最小间隔(秒)
default 600
security 900
ticket 900
alert 600
service 1800
digest 0(不限速)

示例

D=/opt/Cy/services/system.d/nfos

# 1) 只打印配置快照(不发消息、不写状态、不含口令)
sudo python3 "$D/monitoring/notify.py"

# 2) 发一条自检消息,确认飞书与邮件通道可用
sudo python3 "$D/monitoring/notify.py" --test

# 3) 用 API 方式拿同一份快照(供脚本消费)
sudo python3 -c 'import sys; sys.path.insert(0, "/opt/Cy/services/system.d/nfos/monitoring"); \
import notify; print(notify.NotifyConfig.load().describe())'

# 4) 只做「渲染」不发消息:拿到脱敏后的飞书正文与邮件主题/正文
sudo python3 -c 'import sys; sys.path.insert(0, "/opt/Cy/services/system.d/nfos/monitoring"); \
import notify; n = notify.Notifier(); \
print(n.render("security","warning","通道自检","Cy.AliCOS-BJ","SELFTEST-1","正文",{"来源":"手册"}))'

# 5) 临时静音「邮件」通道(飞书保持实时),验证渠道隔离
sudo env NOTIFY_MAIL_ENABLED=0 python3 "$D/monitoring/notify.py" --test

# 6) 应急:把某类别邮件间隔调到 0(不限速)——仅排障期使用
sudo env NFOS_MAIL_MIN_INTERVAL_SECURITY=0 python3 "$D/monitoring/notify.py" --test

更贴近线上链路的通道自检(走真实告警字段、输出更完整)优先用 sudo python3 "$D/monitoring/sec-notify.py" --selftest,见 sec-notify.md。

依赖与权限

项 内容
解释器 任意 python3(零第三方依赖:smtplib + ssl + urllib + json + threading,全部标准库)
同目录模块 sanitize.py(_sanitize_out() 内函数级自取)
外部命令 无
root 取决于调用场景:要读 secrets/notification.env(mode 600)就必须是 root 或 root 组;被 timer 拉起的调用方(ticket-notify、digest)单元里都是 User=root
sudo -n 不需要(本模块自身不调用 sudo)
读取 secrets/notification.env(600)。凭据取值的唯一来源就是这个文件;本页与该文件之外一律不记录
写入 state/notify-mail-throttle.json(原子写:*.tmp → os.replace)。写失败静默忽略(except: pass),不影响发送
网络 出站 HTTPS(飞书 webhook)+ SMTP smtp.feishu.cn:465(SSL,默认超时 20s)。四类发件邮箱都走同一 SMTP 主机
并发 send_mail() 用实例级 threading.Lock 串行化 SMTP 会话(alert-adapter.py 是 ThreadingHTTPServer,会并发调它)

幂等与失败语义

  • 不做业务去重:.py 层不判断「这条告警是不是发过」。去重由调用方负责(digest.py 的 last_sent_bucket、ticket-notify.py 的状态快照、守卫的 dedup_key)。
  • 邮件限速是「跳过」而不是「失败」:被限速时 send_mail() 返回字符串 "skipped"(注意:函数签名标注的是 -> bool,实际可能返回 "skipped"),并置 last_error 为原因;调用方按「渠道未启用」处理,不重试、不丢状态。
  • 服务商限流有独立更长的冷却:send_mail() 捕获到错误串里含 exceeds limit / exceed 时,把该类别标记 rate_limited_until = now + NFOS_MAIL_RATE_LIMIT_COOLDOWN(默认 1800s),短期不再硬撞。
  • 缺配置 = 记 skipped 并返回 False:send_mail() 在「发件人 / 口令 / 收件人」任一缺失时 skipped + last_error,不抛异常。
  • 发送异常不向外抛:send_mail() / send_feishu() 内部 try/except,失败返回 False 并递增统计;因此调用方不会被异常打断(这是「缺省行为:未配置渠道时静默降级为仅日志,不影响告警主链路」的落地方式)。
  • 状态/统计:模块内存里维护 stats(feishu_ok / feishu_failed / mail_ok / mail_failed / skipped / mail_throttled),并提供 metrics_lines() 输出 Prometheus 文本(nfos_notify_feishu_total 等)。这些计数只活在进程内存里,不落盘。
  • 退出码:__main__ 不调用 sys.exit → 正常路径恒为 0;只有未捕获异常才会非 0。

常见错误与排查

现象 代码里的对应分支 排查步骤
日志只写「渠道未配置」但没人收到消息 send_mail() 的 if not sender or not pw or not to: → skipped;send_feishu() 的同类分支 跑 python3 notify.py 看 describe() 的 senders / feishu_channels / passwords_set / alert_to;全为「未配置」说明 secrets/notification.env 没被读到或权限不对
describe() 报 "source": "环境变量" 而不是文件路径 cfg.source = SECRETS_FILE if os.path.exists(SECRETS_FILE) else "环境变量" 说明 NOTIFICATION_ENV 指向的(或默认的)文件不存在;检查路径与 NOTIFICATION_ENV 拼写
邮件全丢、飞书正常、日志里有 501 mail count exceeds limit send_mail() 的 rate_limited 分支 → 冷却 1800s 看 state/notify-mail-throttle.json 里该类别的 rate_limited_until;等冷却过期,或临时 NFOS_MAIL_RATE_LIMIT_COOLDOWN 调整;治本靠 MAIL_MIN_INTERVAL 与「汇总由 digest 承担」
邮件「有时发有时不发」且间隔看起来随机 _mail_throttle_check() 的常规限速分支 按类别核对 MAIL_MIN_INTERVAL(security/ticket 900s、service 1800s、digest 0);需要临时放开用 NFOS_MAIL_MIN_INTERVAL_<CATEGORY>
出站正文里出现内部路径 / 私网 / webhook _sanitize_out() 的 except 分支(静默失效) 用上面示例 4 的 render() 直接看渲染结果;若仍含明文,先 python3 "$D/monitoring/sanitize.py" --selftest 确认 sanitize.py 在同目录且可导入(import sanitize 走的是 notify.py 所在目录)
json.log 里同一件事没有正文行 调用方的问题,不在本模块 本模块只负责渲染与投递;「字段传了但正文里没有」由 TICKET_FIELD_ALIASES 别名归一覆盖,核对调用方传的键名
多线程(alert-adapter.py)下 SMTP 会话互相干扰 send_mail() 的 with self._lock: 已知并已用实例锁串行化;若仍见会话异常,检查是否新建了多个 Notifier 实例(每个实例一把锁)

回滚方式

本模块不修改系统状态、不写配置,唯一的落盘是邮件限速状态文件。回滚路径:

D=/opt/Cy/services/system.d/nfos
BK=$D/state/unit-backups

# 1) 只回滚「限速状态」(例如误把某类别拉黑太久)
TS=$(date +%Y%m%d-%H%M%S)
sudo install -d -m 755 "$BK"
sudo cp -a "$D/state/notify-mail-throttle.json" "$BK/notify-mail-throttle.json.bak-$TS" 2>/dev/null
#    确认后清空(下次发送会重新标记;清空只是「忘记上次发送时间」)
sudo rm -f "$D/state/notify-mail-throttle.json"

# 2) 临时静音通道(不改文件,重启即恢复)
sudo systemctl set-environment NOTIFY_MAIL_ENABLED=0    # 或直接在调用方的 unit 里加 Environment=
sudo systemctl restart nfos-ticket-notify.timer

# 3) 回滚脚本本体
TS=$(date +%Y%m%d-%H%M%S)
sudo cp -a "$D/monitoring/notify.py" "$BK/notify.py.bak-$TS"     # 改之前先留一份
sudo cp -a "$BK/notify.py.bak-<时间戳>" "$D/monitoring/notify.py"
sudo chown root:root "$D/monitoring/notify.py"
sudo chmod 644       "$D/monitoring/notify.py"

备份落点铁律:备份放 /opt/Cy/services/system.d/nfos/state/unit-backups/(命名 <文件名>.bak-YYYYMMDD-HHMMSS)或仓库侧 /opt/Cy/nfarms/.backup/。 绝不能放在 /etc/systemd/system/ —— 会触发 PS-002 完整性告警。见 timers.md 第 11 节。

另注:docs/CHANGELOG-2026-09-26.md 的「§8.2 告警治理:备节点回滚」一节里, 对备节点用的是 sudo rm -f "$D/monitoring/sanitize.py"。那只适用于「本轮新增」的场景—— notify.py 现在依赖 sanitize.py,删掉它会让脱敏静默失效,请勿照抄。

相关 CHANGELOG

CHANGELOG 章节标题
docs/CHANGELOG-2026-09-18.md §36. 新增 sec-notify.py(安全告警 → 飞书安全群/安全邮箱)
docs/CHANGELOG-2026-09-23.md §2. 主节点补齐 notify.py,修复 sec-notify.py 加载器
docs/CHANGELOG-2026-09-26.md §1.7 脱敏:出口统一收口在 notify.py 的 render()
docs/CHANGELOG-2026-09-26.md §9. 通知腿 NameError 缺陷(P0,已修复)
docs/CHANGELOG-2026-09-26.md §10.1 汇总通报统一走新机器人(用户要求)
docs/CHANGELOG-2026-09-26.md §12. 通知风暴与邮件限流(2026-09-26 16:4x,用户投诉「告警过于繁多」)

定时器全景(含各调用方的 ExecStart 逐字抄录)见 timers.md。