← 返回 Day 171 正文

Day 171 · 图解

Day 171 — 通知告警 · 图解

共 11 张图。


1. 告警系统的四层模型

┌──────────────────────────────────────────────────────────────┐
│  ④ 送达层  Channel   ── 邮件(SMTP) / 钉钉 / 企业微信 / Slack │
│                 ▲ 本课 80% 的内容在这里                        │
├──────────────────────────────────────────────────────────────┤
│  ③ 路由层  Router    ── 按级别选渠道、@谁、要不要全渠道        │
├──────────────────────────────────────────────────────────────┤
│  ② 判定层  Evaluator ── 阈值、滞回、时间窗口                   │
├──────────────────────────────────────────────────────────────┤
│  ① 采集层  Collector ── psutil / 日志 / 健康检查               │
└──────────────────────────────────────────────────────────────┘

每一层都会「悄悄」断掉,而断掉时通常不报错 —— 这才是告警系统难的地方。

2. 完整数据流

  ┌──────────┐   指标    ┌──────────┐   Alert    ┌──────────────────┐
  │ Collector│──────────▶│ Evaluator│──────────▶│    AlertHub      │
  │ psutil   │  disk%    │ threshold│  结构化事件 │ fingerprint      │
  └──────────┘           └──────────┘            │ suppress window  │
                                              │ token bucket     │
                                              │ mask()           │
                                              └────────┬─────────┘
                                     ┌─────────────────┼─────────────────┐
                                     ▼                 ▼                 ▼
                              ┌────────────┐   ┌────────────┐   ┌────────────┐
                              │ SMTPChannel│   │ WebhookCh. │   │ StdoutCh.  │
                              │ 邮件(详)   │   │ IM(快)     │   │ 兜底(永不失败)
                              └──────┬─────┘   └─────┬──────┘   └────────────┘
                                     │               │
                                  ┌──▼───────────────▼──┐
                                  │   平台侧 errcode    │
                                  │  200 != 成功        │
                                  └─────────────────────┘

3. ⭐ HTTP 200 ≠ 成功(核心图)

flowchart TD A[发送 Webhook 告警] --> B{raise_for_status<br/>HTTP 传输层} B -->|4xx/5xx| X[失败:网络/地址/服务异常] B -->|200| C{解析 body.errcode} C -->|errcode == 0| OK[✅ 真的送达] C -->|300005 token不存在| P1[❌ 永久错误<br/>重试无意义] C -->|310000 关键词不匹配| P2[❌ 永久错误] C -->|400013 签名/IP错误| P3[❌ 永久错误] C -->|410100 / 45009 限流| R[✅ 值得退避重试] style OK fill:#d4edda,stroke:#28a745 style X fill:#f8d7da,stroke:#dc3545 style P1 fill:#f8d7da,stroke:#dc3545 style P2 fill:#f8d7da,stroke:#dc3545 style P3 fill:#f8d7da,stroke:#dc3545 style R fill:#fff3cd,stroke:#ffc107
flowchart TD
    A[发送 Webhook 告警] --> B{raise_for_status<br/>HTTP 传输层}
    B -->|4xx/5xx| X[失败:网络/地址/服务异常]
    B -->|200| C{解析 body.errcode}
    C -->|errcode == 0| OK[✅ 真的送达]
    C -->|300005 token不存在| P1[❌ 永久错误<br/>重试无意义]
    C -->|310000 关键词不匹配| P2[❌ 永久错误]
    C -->|400013 签名/IP错误| P3[❌ 永久错误]
    C -->|410100 / 45009 限流| R[✅ 值得退避重试]

    style OK fill:#d4edda,stroke:#28a745
    style X fill:#f8d7da,stroke:#dc3545
    style P1 fill:#f8d7da,stroke:#dc3545
    style P2 fill:#f8d7da,stroke:#dc3545
    style P3 fill:#f8d7da,stroke:#dc3545
    style R fill:#fff3cd,stroke:#ffc107

本机实测(故意写错 token,真连官方端点):

