一个”代理超时怎么发个通知”的小需求,怎么一步步长成了覆盖 5 台机器、带死人开关兜底、还顺带监控 GitHub Runner 的告警系统。以及中间那个让我查了半小时的经典 Shell 坑。
起点:一个很小的需求
我用 Clash 代理,节点经常抽风。最初的想法特别朴素:
能不能代理超时的时候,自动发个通知给我?
方案也很直接——定时用 curl 走代理打一个轻量请求,失败就告警:
1 | PROXY="http://127.0.0.1:7890" |
通知渠道选了飞书自定义机器人——建个群机器人,拿到 webhook,POST 一个 JSON 就行:
1 | feishu() { |
定时用 macOS 的 launchd(比 cron 更适合跑在用户登录态、通知能正常弹):每 60 秒跑一次。
到这里,需求”满足”了。但真正有意思的部分,是后面每一次”再加一点”。
演进一:别一抖动就刷屏
代理偶尔丢一两个包很正常,每次都告警等于噪音。加两个机制:
- 连续 N 次失败才告警(
THRESHOLD=3,约 2 分钟持续异常才报)。 - 状态去重:只在 正常→异常 翻转时发一次,恢复时再发一次 ✅,中间不重复刷屏。
1 | fails=$(cat "$STATE" 2>/dev/null || echo 0) |
演进二:不只一台机器
办公室有好几台 Mac 都跑着代理。于是要批量部署到远程机器。这里踩了第一个坑:
macOS 没有 sshpass,而 ssh 的密码提示走 TTY,自动化脚本没法直接喂密码。
解决办法是用 macOS 自带的 expect 包一层。几个要点(都是踩出来的):
- 第二次出现
password:提示就判定认证失败并退出,否则会死等卡住; - 远程命令作为单个参数传给 ssh,否则
;分隔的多条命令会被本地 shell 拆开; - 别用
timeout(macOS 没有),靠 expect 自己的set timeout兜底; - 别在输出后接
| tail,管道缓冲会让你以为它卡死了。
部署后,通知里带上主机名就成了刚需——不然 5 台机器告警长一个样,根本分不清是哪台。技巧是在 feishu() 里统一加前缀,而不是每条消息手写:
1 | HOST="$(scutil --get ComputerName 2>/dev/null || hostname -s)" |
演进三:网络断了,连告警都发不出去怎么办?
这是整个系统里最关键的一次思维转变。
我突然意识到一个根本问题:
你没法用一条已经坏掉的通道,去报告它自己坏了。
如果机器整个断网/关机,代理探测失败,但发飞书的 curl 也一样出不去——告警和故障同归于尽。”出问题才发消息”这个模式,天生覆盖不了”连发消息的网络都没了”。
堵这个盲点只有一条路:反过来做心跳(dead man’s switch,死人开关)。
不再是”出事了才发消息”,而是”正常时定期报平安”,由一个网络独立的外部观察者来判断:
“超过 X 分钟没收到某机器的平安信号 = 它出事了”,然后由外部那一方发告警。
做判断和发告警的一方,网络跟故障机是隔离的,所以故障机断网/关机/脚本死,完全不影响告警发出。这是唯一能覆盖”自己发不出去”的结构。
于是架构变成了两层:
1 | ┌─ 走代理探测 ─ 连续失败 → 本机发飞书 ⚠️(这层机器自己发) |
两层告警的职责分得很清楚:
- 机器本地:报告代理异常(这层网络还通,能自己发);
- 云端心跳:报告机器整体失联(这层故障机发不出,由云端代发)。
心跳的实现
心跳上报就是一行,关键是 --noproxy '*' 强制直连——否则代理挂了心跳也发不出去,就失去意义了:
1 | curl --noproxy '*' -s -o /dev/null -m 8 "https://heartbeat.example.com/hb/$HOST_SLUG" |
云端接收服务用 Python 标准库写了个极小的 HTTP server(无依赖),收到 /hb/<机器名> 就 touch 一个时间戳文件:
1 | def _hb(self): |
判定脚本(云端 cron 每分钟跑):扫描每个机器的时间戳文件 mtime,超时未更新就告警。靠文件 mtime 记录”最后心跳时间”,不需要数据库,简单到不会坏。
云服务器上本来就跑着 Docker + Caddy,所以心跳服务接到现有 Caddy 反代后面就行。这里又踩一个坑:
域名走了 Cloudflare,回源时
:80那段 Caddy 配置匹配不上,返回 404。
排查时看响应头Via: 1.0 Caddy才确认 404 是 Caddy 给的、不是 CF。解决:心跳 URL 统一走https://。
排查这种问题,先分清 404 是哪一层给的(看 Server / Via / CF-RAY 头),能少走很多弯路。
演进四:顺手监控 GitHub Self-hosted Runner
那几台 Mac 同时也是某组织的 GitHub Actions self-hosted runner。既然监控框架都有了,顺手把 runner 掉线也纳进来。
查询走 GitHub API:
1 | curl -s -H "Authorization: Bearer $TOKEN" \ |
告警规则:带 self-hosted 标签的 runner 变 offline 就发飞书,恢复就通知。API 查询失败时只记日志、不误判全部掉线——这个保护很重要,否则 token 过期那天会收到一堆假告警。
这里有个特别值得记的教训。我连续测了三个 token 都报 401 Bad credentials,一度以为是 token 被 GitHub 密钥扫描自动撤销了。结果根因是我自己的传参 bug:
1 | # 错误:远端 $1 取不到,TOKEN 变成空字符串 |
教训:当一个东西”怎么试都失败”时,先怀疑测试方法本身。 我冤枉了三个无辜的 token。
终章:那个让我查了半小时的 Shell 坑
系统跑起来后,配了两个飞书群做广播。测试时发现——只有一个群收到消息。
feishu() 当时是这么写的,看起来人畜无害:
1 | feishu() { |
我先验证了 webhook 都活着(单独发都 200),又验证 tr 分割正确(确实分出 2 行)。逻辑明明没问题,但循环就是只跑一次。
最后用 set -x trace 才看清真相:
1 | + curl ... 第一个URL ← 第1次发送 |
第一次 curl 之后,read 再也读不到第二个 URL。根因是 Shell 经典坑:
while read循环的 stdin 是那个管道。循环体里的curl默认也会从 stdin 读取,一口把管道里剩下的内容(第二个 URL)全吞了,导致read第二次读空、循环提前结束。
很多命令都有这毛病(ssh、ffmpeg、mysql…),它们会”吃掉”循环的 stdin。
修复:彻底脱离管道——用数组 + for:
1 | feishu() { |
for 循环不依赖 stdin,curl 再怎么读也不影响迭代;外加 </dev/null 切断 curl 的 stdin,双保险。
顺带一提:mac 默认交互 shell 是 zsh,
read -ra是 bash 语法,在 zsh 里eval这段会报bad option: -a。但脚本 shebang 是#!/usr/bin/env bash,实际运行没问题——测试时要用bash -c而不是直接在 zsh 里 eval,否则又会误判。
复盘:几条用得上的经验
- 监控的根本盲点是”通道自身故障”。任何”出事才发消息”的告警,都要配一个网络独立的心跳兜底,否则机器一断网你就彻底瞎了。
- 状态去重是告警系统的基本礼仪。只在状态翻转时发,否则就是在训练自己忽略告警。
- 排查问题先定位”是哪一层”。404 是 CF 还是 Caddy?失败是 token 还是传参?分层确认比凭直觉猜快得多。
while read循环体里别放会读 stdin 的命令(curl/ssh/ffmpeg…),否则它会吃掉你的循环。要么</dev/null,要么干脆用数组 + for。- “怎么试都失败”时,先怀疑测试方法。
- 用文件 mtime 当状态存储,对这种轻量场景够用了,没必要上数据库。
一个”代理超时发个通知”的需求,最后变成了一套挺完整的小系统。但真正的收获不是系统本身,而是中间每一个”再想深一点”的瞬间——尤其是那句:
你没法用一条已经坏掉的通道,去报告它自己坏了。