SSH 端口转发的延迟问题分析
tl;dr,OpenSSH 的 sshd 只在某个 session 通道跑过 shell、exec 或 subsystem 请求时,才会调用 set_nodelay()
给 ssh 所在的那条 TCP 连接关掉 Nagle。端口转发时若不开 session 通道,服务端就会一直不给它设 nodelay
JumpServer 用转发网关做 ssh 中转,和 ssh -N -L 一样都仅启用了 ssh 的端口转发,使得服务端不设值 nodelay。
在 RTT 较高的链路上表现为回包攒批、回显一顿一顿
背景
家里云上部署了个 JumpServer 作为访问各种小鸡的枢纽。JumpServer 有个叫网域的功能, 可以配置一台转发网关做 SSH 中转,用于访问内网服务,也可用于改善到目标服务器的访问质量
1 | 客户端 --> JumpServer -> 转发网关 --> 目标服务器 |
前两天给 JumpServer 配了台美西的 CN2-GIA 线路机,作为美国非优化线路小鸡的 SSH 中转。线路本身丢包率很低, RTT 升高后延迟变大属于预期,实际用起来却发现了一个问题:打字时回显一顿一顿,敲下去的字符会攒几个一起冒出来

首先怀疑的是 JumpServer 的实现有问题。JumpServer 的转发网关是借助 SSH 的 Local Port Forwarding 实现的, 它的 SSH 组件叫 koko,里面的 SSH 实现用的是 x/crypto/ssh,中转逻辑分三步:
- 调用
ssh.Dial连上转发网关,得到一个ssh.Client - 调用
ssh.Client.Dial连接目标节点的 SSH 端口,得到一条转发后的 TCP 流 - 调用
ssh.NewClientConn,在这条流上创建指向目标节点的 SSH 连接
具体实现参考 这里
这段代码中规中矩,静态分析下来一切正常
分析
| 地址 | 角色 |
|---|---|
| 10.10.10.10 | 客户端,跑 ssh 命令、按住键打字的那一侧 |
| 1.2.3.4 | 转发网关,也就是做 SSH 中转的那台机器 |
| 55.66.77.88 | 目标服务器,最终要登录的机器 |
复现
先把 koko 自身的变量排除掉,单独验证 golang 的 ssh 转发方式
我照着它的中转逻辑写了一个最小的 Go 工具,做的事情和 koko 一样:连上转发网关,让网关开一条到目标节点的 direct-tcpip 通道,
在这条通道上再跑一个 SSH 会话,相当于用 Go 重新实现一遍 ssh -L
工具在本地监听一个端口,验证方式和正常使用 JumpServer 一样:另开一个终端从它连进去,进到目标机后长按一个键看回显。 这个小工具上可稳定复现卡顿,那么问题出在这套转发方式本身,koko 自身的嫌疑就此排除
为了定位这是 golang ssh 的问题,还是 OpenSSH 的问题,我尝试使用纯 ssh 客户端来复现这个现象
先试试最常用的 ssh -L
1 | ssh -L 2222:55.66.77.88:22 [email protected] |
这条命令把本地的 2222 端口转发到目标机的 SSH 端口,同时登录转发网关并留在它的 shell 里。 另开一个终端,从本地的 2222 端口进目标机
1 | ssh -p 2222 [email protected] |
同样长按一个键,这次回显很跟手

