从手搓多机监控到统一接入 Uptime Kuma

一个”代理超时怎么发个通知”的小需求,怎么一步步长成了覆盖 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 也一样出不去——告警和故障同归于尽。”出问题才发消息”这个模式,天生覆盖不了”连发消息的网络都没了”。

堵这个盲点只有一条路:反向心跳(reverse heartbeat,业界也叫 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,否则又会误判。

演进五:接入 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)

  1. 登录 Uptime Kuma → Add New Monitor
  2. Monitor TypePush
  3. Friendly Name:如 mac-mini-01-代理Github-runner
  4. Heartbeat Interval:必须 大于脚本的 push 周期,留足余量。脚本每 60s push 就设 90~120s,每 120s push 就设 150~180s——否则两次 push 之间会超时变红、下次又恢复,形成 Down/Up 抖动刷屏。
  5. Retries:0 或 1
  6. Notifications:勾选要用的通知渠道(飞书 / Webhook / 邮件等)
  7. 保存后详情页给出 Push URL,记下其中的 <PUSH_TOKEN>
    1
    https://<kuma域名>/api/push/<PUSH_TOKEN>?status=up&msg=OK&ping=

Push 接口参数

GET /api/push/<token> 接受三个参数:

参数 说明
status updown
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
2
3
# --noproxy '*' 强制直连:代理挂了,这条心跳也得照样发出去,否则就失去意义
curl --noproxy '*' -s -o /dev/null -m 8 \
"https://<kuma域名>/api/push/<本机token>?status=up&msg=OK&ping="

launchd / cron 每 60s 跑一次。机器整机断网/关机 → Kuma 收不到 → 超时告警(这正是”反向心跳”:正常时报平安,外部观察者发现没动静才报警,覆盖了”机器自己发不出告警”的盲区)。

脚本只剩两件事:走代理探测一次、把结果 push 给 Kuma。「连续几次算异常」「去重不刷屏」「告警发到哪」全部由 Kuma 配置决定,脚本因此极简、不易坏。而且 push 接口收 statusping,所以「代理异常」和「整机失联」两层能并进同一个监控、一块面板

  • 代理挂但机器还在 → 脚本探测失败、主动 push down(msg 说明是代理);
  • 整机断网/关机 → 脚本根本发不出 → Kuma 超时判 down。

两种情况都告警,且能从 msg / 是否超时区分。

