从"代理又挂了"到一套多机监控告警系统

一个”代理超时怎么发个通知”的小需求,怎么一步步长成了覆盖 5 台机器、带死人开关兜底、还顺带监控 GitHub Runner 的告警系统。以及中间那个让我查了半小时的经典 Shell 坑。

起点:一个很小的需求

我用 Clash 代理,节点经常抽风。最初的想法特别朴素:

能不能代理超时的时候,自动发个通知给我?

方案也很直接——定时用 curl 走代理打一个轻量请求,失败就告警:

1
2
3
4
5
6
7
8
PROXY="http://127.0.0.1:7890"
TEST_URL="https://www.gstatic.com/generate_204" # 返回 204,最轻量

if curl -x "$PROXY" -sf -o /dev/null -m 5 "$TEST_URL"; then
echo "代理正常"
else
echo "代理异常" # ← 这里发通知
fi

通知渠道选了飞书自定义机器人——建个群机器人,拿到 webhook,POST 一个 JSON 就行:

1
2
3
4
5
feishu() {
curl -s -X POST "$WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"msg_type\":\"text\",\"content\":{\"text\":\"$1\"}}"
}

定时用 macOS 的 launchd(比 cron 更适合跑在用户登录态、通知能正常弹):每 60 秒跑一次。

到这里,需求”满足”了。但真正有意思的部分,是后面每一次”再加一点”。

演进一:别一抖动就刷屏

代理偶尔丢一两个包很正常,每次都告警等于噪音。加两个机制:

  1. 连续 N 次失败才告警THRESHOLD=3,约 2 分钟持续异常才报)。
  2. 状态去重:只在 正常→异常 翻转时发一次,恢复时再发一次 ✅,中间不重复刷屏。
1
2
3
4
5
6
7
8
fails=$(cat "$STATE" 2>/dev/null || echo 0)
if curl -x "$PROXY" -sf -o /dev/null -m "$TIMEOUT" "$TEST_URL"; then
[ "$fails" -ge "$THRESHOLD" ] && feishu "✅ Clash 已恢复"
echo 0 > "$STATE"
else
fails=$((fails + 1)); echo "$fails" > "$STATE"
[ "$fails" -eq "$THRESHOLD" ] && feishu "⚠️ Clash 连续 ${THRESHOLD} 次探测失败"
fi

演进二:不只一台机器

办公室有好几台 Mac 都跑着代理。于是要批量部署到远程机器。这里踩了第一个坑:

macOS 没有 sshpass,而 ssh 的密码提示走 TTY,自动化脚本没法直接喂密码。

解决办法是用 macOS 自带的 expect 包一层。几个要点(都是踩出来的):

  • 第二次出现 password: 提示就判定认证失败并退出,否则会死等卡住;
  • 远程命令作为单个参数传给 ssh,否则 ; 分隔的多条命令会被本地 shell 拆开;
  • 别用 timeout(macOS 没有),靠 expect 自己的 set timeout 兜底;
  • 别在输出后接 | tail,管道缓冲会让你以为它卡死了。

部署后,通知里带上主机名就成了刚需——不然 5 台机器告警长一个样,根本分不清是哪台。技巧是在 feishu() 里统一加前缀,而不是每条消息手写:

1
2
3
4
5
HOST="$(scutil --get ComputerName 2>/dev/null || hostname -s)"
feishu() {
local text="[$HOST] $1" # ← 统一前缀
...
}

演进三:网络断了,连告警都发不出去怎么办?

这是整个系统里最关键的一次思维转变。

我突然意识到一个根本问题:

你没法用一条已经坏掉的通道,去报告它自己坏了。

如果机器整个断网/关机,代理探测失败,但发飞书的 curl 也一样出不去——告警和故障同归于尽。”出问题才发消息”这个模式,天生覆盖不了”连发消息的网络都没了”。

堵这个盲点只有一条路:反过来做心跳(dead man’s switch,死人开关)。

不再是”出事了才发消息”,而是”正常时定期报平安”,由一个网络独立的外部观察者来判断:

“超过 X 分钟没收到某机器的平安信号 = 它出事了”,然后由外部那一方发告警。

做判断和发告警的一方,网络跟故障机是隔离的,所以故障机断网/关机/脚本死,完全不影响告警发出。这是唯一能覆盖”自己发不出去”的结构。

于是架构变成了两层:

1
2
3
4
5
6
7
                       ┌─ 走代理探测 ─ 连续失败 → 本机发飞书 ⚠️(这层机器自己发)
每台 mac (每60s) │
监控脚本 ────────────┤
└─ 强制直连 curl 心跳 ──► 云端心跳接收服务
(代理挂了也照发) │

云端 cron 每分钟检查:超 5 分钟没心跳 → 云端发飞书 🔴

