你刚在远程服务器上执行了一个耗时两小时的数据库迁移脚本,眼看就要完成,切回终端一看——client_loop: send disconnect: Broken pipe。脚本进程随SSH会话一起灰飞烟灭,所有进度化为乌有。

这不是运气问题,而是TCP协议、NAT设备、防火墙三者在幕后精密协作的结果。要彻底解决这个问题,需要理解连接断开究竟发生在哪一层。

那个被你忽视的"空闲"连接

SSH连接本质上是一条TCP长连接。TCP协议设计之初假设的是:连接建立后双方会持续通信。如果一个连接长时间没有任何数据交换,TCP协议栈本身并不会主动断开它——理论上,一条TCP连接可以在没有任何数据传输的情况下永远存活。

但现实世界不允许这样做。

中间的网络设备需要回收资源。NAT路由器需要维护一张连接表,记录内网IP端口到公网IP端口的映射关系。防火墙需要追踪每条通过它的连接状态。这些设备的内存是有限的,不可能为一条"看起来已经死了"的连接永久保留状态条目。

这就是问题的根源:你的SSH客户端和服务器都认为连接还活着,但中间的NAT设备或防火墙已经把这条连接从它的状态表中删除了。

当你再次敲击键盘时,数据包从客户端发出,经过NAT设备——NAT设备翻遍整张表也找不到对应的映射关系,于是这个数据包被悄无声息地丢弃。服务器的TCP栈永远收不到这个包,客户端也永远等不到响应。终端就这样"冻结"了。

TCP Keepalive:为什么默认两小时根本不够用?

TCP协议本身提供了保活机制(TCP Keepalive)。当连接空闲超过一定时间后,TCP栈会自动发送一个保活探测包,对方收到后必须响应ACK。如果连续多次探测失败,TCP栈才会认为连接已断开。

问题在于默认参数。在Linux系统上,这三个关键参数的默认值如下:

net.ipv4.tcp_keepalive_time = 7200    # 首次保活探测前的空闲时间(秒)
net.ipv4.tcp_keepalive_intvl = 75     # 保活探测之间的间隔(秒)
net.ipv4.tcp_keepalive_probes = 9     # 探测失败多少次后认为连接断开

tcp_keepalive_time 默认为7200秒——整整两个小时。这个数值源自 RFC 1122 的规定:保活探测的默认间隔不得少于两小时

RFC 1122 写于1989年,当时的互联网环境与今天截然不同。文档中解释了为什么选择这么长的间隔:

TCP规范本身不包含保活机制,因为它可能:(1)在暂时的网络故障中错误地断开有效连接;(2)消耗不必要的带宽;(3)对于按数据包计费的网络路径产生费用。

这个设计哲学假设的是:如果没有人使用连接,为什么要关心它是否还有效?

但今天的情况完全不同。NAT无处不在,防火墙广泛部署,连接经过的每一跳都可能有自己的空闲超时。两小时的保活间隔,根本无法阻止中间设备提前清理连接状态。

SSH应用层心跳 vs TCP层保活:哪个更靠谱?

OpenSSH提供了两种保活机制,它们工作在不同层面:

TCPKeepAlive(SSH配置中的TCPKeepAlive选项):

  • 工作在TCP协议层
  • 由操作系统内核实现
  • 使用系统默认的保活参数(默认两小时)
  • 保活包内容为空,不经过SSH加密
  • 可能被中间设备伪造或篡改

ServerAliveInterval / ClientAliveInterval

  • 工作在SSH应用层
  • 由OpenSSH进程实现
  • 保活包通过SSH加密通道发送,不可伪造
  • 可以在SSH配置中灵活设置间隔

从OpenSSH官方手册的描述可以看出两者的关键区别:

Client alive messages通过加密通道发送,因此不可被伪造。TCPKeepAlive启用的TCP保活选项则是可伪造的。当客户端或服务器需要知道连接何时变得无响应时,client alive机制非常有价值。

更实际的区别在于:TCP层保活受系统全局参数控制,而SSH应用层心跳可以针对每个连接单独配置,且不受NAT设备对TCP保活包的干扰。

NAT超时:真正的幕后黑手

网络地址转换(NAT)设备为了节省资源,会清理长时间没有数据传输的连接表项。不同类型的NAT设备,超时时间差异巨大:

