如何做到十万TCP连接转发


问题描述 有一天我收到这么一个需求,在某业务设备端和业务服务器之间假设一个应用层代理服务器,
并设置了性能指标要求单个服务器支持至少 10 万 TCP 长连接。
如何做到十万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] \) 。
0123 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 3276860999

跟代码注释里一样,是默认值 \( [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 代码的判断条件,它查找的端口号需要满足下面的条件之一:
  1. 空闲的,没被占用。
  2. 被占用了,但是可以复用 ( check_established )。
"可以被复用" 这个条件,看起来很动人。为了让端口号可以复用,需要设置
SO_REUSEADDR
man 7 socketSO_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 有三个用途:
  1. TIME_WAIT 状态的端口可以被 bind
  2. 当一个服务程序 bind0.0.0.0:8080 ,另一个服务程序可以 bind 特定IP的
    同一个端口如 10.0.0.3:8080
  3. 只要目的 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_REUSEADDRSO_REUSEPORT 是否设置,
来判断冲突。只要目标 IP 不同,就可以复用源端口,因此上述代码可以达到超过 6 万的
连接数。
然而有个问题:调用 bind() 的时候,内核只知道源 IP,不知道目的 IP,所以
实际会有一些冲突, connect() 会报 EADDRNOTAVAIL 错误。
这个问题可以通过哈希表解决冲突,然而实际冲突概率很小,如果冲突了,就重试更方便。

总结 【如何做到十万TCP连接转发】主要使用了下列方法提高代理连接数:
  • 调整文件描述符限制
  • 调整 ip_local_port_range
  • 使用多个源 IP
  • 使用 SO_REUSEADDR

    推荐阅读