两层告警的职责分得很清楚:

  • 机器本地:报告代理异常(这层网络还通,能自己发);
  • 云端心跳:报告机器整体失联(这层故障机发不出,由云端代发)。

心跳的实现

心跳上报就是一行,关键是 --noproxy '*' 强制直连——否则代理挂了心跳也发不出去,就失去意义了:

1
curl --noproxy '*' -s -o /dev/null -m 8 "https://heartbeat.example.com/hb/$HOST_SLUG"

云端接收服务用 Python 标准库写了个极小的 HTTP server(无依赖),收到 /hb/<机器名>touch 一个时间戳文件:

1
2
3
4
5
6
7
8
def _hb(self):
m = re.match(r'^/hb/([^/]+)$', self.path.split('?',1)[0])
name = m.group(1)
if not NAME_RE.match(name): # 白名单正则,防路径穿越
self.send_response(400); ...; return
open(os.path.join(DATA, name), 'w').close()
os.utime(os.path.join(DATA, name), None)
self.send_response(200); ...

判定脚本(云端 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
2
3
4
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.github.com/orgs/<ORG>/actions/runners?per_page=100" \
| jq -r '.runners[] | select([.labels[].name] | index("self-hosted"))
| "\(.name)\t\(.status)"'

告警规则:带 self-hosted 标签的 runner 变 offline 就发飞书,恢复就通知。API 查询失败时只记日志、不误判全部掉线——这个保护很重要,否则 token 过期那天会收到一堆假告警。

这里有个特别值得记的教训。我连续测了三个 token 都报 401 Bad credentials,一度以为是 token 被 GitHub 密钥扫描自动撤销了。结果根因是我自己的传参 bug

1
2
3
4
5
# 错误:远端 $1 取不到,TOKEN 变成空字符串
ssh host 'GH_TOKEN="$1" bash -s' _ "$TOKEN" <<'EOF' ...

# 正确:token 经 stdin 写进文件,脚本从文件读
printf '%s' "$TOKEN" | ssh host 'umask 077; cat > ~/.token'

教训:当一个东西”怎么试都失败”时,先怀疑测试方法本身。 我冤枉了三个无辜的 token。

终章:那个让我查了半小时的 Shell 坑

系统跑起来后,配了两个飞书群做广播。测试时发现——只有一个群收到消息

feishu() 当时是这么写的,看起来人畜无害:

1
2
3
4
5
6
7
feishu() {
local payload="..."
printf '%s' "$WEBHOOK" | tr ', \t' '\n\n\n' | while IFS= read -r url; do
[ -z "$url" ] && continue
curl -s -X POST "$url" -d "$payload" >/dev/null
done
}

我先验证了 webhook 都活着(单独发都 200),又验证 tr 分割正确(确实分出 2 行)。逻辑明明没问题,但循环就是只跑一次。

最后用 set -x trace 才看清真相:

1
2
3
+ curl ... 第一个URL       ← 第1次发送
+ read -r url ← 准备读第2个
+ set +x ← 循环结束了!

第一次 curl 之后,read 再也读不到第二个 URL。根因是 Shell 经典坑:

while read 循环的 stdin 是那个管道。循环体里的 curl 默认也会从 stdin 读取,一口把管道里剩下的内容(第二个 URL)全吞了,导致 read 第二次读空、循环提前结束。

很多命令都有这毛病(sshffmpegmysql…),它们会”吃掉”循环的 stdin。

修复:彻底脱离管道——用数组 + for

1
2
3
4
5
6
7
8
feishu() {
local payload="..." url _urls
IFS=', '$'\t' read -ra _urls <<< "$WEBHOOK" # 读进数组
for url in "${_urls[@]}"; do
[ -z "$url" ] && continue
curl -s -X POST "$url" -d "$payload" </dev/null >/dev/null # </dev/null 双保险
done
}

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,否则又会误判。

复盘:几条用得上的经验

  1. 监控的根本盲点是”通道自身故障”。任何”出事才发消息”的告警,都要配一个网络独立的心跳兜底,否则机器一断网你就彻底瞎了。
  2. 状态去重是告警系统的基本礼仪。只在状态翻转时发,否则就是在训练自己忽略告警。
  3. 排查问题先定位”是哪一层”。404 是 CF 还是 Caddy?失败是 token 还是传参?分层确认比凭直觉猜快得多。
  4. while read 循环体里别放会读 stdin 的命令(curl/ssh/ffmpeg…),否则它会吃掉你的循环。要么 </dev/null,要么干脆用数组 + for。
  5. “怎么试都失败”时,先怀疑测试方法
  6. 用文件 mtime 当状态存储,对这种轻量场景够用了,没必要上数据库。

一个”代理超时发个通知”的需求,最后变成了一套挺完整的小系统。但真正的收获不是系统本身,而是中间每一个”再想深一点”的瞬间——尤其是那句:

你没法用一条已经坏掉的通道,去报告它自己坏了。