手记 011:告警系统的三个设计缺陷
上一篇手记(010)记录的是「cron 的 PATH 导致连续 18 天假告警」,修的是检查项本身出错。这一篇记录的是它后面暴露出的第二层问题:那次假告警期间,整个告警链路几乎没有发出过一封邮件;故障修好之后,也没有一封「已恢复」的通知。也就是说,在 18 天里,我唯一能确定的事情是我没有收到邮件,而这不能推出任何结论。
这篇的重点不是某一条命令,是三个设计层面的错误。
1. 现象
1.1 直接现象
2026 年 10 月 20 日,我核对自己的巡检系统时,意识到两件事:
- 我在等一封「巡检恢复正常」的邮件,从 10 月 11 日等到 10 月 20 日,没有等到。
- 回头翻收件箱,9 月 27 日那封「服务器巡检告警」的邮件也没有出现过 —— 但服务器日志里明确记着「邮件已发送」。
1.2 先排除的几种可能
在查代码之前,先排除「邮件根本没配置」「服务器发不出去」这两类最常见的原因。做法是查配置和实际发信记录:
ls -l /etc/ops-lab/mail.conf
grep -v password /etc/ops-lab/mail.conf
输出:
-rw------- 1 root root 138 Sep 21 13:49 /etc/ops-lab/mail.conf
[smtp]
host = smtp.qq.com
port = 465
user = <发件邮箱>
from = <发件邮箱>
to = <接收邮箱>
配置文件在、权限 600、用的是 465 SSL 端口(25 端口在阿里云 ECS 上是默认封禁的)。再用模块自带的测试入口实发一封:
cd /root/ops-lab
python3 lib/daily-check.py --test-mail
输出:
✅ 已发送至 <接收邮箱>
邮箱里也收到了。所以 SMTP 链路本身是通的。 问题不在传输层。
2. 排查过程
2.1 任务到底有没有在跑
crontab -l
ls -l /etc/cron.d/ | grep ops-lab
输出:
0 8 * * * /usr/bin/python3 /root/ops-lab/lib/daily-check.py >> /root/ops-lab/logs/daily-check/cron.log 2>&1 # ops-lab daily-check
30 3 * * * /root/ops-lab/bin/db/backup-mariadb.sh >> /root/ops-lab/logs/db-backup/cron.log 2>&1 # ops-lab db-backup
*/10 * * * * /usr/bin/python3 /root/ops-lab/bin/visit-stats.py --output /var/www/stats/visits.json >> /root/ops-lab/logs/visit-stats.log 2>&1 # ops-lab visit-stats
-rw-r--r-- 1 root root 241 Sep 13 15:07 ops-lab-daily-report
-rw-r--r-- 1 root root 536 Sep 13 15:07 ops-lab-traffic
任务在跑。而且 /etc/cron.d/ 里有个叫 ops-lab-daily-report 的文件,名字看起来像「日报」。打开看:
cat /etc/cron.d/ops-lab-daily-report
10 0 * * * root /root/ops-lab/bin/traffic-check.sh > /root/ops-lab/logs/traffic/daily-$(date +\%Y\%m\%d).txt 2>&1
它每天 00:10 跑的是流量检查,输出重定向到一个文本文件,根本不发邮件。名字和实际行为不符,这是第一个误导项 —— 我以为存在一个「日报邮件」,其实从来没有。
2.2 日志里每天的结果
grep -h "结果:" logs/daily-check/cron.log | sort | uniq -c
grep -n "邮件" logs/daily-check/cron.log
第一条命令按日期排开是:
2026-10-01 08:00:04 结果: CRITICAL(警告 0,严重 1)
2026-10-02 08:00:03 结果: CRITICAL(警告 0,严重 1)
...
2026-10-10 08:00:04 结果: CRITICAL(警告 0,严重 1)
2026-10-11 08:00:04 结果: OK(警告 0,严重 0)
2026-10-12 08:00:04 结果: OK(警告 0,严重 0)
...
2026-10-20 08:00:04 结果: OK(警告 0,严重 0)
第二条命令的输出只有一行:
邮件: ✅ 已发送至 <接收邮箱>
也就是:整份日志里只出现过一行「邮件: ✅」(9 月 27 日那次)。之后 13 天持续 CRITICAL,一封没发;10/11 转 OK,一封没发。
2.3 看发送条件
sed -n '/^def main/,/^if __name__/p' lib/daily-check.py
关键片段(简化后):
if rep.level() != 'OK':
alert_path = save_alert(rep, text)
...
# 只在等级变化或首次告警时发邮件(避免每天重复轰炸)
last = load_last_level()
should_mail = (last != rep.level())
if no_mail:
print('(--no-mail)未发送邮件')
elif notify is None:
print('(无法导入 notify 模块)未发送邮件')
elif not should_mail and last is not None:
print('(上次已是 %s,不重复发送)' % last)
else:
subject = '[%s] 服务器巡检告警 (%s)' % (rep.level(), datetime.now().strftime('%m-%d %H:%M'))
ok, info = notify.send(subject, body)
print(('邮件: ✅ %s' % info) if ok else ('邮件: ❌ %s' % info))
save_level(rep.level())
return rep.code()
这段代码有三处问题,逐条看。
2.4 为什么 /var/log/maillog 是 0 字节
排查时我一度以为「没有发信记录 = 没发信」。这个推论是错的:
ls -l /var/log/maillog
-rw------- 1 root root 0 Oct 18 03:26 /var/log/maillog
/var/log/maillog 是本地 MTA(postfix/sendmail) 的日志。而 notify.py 用 Python 的 smtplib 直连 smtp.qq.com:465,走的是应用自己建的 TLS 连接,完全不经过本地 MTA。所以这份文件永远是空的,它不能作为「有没有发信」的证据。
这条值得单独记:判断某个动作有没有发生,必须先确认你看的那份日志是不是那个动作写出来的。
2.5 那封 9/27 的邮件去哪了
服务器侧的证据是「已交给 smtp.qq.com」,邮箱侧是「没收到」。中间只剩两种可能:
- 被 QQ 邮箱判定为垃圾邮件 ——
from和to是同一个地址,内容是机器生成的格式化文本,很容易触发。 - 进了垃圾箱,被自动清理 —— QQ 的垃圾邮件默认保留约 30 天,这封距今 23 天,可能已被清。
但真正的问题不是它为什么丢,而是它丢了之后没有任何机制补上来。 一封邮件丢失 → 后面 13 天持续故障 → 全部静默。这才是要修的。
3. 根因
3.1 缺陷一:告警只在等级变化时发,一封丢了就永久静默
should_mail = (last != rep.level()) 的设计目的是「避免每天重复轰炸」,方向是对的,但它有一个前提没被写出来:它假设「上一次的通知一定送到了」。
一旦某封邮件丢失(被过滤、被清理、SMTP 抖动),state 文件已经写成 CRITICAL,下一轮看到 last == 'CRITICAL',就再也不发了。系统进入一个自我确认的状态:它以为自己通知过了。
3.2 缺陷二:恢复正常没有出口
if rep.level() != 'OK': 这一个条件决定了整个发送逻辑都在「异常」这一侧。当等级回到 OK 时,代码直接跳过整块,执行 save_level('OK') 然后返回。
告警必须成对出现。 收到「出故障了」的人,有权收到「恢复正常了」。否则收到告警之后出差两天,回来无法判断现在到底好了没有,只能登录机器看。更糟的是:如果恢复通知从来没来过,用户会开始怀疑告警本身是否可信,最终的结果是告警被忽略 —— 这是所有告警系统失效的统一路径。
3.3 缺陷三:没有存活报告,沉默与死亡不可区分
连续两个月没收到任何邮件时,有两种可能:
- 系统一直正常;
- 告警功能本身已经死了(模块导入失败、授权码过期、cron 被删、邮件被持续过滤)。
从收件箱这一侧无法区分这两种情况。这是可观测性里最经典的陷阱:没有消息不等于好消息。要区分它们,唯一的方法是让系统在「无事发生」时也主动说话,也就是心跳 / 存活报告。
顺带说,本次排查中 --test-mail 是能通的,但如果这封测试邮件当时也被过滤了,我会走更长的弯路。所以存活报告的价值不只是「证明系统活着」,也是「定期验证通知链路本身活着」。
3.4 附带问题:任务名与实际行为不符
/etc/cron.d/ops-lab-daily-report 这个名字暗示它有「日报」行为,实际只做流量检查并写文本文件。这类命名问题不会直接导致故障,但会让人在排查时形成错误预期 —— 我这次就花了几分钟去确认「日报到底发不发邮件」。
4. 修复
4.1 把上一轮状态提到分支外
last 原本写在 if rep.level() != 'OK': 内部,导致「正常」这一侧拿不到上一轮是什么状态。提到外面:
last = load_last_level()
if rep.level() != 'OK':
... # 原有告警逻辑,一行未改
4.2 增加正常状态的出口
在原有 if 块后面加一个 else:,按优先级处理三件事:周报、恢复通知、其余情况静默。
else:
# 正常状态也要有出口:周报存活报告 + 故障恢复通知
if no_mail:
print('(--no-mail)未发送邮件')
elif notify is None:
print('(无法导入 notify 模块)未发送邮件')
elif weekly:
subject = '[周报] 服务器巡检周报 (%s)' % datetime.now().strftime('%m-%d')
body = text + '\n\n--\n本周存活报告:收到这封邮件说明告警链路本身是通的。\n'
ok, info = notify.send(subject, body)
print(('周报: ✅ %s' % info) if ok else ('周报: ❌ %s' % info))
elif last is not None and last != 'OK':
subject = '[已恢复] 服务器巡检恢复正常 (%s)' % datetime.now().strftime('%m-%d %H:%M')
body = text + '\n\n--\n上一轮状态: %s,现已恢复正常。\n' % last
ok, info = notify.send(subject, body)
print(('邮件: ✅ %s' % info) if ok else ('邮件: ❌ %s' % info))
else:
print('(状态正常,无需发信)')
同时增加 --weekly 参数解析:
no_mail = '--no-mail' in args
weekly = '--weekly' in args
4.3 把周报挂进 cron
crontab -l > /tmp/ct.bak
crontab -l | grep -q 'ops-lab weekly-report' || (crontab -l; echo '0 9 * * 1 /usr/bin/python3 /root/ops-lab/lib/daily-check.py --weekly >> /root/ops-lab/logs/daily-check/cron.log 2>&1 # ops-lab weekly-report') | crontab -
选周一 09:00 的理由:一周一次足够发现「链路死了」,且和每天 08:00 的巡检错开,避免同一条日志里两个任务混在一起。
|| 的写法保证这条命令可以重复执行而不重复添加,比手工 crontab -e 更适合脚本化。
4.4 更新文档
README.md 里原来只有一句「邮件告警:仅在等级变化时发送(避免重复轰炸)」。改成三条明确的发送时机:
# 邮件发送时机:① 告警(等级变化时)② 恢复(本轮 OK 且上轮非 OK)③ 周报(每周一 09:00 强制一封存活报告)
文档里写错的东西会变成下一个人的错误预期,所以改代码和改文档是同一件事。
5. 验证
5.1 语法
python3 -m py_compile lib/daily-check.py && echo SYNTAX-OK
SYNTAX-OK
5.2 伪造「上一轮是 CRITICAL」,验证恢复分支
这里的关键是不能只靠读代码判断改对了。要构造一个「上一轮非 OK」的状态,真实触发一次。
echo -n "CRITICAL" > logs/daily-check.state
python3 lib/daily-check.py 2>&1 | tail -5
输出:
邮件: ✅ 已发送至 <接收邮箱>
服务器这一侧的证据到「已交给 smtp.qq.com」为止。这一行输出不等于对方收到了——投递结果在本机不可观测,这正是第 7 节第 2 条记下的遗留问题。所以我把「邮件到了」当作待验证项,而不是结论。
5.3 验证不会重复轰炸
python3 lib/daily-check.py 2>&1 | tail -3
cat logs/daily-check.state
(状态正常,无需发信)
OK
第二次跑不再发信,state 已回到 OK。这一条确认了「一次异常 → 一次恢复通知」的配对关系成立,且不会变成常态噪音。
5.4 验证周报
python3 lib/daily-check.py --weekly 2>&1 | tail -3
周报: ✅ 已发送至 <接收邮箱>
5.5 验证 cron 条目
crontab -l | grep -c ops-lab
crontab -l | tail -2
4
0 9 * * 1 /usr/bin/python3 /root/ops-lab/lib/daily-check.py --weekly >> /root/ops-lab/logs/daily-check/cron.log 2>&1 # ops-lab weekly-report
原来的 3 条 + 新增 1 条 = 4 条。
6. 结论
这次改动只有二十几行,但暴露的是三类问题,按重要性排:
- 通知是有状态的,必须处理「送不到」的情况。 「只在变化时发」是一个去噪优化,它隐含假设了「上一次送到了」。任何依赖送达的优化,都要有一个兜底的周期性重发机制,否则一次丢失就等于永久静默。
- 告警与恢复必须成对。 只报故障不报恢复,会让告警从「可信的信号」退化成「背景噪音」。用户最终会主动忽略它 —— 这不是用户的问题,是设计的失败。
- 沉默不是证据。 没有消息可能意味着正常,也可能意味着监控本身死了。要区分两者,必须有心跳 / 存活报告。
另外两条方法论层面的收获:
- 判断某个动作有没有发生,先确认你看的日志是不是那个动作写出来的。
/var/log/maillog为空不代表没发信,因为发信根本没走本地 MTA。 - 验证要构造状态,不能只读代码。 改完「恢复通知」以后,我伪造了一个
CRITICAL的 state 文件,让它真实走了一遍分支,而不是看一眼else就认为对了。
7. 遗留问题
- 邮件投递失败没有重试。 现在
notify.send()失败只打印一行日志,下一个 24 小时才可能再有机会。应该加一次短间隔重试(比如 5 分钟后重试一次),并记录失败次数。 - 送达情况没有监控。 服务器只知道「交给了 smtp.qq.com」,不知道「用户收到了」。QQ 邮箱的过滤策略不可观测,这是这次 9/27 邮件消失最可能的原因,但我没有手段确认。
- 单一通知渠道。 目前只有邮件。邮件被过滤时,整个通知体系归零。可选方案是增加一个第二渠道(比如 Server 酱 / 企业微信机器人),两者至少有一个能到达。
/etc/cron.d/ops-lab-daily-report命名误导。 应改名为ops-lab-traffic-daily,但这涉及 cron 文件的删建,要单独安排一次改动。
8. 后续计划
- 把
weekly的发送结果也写进daily-check.state之外的一个独立文件,用于判断「最近一次成功发信是什么时候」。这样即使邮件被过滤,登录服务器也能看到链路最近一次成功的时刻。 - 给
notify.send()增加重试与超时退避,并让--test-mail输出更详细的 SMTP 阶段(连接 / 认证 / DATA)。 - 评估增加第二个通知渠道,优先级放在邮件重试之后 —— 先保证现有渠道可靠,再谈冗余。
可用键盘 ← → 切换上下篇