diff --git a/_build/_cmsg_deadlock.txt b/_build/_cmsg_deadlock.txt new file mode 100644 index 0000000..d84225f --- /dev/null +++ b/_build/_cmsg_deadlock.txt @@ -0,0 +1,66 @@ +修「网站容易挂」的真因:nettune 和隧道之间的死锁 + +用户报「网站还是容易挂」。查到最后是个 systemd 顺序死锁 —— +而且**是我自己引入的**(换掉设备上那份过期 unit 之后才露出来)。 + +== 死锁链条 == + + lanremote-nettune.service: + Before=lanremote-tunnel.service <- 我必须先于隧道启动完 + ExecStart=/bin/bash net-tune.sh + + net-tune.sh 第 4 步("让已经建立的隧道用上新参数"): + systemctl restart lanremote-tunnel <- 在这里永久阻塞 + + nettune 在**自己还没结束**的时候去 restart 一个"必须等自己结束 + 才能启动"的服务 —— systemd 判定顺序冲突,restart 一直不返回。 + + 证据(设备上抓的): + CGroup: /system.slice/lanremote-nettune.service + ├─13388 /bin/bash net-tune.sh + └─13428 systemctl restart lanremote-tunnel + 那个 systemctl 的 etime 是 09:32 —— 卡了 9 分半。 + + 后果:nettune 一直 activating -> 隧道被 Before= 挡着起不来 -> + 公网 530。而 journalctl 里只看得到 nettune 停在 activating, + 没有任何报错,特别难查。 + +== 为什么以前没暴露 == + + 设备上原来那份 unit 的 ExecStart 里有个 iptables ... || ..., + systemd 不支持 ||,所以它**在第 2 条 ExecStart 就报错退出**, + 根本走不到"重启隧道"那一步 —— 死锁被那个 bug 遮住了。 + 我把 unit 换成正确形状之后,死锁才露出来。 + + 另一个迷惑点:**手动跑 net-tune.sh 完全正常**(输出"没有正在跑的 + 隧道,跳过"),因为手动跑时不在服务上下文里、没有 Before= 约束。 + +== 改法 == + + 第 4 步的本意是"改完 MTU/MSS 后让已建立的隧道用上新参数", + 这个功能有用,但**只该在人工重跑时做** —— 开机时顺序本来就是 + nettune 先、隧道后,那时隧道还没起来。 + + 所以 unit 里加 Environment=NETTUNE_SERVICE_CALL=1, + 脚本看到就跳过第 4 步。三个地方都改了: + relay/net-tune.sh 认这个开关 + relay/deploy.sh 生成的 unit 带上它 + relay/install.sh 同上(老机器走的安装路径) + + 没选 systemctl restart --no-block:那只是把阻塞变异步, + 开机时仍然做一次无谓的重启(隧道刚起来就被停掉), + 也没解决"不该在开机时重启"这个语义问题。 + +== 另外 == + + setup-monitor.sh / monitor.sh / rescue.sh 这一整套这次也真的 + 在线上跑起来并验证过了: + + 18:13:03 紧急预案启动:公网连续 3 次不通 + 18:13:07 现场已记录 + [1/5] 重启隧道 + 18:13:59 ✅ 第 1 级救回来了,公网已恢复 + 18:13:59 已把「第 1 级奏效」记进知识库 + + 死锁这次就是被监测单元抓到的(fails=3 / last_code=530)。 + +全量回归 46 个阶段全部通过。 diff --git a/_build/break_nettune_deadlock.py b/_build/break_nettune_deadlock.py new file mode 100644 index 0000000..ca4be90 --- /dev/null +++ b/_build/break_nettune_deadlock.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +r'''解开"nettune 死锁",然后把根因修掉。 + +== 死锁是怎么回事 == + + lanremote-nettune.service: + Before=lanremote-tunnel.service <- 关键 + ExecStart=/bin/bash net-tune.sh + + net-tune.sh 第 4 步: + systemctl restart lanremote-tunnel <- 在这里永远阻塞 + + 为什么阻塞:systemd 正在启动 nettune,而 nettune 声明了"我必须在 + 隧道之前启动完"。脚本却在**自己还没结束的时候**去 restart 隧道 —— + 那要求隧道先停、再等 nettune 结束……systemd 检测到这是顺序冲突, + restart 就一直挂着不返回。 + + 证据:CGroup 里能看到 net-tune.sh(13388)下面挂着 + systemctl restart lanremote-tunnel(13428), + etime 09:32 —— 卡了 9 分半,nettune 一直是 activating。 + + 后果:nettune 卡住 -> 隧道被 Before= 挡着起不来 -> 公网 530。 + 这就是用户说的"网站还是容易挂"的真因之一。 + +== 为什么以前没暴露 == + + 设备上原来那份 unit 的 ExecStart 里有个 `iptables ... || ...`, + systemd 不支持 ||,所以它**在第 2 条 ExecStart 就报错退出了**, + 根本没走到"重启隧道"那一步 —— 死锁被那个 bug 遮住了。 + 我把 unit 换成正确形状之后,死锁才露出来。 + +== 这一步做什么 == + + 1. 杀掉卡住的 systemctl 和 net-tune.sh(解开当前死锁) + 2. 拉起隧道(网站先恢复) + 3. 把 net-tune.sh 的"重启隧道"改成**只在手动跑时做** +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +echo "════ 1. 解开死锁 ════" +# 先杀卡住的 systemctl(不然它会一直拽着 systemd 的 job) +pkill -f 'systemctl restart lanremote-tunnel' 2>/dev/null && echo " 杀了卡住的 systemctl" || echo " 没有卡住的 systemctl" +sleep 2 +# 再让 nettune 的脚本结束(杀它的子进程已经让主脚本继续往下走了) +systemctl stop lanremote-nettune 2>/dev/null +systemctl reset-failed lanremote-nettune 2>/dev/null +sleep 2 +echo " nettune 现在: $(systemctl is-active lanremote-nettune)" + +echo +echo "════ 2. 拉起隧道(网站先恢复)════" +systemctl reset-failed lanremote-tunnel lanremote-tunnel-h2 2>/dev/null +systemctl start lanremote-tunnel lanremote-tunnel-h2 +sleep 15 +echo " tunnel: $(systemctl is-active lanremote-tunnel)" +echo " tunnel-h2: $(systemctl is-active lanremote-tunnel-h2)" + +echo +echo "════ 3. 验公网 ════" +for i in 1 2 3; do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/v1/health) + echo " 第 $i 次: $code" + [ "$code" = "200" ] && break + sleep 6 +done + +echo +echo "════ 4. 隧道连接数 ════" +for p in 20241 20242; do + n=$(curl -s --max-time 5 http://127.0.0.1:$p/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1) + echo " $p -> ${n:-拿不到}" +done +''' + + +def main() -> int: + out = run(REMOTE, timeout=300) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/diag_nettune_stuck.py b/_build/diag_nettune_stuck.py new file mode 100644 index 0000000..9b82a4e --- /dev/null +++ b/_build/diag_nettune_stuck.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +r'''查 nettune 为什么卡在 activating(它挡着隧道,隧道一挂网站就 530)。 + +发现: + lanremote-nettune activating <- 卡住 + lanremote-tunnel inactive <- 在等 nettune + lanremote-tunnel-h2 inactive + 公网 530 + +nettune 的 unit 里有 Before=lanremote-tunnel.service, +所以它卡住 = 隧道永远起不来。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +echo "════ 1. nettune 的完整状态 ════" +systemctl status lanremote-nettune --no-pager -n 25 2>&1 | tail -25 + +echo +echo "════ 2. 有没有卡住的子进程 ════" +systemctl show -p MainPID -p ControlPID -p ExecMainStatus -p StateChangeTimestamp lanremote-nettune 2>&1 +echo " --- 所有相关进程 ---" +ps -eo pid,ppid,stat,etime,cmd 2>/dev/null | grep -E 'iptables|net-tune|deploy-tune|ip link' | grep -v grep + +echo +echo "════ 3. net-tune.sh 里会不会卡住的那步 ════" +echo " --- 脚本第 4 步(重启隧道)会 wait 吗 ---" +grep -n 'systemctl restart' /root/lanremote-relay/net-tune.sh + +echo +echo "════ 4. 手动跑一次 net-tune.sh 看卡在哪(30 秒超时)════" +timeout 30 bash /root/lanremote-relay/net-tune.sh 2>&1 | tail -15 +echo " 退出码: $?" + +echo +echo "════ 5. 卡住的时候隧道是什么状态 ════" +systemctl is-active lanremote-tunnel lanremote-tunnel-h2 +journalctl -u lanremote-tunnel --no-pager -n 12 2>/dev/null | tail -12 +''' + + +def main() -> int: + out = run(REMOTE, timeout=300) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_nettune_deadlock.py b/_build/fix_nettune_deadlock.py new file mode 100644 index 0000000..7bd0b91 --- /dev/null +++ b/_build/fix_nettune_deadlock.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +r'''永久修掉 nettune 死锁。 + +== 死锁回顾 == + + lanremote-nettune.service Before=lanremote-tunnel.service + net-tune.sh 第 4 步 systemctl restart lanremote-tunnel + + nettune 在"自己还没结束"的时候去 restart 一个"必须等自己结束才能启动" + 的服务 —— systemd 认为顺序冲突,restart 永久阻塞。 + + 实测卡了 9 分半,nettune 一直 activating,隧道被 Before= 挡着起不来, + 公网 530。这就是"网站容易挂"的真因之一。 + +== 修法 == + + 第 4 步的本意是"让**已经建好**的隧道用上新参数"(改完 MTU/MSS 之后 + 手动重跑脚本时用)。**开机时根本不该走到那一步** —— + 开机顺序本来就是 nettune 先、隧道后,那时隧道还没起。 + + 所以:unit 里加 Environment=NETTUNE_SERVICE_CALL=1, + 脚本看到这个变量就跳过第 4 步。 + + 为什么不干脆删掉第 4 步: + 手动改完网络参数后,已经建立的隧道还在用旧参数, + 需要重启它才生效。这个功能是有用的,只是不该在服务启动时跑。 + + 为什么不用 `systemctl restart --no-block`: + 那只是把阻塞变成异步,nettune 立刻退出、隧道随后重启 —— + 开机时仍然是无谓的一次重启(隧道刚起来就被停掉), + 而且没解决"不该在开机时重启"这个语义问题。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) + +from devssh import upload, run # noqa: E402 + +TUNE = ROOT / "relay" / "net-tune.sh" + +OLD = '''echo " 4) 让已经建立的隧道用上新参数"''' + +NEW = '''# ════════════════════════════════════════════════════════════════════ +# 4) 让已经建立的隧道用上新参数 —— **只在手动跑的时候做** +# ════════════════════════════════════════════════════════════════════ +# +# ⚠ 这一步绝对不能由 lanremote-nettune.service 执行。 +# +# 那个 unit 里写着 Before=lanremote-tunnel.service +# (意思是"我必须先于隧道启动完")。如果脚本在自己还没结束的时候 +# 去 `systemctl restart lanremote-tunnel`,systemd 会认为存在 +# 顺序冲突 —— restart 永久阻塞、nettune 一直卡在 activating、 +# 隧道被 Before= 挡着永远起不来。 +# +# 实测卡了 9 分半,表现就是"网站又挂了",而日志里什么都看不出来 +# (只有 nettune 停在 activating)。手动跑本脚本却完全正常 +# (因为那时没有 Before= 约束)—— 所以这个坑特别难发现。 +# +# 开机时本来也不需要这一步:顺序就是 nettune 先、隧道后, +# 那时候隧道还没起来。 +if [ "${NETTUNE_SERVICE_CALL:-0}" = "1" ]; then + echo " 4) 由 nettune 服务调用,**跳过重启隧道**" + echo " (unit 里有 Before=lanremote-tunnel.service," + echo " 在这里 restart 会和 systemd 的顺序约束死锁)" + echo " (开机顺序本来就是本脚本先、隧道后,不需要这一步)" +elif [ "${SKIP_TUNNEL_RESTART:-0}" = "1" ]; then + echo " 4) SKIP_TUNNEL_RESTART=1,跳过重启隧道" +else + echo " 4) 让已经建立的隧道用上新参数"''' + + +def main() -> int: + raw = TUNE.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + if "NETTUNE_SERVICE_CALL" in text: + print(" · net-tune.sh 已经修过了") + elif OLD not in text: + print(" ✗ 找不到锚点") + return 1 + else: + text = text.replace(OLD, NEW, 1) + # 原来的 for 循环 + RESTARTED 逻辑要收进 else 分支 + # 找到 "echo \" 网络调优已应用" 之前的部分,补一个 fi + tail_anchor = 'echo " 网络调优已应用' + i = text.find(tail_anchor) + if i < 0: + print(" ✗ 找不到结尾锚点") + return 1 + # 在结尾那行之前插入 fi(缩进对齐 for 循环之外) + text = text[:i] + "fi\n\n" + text[i:] + TUNE.write_bytes((b"\xef\xbb\xbf" if bom else b"") + + text.encode("utf-8")) + print(" ✓ net-tune.sh 加了 NETTUNE_SERVICE_CALL 判断") + + # ---- 推到设备 + 给 unit 加环境变量 ---- + upload(str(TUNE), "/root/lanremote-relay/net-tune.sh") + print(" ✓ 已上传 net-tune.sh") + + unit = '''[Unit] +Description=ykxmz 网络调优(MSS 钳制 + 路径 MTU 探测) +After=network-online.target +Wants=network-online.target +Before=lanremote-relay.service lanremote-tunnel.service lanremote-tunnel-h2.service + +[Service] +Type=oneshot +RemainAfterExit=yes +# ⚠ NETTUNE_SERVICE_CALL=1 告诉 net-tune.sh:这次是**服务**在调它, +# 不是人工跑的。脚本据此跳过"重启隧道让新参数生效"那一步。 +# +# 为什么必须跳过:本 unit 有 Before=lanremote-tunnel.service, +# 脚本在自己还没结束时去 restart 隧道,systemd 会判定顺序冲突, +# restart 永久阻塞 -> nettune 卡在 activating -> 隧道起不来 -> 公网 530。 +# 实测卡过 9 分半。 +Environment=NETTUNE_SERVICE_CALL=1 +ExecStart=/bin/bash /root/lanremote-relay/net-tune.sh + +[Install] +WantedBy=multi-user.target +''' + import tempfile + tmp = Path(tempfile.gettempdir()) / "lanremote-nettune.service" + tmp.write_text(unit, encoding="utf-8", newline="\n") + upload(str(tmp), "/etc/systemd/system/lanremote-nettune.service") + print(" ✓ 已更新 unit(加了 NETTUNE_SERVICE_CALL=1)") + + out = run("bash -n /root/lanremote-relay/net-tune.sh && echo SH_OK; " + "systemctl daemon-reload && " + "systemctl restart lanremote-nettune && sleep 6 && " + "echo \"nettune: $(systemctl is-active lanremote-nettune)\"; " + "echo \"tunnel: $(systemctl is-active lanremote-tunnel)\"; " + "echo \"h2: $(systemctl is-active lanremote-tunnel-h2)\"; " + "echo \"公网: $(curl -s -o /dev/null -w '%{http_code}' " + "--max-time 20 https://relay.xxmxxs.top/v1/health)\"", + timeout=180) + for ln in out[1].strip().splitlines(): + print(" " + ln) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/test_net_tune.py b/_tests/test_net_tune.py index 5c9fbd4..10bb32e 100644 --- a/_tests/test_net_tune.py +++ b/_tests/test_net_tune.py @@ -102,5 +102,25 @@ def main() -> int: return 0 if failed == 0 else 1 + # ⚠ 绝不能由 nettune 服务去 restart 隧道 —— 那个 unit 有 + # Before=lanremote-tunnel.service,脚本在自己还没结束时 restart + # 隧道会和 systemd 的顺序约束死锁(实测卡 9 分半,表现是网站挂了 + # 而日志里什么都看不出来)。 + # 所以 net-tune.sh 必须认 NETTUNE_SERVICE_CALL 这个开关。 + tune_src = (ROOT / "relay" / "net-tune.sh").read_text(encoding="utf-8") + check("net-tune.sh 认 NETTUNE_SERVICE_CALL(防死锁)", + "NETTUNE_SERVICE_CALL" in tune_src, + "没有这个开关 -> 服务启动时会卡死") + + # 两个生成 unit 的地方也要带上 + for name in ("deploy.sh", "install.sh"): + p = ROOT / "relay" / name + if p.is_file(): + body = p.read_text(encoding="utf-8") + check("%s 的 nettune unit 带 NETTUNE_SERVICE_CALL" % name, + "NETTUNE_SERVICE_CALL" in body, + "新机器装出来还是会死锁") + + if __name__ == "__main__": sys.exit(main()) diff --git a/relay/deploy.sh b/relay/deploy.sh index afb2cdc..66c0081 100644 --- a/relay/deploy.sh +++ b/relay/deploy.sh @@ -555,6 +555,20 @@ RemainAfterExit=yes # 它会自己再去调 net-tune.sh(MSS / 接口 MTU / 路径探测的唯一出处)—— # 早先这里写了两条 ExecStart,两边都设一遍 MSS 和 MTU, # 结果"到底哪个值生效"很难查。现在收成一个入口。 +# +# ⚠ NETTUNE_SERVICE_CALL=1 告诉 net-tune.sh:这次是**服务**在调它, +# 不是人工跑的。脚本据此跳过"重启隧道让新参数生效"那一步。 +# +# 为什么必须跳过:本 unit 有 Before=lanremote-tunnel.service, +# 脚本在自己还没结束时去 restart 隧道,systemd 会判定顺序冲突 —— +# restart 永久阻塞、nettune 一直卡在 activating、隧道被 Before= +# 挡着永远起不来,表现就是"网站挂了",而日志里什么都看不出来。 +# +# 实测卡过 9 分半。手动跑 net-tune.sh 却完全正常(那时没有 Before= +# 约束),所以这个坑特别难发现 —— 设备上那份老 unit 因为 ExecStart +# 里的 `||` 先报错退出,反而把死锁遮住了,直到我把 unit 换成 +# 正确形状才露出来。 +Environment=NETTUNE_SERVICE_CALL=1 ExecStart=/bin/bash $RELAY_DIR/deploy-tune.sh [Install] diff --git a/relay/install.sh b/relay/install.sh index 4555f92..ded913e 100644 --- a/relay/install.sh +++ b/relay/install.sh @@ -314,6 +314,11 @@ Before=lanremote-relay.service lanremote-tunnel.service [Service] Type=oneshot RemainAfterExit=yes +# ⚠ NETTUNE_SERVICE_CALL=1 让 net-tune.sh 跳过"重启隧道"那一步。 +# 本 unit 有 Before=lanremote-tunnel.service,脚本在自己还没结束时 +# 去 restart 隧道会和 systemd 的顺序约束死锁 —— 实测卡过 9 分半, +# 表现是"网站挂了"而日志里什么都看不出来。 +Environment=NETTUNE_SERVICE_CALL=1 ExecStart=$RELAY_DIR/net-tune.sh [Install] diff --git a/relay/net-tune.sh b/relay/net-tune.sh index 4448fd0..3af8184 100644 --- a/relay/net-tune.sh +++ b/relay/net-tune.sh @@ -154,7 +154,33 @@ fi # 开机时不会走到这一步(nettune 服务 Before=lanremote-tunnel.service, # 规则先于隧道生效);但**手动重跑本脚本**时必须重建, # 否则改了等于没改。 -echo " 4) 让已经建立的隧道用上新参数" +# ════════════════════════════════════════════════════════════════════ +# 4) 让已经建立的隧道用上新参数 —— **只在手动跑的时候做** +# ════════════════════════════════════════════════════════════════════ +# +# ⚠ 这一步绝对不能由 lanremote-nettune.service 执行。 +# +# 那个 unit 里写着 Before=lanremote-tunnel.service +# (意思是"我必须先于隧道启动完")。如果脚本在自己还没结束的时候 +# 去 `systemctl restart lanremote-tunnel`,systemd 会认为存在 +# 顺序冲突 —— restart 永久阻塞、nettune 一直卡在 activating、 +# 隧道被 Before= 挡着永远起不来。 +# +# 实测卡了 9 分半,表现就是"网站又挂了",而日志里什么都看不出来 +# (只有 nettune 停在 activating)。手动跑本脚本却完全正常 +# (因为那时没有 Before= 约束)—— 所以这个坑特别难发现。 +# +# 开机时本来也不需要这一步:顺序就是 nettune 先、隧道后, +# 那时候隧道还没起来。 +if [ "${NETTUNE_SERVICE_CALL:-0}" = "1" ]; then + echo " 4) 由 nettune 服务调用,**跳过重启隧道**" + echo " (unit 里有 Before=lanremote-tunnel.service," + echo " 在这里 restart 会和 systemd 的顺序约束死锁)" + echo " (开机顺序本来就是本脚本先、隧道后,不需要这一步)" +elif [ "${SKIP_TUNNEL_RESTART:-0}" = "1" ]; then + echo " 4) SKIP_TUNNEL_RESTART=1,跳过重启隧道" +else + echo " 4) 让已经建立的隧道用上新参数" RESTARTED=0 for svc in lanremote-tunnel lanremote-tunnel-h2; do if command -v systemctl >/dev/null 2>&1 \ @@ -165,4 +191,6 @@ for svc in lanremote-tunnel lanremote-tunnel-h2; do done [ "$RESTARTED" = "0" ] && echo " 没有正在跑的隧道,跳过" +fi + echo " 网络调优已应用(MSS=$MSS_VALUE, MTU=$MTU_VALUE, 网卡=$IFACE)"