从“自动恢复”到“删除恢复器”:一次 SSH 双跳事故复盘
背景:一个本来很小的问题
这次改造最初只有一个目标:在 Local Machine 上保留一个稳定的 SOCKS5 入口,给应用流量使用。
流量入口是 127.0.0.1:1080,链路是:
Local Machine -> Entry Server -> Exit Server -> Internet
Entry Server 只负责跳板,Exit Server 只负责出口。
最初的双跳:一个 SSH 进程就够了
第一版只有一条命令:
ssh -N -D 127.0.0.1:1080 exit
这个版本很直接:一个 SSH 进程、一个本地端口、一个固定路径。
问题也很直接:合盖、网络切换、Wi-Fi 抖动后,隧道会断,需要手动重连。
最开始我一直把这当成偶发问题。后来它开始变得更频繁:有时合盖后复现,有时网络切换后复现,有时甚至什么都没做也会卡死。真正危险的不是某一次故障,而是我开始逐渐失去对系统状态的确定性。
我是怎么把它一步步做复杂的
后续每一步在当时看都合理:
- 手动重连太频繁,加
autossh - 想开机自动拉起,加
launchd - 想处理唤醒后断连,加
sleepwatcher - 想确认 1080 不是假活,加 health check
- 想在探测失败后自动修复,加 watchdog
- 甚至一度考虑/尝试过让 Entry Server 周期性重启
sshd
问题不是某一步“明显错误”,而是这些局部最优叠加后,系统里出现了多个同时有恢复权限的组件。
这些自动恢复器最危险的地方在于,它们在前期确实会提升体验。第一次自动重连成功时,我也会觉得系统“更高级”了。问题在于,当恢复逻辑互相叠加后,复杂度增长速度会快过人的理解速度。
故障不是断线,而是状态混乱
真正把我拖进故障现场的,不是一次“彻底断线”,而是一种很诡异的状态:看起来都活着,但就是不工作。
一次典型过程是这样的。
我先发现网页打不开,但 Shadowrocket 还显示 SOCKS 已连接。我第一反应是出口抖动,先等一会。几分钟后还是不通,于是去看本地端口:lsof -i :1080 还能看到 SSH 在 LISTEN。
这时候直觉会告诉你“进程在,端口在,应该只是慢”。但我跑 curl --socks5-hostname 127.0.0.1:1080 ...,请求直接超时。
接下来我开始手动 restart tunnel。第一次通常会短暂恢复,但很快又坏。然后状态变得越来越难理解:有时 1080 已经被旧 SSH 占着;有时 autossh 已经悄悄拉起新 SSH;有时 launchd KeepAlive 又在后台再起一个;有时 sleepwatcher 的唤醒动作又刚好撞上 health check 的 kill/restart。
最后我已经分不清一件最基本的事:现在到底是谁在控制 tunnel。
最严重的问题:控制面也失效了
最开始我还以为只是 SOCKS 不通,范围只在代理层。
后来我发现事情升级了:ssh entry 也开始失败,终端里直接出现 connection closed。不是偶发一次,而是连续几次都上不去。
那一刻压力很真实,因为控制面也掉了。你不只是“代理坏了”,而是连用 SSH 进去修 SSH 的能力都不稳定。
最后我只能打开 Provider Console 手动重启 sshd。到这一步我才确认:故障源已经不是单点网络抖动,而是恢复系统本身在制造故障。
误判:我以为是服务器的 sshd 坏了
我当时的误判链也很典型。
第一步,我怀疑 Entry Server 的 sshd 不稳定。理由很“充分”:每次重启 sshd 后,问题经常会暂时恢复。
第二步,我顺着这个判断走,开始在服务端加 watchdog,反复清理/重启 SSH 服务,甚至认真考虑“定时重启 sshd”。
现在回看,关键误读在这里:我把“重启后短暂恢复”解释成“sshd 被修好了”。
实际上,重启动作很可能只是强制清掉了旧 session 和异常 TCP 状态。也就是说,我误把“清状态”理解成了“修服务”。
清空:先删掉所有恢复器
排查转折点是先清空本地自动化,再观察基线。
本机清理动作:
- unload tunnel launchd
- 删除 tunnel plist
- 删除 health check plist
- 停掉
sleepwatcher - 删除 wakeup/sleep 脚本
- 删除 autossh 脚本
- 卸载
autossh
清理后确认项:
lsof -i :1080无旧监听launchctl list中无 tunnel 项pgrep -af autossh无输出- 只剩系统
ssh-agent
重建:Entry Server 只做 SSH bastion
随后清理 Entry Server,让职责回到最小:
- 删除 SSH watchdog service
- 删除 SSH restart timer
- 删除 watchdog shell script
- 执行
systemctl daemon-reload sshd_config回到极简- 仅保留一个 SSH 监听端口
- firewall 只保留必要端口
做完后,Entry Server 回到“纯 SSH bastion”角色,不再承担自恢复编排。
关键发现:基础设施流量必须 DIRECT
这是本次复盘里最关键的发现。
Shadowrocket 应该代理的是应用流量,不是基础设施流量。Entry Server、Exit Server、Blog Server、Lab Server 都必须强制 DIRECT。
我后来才确认,在旧 VPN 和 Shadowrocket 规则残留同时存在时,SSH 控制连接自己也可能正在走代理。
也就是说,我以为自己在直连 entry,实际上 SSH 可能已经先进入 SOCKS。然后 tunnel 又在这个路径里再建 tunnel,形成嵌套。
常见异常就是:旧 1080 连接还被 Shadowrocket 持有,新 SSH 又试图占用同一个 1080。表面看像“偶发不稳定”,实际是路径已经不再线性。
把基础设施地址提到规则最前面的 DIRECT 之后,变化是立刻可见的:
ssh entry延迟明显下降- tunnel 重建速度明显变快
- 之前很多“玄学不稳定”直接消失
到这一步我才第一次认真怀疑:前面不少问题并不是 SSH 本身,而是路径嵌套。
验证:双跳链路本身是健康的
清空和规则修正后,我做了连续验证:
- 普通
ssh entry可稳定登录 - 普通
ssh exit可经由 Entry Server 登录 - 手动启动
ssh -N -D 127.0.0.1:1080 exit curl --socks5-hostname 127.0.0.1:1080 https://api.ip.sb返回 Exit Server 出口70MB文件完整下载100MB文件完整下载
验证重点不是峰值速度,而是连续传输过程中没有断流、没有 reset、没有再次触发 sshd 失联。
这组证据说明:双跳路径本身可用,问题主要出在后面叠加的恢复层。
最终方案:半自动恢复
最终方案不是纯手动,而是低复杂度半自动:
- 平时用普通 SSH 在后台运行 tunnel
- 不使用
autossh - 不使用
launchd自愈 - 不使用 watchdog
- 不使用
sleepwatcher - 只保留一个
tunnelalias
后台启动命令:
nohup ssh -o ExitOnForwardFailure=yes -N -D 127.0.0.1:1080 exit >/tmp/tunnel.log 2>&1 &
alias(先清旧进程,再起新连接):
alias tunnel='pkill -f "127.0.0.1:1080" 2>/dev/null; sleep 1; nohup ssh -o ExitOnForwardFailure=yes -N -D 127.0.0.1:1080 exit >/tmp/tunnel.log 2>&1 &'
我最后接受了“恢复需要人工参与”。但这个人工流程必须满足四个条件:快、固定、可重复、不需要猜。
现在断线后的动作固定为:
- 关 Shadowrocket
- 执行
tunnel - 开 Shadowrocket
它不再需要我去猜谁在重连,也不再需要等 watchdog、看 launchd、进 Provider Console、重启 sshd。
删掉这层自动化之后,系统重新变得线性:断线就是断线,重连就是重连。我可以明确知道哪个进程负责 tunnel,哪个端口负责 SOCKS,哪个动作会触发恢复,哪一步失败了。系统重新回到了人的理解范围。
我最后删掉了什么
我最后删掉的是“多恢复器并发决策机制”,包括:
- 自动重连协调(
autossh+launchd+ watchdog) - 唤醒触发恢复(
sleepwatcher) - 服务端周期性重启 SSH 的兜底思路
我保留的是最小恢复动作:一个明确的端口、一个明确的命令、一个明确的重建入口。
和《简单的事》的关系
《简单的事》讲的是原则。
这篇文章的目标不是介绍 SSH 双跳,而是用 SSH 双跳这个具体事故证明《简单的事》那篇文章的观点。
如果你还没读过,可以先看《简单的事》。
结论:删除机制也是工程能力
“真正危险的不是断线,而是不知道系统正在做什么。”
在这个场景里,我最后接受了一个更朴素的判断:能在 30 秒内被人稳定恢复、并且恢复过程完全可解释的系统,比一个看似无人值守但内部状态不可见的系统更可靠。