如何做到单机十万TCP连接转发

Table of Contents

1 问题描述

有一天我收到这么一个需求,在某业务设备端和业务服务器之间假设一个应用层代理服务器, 并设置了性能指标要求单个服务器支持至少 10 万 TCP 长连接。

need.png

Figure 1: TCP 连接转发服务器示意图

当时单个服务器支持 10 万 TCP 连接的问题已经有很多解决方案了,比如 nginx, libevent。然而 TCP 转发服务这样既作为服务端,又作为客户端的场景,却也有其它问题 需要解决。再次记录遇到的问题,以及如何解决他们的。

1.1 为什么会有这种需求?

以上只是问题的简化描述,TCP 长连接转发只是它的基本功能,在这之上,还会有别的工作, 比如:

  • 将上游服务器转化为 TLS 服务器或者国密 SSN VPN 服务器。
  • 提供安全功能,比如 IDS/IPS,防火墙的功能。
  • 为设备和业务服务器提供协议转换,以求兼容。
  • 处理 DDos 流量。
  • 增加身份验证功能。

2 文件描述符限制问题

在 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。我也听过一些传闻,几年前某个订饭公司没有扩大这个参数,导致他们业务高峰时 再三出现异常,虽然找到了原因,但最后还是不出意外地黄了(别乱想,他们活不下去不是 技术原因)。

3 端口号的限制问题

实现的时候没多想,测试时遇到了这个问题。 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 代码的判断条件,它查找的端口号需要满足下面的条件之一:

  1. 空闲的,没被占用。
  2. 被占用了,但是可以复用 ( 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 有三个用途:

  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 错误。 这个问题可以通过哈希表解决冲突,然而实际冲突概率很小,如果冲突了,就重试更方便。

4 总结

主要使用了下列方法提高代理连接数:

  • 调整文件描述符限制
  • 调整 ip_local_port_range
  • 使用多个源 IP
  • 使用 SO_REUSEADDR

By .