通过观察转发网关上的 sshd 的日志,发现两者在转发上的差别只有一处:ssh -L 在外层连接上开了一个 session 通道,
这个通道跑的就是登录转发网关的那个 shell,小工具从头到尾只有 direct-tcpip。
为了坐实这个差别,用 -N 把 ssh -L 的 shell 去掉,只保留转发
1 | ssh -N -L 2222:55.66.77.88:22 [email protected] |
再从本地的 2222 端口进目标机,回显果然变回了经典的一顿一顿。到这里,基本可定位卡顿与否只取决于外层连接上有没有 session 通道
两条命令各用 -vvv 跑了一遍,日志如下
1 | // ssh -L |
1 | // ssh -N -L |
其中最重要的差异只有这个 session 通道。ssh -L 有,ssh -N -L 无。
两份日志里都有 fd 3 setting TCP_NODELAY,fd 3 对应 ssh 所在的那条 TCP 连接,fd 6 是 -L 在本地接受的
连接。客户端这侧在两种情况下都设了 TCP_NODELAY,差异只剩 session 通道这一处
抓包
少一个 session 通道为什么会导致卡顿呢?尝试从链路行为上找一下答案
卡顿表现为“客户端 -> 转发网关”的回包不连续,这一跳的 RTT 又是整条链路上最大的,先在这一跳抓包, 把转发网关发往客户端这一个方向的包捞出来
1 | tcpdump -nn -i eth0 host 10.10.10.10 and port 22 | grep '1.2.3.4.22 > 10.10.10.10.48720' |
抓包的对象是 -N 那条连接,从 2222 端口进去的会话停在 bash 提示符,按住 a 观察回显。抓到的片段如下
1 | 21:56:51.319876 IP 1.2.3.4.22 > 10.10.10.10.48720: Flags [P.], seq 1:85, ack 84, length 84 |
这 3.17 秒里,转发网关方向的数据包稳定在 588 到 672 字节,间隔 159 毫秒,中间夹着的纯 ACK 包每 40 毫秒 一个。按 ack 号推算,客户端上行一共 13356 字节,平均每 40 毫秒 168 字节,两个方向的总量都在 13.3 KB 左右
按住一个键产生的输入是连续的,回显应当跟着输入连续返回,抓包里的回显却攒成了每 159 毫秒一批。同时 ping 转发网关得到的 RTT 是 160 毫秒,跟回包间隔对得上,也就是一个 RTT 才发出一包
看到这“攒包发送”的表现,直接怀疑是 TCP_NODELAY 没启用导致的问题。但现在也只是怀疑,还得看看 OpenSSH 的代码是怎么实现的。
根因
转发网关的 OpenSSH 版本是 8.7p1,搞一份它的 源码,
搜一波在里面 set_nodelay 的调用点。能发现,作用在 ssh 所在的那条 TCP 连接上的只有一处:packet.c 的 ssh_packet_set_interactive()
1 | void |
set_nodelay 是无条件执行的,跟 interactive 这个参数无关。set_interactive_called 保证这个函数只执行一次,
TCP_NODELAY 本身又是 socket 级的标志,所以 ssh 所在的那条 TCP 连接上调用过一次之后,后面复用的所有通道都跟着受益
接下来就要定位 sshd 什么时候调用这个函数。顺着调用点往上找,服务端只有两处,都在 session.c 里,
分别是 do_exec_no_pty 和 do_exec_pty。这两个函数的入口是 do_exec,而 do_exec 由 session 通道上的
shell、exec、subsystem 三种请求触发
| 服务端收到的请求 | 服务端的动作 | ssh 所在的那条 TCP 连接上的 TCP_NODELAY |
|---|---|---|
session 通道 + shell / exec / subsystem |
进入 do_exec |
设置 |
session 通道 + pty-req |
分配 pty | 维持默认 |
direct-tcpip(端口转发) |
在 connect_next 里给出站 socket 设置 |
维持默认 |
到这里,现象和代码就对上了。有 session 通道并且跑过命令的 ssh 连接会设置 TCP_NODELAY,只做端口转发的 ssh 连接
则一直开着 Nagle,回包被攒成了一个 RTT 一批
端口转发里也有设置 TCP_NODELAY 的地方,只是设的是别的 socket。出站 socket 在 channels.c 的
connect_next 里设,-L 在本地接受的连接也是单独设的,ssh 所在的那条 TCP 连接自己留在 Nagle 状态
被压住的方向只有转发网关发往客户端这一个,上行方向一直正常,因为 koko 那侧是 Go 的 net.TCPConn,
newTCPConn 里默认调用了 setNoDelay(true)
解决方法
原因清楚了,解决方向也就出来了:只要 ssh 所在的那条 TCP 连接上出现过一次 session 通道的 shell、exec、
subsystem 请求,转发网关的 sshd 就会把 TCP_NODELAY 设上。所以在连接建立之后,可以先开一个 session
通道并请求一个 shell,再去做后面的端口转发,后面复用的通道就都落在已经关掉 Nagle 的那条 TCP 连接上
具体到 koko 层面,则可以用下面这段代码来使用转发网关的 sshd 配置 TCP_NODELAY
1 | func fixProxySshClientDialConnectionLag(proxyClient *SSHClient) (func(), error) { |
这个 session 的作用只是让转发网关的 sshd 调用一次 set_nodelay()。TCP_NODELAY 设上之后就留在这条 TCP 连接上,
sshd 侧的 set_interactive_called 已经标记过,所以这个 session 用完可以直接关掉,之后复用这条 TCP 连接的
转发继续沿用这个状态
手动用 OpenSSH 客户端做同样的转发时,去掉 -N 就是同一个思路的现成用法:外层连接上会带一个 shell,
转发网关的 sshd 因此给 ssh 所在的那条 TCP 连接设上了 TCP_NODELAY,后面复用这条连接的转发都跟着受益
环境信息
- 转发网关:OpenSSH_8.7p1(RHEL 9.5 自带)
- koko 版本:v4.10.1-ce
- GoLang 版本:1.24.1
参考
搜这个问题的时候也翻到过几处社区里的记录,时间跨度挺大,说明这个行为在 OpenSSH 里存在很久了
-
2002 年的邮件列表里讨论过给 Nagle 加一个开关,也有人认为转发的 TCP 端口和 X11 应该关掉 Nagle, 这个改动最后没有合进代码(openssh 邮件存档)
-
2003 年的 openssh bug 556 说的就是端口转发时
TCP_NODELAY只设到了转发端口上,服务端发往客户端的数据被缓存,形成 bursting effect,状态至今是 NEW