摘要
在压测我写的内网穿透工具时,遇到了一个很神奇的现象。客户端连接服务端,客户端当前连接已处于ESTABLISHED状态,但是服务端却没有该连接,并且程序也没有收到该连接的日志。
本文记录下该神奇现象的成因,以及背后的原理。
正文
一、原因定位 该问题是偶然发现的,起源于内网穿透在高并发时,DataProxyServer返回给User数据时,偶现数据丢失。 · Issue #8 · meethigher/tcp-reverse-proxy 。
复现步骤
开启TunnelServer 开启TunnelClient 开启自定义BackendServer。简单实现一个长连接TcpServer,连接进来之后,返回一段字符串即可。 BackendServer源码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import io.vertx.core.Vertx ;
import io.vertx.core.buffer.Buffer ;
import io.vertx.core.net.NetServer ;
public class BackendServer {
public static void main ( String [] args ) {
Vertx vertx = Vertx . vertx ();
NetServer netServer = vertx . createNetServer ();
netServer . connectHandler ( socket -> {
socket . remoteAddress ();
socket . write ( Buffer . buffer ( "SSH-2.0-OpenSSH_8.7" )). onComplete ( ar -> {
System . out . println ( socket . remoteAddress (). toString () + " write " + ar . succeeded ());
});
}). listen ( 23 ). onComplete ( ar -> {
if ( ar . succeeded ()) {
System . out . println ( "Server started on port " + ar . result (). actualPort ());
} else {
System . err . println ( "Server failed to start" );
ar . cause (). printStackTrace ();
System . exit ( 1 );
}
});
}
}
客户端与TunnelServer建立2000个长连接,会出现一种情况:客户端显示连接已经处于ESTABLISHED,但是服务端并没有该连接。
展开
那简单,tcp抓包。发现客户端和服务端的三次握手均成功了。
展开
那么,就得去了解三次握手建立连接的这个过程了。
展开
对于客户端来说,当三次握手后,连接就会进入ESTABLISHED。
对于服务端来说,当三次握手后,操作系统内核会将连接放入到全连接队列AcceptQueue,此时连接会进入ESTABLISHED。若放入失败,则丢弃该连接或者发送RST。如果accept()过慢、连接建立过快。就会出现连接丢失的问题。而客户端的连接却仍然处于ESTABLISHED。
解决办法如下
优化程序,提升accept()速度。 调大全连接队列AcceptQueue的参数值。临时立即生效sysctl -w net.core.somaxconn=5000 ,重启会失效。不过切记在Linux中,全连接队列的大小为min(backlog,net.core.somaxconn) 设置若超过AcceptQueue最大值,则向客户端发送RST中止连接。临时立即生效sysctl -w net.ipv4.tcp_abort_on_overflow=1,重启会失效。 但是第三个方法有点鸡肋,即使AcceptQueue满了,也不一定会触发。因此保险起见,还是调大AcceptQueue比较好。
二、简单复现 上面的复现步骤较为麻烦,我这边直接写一个更简单的示例。
源码meethigher/bug-test at close-wait-server
服务端10.0.0.10代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.net.InetSocketAddress ;
import java.net.ServerSocket ;
import java.util.concurrent.locks.LockSupport ;
public class AcceptQueueTest {
public static void main ( String [] args ) throws Exception {
/**
* Windows与Linux的机制不同,使用Linux进行复现
*/
ServerSocket serverSocket = new ServerSocket ();
// net.core.somaxconn=128,这时候全连接队列为min(5,net.core.somaxconn)=5,也就是说,同时只允许5个连接ESTAB
// 实际由于Linux的延迟判定,可能会多于5个
serverSocket . bind ( new InetSocketAddress ( "0.0.0.0" , 6666 ), 5 );
LockSupport . park ();
}
}
客户端10.0.0.20代码
batch_telnet.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env bash
# 连接数
connNum = 20
# host:port 列表
target = "10.0.0.10:6666"
for (( i = 0; i < $connNum ; i++)) ; do
host = " ${ target %%:* } "
port = " ${ target ##*: } "
echo "telnet $host $port ..."
nohup ./telnet_keepalive.expect " $host " " $port " >/dev/null 2>& 1 &
echo " $! "
done
echo "all started"
telnet_keepalive.expect
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env expect
# 获取命令行参数
set timeout -1
set host [ lindex $argv 0]
set port [ lindex $argv 1]
# 开始 telnet 连接
spawn telnet $host $port
# 保持连接:每隔一段时间发送一个空命令或心跳
while { 1} {
sleep 30
# send "\r"
}
服务端启动服务,客户端执行./batch_telnet.sh
展开
上图,就出现了“客户端连接已建立,但服务端却说没连上。”
我们通过ss -nplt命令查看,可知全连接队列数5,等待accpet()的连接数6。这个命令的使用可以参考TCP与UDP的端口连接 - 言成言成啊
展开
已经超过了允许的最大全连接队列容量,因此后续的连接内核直接丢弃了,这就出现服务端不存在这个连接的现象。