钉钉     HTTP 200  errcode=300005  token is not exist     ← 失败
企业微信  HTTP 200  errcode=93000   invalid webhook url   ← 失败
Slack    HTTP 404  no_team                               ← 失败(语义不同)

4. 告警风暴与指纹去重

时间 ──────────────────────────────────────────────────────▶
指标  92%  88%  93%  89%  91%  87%  94%  86%  90%  92%
      ███    ░    ███    ░    ███    ░    ███    ░    ███

朴素实现:  ! ! ! ! ! ! ! ! !          → 10 条一模一样的内容
            ████████████████████

指纹去重:  █                           → 1 条(抑制窗口 30 分钟内不重复)
fingerprint = sha1("disk_usage|web-01|disk")
            = 5fa27966b740a126

本机实测压缩比:40 次越界 → 1 条通知(40:1)

5. 令牌桶限流原理

        容量 capacity = 20(= 平台 1 分钟上限)
     ┌───────────────────────────┐
     │ ● ● ● ● ● ● ● ● ● ● ● ●  │  ← 满桶,可突发 20 条
     │ ● ● ● ● ● ● ● ● ● ● ○ ○  │  ← 消耗中
     │ ● ● ● ● ● ○ ○ ○ ○ ○ ○ ○  │  ← 空了
     └───────────────────────────┘
                ↑
      以 rate = 20/60 每秒 的速度持续回充
      桶空时 acquire() 阻塞等待(或返回 False = 丢弃)

本机实测:40 次突发调用 → 放行 21 次(多出的 1 个来自 1 秒多的回充),19 次被拦

6. SMTP 会话完整时序

sequenceDiagram participant C as smtplib 客户端 participant S as 抓包 SMTP 服务器 C->>S: EHLO [127.0.1.1] S-->>C: 250-8BITMIME / 250-SMTPUTF8 / 250 SIZE Note over C: starttls() 之后必须再 EHLO 一次 C->>S: MAIL FROM:<a@b.c> size=635 Note over C,S: ⚠️ size= 是 ESMTP 参数,不是地址的一部分 S-->>C: 250 2.1.0 Ok C->>S: RCPT TO:<user@x.com> S-->>C: 250 2.1.5 Ok C->>S: DATA S-->>C: 354 以单独一行 . 结束 C->>S: MIME 原文(Subject 已做 RFC2047 编码) Note over C: 正文中的 "." 会被点填充成 ".." S-->>C: 250 2.0.0 Ok: queued C->>S: QUIT S-->>C: 221 closing connection
sequenceDiagram
    participant C as smtplib 客户端
    participant S as 抓包 SMTP 服务器
    C->>S: EHLO [127.0.1.1]
    S-->>C: 250-8BITMIME / 250-SMTPUTF8 / 250 SIZE
    Note over C: starttls() 之后必须再 EHLO 一次
    C->>S: MAIL FROM:<a@b.c> size=635
    Note over C,S: ⚠️ size= 是 ESMTP 参数,不是地址的一部分
    S-->>C: 250 2.1.0 Ok
    C->>S: RCPT TO:<user@x.com>
    S-->>C: 250 2.1.5 Ok
    C->>S: DATA
    S-->>C: 354 以单独一行 . 结束
    C->>S: MIME 原文(Subject 已做 RFC2047 编码)
    Note over C: 正文中的 "." 会被点填充成 ".."
    S-->>C: 250 2.0.0 Ok: queued
    C->>S: QUIT
    S-->>C: 221 closing connection

7. 邮件身份:Envelope vs Header

┌─────────────────────────────────────────────────────────┐
│ Envelope(信封)— 由 smtplib.login() 决定,参与 SPF/退信 │
│   MAIL FROM:<noreply@company.com>                        │
│   RCPT TO:<niedong@company.com>                           │
├─────────────────────────────────────────────────────────┤
│ Header(信头)— 你自己写的,只影响显示                    │
│   From: "Learn-Python 告警" <noreply@company.com>        │
│   To:   niedong@company.com                              │
│   Subject: [P1] 磁盘告警                                   │
└─────────────────────────────────────────────────────────┘

