SSH 端口转发的延迟问题分析

tl;dr,OpenSSH 的 sshd 只在某个 session 通道跑过 shellexecsubsystem 请求时,才会调用 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,中转逻辑分三步:

  1. 调用 ssh.Dial 连上转发网关,得到一个 ssh.Client
  2. 调用 ssh.Client.Dial 连接目标节点的 SSH 端口,得到一条转发后的 TCP 流
  3. 调用 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]

同样长按一个键,这次回显很跟手

带 shell 时的回显

通过观察转发网关上的 sshd 的日志,发现两者在转发上的差别只有一处:ssh -L 在外层连接上开了一个 session 通道, 这个通道跑的就是登录转发网关的那个 shell,小工具从头到尾只有 direct-tcpip

为了坐实这个差别,用 -Nssh -L 的 shell 去掉,只保留转发

1
ssh -N -L 2222:55.66.77.88:22 [email protected]

再从本地的 2222 端口进目标机,回显果然变回了经典的一顿一顿。到这里,基本可定位卡顿与否只取决于外层连接上有没有 session 通道

两条命令各用 -vvv 跑了一遍,日志如下

1
2
3
4
5
6
7
// ssh -L
debug1: channel 2: new session [client-session]
debug2: channel 2: send open
debug2: channel 2: request pty-req confirm 1
debug2: channel 2: request shell confirm 1
debug2: shell request accepted on channel 2
debug1: channel 3: new direct-tcpip [direct-tcpip]
1
2
3
4
5
6
7
8
// ssh -N -L
debug1: Local connections to LOCALHOST:2222 forwarded to remote address 55.66.77.88:22
debug1: channel 0: new port-listener [port listener]
debug2: fd 3 setting TCP_NODELAY
debug1: Entering interactive session.
debug1: Connection to port 2222 forwarding to 55.66.77.88 port 22 requested.
debug2: fd 6 setting TCP_NODELAY
debug1: channel 2: new direct-tcpip [direct-tcpip]

其中最重要的差异只有这个 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
2
3
4
5
6
7
8
9
21:56:51.319876 IP 1.2.3.4.22 > 10.10.10.10.48720: Flags [P.], seq 1:85, ack 84, length 84
21:56:51.358738 IP 1.2.3.4.22 > 10.10.10.10.48720: Flags [.], ack 252, length 0
21:56:51.398767 IP 1.2.3.4.22 > 10.10.10.10.48720: Flags [.], ack 420, length 0
21:56:51.437591 IP 1.2.3.4.22 > 10.10.10.10.48720: Flags [.], ack 588, length 0
21:56:51.468702 IP 1.2.3.4.22 > 10.10.10.10.48720: Flags [P.], seq 85:673, ack 672, length 588
21:56:51.497663 IP 1.2.3.4.22 > 10.10.10.10.48720: Flags [.], ack 840, length 0
21:56:51.539631 IP 1.2.3.4.22 > 10.10.10.10.48720: Flags [.], ack 1008, length 0
21:56:51.579487 IP 1.2.3.4.22 > 10.10.10.10.48720: Flags [.], ack 1176, length 0
21:56:51.619685 IP 1.2.3.4.22 > 10.10.10.10.48720: Flags [P.], seq 673:1261, ack 1344, length 588

这 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.cssh_packet_set_interactive()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void
ssh_packet_set_interactive(struct ssh *ssh, int interactive, int qos_interactive, int qos_bulk)
{
struct session_state *state = ssh->state;

if (state->set_interactive_called)
return;
state->set_interactive_called = 1;

state->interactive_mode = interactive;

if (!ssh_packet_connection_is_on_socket(ssh))
return;
set_nodelay(state->connection_in);
ssh_packet_set_tos(ssh, interactive ? qos_interactive : qos_bulk);
}

set_nodelay 是无条件执行的,跟 interactive 这个参数无关。set_interactive_called 保证这个函数只执行一次, TCP_NODELAY 本身又是 socket 级的标志,所以 ssh 所在的那条 TCP 连接上调用过一次之后,后面复用的所有通道都跟着受益

接下来就要定位 sshd 什么时候调用这个函数。顺着调用点往上找,服务端只有两处,都在 session.c 里, 分别是 do_exec_no_ptydo_exec_pty。这两个函数的入口是 do_exec,而 do_execsession 通道上的 shellexecsubsystem 三种请求触发

服务端收到的请求 服务端的动作 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.cconnect_next 里设,-L 在本地接受的连接也是单独设的,ssh 所在的那条 TCP 连接自己留在 Nagle 状态

被压住的方向只有转发网关发往客户端这一个,上行方向一直正常,因为 koko 那侧是 Go 的 net.TCPConnnewTCPConn 里默认调用了 setNoDelay(true)

解决方法

原因清楚了,解决方向也就出来了:只要 ssh 所在的那条 TCP 连接上出现过一次 session 通道的 shellexecsubsystem 请求,转发网关的 sshd 就会把 TCP_NODELAY 设上。所以在连接建立之后,可以先开一个 session 通道并请求一个 shell,再去做后面的端口转发,后面复用的通道就都落在已经关掉 Nagle 的那条 TCP 连接上

具体到 koko 层面,则可以用下面这段代码来使用转发网关的 sshd 配置 TCP_NODELAY

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func fixProxySshClientDialConnectionLag(proxyClient *SSHClient) (func(), error) {
ses, err := proxyClient.NewSession()
if err != nil {
return nil, err
}

err = ses.Shell() // 创建一个 shell session
if err != nil {
return nil, err
}

return func() {
_ = ses.Close()
}, nil
}

这个 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