Cloudflare Tunnel 保活看门狗设计:从能连边缘到端到端可达的实战总结
Cloudflare Tunnel 保活看门狗设计:从"能连边缘"到"端到端可达"的实战总结
背景:一台 Windows 云电脑(以下简称被控端)通过 Cloudflare Tunnel 对外暴露 SSH, 控制端经
cloudflared access ssh建连。隧道在边缘侧显示 Connected,但控制端 频繁报Connection timed out during banner exchange。本文记录我们为这条隧道 设计的保活看门狗:它不止盯进程存活,还盯端到端建连延迟,并把 DNS 解析、 二进制自愈、降级通道都收进同一个闭环。
一、为什么"进程在跑"不等于"隧道可用"
最开始的看门狗很朴素:pgrep cloudflared 找不到就重启。结果隧道照样三天两头 "假死":
- 被控端 cloudflared 进程 Running,且 cloudflared 边缘连接数正常;
- 控制端
ssh报 banner 交换超时; - 重启 cloudflared,"好像"好了——但 20 秒后大概率又不行。
事后复盘,真正的原因有两层,而且都不在被控端:
- 控制端 DNS 解析抖动:控制端走沙箱 Fake-IP DNS,
*.cfargotunnel.com的 真实解析依赖一个本地 DoH 存根(doh-stub)。上游抖动时它回 SERVFAIL 空包,cloudflared access拿不到正确 IP 就建连失败。cloudflared 本体没死, 是解析链路先死了。 - 连接前置延迟吃掉超时预算:为了绕开 flaky DNS,我们在
ProxyCommand里加了"连接前同步解析 hosts"的步骤。hosts 缓存过期后,每次 ssh 都要同步 走 doh-stub 重解析 5 次重试,烧掉约 20 秒,而 ssh 的ConnectTimeout只有 10 秒——还没轮到 cloudflared 启动,ssh 自己先超时了。
教训:看门狗必须做端到端探活(真的发起一次 ssh 建连并拿到 banner), 只查"进程在、边缘连着"会有盲区。以及:重启后恢复 ≠ 重启修好的, 先排除测量路径的差异再下结论。
二、看门狗的三层设计
最终落地的是一套三层结构,各管一段链路:
┌──────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ L1 本机自查 │────▶│ L2 端到端探活 │────▶│ L3 降级与自愈 │
│ 组件存活 │ │ 真实 ssh 建连 │ │ 换通道/重建/告警 │
└──────────────┘ └──────────────────┘ └─────────────────────┘
L1:本机自查(快、便宜,每分钟可跑)
检查清单(每项都是"存在性"断言,脚本退出码按 0 健康 / 1 需恢复 / 2 环境异常 三段约定):
- cloudflared 二进制存在且可执行(必须用持久化路径,血的教训见下);
- 隧道配置文件存在、隧道 ID 与凭证对得上;
- doh-stub 存根进程在、hosts 预解析缓存文件新鲜度 < 1 小时;
cloudflared-access-wrap.sh语法可通过bash -n。
L2:端到端探活(慢、准,5 分钟一轮)
用生产完全相同的命令发起一次真实连接:
timeout 15 ssh -n -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=10 ydy "echo alive" >/dev/null 2>&1
注意三个细节:
-n是保命参数:无 TTY 环境下 stdin 不送 EOF,OpenSSH 会在 banner 阶段假死,看起来像"连接超时",其实是本地 stdin 的问题;ConnectTimeout=10与timeout 15双保险,探活本身绝不能 hang 住 看门狗;- 探活走
ssh ydy这个真实别名(含 ProxyCommand),不走裸cloudflared access——要测的就是用户实际走的那条路。
连续 2 次探活失败才判隧道故障,单次失败只记抖动(doh-stub 上游抖是常态)。
L3:降级与自愈
按顺序执行,每步做完都回 L2 验证,通了就停:
- 刷新 DNS 预解析缓存:后台异步重拉 hosts(
flock防并发,成功才原子 覆盖,失败保留旧文件——缓存旧一点也能用,空文件会直接断连); - 重启被控端 cloudflared(经备用通道下发,保持最后手段);
- 切备用通道:主通道(CFT)与备用通道(FRP STCP / Tailscale 直连) 交替,ssh 配置里三个 Host 别名平时就都保持可用;
- 告警:三次自愈无效,发邮件给机主,附上最近一次
ssh -v的真实报错 (定位时永远先看-v输出,不要靠猜)。
三、四个实战踩过的坑
坑 1:ProxyCommand 里不要放同步重解析
早期 cloudflared-access-wrap.sh 在每次建连前同步刷新 hosts。缓存过期后 单次建连前置延迟 20 秒 > ConnectTimeout 10 秒,全灭。
修复:hosts 有可用 IP 直接放行,刷新丢到后台异步做:
# 有缓存直接用,不阻塞本次连接
if [ -s "$HOSTS_CACHE" ] && [ $(($(date +%s) - $(stat -c %Y "$HOSTS_CACHE"))) -lt 3600 ]; then
exec cloudflared access ssh --hostname "$TUNNEL_HOST"
fi
# 缓存过期:后台刷新,本次先用旧 IP 顶着
(
flock -n 200 || exit 0
resolve_and_update_hosts && mv "$HOSTS_TMP" "$HOSTS_CACHE"
) 200>"$HOSTS_LOCK" &
exec cloudflared access ssh --hostname "$TUNNEL_HOST"
冷启动(无缓存)才同步解析一次。
坑 2:脚本里写死 /usr/local/bin 路径
一次 VM 重建清空了 /usr/local/bin/cloudflared(当时是个软链), wrapper 里写死的绝对路径导致 env: No such file or directory, ssh 全天失败;而 L1 自查只验了"wrapper 语法对",没验"它引用的二进制存在", 于是自查天天报全绿、实际全红,还连发了 4 封误向升级邮件。
铁律:脚本引用的二进制一律用家目录下持久路径;自查清单必须覆盖 "探活链路上每个外部命令的二进制存在性"——语法对 ≠ 能跑。
坑 3:doh-stub 上游抖动
doh-stub 的上游 DoH 经代理出去,延迟 5~9 秒是常态,超 8 秒就回 SERVFAIL。 对策是解析器侧加 rcode 检查 + 截断包防护 + 有限重试(5 次), 以及上面说的"失败保留旧缓存"。DNS 这种基础设施抖动, 设计时就当它一定会抖,而不是当异常处理。
坑 4:自愈脚本的并发双跑
看门狗(cron)和平台开机钩子可能同时触发恢复脚本。mkdir 抢锁的经典写法 有个窗口期:A 抢到锁还没写完标记,B 看到没标记就 rm -rf 抢走, 两个恢复脚本并发双跑。解法是换成 noclobber 原子建文件锁 (O_EXCL,时间戳与锁文件同时出现,无窗口期),stale 20 分钟自动接管:
set -o noclobber
if ! ( : > "$LOCK_FILE" ) 2>/dev/null; then
# 锁存在:检查是否 stale(20 分钟)
if [ $(($(date +%s) - $(stat -c %Y "$LOCK_FILE"))) -gt 1200 ] \
&& pgrep -af 'recover\.sh' | grep -qv "$$"; then
rm -f "$LOCK_FILE" # 确认真无进程在跑才删
else
exit 0
fi
fi
注意删锁前必须 pgrep 确认真无进程在跑,别只看锁文件。
四、现在这套看门狗长什么样
health-check.sh:L1 自查,退出码 0/1/2 契约,cron 每 10 分钟跑;tunnel-probe.sh:L2 端到端探活,5 分钟一轮,连续 2 次失败触发 L3;recover.sh:L3 自愈,幂等、可重入,开机钩子与 cron 双触发, 原子锁防双跑;- 降级通道 FRP STCP 与 Tailscale 的探活同样进 L2,主备交替, 批量命令走 ControlMaster 长连接复用。
上线两个月,隧道"假死"误报从每周数次降到零(剩下的都是真故障: 被控端断电、运营商劫持之类,看门狗修不了、但能 5 分钟内准确定位并发邮件)。
五、一句话总结
隧道保活看门狗的三条设计原则:
- 探活必须端到端:用生产相同的命令真的建连一次,进程存活只是必要条件;
- 前置步骤计入超时预算:ProxyCommand 里的同步动作(DNS、鉴权)有多慢, ConnectTimeout 就要留多大余量,或者干脆异步化;
- 自查清单要闭环到二进制:语法对、配置对、进程对都不够, 链路上每个外部命令存在且可执行,才算"自查通过"。
希望这套"从误报里长出来"的设计,能帮你少踩几遍我们踩过的坑。
- 点赞
- 收藏
- 关注作者
评论(0)