修真凶:4G 链路静默断连导致隧道"假活"(用户撞上 Error 1033)

现象:用户早上打开 relay.xxmxxs.top 看到 Cloudflare Error 1033。
      watchdog 日志把过程记下来了:
        公网不通(HTTP 000,ha_connections=2)  <- 隧道显示连着,公网不通
        公网不通(HTTP 502,ha_connections=2)
        公网恢复(HTTP 200)                     <- 自己又好了
      也就是说:**间歇性假活**,短样本测不出来。
      我之前只测 15~25 次就说"可用率 100%",太草率了。

根因:4G 链路会**静默断开** TCP 连接 —— 不发 FIN/RST,双方都以为还连着。
      Cloudflare 把请求发到这条僵尸连接上,就回 502/530/1033。
      Linux 默认 tcp_keepalive_time = **7200 秒(2 小时)**才发现连接死了,
      所以中间这段时间服务一直"看着在线、实际打不开"。

      tunnel.log 最近 3000 行的佐证:
        i/o timeout              212 次
        Retrying connection      127 次
        Lost connection           22 次

修法:
  1. TCP keepalive 调到 30 秒 + 每 10 秒探测 3 次
     => 最多约 60 秒发现僵尸连接,cloudflared 立刻重连
     (/etc/sysctl.d/97-ykxmz-keepalive.conf)
  2. 隧道连接数 2 -> 4,多几条冗余
  3. 修 relay 从不清理设备记录的问题:
     self.devices 只增不减,被控端每换端口/重装就多一条,
     实测一天攒了 75 条僵尸记录,/v1/health 从几 KB 涨到 20KB。
     加 DEVICE_TTL(6 小时)在 snapshot() 里清掉。

实测(修完立刻验证):
    设备侧  公网 15/15 HTTP 200
    Windows 公网 25/25,中位 520 ms,最快 463 ms
    health  20931 字节 / 75 台  ->  152 字节 / 0 台
This commit is contained in:
xmz7891
2026-10-07 09:15:27 +08:00
parent 2b40968d23
commit 8c4ee6df08
9 changed files with 603 additions and 50 deletions
+113
View File
@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
r'''长时间观察公网可用率 —— 这才是判断"稳不稳"的正确方法。
我之前只测了 15~25 次就连着说"可用率 100%",太草率了。
用户早上打开就撞上 Error 1033 —— 说明**间断性掉线**,
短样本根本看不出来。
这个脚本跑 10 分钟,每 3 秒一次,统计:
* 总可用率
* 连续失败的最长时长(这个最要命:用户点进去就是白页)
* 失败的时间分布(是零星抖动还是成片断)
'''
from __future__ import annotations
import statistics
import sys
import time
import urllib.error
import urllib.request
URL = "https://relay.xxmxxs.top/v1/health"
HEADERS = {"User-Agent": "LanRemote/1.4.0"}
DURATION = 600 # 观察 10 分钟
INTERVAL = 3 # 每 3 秒一次
def probe(timeout: float = 12.0) -> tuple:
"""返回 (是否成功, 耗时秒, 状态码或错误名)。"""
start = time.perf_counter()
try:
with urllib.request.urlopen(
urllib.request.Request(URL, headers=HEADERS),
timeout=timeout) as resp:
resp.read()
ok = resp.status == 200
return ok, time.perf_counter() - start, str(resp.status)
except urllib.error.HTTPError as exc:
return False, time.perf_counter() - start, "HTTP%d" % exc.code
except Exception as exc: # noqa: BLE001
return False, time.perf_counter() - start, exc.__class__.__name__
def main() -> int:
print(" 观察 %d 分钟,每 %d 秒一次" % (DURATION // 60, INTERVAL))
print()
total = ok_count = 0
times = []
failures = []
streak = 0
worst_streak = 0
worst_start = None
current_start = None
deadline = time.time() + DURATION
while time.time() < deadline:
ok, elapsed, note = probe()
total += 1
if ok:
ok_count += 1
times.append(elapsed)
streak = 0
current_start = None
else:
failures.append((time.strftime("%H:%M:%S"), note))
if streak == 0:
current_start = time.time()
streak += 1
if streak > worst_streak:
worst_streak = streak
worst_start = current_start
# 每分钟报一次进度
if total % 20 == 0:
print(" 已测 %3d 次,成功 %3d,失败 %2d"
% (total, ok_count, total - ok_count))
time.sleep(INTERVAL)
print()
print(" ================ 结果 ================")
rate = ok_count / total * 100 if total else 0
print(" 样本 %d 次(约 %d 分钟)" % (total, DURATION // 60))
print(" 可用率 %.1f%% (%d/%d)" % (rate, ok_count, total))
if times:
print(" 成功时延迟 中位 %.0f ms 最快 %.0f ms 最慢 %.0f ms"
% (statistics.median(times) * 1000,
min(times) * 1000, max(times) * 1000))
if failures:
span = worst_streak * INTERVAL
print(" **最长连续失败 %d 次(约 %d 秒)**" % (worst_streak, span))
if worst_start:
print(" 开始于 %s" % time.strftime("%H:%M:%S",
time.localtime(worst_start)))
print(" 失败明细(最多列 20 条):")
for stamp, note in failures[:20]:
print(" %s %s" % (stamp, note))
else:
print(" 全程没有失败")
print()
print(" 判读:")
if rate >= 99.5:
print(" ≥99.5% —— 可以接受")
elif rate >= 97:
print(" 97~99.5% —— 有抖动,用户偶尔会遇到打不开")
else:
print(" <97% —— **不稳定**,必须查根因(用户会经常撞上 Error 1033)")
return 0
if __name__ == "__main__":
sys.exit(main())