两者解耦的好处:用 noreply@ 账号也能发出"运维组"署名的告警,
且不会被邮箱的 SPF 规则拒收。

8. 中文主题的 RFC 2047 编码

原始(有中文):  Subject: 数据库磁盘使用率 91%

编码后(实际发出): Subject: =?utf-8?b?5pWw5o2u5bqT56OB55uY5L2/55So546H?= 91%
                                            └──── base64 ────┘
                                            └─ utf-8 ─┘

原因:邮件头在 SMTP 协议里只允许 ASCII(RFC 5322),
      所以任何非 ASCII 标题必须先按 RFC 2047 编码。

✅ EmailMessage 会自动做这件事 —— 你不需要手写 base64。

9. MIME multipart 结构

Content-Type: multipart/mixed; boundary="===5168995940=="

--===5168995940==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 8bit

磁盘使用率超过阈值。                        ← 正文段
--===5168995940==
Content-Type: text/plain; charset="utf-8"
Content-Disposition: attachment; filename="app.log"

log line 1
log line 2                                 ← 附件段
--===5168995940==--

类型速查:multipart/mixed(任意组合)、multipart/alternative(多版本二选一)、 multipart/related(HTML + 内嵌图片,邮件里显示图片必须用它)。


10. 三平台安全设置与失败语义

flowchart LR subgraph 钉钉 D1[access_token] --> D2{安全设置} D2 --> D3[加签 HMAC-SHA256] D2 --> D4[自定义关键词] D2 --> D5[IP 白名单] D3 & D4 & D5 --> D6["失败也在 HTTP 200<br/>errcode 300005/310000/410100"] end subgraph 企业微信 W1[key=xxx] --> W2["失败也在 HTTP 200<br/>errcode 93000/45009"] end subgraph Slack S1[hooks.slack.com/services/...] --> S2["失败是 4xx/5xx/302<br/>body 为纯文本"] end style D6 fill:#f8d7da,stroke:#dc3545 style W2 fill:#f8d7da,stroke:#dc3545 style S2 fill:#fff3cd,stroke:#ffc107
flowchart LR
    subgraph 钉钉
      D1[access_token] --> D2{安全设置}
      D2 --> D3[加签 HMAC-SHA256]
      D2 --> D4[自定义关键词]
      D2 --> D5[IP 白名单]
      D3 & D4 & D5 --> D6["失败也在 HTTP 200<br/>errcode 300005/310000/410100"]
    end
    subgraph 企业微信
      W1[key=xxx] --> W2["失败也在 HTTP 200<br/>errcode 93000/45009"]
    end
    subgraph Slack
      S1[hooks.slack.com/services/...] --> S2["失败是 4xx/5xx/302<br/>body 为纯文本"]
    end

    style D6 fill:#f8d7da,stroke:#dc3545
    style W2 fill:#f8d7da,stroke:#dc3545
    style S2 fill:#fff3cd,stroke:#ffc107

⚠️ 本课实验发现:企业微信的错误信息会回显调用方的公网出口 IP。 所以 Webhook URL 必须当密钥保管,并开启 IP 白名单。


11. 告警风暴的整体防线(分层降级)

第 1 层  抑制窗口 30 分钟      → 600 事件降到 21 条   (本课实测 99.1%)
   │  降不下来?
第 2 层  令牌桶 20 条/分钟     → 不撞平台限流
   │  还是降不下来?
第 3 层  渠道 fan-out          → IM + 邮件并行,互不影响
   │  某个渠道挂了?
第 4 层  单渠道失败隔离        → 其余渠道照常送达
   │  全挂了?
第 5 层  stdout 兜底           → 至少在日志/CI 里留痕(永不失败)

本课 self-test 第 5 项即验证第 4/5 层:
  3 渠道全失败 → 自动降级 stdout,且 errcode 300005 被正确识别为失败

真正的第 6 层不在代码里 —— 那是"用什么完全独立的路径监控告警系统本身", 见 README 思考题 5。