脚本 ~/bin/clash-kuma.sh(macOS 落地版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/env bash
# 探测 clash 代理,结果 push 到 Uptime Kuma。判定/去重/告警全交给 Kuma。
PROXY="http://127.0.0.1:7890" # clash 的 HTTP/mixed 端口
TEST_URL="https://www.gstatic.com/generate_204" # 轻量探测,返回 204
TIMEOUT=5
KUMA="https://<kuma域名>/api/push/<本机token>"

# 走代理探测:一次拿 HTTP 码和耗时
out=$(curl -x "$PROXY" -s -o /dev/null -m "$TIMEOUT" -w '%{http_code} %{time_total}' "$TEST_URL" 2>/dev/null)
code=${out%% *}
secs=${out##* }
ping_ms=$(awk -v s="${secs:-0}" 'BEGIN{printf "%d", s*1000}') # 转毫秒整数,不依赖 bc

if [ "$code" = "204" ] || [ "$code" = "200" ]; then
# 代理正常:上报 up,顺便把探测延迟当 ping 画成曲线
curl --noproxy '*' -s -o /dev/null -m 8 "${KUMA}?status=up&msg=OK&ping=${ping_ms}" </dev/null
else
curl --noproxy '*' -s -o /dev/null -m 8 "${KUMA}?status=down&msg=clash-proxy-down-code-${code}&ping=" </dev/null
fi

几个小决策(都是踩过坑后的选择):

  • 一次 curl 同时取码和耗时-w '%{http_code} %{time_total}',再用 ${out%% *} / ${out##* } 拆分,省一次请求;
  • awk 算毫秒而非 bc:macOS 默认没 bcawk 一定有;
  • down 的 msg 用纯 ASCIIclash-proxy-down-code-000):免 URL 编码的麻烦;超时探测时 code000
  • curl 加 </dev/null:习惯性切断 stdin,避免在循环里被吞。

定时:launchd ~/Library/LaunchAgents/com.user.clashkuma.plist

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.user.clashkuma</string>
<key>ProgramArguments</key>
<array><string>/Users/<用户>/bin/clash-kuma.sh</string></array>
<key>StartInterval</key><integer>60</integer>
<key>RunAtLoad</key><true/>
<key>StandardErrorPath</key><string>/tmp/clash-kuma.err</string>
</dict>
</plist>
1
2
3
launchctl unload ~/Library/LaunchAgents/com.user.clashkuma.plist 2>/dev/null
launchctl load ~/Library/LaunchAgents/com.user.clashkuma.plist
launchctl list | grep clashkuma # 确认注册

关于「开机自启」的关键区别

~/Library/LaunchAgents/(用户级 LaunchAgent)是 登录后自启,不是开机即启:

场景 是否自动跑
登录账户后
重启后停在登录界面、未登录 ❌(登录后才加载)
登录态下睡眠/唤醒 ✅ 继续
注销 logout ❌ 停止
  • 日常登录使用的 Mac → 等同开机自启,无需额外处理;
  • 开了自动登录 → 真·开机自启;
  • 要求「未登录也后台跑」→ 改放 /Library/LaunchDaemons/(系统级,需 sudo),开机即启无需登录。
1
2
launchctl print gui/$(id -u)/com.user.clashkuma | grep -E 'state|runs'
# state = not running 正常——它不是常驻,每 60s 拉起跑一次就退出;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
2
3
4
5
6
7
云端 check-runners.sh (cron 每2分钟)
├─ 查 GitHub API 全部 self-hosted runner
├─ 某台 offline → 飞书告警 🔴 (精确到台,原有逻辑保留)
└─ push 到 Kuma 单个 Push 监控:
全 online → status=up, msg="全部N个在线"
有掉线 → status=down, msg="掉线: <机器名>"
API失败 → status=down, msg="API查询失败:..."

关键设计点: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
KUMA_PUSH="https://<kuma域名>/api/push/<PUSH_TOKEN>"

# 向 Kuma push:$1=up|down $2=消息文本(自动 URL 编码)
kuma() {
[ -z "$KUMA_PUSH" ] && return
local status="$1" msg="$2" enc
enc=$(printf '%s' "$msg" | jq -sRr @uri) # URL 编码,处理中文/空格
curl -s -m 10 -o /dev/null "${KUMA_PUSH}?status=${status}&msg=${enc}&ping=" </dev/null
}

# ... 查询 GitHub API、遍历 runner、收集 down_list ...

# 汇报整体状态
if [ -n "$down_list" ]; then
kuma down "掉线:${down_list} (共${cnt}个runner)"
else
kuma up "全部 ${cnt} 个 runner 在线"
fi

要点:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
# a. push 端点连通性(应返回 {"ok":true},Kuma 监控变绿)
curl -s "https://<kuma域名>/api/push/<PUSH_TOKEN>?status=up&msg=test&ping="

# b. 手动跑一次 runner 检查脚本(全 online 应 push up)
sudo HB_WEBHOOK="<飞书webhook>" /opt/docker-compose/heartbeat/check-runners.sh

# c. 模拟掉线(push down,Kuma 应变红并按通知渠道告警)
curl -s "https://<kuma域名>/api/push/<PUSH_TOKEN>?status=down&msg=$(printf '%s' '掉线: office-build-mac-04' | jq -sRr @uri)&ping="

# d. 确认 cron 在自动跑
tail -3 /opt/docker-compose/heartbeat/runner-check.log

# e. 代理探测脚本:看探测+push 的实际行为(clash 没开时应如实 push down,code=000)
bash -x ~/bin/clash-kuma.sh

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 等均为占位符,实际值见服务器配置,勿外泄。

终极复盘:几条用得上的经验

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

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

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