一个”代理超时怎么发个通知”的小需求,怎么一步步长成了覆盖 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 也一样出不去——告警和故障同归于尽。”出问题才发消息”这个模式,天生覆盖不了”连发消息的网络都没了”。
堵这个盲点只有一条路:反向心跳(reverse heartbeat,业界也叫 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,否则又会误判。
演进五:接入 Uptime Kuma 的共同模式
手搓那套「云端接收服务 + cron 判定 + 自拼飞书 JSON」,本质就是在重造 Uptime Kuma 的 Push 监控。如果已经有一个稳定的 Kuma(比如挂在公司云上),就把判定、超时、通知、历史曲线、仪表盘全交给它,自己只管”按时上报”。
两类监控走的是同一个模式——主动 Push:
- 不是 Kuma 去拨测目标(runner 状态藏在要鉴权的 GitHub API 里、代理探测要在机器本地做,Kuma 都拨不到);
- 而是本地脚本自己判定,把结论 push 给 Kuma:正常
status=up,异常status=down; - Kuma 超时收不到 push → 兜底告警(脚本挂了/断网也能发现)。
这样判定逻辑(连续失败才报、API 失败不误判、强制直连探测……)全留在脚本里,Kuma 只当”展示 + 告警的出口”。token / webhook 这些重凭证也不必塞进 Kuma。
1. 共同基础:Kuma Push 接口
两类监控都用 Push 类型监控,配置一次、理解一次即可。
建一个 Push 监控(Web UI)
- 登录 Uptime Kuma → Add New Monitor
- Monitor Type 选 Push
- Friendly Name:如
mac-mini-01-代理或Github-runner - Heartbeat Interval:必须 大于脚本的 push 周期,留足余量。脚本每 60s push 就设 90~120s,每 120s push 就设 150~180s——否则两次 push 之间会超时变红、下次又恢复,形成 Down/Up 抖动刷屏。
- Retries:0 或 1
- Notifications:勾选要用的通知渠道(飞书 / Webhook / 邮件等)
- 保存后详情页给出 Push URL,记下其中的
<PUSH_TOKEN>:1
https://<kuma域名>/api/push/<PUSH_TOKEN>?status=up&msg=OK&ping=
Push 接口参数
GET /api/push/<token> 接受三个参数:
| 参数 | 说明 |
|---|---|
status |
up 或 down |
msg |
展示文本(需 URL 编码,中文/空格必须编码) |
ping |
延迟毫秒数,可留空 |
成功返回 {"ok":true};token 错或监控停用返回 {"ok":false,"msg":"Monitor not found or not active."}。
飞书通知
Settings → Notifications 加一个 Feishu(填群机器人 webhook),在每个监控里勾上即可。Kuma 原生支持,不用自己拼 JSON。
关键认知:Kuma 里一个 token = 一个监控 = 一个 up/down 状态。
msg/ping只是附带展示文本,不参与判活。所以”具体是哪台/哪个出问题”要靠msg带出来,或者拆成多个监控(多个 token)。
2. 代理 / 网络心跳接入
每台机器原本就在本地探测代理(走代理打一个轻量请求,连续失败才算异常)。现在把”报平安”从自建云端服务改成 push 给 Kuma。
心跳上报:强制直连是命根子
1 | # --noproxy '*' 强制直连:代理挂了,这条心跳也得照样发出去,否则就失去意义 |
launchd / cron 每 60s 跑一次。机器整机断网/关机 → Kuma 收不到 → 超时告警(这正是”反向心跳”:正常时报平安,外部观察者发现没动静才报警,覆盖了”机器自己发不出告警”的盲区)。
脚本只剩两件事:走代理探测一次、把结果 push 给 Kuma。「连续几次算异常」「去重不刷屏」「告警发到哪」全部由 Kuma 配置决定,脚本因此极简、不易坏。而且 push 接口收 status 和 ping,所以「代理异常」和「整机失联」两层能并进同一个监控、一块面板:
- 代理挂但机器还在 → 脚本探测失败、主动 push
down(msg 说明是代理); - 整机断网/关机 → 脚本根本发不出 → Kuma 超时判 down。
两种情况都告警,且能从 msg / 是否超时区分。
脚本 ~/bin/clash-kuma.sh(macOS 落地版)
1 |
|
几个小决策(都是踩过坑后的选择):
- 一次 curl 同时取码和耗时:
-w '%{http_code} %{time_total}',再用${out%% *}/${out##* }拆分,省一次请求; awk算毫秒而非bc:macOS 默认没bc,awk一定有;- down 的 msg 用纯 ASCII(
clash-proxy-down-code-000):免 URL 编码的麻烦;超时探测时code为000; - curl 加
</dev/null:习惯性切断 stdin,避免在循环里被吞。
定时:launchd ~/Library/LaunchAgents/com.user.clashkuma.plist
1 |
|
1 | launchctl unload ~/Library/LaunchAgents/com.user.clashkuma.plist 2>/dev/null |
关于「开机自启」的关键区别
~/Library/LaunchAgents/(用户级 LaunchAgent)是 登录后自启,不是开机即启:
| 场景 | 是否自动跑 |
|---|---|
| 登录账户后 | ✅ |
| 重启后停在登录界面、未登录 | ❌(登录后才加载) |
| 登录态下睡眠/唤醒 | ✅ 继续 |
| 注销 logout | ❌ 停止 |
- 日常登录使用的 Mac → 等同开机自启,无需额外处理;
- 开了自动登录 → 真·开机自启;
- 要求「未登录也后台跑」→ 改放
/Library/LaunchDaemons/(系统级,需 sudo),开机即启无需登录。
1 | launchctl print gui/$(id -u)/com.user.clashkuma | grep -E 'state|runs' |
3. GitHub Runner 状态接入
那几台机器同时也是某组织的 GitHub Actions self-hosted runner。runner 在线状态藏在要 Bearer token 认证、返回 JSON 数组的 GitHub API 里,不能让 Kuma 直接拨测:
- Kuma 的 HTTP 关键词检查是对整个响应体做包含匹配,而 GitHub 返回的是所有 runner 的数组,无法表达「每一个都 online」;
- 致命盲区:runner 被删除/注销时响应里没有
offline字样,Kuma 反而以为正常(漏报)。
所以同样走「脚本查询 + 主动 push」:
1 | 云端 check-runners.sh (cron 每2分钟) |
关键设计点:1 个监控 + msg 区分哪台
- 用 1 个 Push 监控表达「runner 整体是否健康」,把具体掉线的机器名拼进
msg——告警通知里就能看到是哪台。 - 脚本主动 push
status=down→ 掉线当下立即告警,不必等 Kuma 超时; - Kuma 的超时判活作为兜底:脚本挂了/云端断网,Kuma 收不到也会告警。
若需要每台 runner 独立的历史曲线,得改成每台一个 Push 监控(一个 token)。当前一个监控只够”整体红绿 + 告警里看哪台”。
云端脚本(关键片段)
位置:/opt/docker-compose/heartbeat/check-runners.sh(云服务器,root,cron 每 2 分钟)。
1 | KUMA_PUSH="https://<kuma域名>/api/push/<PUSH_TOKEN>" |
要点:
jq -sRr @uri对 msg 做 URL 编码,否则中文/空格会破坏请求;curl ... </dev/null:切断 curl 的 stdin(在循环里 curl 会吞 stdin 的经典坑,统一加上);- API 查询失败也 push down:否则 token 过期那天 Kuma 反而以为一切正常(这条防误判是原系统就有的,迁到 Kuma 也别丢)。
cron
/etc/cron.d/runner-check:
1 | */2 * * * * root /opt/docker-compose/heartbeat/check-runners.sh >> /opt/docker-compose/heartbeat/runner-check.log 2>&1 |
GitHub token 存在 /root/.gh-runner-token(600),脚本从文件读,不进 cron/日志。
4. 谁来监控这个监控者?
这一步恰好绕回原文的主题。手搓版的兜底是 Python 标准库 + 文件 mtime,”简单到不会坏”——对最该被信任的兜底层而言,零件越少越好。换成 Kuma(Node + SQLite + Web 栈),监控者自己的故障面就变大了;而且 Kuma 要是挂了,是静默挂,没人告诉你。
你没法用一个挂掉的 Kuma 去报告它自己挂了。
所以真用 Kuma,记得再给它自己挂一层外部 healthcheck:healthchecks.io、或者第二个独立实例去 ping 它的状态页。把花哨的仪表盘交给 Kuma,但别让”更重的监控栈”把你最底层的那点信任也变复杂。
5. 验证与常见问题
1 | # a. push 端点连通性(应返回 {"ok":true},Kuma 监控变绿) |
Q: 收到成对的 [Down]→[Up] 告警,间隔约等于 push 周期?
A: Kuma 的 Heartbeat Interval 设得短于脚本 push 周期,两次 push 之间超时。把 Interval 调到大于 push 周期并留余量(push 60s → 90~120s;push 120s → 150~180s)。Down 消息为 No heartbeat in the time window 即此类超时(非脚本主动报 down)。
Q: 告警里 Ping: N/A?
A: 这是 Kuma 通知渠道的固定消息格式,push 脚本控制不了。超时 down 本就无 ping 可测;up 时若 ping= 传空就是 N/A。想显示数字就把探测/响应耗时当 ping 传入(仅 up 有效,见第 2 节代理延迟那段)。要彻底删除该行,需在 Kuma 后台改通知渠道的消息模板,且仅部分通知类型支持自定义模板。
Q: 告警能精确到哪台吗?
A: 能,看告警 Message 里的 掉线: <机器名>。但单个 Kuma 监控本身是整体红绿,不区分台;要分台就拆成每台一个 Push 监控。
Q: GitHub token 过期了会怎样?
A: API 返回非 runners 响应,脚本走「API 失败」分支:push down + 日志记 API 查询失败,不会误判全部 runner 掉线。
Q: 代理挂了,心跳还发得出去吗?
A: 发得出——心跳那行带了 --noproxy '*' 强制直连,不走代理。只有整机断网/关机才发不出,那种情况由 Kuma 超时兜底告警。
Q: clash 没开就报红,正常吗?
A: 正常,脚本如实上报了代理不可用(探测 code=000)。开启 clash 后下一周期(≤60s)自动 push up 变绿。
Q: 和「自建飞书告警」版本有什么区别?该选哪个?
A: 自建版脚本自己做阈值/去重/飞书广播(见 上一篇);本文这套把这些全交给 Kuma,脚本只探测+push。二者择一即可,别对同一个 Kuma token 同时双发。
6. 待办 / 可选改进
- 每个 Push 监控的 Heartbeat Interval 都大于对应脚本的 push 周期(消除 Down/Up 抖动)
- 每个监控都已勾选通知渠道(否则只变红不告警)
- 给 Kuma 自己挂一层外部 healthcheck(它在公司云稳,但 down 了是静默 down)
- (可选)代理 up 时把探测延迟当
ping上报,让 N/A 变成真实曲线 - (可选)runner / 每台机器若需独立历史曲线,改为一台一个 Push 监控
注:文中 <kuma域名>、<PUSH_TOKEN>、<本机token>、<飞书webhook>、GitHub token 等均为占位符,实际值见服务器配置,勿外泄。
终极复盘:几条用得上的经验
- 监控的根本盲点是”通道自身故障”。任何”出事才发消息”的告警,都要配一个网络独立的心跳兜底,否则机器一断网你就彻底瞎了。
- 状态去重是告警系统的基本礼仪。只在状态翻转时发,否则就是在训练自己忽略告警。
- 排查问题先定位”是哪一层”。404 是 CF 还是 Caddy?失败是 token 还是传参?分层确认比凭直觉猜快得多。
while read循环体里别放会读 stdin 的命令(curl/ssh/ffmpeg…),否则它会吃掉你的循环。要么</dev/null,要么干脆用数组 + for。- “怎么试都失败”时,先怀疑测试方法。
- 用文件 mtime 当状态存储,对这种轻量场景够用了,没必要上数据库。
一个”代理超时发个通知”的需求,最后变成了一套挺完整的小系统。但真正的收获不是系统本身,而是中间每一个”再想深一点”的瞬间——尤其是那句:
你没法用一条已经坏掉的通道,去报告它自己坏了。