-今日
-本周
-本月
-累计
独立访客
项目工作台‹运维 个人项目与实践记录
我的手记第 11 篇 / 共 11 篇
→ 下一篇

手记 011:告警系统的三个设计缺陷

上一篇手记(010)记录的是「cron 的 PATH 导致连续 18 天假告警」,修的是检查项本身出错。这一篇记录的是它后面暴露出的第二层问题:那次假告警期间,整个告警链路几乎没有发出过一封邮件;故障修好之后,也没有一封「已恢复」的通知。也就是说,在 18 天里,我唯一能确定的事情是我没有收到邮件,而这不能推出任何结论。

这篇的重点不是某一条命令,是三个设计层面的错误。

1. 现象

1.1 直接现象

2026 年 10 月 20 日,我核对自己的巡检系统时,意识到两件事:

  1. 我在等一封「巡检恢复正常」的邮件,从 10 月 11 日等到 10 月 20 日,没有等到。
  2. 回头翻收件箱,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」,邮箱侧是「没收到」。中间只剩两种可能:

  1. 被 QQ 邮箱判定为垃圾邮件 —— from 和 to 是同一个地址,内容是机器生成的格式化文本,很容易触发。
  2. 进了垃圾箱,被自动清理 —— 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 缺陷三:没有存活报告,沉默与死亡不可区分

连续两个月没收到任何邮件时,有两种可能:

从收件箱这一侧无法区分这两种情况。这是可观测性里最经典的陷阱:没有消息不等于好消息。要区分它们,唯一的方法是让系统在「无事发生」时也主动说话,也就是心跳 / 存活报告。

顺带说,本次排查中 --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. 结论

这次改动只有二十几行,但暴露的是三类问题,按重要性排:

  1. 通知是有状态的,必须处理「送不到」的情况。 「只在变化时发」是一个去噪优化,它隐含假设了「上一次送到了」。任何依赖送达的优化,都要有一个兜底的周期性重发机制,否则一次丢失就等于永久静默。
  2. 告警与恢复必须成对。 只报故障不报恢复,会让告警从「可信的信号」退化成「背景噪音」。用户最终会主动忽略它 —— 这不是用户的问题,是设计的失败。
  3. 沉默不是证据。 没有消息可能意味着正常,也可能意味着监控本身死了。要区分两者,必须有心跳 / 存活报告。

另外两条方法论层面的收获:

7. 遗留问题

  1. 邮件投递失败没有重试。 现在 notify.send() 失败只打印一行日志,下一个 24 小时才可能再有机会。应该加一次短间隔重试(比如 5 分钟后重试一次),并记录失败次数。
  2. 送达情况没有监控。 服务器只知道「交给了 smtp.qq.com」,不知道「用户收到了」。QQ 邮箱的过滤策略不可观测,这是这次 9/27 邮件消失最可能的原因,但我没有手段确认。
  3. 单一通知渠道。 目前只有邮件。邮件被过滤时,整个通知体系归零。可选方案是增加一个第二渠道(比如 Server 酱 / 企业微信机器人),两者至少有一个能到达。
  4. /etc/cron.d/ops-lab-daily-report 命名误导。 应改名为 ops-lab-traffic-daily,但这涉及 cron 文件的删建,要单独安排一次改动。

8. 后续计划

  1. 把 weekly 的发送结果也写进 daily-check.state 之外的一个独立文件,用于判断「最近一次成功发信是什么时候」。这样即使邮件被过滤,登录服务器也能看到链路最近一次成功的时刻。
  2. 给 notify.send() 增加重试与超时退避,并让 --test-mail 输出更详细的 SMTP 阶段(连接 / 认证 / DATA)。
  3. 评估增加第二个通知渠道,优先级放在邮件重试之后 —— 先保证现有渠道可靠,再谈冗余。
我的手记第 11 篇 / 共 11 篇
→ 下一篇

可用键盘 ← → 切换上下篇