摘要
在压测我写的内网穿透工具时,遇到了一个很神奇的现象。客户端连接服务端,客户端当前连接已处于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的端口连接 - 言成言成啊
已经超过了允许的最大全连接队列容量,因此后续的连接内核直接丢弃了,这就出现服务端不存在这个连接的现象。