设备类型 典型空闲超时 说明
家庭路由器 5-30分钟 视具体型号和固件而定
企业防火墙 15-60分钟 可配置,部分默认较短
云NAT网关 4-20分钟 AWS默认350秒,Azure默认4分钟
运营商CGN 30分钟-2小时 可能违反RFC规定

RFC 5382 明确规定:NAT设备对于已建立的TCP连接,其空闲超时不得少于2小时4分钟。然而现实中,大量NAT实现违反这一规定。有开发者专门编写了测试工具来检测运营商CGN是否合规,结果显示许多ISP的NAT超时只有1小时甚至更短。

一个典型案例:某开发者发现SSH连接总是在约60分钟后断开。经过排查,发现是ISP提供的"光猫"设备在桥接模式下仍然进行连接跟踪,且空闲超时设置为1小时——远低于RFC要求的2小时4分钟。

保活间隔应该设多少?

要让SSH连接穿越各种NAT和防火墙,保活间隔必须小于路径上所有中间设备的最短空闲超时。

一个经验值:60秒

为什么不是更短?理论上越短越好,但实际上:

  • 每分钟发送一个小数据包,带宽消耗可以忽略不计
  • 过于频繁的心跳可能被某些网络设备识别为异常流量
  • 移动网络环境下,频繁唤醒无线电模块会增加耗电

以下是推荐的SSH客户端配置(~/.ssh/config):

Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3

这个配置的含义是:每60秒发送一次心跳,如果连续3次没有收到响应,则断开连接。总的检测时间是 60 × 3 = 180秒

服务器端配置(/etc/ssh/sshd_config):

ClientAliveInterval 60
ClientAliveCountMax 3

注意:服务器端的配置主要作用是主动断开无响应的客户端,而不是防止被中间设备断开。但从另一个角度,如果客户端已经断开(比如网络中断),服务器端主动清理僵尸连接也是有益的。

各种客户端的配置方法

命令行参数(临时使用)

ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=3 user@host

OpenSSH配置文件(永久配置)

~/.ssh/config 中添加:

Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3

# 针对特定主机
Host my-server.example.com
    ServerAliveInterval 30
    ServerAliveCountMax 5

Windows PuTTY

进入 Connection 配置项,设置 “Seconds between keepalives” 为 60。

Windows MobaXterm

Settings → Configuration → SSH → SSH keepalive,设置为 60 秒。

移动端 Termius

进入 Host 设置,找到 Keep alive interval 选项,设置为 60 秒。

注意:iOS系统对后台应用有严格限制,即使设置了心跳,应用进入后台后几分钟后仍会被系统暂停。这是操作系统层面的限制,不是SSH客户端能解决的。

进阶方案:当心跳也无法拯救你的连接

有些场景下,单纯的心跳机制仍然不够。比如:

网络切换:从WiFi切换到移动数据,IP地址发生变化,原有TCP连接必然断开。

极端弱网:丢包率过高,心跳包本身都无法送达。

长时间断网:网络完全中断超过几分钟,TCP连接状态在两端都已经超时。

针对这些场景,需要更强大的工具:

Mosh:移动终端的救星

Mosh(Mobile Shell)是专门为解决移动网络环境下的连接问题而设计的SSH替代品。它使用UDP协议和自定义的状态同步协议(SSP),具有以下特点:

  • 漫游支持:IP地址变化时连接不中断
  • 预测本地回显:高延迟环境下操作更流畅
  • 断网恢复:网络恢复后自动继续会话

Mosh的工作原理是:首先通过SSH建立连接,然后在服务器上启动mosh-server进程,客户端通过UDP与服务器通信。由于UDP是无连接的,只要客户端能重新找到服务器,会话就能继续。

使用方法:

# 安装(服务器和客户端都需要)
apt install mosh  # Debian/Ubuntu
brew install mosh # macOS

# 连接
mosh user@host

autossh:自动重连的SSH隧道

autossh是一个SSH连接监控工具,能够在连接断开时自动重新建立连接。它特别适合需要维护长期SSH隧道的场景。

# 基本用法
autossh -M 0 -o "ServerAliveInterval 60" -o "ServerAliveCountMax 3" -N -R 2222:localhost:22 user@jump-server

