如何做到十万TCP连接转发

问题描述

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

如何做到十万TCP连接转发_第1张图片

当时单个服务器支持 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 代码的判断条件,它查找的端口号需要满足下面的条件之一:

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

总结

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

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

你可能感兴趣的:(linux网络异步)