Files
ykxmz/_build/fix_nettune_deadlock.py
T
xmz7891 6adf7acbfb 修「网站容易挂」的真因: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 个阶段全部通过。
2026-10-08 18:56:44 +08:00

149 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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())