-M 0 参数关闭autossh自带的老式监控端口,改用SSH原生的保活机制。配合Systemd可以创建可靠的后台隧道服务。

tmux / screen:会话持久化

无论连接多么稳定,总会遇到不可抗力的断开情况。最有效的保护措施是:让关键进程与SSH会话解耦

tmux和screen是终端复用器,它们创建的会话运行在服务器上,与SSH连接无关。即使SSH断开,会话内的进程仍在后台运行。重新连接后,可以"attach"回原来的会话,一切如初。

# 创建新会话
tmux new -s work

# 在会话内运行长时间任务
./long-running-script.sh

# 分离会话:Ctrl+B, D

# 重新连接后恢复
tmux attach -t work

VPN环境下的特殊情况

SSH over VPN 的断开问题更复杂。VPN本身就是一个隧道,SSH连接经过VPN时实际上是被"双重封装"的。

常见问题:

  1. VPN自身的保活机制与SSH心跳冲突:WireGuard默认每3分钟发送一次keepalive,如果SSH心跳间隔设置得更长,VPN隧道可能先断开。

  2. MTU问题:VPN封装增加了额外的头部开销,如果SSH数据包加上VPN头超过了路径MTU,会导致分片或丢弃。表现为连接"冻结"而不是断开。

  3. VPN路由切换:VPN断开后重连可能获得不同的IP,原有SSH连接无法恢复。

解决方案:

  • VPN层面的保活间隔应短于SSH心跳
  • 检查并调整MTU:ping -M do -s 1472 target_host 测试
  • 使用Mosh代替SSH

从Broken Pipe到Connection Reset:错误信息解读

SSH断开时会显示不同的错误信息,它们暗示了不同的断开原因:

错误信息 可能原因
Broken pipe 写入已关闭的连接,通常是被NAT/防火墙断开
Connection reset by peer 对端发送了TCP RST包,可能是服务器主动关闭或中间设备干预
Connection timed out 连接尝试超时,可能是网络不通或防火墙静默丢弃
Write failed: Broken pipe 与Broken pipe类似,发生在发送数据时

当看到"Broken pipe"时,几乎可以确定是中间设备清理了连接状态。此时检查保活配置是首要任务。

一个完整的排查流程

  1. 确认问题:连接在空闲多长时间后断开?固定时间还是随机?

  2. 检查SSH心跳配置

    # 客户端
    ssh -G host | grep -i alive
    
    # 服务器端
    sudo sshd -T | grep -i alive
    
  3. 检查系统TCP保活参数

    sysctl net.ipv4.tcp_keepalive_time
    sysctl net.ipv4.tcp_keepalive_intvl
    sysctl net.ipv4.tcp_keepalive_probes
    
  4. 抓包分析

    # 在客户端
    tcpdump -i any port 22 -w ssh.pcap
    

    观察是否有心跳包发送,是否有RST包返回。

  5. 检测NAT超时: 使用 Anders Trier 开发的 NAT-TCP-test 工具测试你的网络环境。

写在最后

SSH连接断开看起来是个小问题,但它折射出网络协议栈的多层复杂性。TCP协议的保守设计、NAT设备的资源约束、防火墙的安全策略,每一层都在"帮助"你清理"不需要"的连接。

理解这些机制后,解决方案其实很简单:定期发送数据包,让中间设备知道这条连接还活着。60秒的心跳间隔足以应对绝大多数场景。

如果希望更健壮的连接,考虑Mosh。如果希望任务不受连接断开影响,使用tmux。不要让一条断开的SSH连接毁掉你几小时的工作成果。


参考资料

  1. RFC 1122 - Requirements for Internet Hosts - Communication Layers, Section 4.2.3.6 TCP Keep-Alives
  2. RFC 5382 - NAT Behavioral Requirements for TCP
  3. RFC 4254 - The Secure Shell (SSH) Connection Protocol
  4. OpenSSH Manual Pages - ssh_config(5), sshd_config(5)
  5. TCP Keepalive HOWTO - The Linux Documentation Project
  6. Anders Trier, “My ISP Is Killing My Idle SSH Sessions. Yours Might Be Too.”
  7. Mosh: the mobile shell - https://mosh.org/
  8. AWS Networking Blog, “Implementing long-running TCP Connections within VPC networking”