记一次 Kotlin Ktor 库的 PR 提交记录(TCP 自连接)原创
前言
去年 techday 上有幸邀请到了 Go 夜读的大佬杨文,他跟我们分享了开源的主题,深受鼓舞。正好前段时间测试 Kotlin 的官方库 ktor 发现了一个很冷门的问题,于是提了一个 PR。经过了一个月漫长的等待,终于有人 review 并 merge 到 master 分支了,估计下个 release 就可以看到了。
这里记录了一下相关的内容,PR 地址在这里:https://github.com/ktorio/ktor/pull/2237
文章的主要内容是:
- TCP 自连接是什么
- Linux 高版本内核 connect、bind(0) 端口号分配的奇偶性分析
- 如何修复 TCP 自连接的代码
1.背景说明
这个 PR 是什么?
复现的代码如下,getAvailablePort() 用于寻找一个可用的偶数的端口号,测试例子中返回的是 42064。至于为什么这里的端口号要是偶数,我后面会重点说。
fun testSelfConnect() {
runBlocking {
// Find a port that would be used as a local address.
val port = getAvailablePort()
val tcpSocketBuilder = aSocket(ActorSelectorManager(Dispatchers.IO)).tcp()
// Try to connect to that address repeatedly.
for (i in 0 until 100000) {
try {
val socket = tcpSocketBuilder.connect(InetSocketAddress("127.0.0.1", port))
println("connect to self succeed: ${socket.localAddress} to ${socket.remoteAddress}")
System.`in`.read()
break
} catch (ex: Exception) {
// ignore
}
}
}
}
运行上面的代码,会出现一个源端口号和目标端口号一样的连接。
这显然不正常,如果这种情况发生了,如果服务端程序就无法再监听 42064 端口。这个问题的本质就是 TCP 的自连接,这个 PR 就是为了解决这个问题。接下来我们来看什么是 TCP 自连接。
2.TCP 自连接
TCP 的自连接是一个比较有意思的现象,甚至很多人认为是 Linux 内核的 bug。我们先来看看 TCP 的自连接是什么。
新建一个脚本 self_connect.sh,内容如下:
while true
do
telnet 127.0.0.1 50000
done
执行这段脚本之前先用 netstat 等命令确认 50000 没有进程监听。然后执行脚本,经过一段时间,telnet 居然成功了。
Trying 127.0.0.1...
telnet: connect to address 127.0.0.1: Connection refused
Trying 127.0.0.1...
telnet: connect to address 127.0.0.1: Connection refused
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
使用 netstat 查看当前的 50000 端口的连接状况,如下所示。
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.1:50000 127.0.0.1:50000 ESTABLISHED 24786/telnet
可以看到源 IP、源端口是 127.0.0.1:50000,目标 ip、目标端口也是 127.0.0.1:50000,通过上面的脚本,我们连上了本来没有监听的端口号。
3.自连接原因分析
自连接成功的抓包结果如下图所示。
对于自连接而言,上图中 wireshark 中的每个包的发送接收双方都是自己,所以可以理解为总共是六个包,包的交互过程如下图所示。
这个图是不是似曾相识?前四个包的交互过程就是 TCP 同时打开的过程。
当一方主动发起连接时,操作系统会自动分配一个临时端口号给连接主动发起方。如果刚好分配的临时端口是 50000 端口,过程如下:
- 第一个包是发送 SYN 包给 50000 端口
- 对于发送方而已,它收到了这个 SYN 包,以为对方是想同时打开,会回复 SYN+ACK
- 回复 SYN+ACK 以后,它自己就会收到这个 SYN+ACK,以为是对方回的,对它而已握手成功,进入 ESTABLISHED 状态
4.自连接的危害
设想一个如下的场景:
- 你写的业务系统 B 会访问本机服务 A,服务 A 监听了 50000 端口
- 业务系统 B 的代码写的稍微比较健壮,增加了对服务 A 断开重连的逻辑
- 如果有一天服务 A 挂掉比较长时间没有启动,业务系统 B 开始不断 connect 重连
- 系统 B 经过一段时间的重试就会出现自连接的情况
- 这时服务 A 想启动监听 50000 端口就会出现地址被占用的异常,无法正常启动
如果出现了自连接,至少有两个显而易见的问题:
- 自连接的进程占用了端口,导致真正需要监听端口的服务进程无法监听成功
- 自连接的进程看起来 connect 成功,实际上服务是不正常的,无法正常进行数据通信
5.如何解决自连接问题
自连接比较罕见,但一旦出现逻辑上就有问题了,因此要尽量避免。解决自连接有两个常见的办法。
- 让服务监听的端口与客户端随机分配的端口不可能相同即可
- 出现自连接的时候,主动关掉连接
对于第一种方法,客户端随机分配的范围由 /proc/sys/net/ipv4/ip_local_port_range 文件决定,在我的 Centos 8 上,这个值的范围是 32768~60999,只要服务监听的端口小于 32768 就不会出现客户端与服务端口相同的情况。这种方式比较推荐。
6.如何修改这个问题
只需要在连接建立以后判断是不是自连接,如果是自连接,close 这个连接即可。
麻烦的是写测试用例,首先要获取一个可用端口来测试,如果我只是随机来选一个端口号,有可能这个端口本身就被服务器的某个程序锁监听,很容易出现端口号冲突的情况。所以一开始我采用的是 bind(0)端口由内核来分配一个空闲的端口出来,如下所示。
private fun getAvailablePort(): Int {
val port = ServerSocket().apply {
bind(InetSocketAddress("127.0.0.1", 0))
close()
}.localPort
}
问题就出在这里,用这种方式我在 ubuntu 18.04 上测试怎么样都复现不了问题,换成固定的 50000 等端口就可以复现。
7.为什么上面的测试代码一定连接偶数端口才可以呢?
早期的 Linux 内核版本上,connect 非偶数端口也是可以复现的,在新的 4.2 内核版本中引入了一个特性(部分 Linux 发行版本有没有 backport 这个 feature) https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=07f4c90062f8fc7c8c26f8f95324cbe8fa3145a5 ,如下图所示。
/proc/sys/net/ipv4/ip_local_port_range 文件指定了临时端口号的下界 low 和上界 high,默认情况,low 是偶数,在我的电脑上 low 和 high 的值分别是 32768 和 60999。
简单来说新版内核对端口的分配策略做了一些调整:
- 优先给 bind(0) 分配随机的与 low 奇偶性不同的端口,也就是奇数端口。如果奇数端口号分配完了,才去尝试分配偶数端口
- 优先给 connect 分配与 low 奇偶性相同的临时端口,也就是偶数端口。如果偶数端口号分配完了,才去尝试分配奇数端口
可以写一段下面的代码来测试。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void print_local_port() {
int sockfd;
if (sockfd = socket(AF_INET, SOCK_STREAM, 0), -1 == sockfd) {
perror("socket create error");
}
// 连接本地的 8080 端口的服务器
const struct sockaddr_in remote_addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
.sin_addr = htonl(INADDR_ANY)
};
if (connect(sockfd, (const struct sockaddr *) &remote_addr, sizeof(remote_addr)) < 0) {
perror("connect error");
}
// 获取本地套接字地址
const struct sockaddr_in local_addr;
socklen_t local_addr_len = sizeof(local_addr);
if (getsockname(sockfd, (struct sockaddr *) &local_addr, &local_addr_len) < 0) {
perror("getsockname error");
}
printf("local port: %d\n", ntohs(local_addr.sin_port));
close(sockfd);
}
int main() {
int i;
for (i = 0; i < 10; i++) {
print_local_port();
}
return 0;
}
运行上面的代码就可以看到 connect 10 次的本地端口号是全是偶数。
$ ./a.out
local port: 49238
local port: 49240
local port: 49242
local port: 49244
local port: 49246
local port: 49248
local port: 49250
local port: 49252
local port: 49254
local port: 49256
也可以写一段类似的代码来测试在端口资源充足的情况下,bind(0) 内核会随机分配一个奇数的端口号。
// bind(0)
const struct sockaddr_in serv_addr = {
.sin_family = AF_INET,
.sin_port = htons(0),
.sin_addr = htonl(INADDR_ANY)
};
if (bind(sockfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) < 0) {
perror("bind error");
}
// 获取本地套接字地址
const struct sockaddr_in local_addr;
socklen_t local_addr_len = sizeof(local_addr);
if (getsockname(sockfd, (struct sockaddr *) &local_addr, &local_addr_len) < 0) {
perror("getsockname error");
}
printf("bind local port: %d\n", ntohs(local_addr.sin_port));
close(sockfd);
在新版的内核中输出结果如下,可以看到 bind(0) 返回的随机端口号全是奇数。
$ ./a.out
bind local port: 37173
bind local port: 43605
bind local port: 51155
bind local port: 37209
bind local port: 45985
bind local port: 57833
bind local port: 39517
bind local port: 45873
bind local port: 42387
bind local port: 53887
因此在之前的 getAvailablePort 例子中,bind(0) 返回的随机端口都是奇数,connect 的临时端口号都是偶数,这下永远无法 self connect 成功了,也就无从测试是否改动是必要的了。
8.bind(0) 端口号奇偶性源码分析
bind 是一个系统调用,这个函数最终调用到了 inet_csk_find_open_port 函数里,调用栈如下图所示。
这里有一个很关键的一行:
offset |= 1U
实际上这句话的意思是将生成随机数变为奇数,于是后面的 port 生成方式
port = low + offset;
这样 low 会与一个奇数相加:
- 如果 low 是一个奇数,则 port 就是一个偶数
- 如果 low 是一个偶数,则 port 就是一个奇数
用这种方式,就实现了生成的端口号与端口范围的下界 low 值保持奇偶相反。在 low 值默认为偶数的情况下,bind(0) 随机生成的端口号就是一个奇数。
9.connect 临时端口号的奇偶性分析
connect 临时端口号的分配源码在 __inet_hash_connect 中实现
可以看到,与 bind(0) 正好相反,它将 offset 通过下面这种方式强制变为了偶数。
offset &= ~1U;
这样后面与 low 相加,port 就与 low 的奇偶性保持一致。
10.测试代码修改
那要怎么改呢?这里简单在 for 循环里尝试取一个可用的偶数端口即可。
private fun getAvailablePort(): Int {
while (true) {
val port = ServerSocket().apply {
bind(InetSocketAddress("127.0.0.1", 0))
close()
}.localPort
if (port % 2 == 0) {
return port
}
try {
// try bind the next even port
ServerSocket().apply {
bind(InetSocketAddress("127.0.0.1", port + 1))
close()
}
return port + 1
} catch (ex: Exception) {
// ignore
}
}
}
11.番外篇
这个问题不是 Kotlin 或者 Java 特有的,我在 Golang 的源码时早期的 connect 的代码也有这个问题,不过后面有人提了 issue 修复了,代码如下所示。
func (sd *sysDialer) doDialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) {
fd, err := internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control)
// TCP has a rarely used mechanism called a 'simultaneous connection' in
// which Dial("tcp", addr1, addr2) run on the machine at addr1 can
// connect to a simultaneous Dial("tcp", addr2, addr1) run on the machine
// at addr2, without either machine executing Listen. If laddr == nil,
// it means we want the kernel to pick an appropriate originating local
// address. Some Linux kernels cycle blindly through a fixed range of
// local ports, regardless of destination port. If a kernel happens to
// pick local port 50001 as the source for a Dial("tcp", "", "localhost:50001"),
// then the Dial will succeed, having simultaneously connected to itself.
// This can only happen when we are letting the kernel pick a port (laddr == nil)
// and when there is no listener for the destination address.
// It's hard to argue this is anything other than a kernel bug. If we
// see this happen, rather than expose the buggy effect to users, we
// close the fd and try again. If it happens twice more, we relent and
// use the result. See also:
// https://golang.org/issue/2690
// https://stackoverflow.com/questions/4949858/
//
// The opposite can also happen: if we ask the kernel to pick an appropriate
// originating local address, sometimes it picks one that is already in use.
// So if the error is EADDRNOTAVAIL, we have to try again too, just for
// a different reason.
//
// The kernel socket code is no doubt enjoying watching us squirm.
for i := 0; i < 2 && (laddr == nil || laddr.Port == 0) && (selfConnect(fd, err) || spuriousENOTAVAIL(err)); i++ {
if err == nil {
fd.Close()
}
fd, err = internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control)
}
if err != nil {
return nil, err
}
return newTCPConn(fd), nil
}
func selfConnect(fd *netFD, err error) bool {
// If the connect failed, we clearly didn't connect to ourselves.
if err != nil {
return false
}
// The socket constructor can return an fd with raddr nil under certain
// unknown conditions. The errors in the calls there to Getpeername
// are discarded, but we can't catch the problem there because those
// calls are sometimes legally erroneous with a "socket not connected".
// Since this code (selfConnect) is already trying to work around
// a problem, we make sure if this happens we recognize trouble and
// ask the DialTCP routine to try again.
// TODO: try to understand what's really going on.
if fd.laddr == nil || fd.raddr == nil {
return true
}
l := fd.laddr.(*TCPAddr)
r := fd.raddr.(*TCPAddr)
return l.Port == r.Port && l.IP.Equal(r.IP)
}
这里详细解释了为什么有 selfConnect 方法的判断,判断是否是自连接的逻辑是判断源 IP 和目标 IP 是否相等,源端口号和目标端口号是否相等。
一点感想
提交一个 pr 改动的代码可能就几行,但测试代码写起来还是比较头疼的。