问题描述
有一天我收到这么一个需求,在某业务设备端和业务服务器之间假设一个应用层代理服务器,
并设置了性能指标要求单个服务器支持至少 10 万 TCP 长连接。
当时单个服务器支持 10 万 TCP 连接的问题已经有很多解决方案了,比如 nginx,
libevent。然而 TCP 转发服务这样既作为服务端,又作为客户端的场景,却也有其它问题
需要解决。再次记录遇到的问题,以及如何解决他们的。
为什么会有这种需求?
以上只是问题的简化描述,TCP 长连接转发只是它的基本功能,在这之上,还会有别的工作,
比如:
- 将上游服务器转化为 TLS 服务器或者国密 SSN VPN 服务器。
- 提供安全功能,比如 IDS/IPS,防火墙的功能。
- 为设备和业务服务器提供协议转换,以求兼容。
- 处理 DDos 流量。
- 增加身份验证功能。
文件描述符限制问题
在 Linux 系统中,一个 TCP 连接就会占用一个文件描述符。 转发服务器里,每转发一个
TCP 连接就会占用 2 个文件描述符,其中一个代表下游和转发服务器的连接,另一个代表
转发服务器到上游业务服务器的连接。而文件描述符数量是有限的,使用下列命令查看:
$ ulimit -n
1024
大多数 Linux 发行版会显示 1024,这就是当前用户可以使用的文件描述符限制。要修改这
个限制,这个限制可以在 /etc/security/limits.conf
里修改,参考 这里 。
# /etc/sysctl.conf
fs.nr_open=2000000
fs.file-max=2000000
# /etc/security/limits.conf
* soft nofile 600000
* hard nofile 600000
# 并设置开机运行: sysctl --system
我想这个限制应该广为流传了,以至于**云修改了他们虚拟机镜像,使得限制扩大为
65536。我也听过一些传闻,几年前某个订饭公司没有扩大这个参数,导致他们业务高峰时
再三出现异常,虽然找到了原因,但最后还是不出意外地黄了(别乱想,他们活不下去不是
技术原因)。
端口号的限制问题
实现的时候没多想,测试时遇到了这个问题。 TCP 头结构如下, Source Port
长度是 2
个字节,所以源端口范围是 \( [0..65535] \) 。
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data | |U|A|P|R|S|F| |
| Offset| Reserved |R|C|S|S|Y|I| Window |
| | |G|K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options | Padding |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| data |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
TCP Header Format
所以,最多只能有 6 万多个端口号,也就是 6 万多个客户端连接,离 10 万连接不是很远,
真是大惊喜! 而且默认情况下, Linux 上是不能用 6 万个端口号的,请看 Linux 中获取
空闲端口号的代码 \_\_inet\_hash\_connect :
inet_get_local_port_range(net, &low, &high);
high++; /* [32768, 60999] -> [32768, 61000[ */
remaining = high - low;
if (likely(remaining > 1))
remaining &= ~1U;
net_get_random_once(table_perturb, sizeof(table_perturb));
index = hash_32(port_offset, INET_TABLE_PERTURB_SHIFT);
offset = (READ_ONCE(table_perturb[index]) + port_offset) % remaining;
/* In first pass we try ports of @low parity.
* inet_csk_get_port() does the opposite choice.
*/
offset &= ~1U;
other_parity_scan:
port = low + offset;
for (i = 0; i < remaining; i += 2, port += 2) {
if (unlikely(port >= high))
port -= remaining;
if (inet_is_local_reserved_port(net, port))
continue;
head = &hinfo->bhash[inet_bhashfn(net, port,
hinfo->bhash_size)];
spin_lock_bh(&head->lock);
/* Does not bother with rcv_saddr checks, because
* the established check is already unique enough.
*/
inet_bind_bucket_for_each(tb, &head->chain) {
if (net_eq(ib_net(tb), net) && tb->l3mdev == l3mdev &&
tb->port == port) {
if (tb->fastreuse >= 0 ||
tb->fastreuseport >= 0)
goto next_port;
WARN_ON(hlist_empty(&tb->owners));
if (!check_established(death_row, sk,
port, &tw))
goto ok;
goto next_port;
}
}
tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep,
net, head, port, l3mdev);
if (!tb) {
spin_unlock_bh(&head->lock);
return -ENOMEM;
}
tb->fastreuse = -1;
tb->fastreuseport = -1;
goto ok;
next_port:
spin_unlock_bh(&head->lock);
cond_resched();
}
offset++;
if ((offset & 1) && remaining > 1)
goto other_parity_scan;
return -EADDRNOTAVAIL;
ok:
如你所见, Linux 查找端口号并不是从 0 开始的,而是从区间 \( [low, hight] \) 中查找。
区间范围可以通过下列命令查看:
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768 60999
跟代码注释里一样,是默认值 \( [32768, 61000] \) ,也就是说,只能有 3 万左右个客户端
连接。
这个其实好解决,修改内核参数即可:
# /etc/sysctl.conf
net.ipv4.ip_local_port_range = 2048 65535
# TIME_WAIT 状态的连接没必要保持了
net.ipv4.tcp_tw_reuse = 1
注意别设置 tcp_tw_recycle=1
,不然负载均衡器、或者 NAT 环境中会有问题。
现在可以真的达到 6 万多连接了。 下面介绍如何扩展到 10 万连接以上。
在一个服务器中,下列元组确定一个 TCP 连接:
{ 源IP, 源端口, 目的IP,目的端口 }
最简单的就是给 TCP 连接转发服务器设置 2 个 IP 地址,每个 IP 地址可以使用 6 万多
个端口,这样就可以有 12 万连接了。
一般我们使用下面的代码进行 TCP 连接:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("quant67.com", 443))
使用 bind()
方法可以指定源 IP 和源端口号,这样就可以分散使用 2 个 IP 地址了:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("10.0.0.3", 1122))
s.connect(("quant67.com", 443))
仔细观察上面的 Linux 代码的判断条件,它查找的端口号需要满足下面的条件之一:
- 空闲的,没被占用。
- 被占用了,但是可以复用 (
check_established
)。
"可以被复用" 这个条件,看起来很动人。为了让端口号可以复用,需要设置SO_REUSEADDR
。
man 7 socket
SO_REUSEADDR
Indicates that the rules used in validating addresses
supplied in a bind(2) call should allow reuse of local
addresses. For AF_INET sockets this means that a socket
may bind, except when there is an active listening socket
bound to the address. When the listening socket is bound
to INADDR_ANY with a specific port then it is not possible
to bind to this port for any local address. Argument is
an integer boolean flag.
简单地说,设置 SO_REUSEADDR
有三个用途:
TIME_WAIT
状态的端口可以被bind
。- 当一个服务程序
bind
了0.0.0.0:8080
,另一个服务程序可以bind
特定IP的
同一个端口如10.0.0.3:8080
。 - 只要目的 IP 或者目的端口不同,就可以在不同的连接里
bind
相同的端口。
代码如下:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("10.0.0.3", 0))
s.connect(("quant67.com", 443))
上面代码 bind
参数设置端口号为 0,表示让内核自动安排端口号。 与 connect()
的
安排方法不同, 代码在 这里 。他会根据 SO_REUSEADDR
和 SO_REUSEPORT
是否设置,
来判断冲突。只要目标 IP 不同,就可以复用源端口,因此上述代码可以达到超过 6 万的
连接数。
然而有个问题:调用 bind()
的时候,内核只知道源 IP,不知道目的 IP,所以
实际会有一些冲突, connect()
会报 EADDRNOTAVAIL
错误。
这个问题可以通过哈希表解决冲突,然而实际冲突概率很小,如果冲突了,就重试更方便。
总结
主要使用了下列方法提高代理连接数:
- 调整文件描述符限制
- 调整
ip_local_port_range
- 使用多个源 IP
- 使用
SO_REUSEADDR