关于Linux UDP/TCP reuseport 二三事

版权声明:本文为博主原创,无版权,未经博主允许可以随意转载,无需注明出处,随意修改或保持可作为原创! https://blog.csdn.net/dog250/article/details/80458669

聊到reuseport,大致要从四年前说起。

OpenVPN-以往的故事

当时要优化OpenVPN的并发性能,了解到socket有一个reuseport特性,于是非常兴奋,本着拿来主义的想法,无非就是在OpenVPN的源码里加一个setsockopt吧…我们当时基于Linux 2.6.32开发,无奈并没有实现这个特性,后来的版本也只是实现了reuseport热备份模式,并没有实现负载均衡模式,也是无奈…

不过无所谓,好在我对Linux网络部分的代码是很熟悉的,当我知道Linux 3.9内核实现了完备的reuseport负载均衡模式时,就迫不及待地将它移植到了2.6.32内核,也就半个小时不到的样子搞定,然后就可以愉快地玩OpenVPN了!

故事零零散散地被记录,从未被整理,找到下面几篇,可见一斑:
基于UDP服务的负载均衡方法https://blog.csdn.net/dog250/article/details/17061277
OpenVPN多处理之-为什么一定要做https://blog.csdn.net/dog250/article/details/38154707
Linux 4.6内核对TCP REUSEPORT的优化https://blog.csdn.net/dog250/article/details/51510823

我玩reuseport的几个阶段-故事的延续

在继续reuseport的故事之前,先来回顾一下reuseport的版本。

围绕着reuseport如何来hash的细节,大致可以分为以下几个阶段:

  • 版本1:3.96内核轻移植到2.6.32内核的reuseport版本
    这个不多说,我当时只是保证能用即OK,二十分钟的移植工作当然是没有时间去深究的。
  • 版本2:加入OpenVPN session ID的版本
    SID版OpenVPN,即2013年底对OpenVPN的一个增强版本,我觉得我这个是个创举,一举解决了移动终端频繁的3G/Wifi,强信号/弱信号切换导致的断线重连问题。该版本的OpenVPN对应的Linux内核需要对reuseport逻辑进行修正,即用OpenVPN协议头中的sid来计算hash key,而不再使用传统的四元组。详情参见:
    OpenVPN移动性改造-靠新的session iD而不是IP/Port识别客户端https://blog.csdn.net/dog250/article/details/29180765
  • 版本3:3.96内核-4.5内核版本的数值分析伪随机版本
    返璞归真,自3.96原生内核,我便不再自己玩了,这个时候我已经不再继续做OpenVPN了,扔下一个烂摊子,发现reuseport已经被我玩坏了。
    于是仔细分析了原生的实现,其中的核心是next_pseudo_random32函数,这是一个数值分析方法里导出的公式,该版本的reuseport就是用它来做next伪随机数的,我们信赖这个公式,所以我们信赖最终的结果是均匀的。
  • 版本4:4.6内核的取模版本,顺带支持bpf
    这个版本就是迄至4.14版本中一直在用的,详情参见:
    Linux 4.6内核对TCP REUSEPORT的优化https://blog.csdn.net/dog250/article/details/51510823

以上,除了版本2是我自己派生的之外,其它3个版本几乎都是原生的,不管是采用数值复分析的方法还是采用取模的方法,其最终的hash结果都是不可靠的。这种不可靠主要由两个原因导致,我来分别说。

  1. 数据接收端的socket变更导致的不一致

    以取模版的hash为例,考虑以下情景,一共建立了n个reuseport的socket,那么到达数据包的四元组经过计算后对n取模得到k1,此时有一个socket挂掉了,那么同样的四元组经过计算后将对n1取模,得到k2,很显然k1k2是不相等的。如果socket和处理进程或者处理线程是绑定的话,那么单独进程/线程的新建,销毁将会导致hash结果的不一致。为此我们需要一种一致性hash的解法。

    注意,上述问题对于UDP而言,比TCP更加严重。对于TCP而言,hash的不一致仅仅影响新建连接三次握手的瞬间,而对于UDP,将会影响整个四元组的整个生命周期!

  2. 数据发送端的IP/Port变更导致的不一致

    对于UDP协议而言,我们不希望由于客户端切换了一个IP地址而导致整个应用层数据传输的中断,毕竟UDP仅仅只是起到运输的作用,它不像TCP那样和应用进程是强关联的。这种切换IP和端口的场景在移动设备上特别常见,就我个人而言,当我发现Wifi信号差的时候,就会直接禁掉Wifi,如此这般频繁切换,这个时候如果后台有数据正在传输,我当然不希望它由于我的这次切换而中断。
    那显然不能再用IP和Port来做hash计算源了。

我在版本2中解决了以上的问题,所以我觉得这是个创举。版本2基本上就做了两件事:

  • 用OpenVPN协议头里新增加的SID来做hash源
  • 在服务器端实现一致性hash替代内核原生的数值分析hash

效果如何呢?就说一件事,设备在2014年底就开始陆续上线了,可能现在还在运行也说不准…

如今-故事依然在继续

哈哈,近日里又碰到了这种事情,reuseport,reuseport,reuseport。已经三年没玩了,依然是觉得有趣。

又有什么新玩法呢?玩法就不说了,这里指出一个问题。我们来看看最新内核的reuseport socket的hash定位方法:

struct sock *reuseport_select_sock(struct sock *sk,
                   u32 hash,
                   struct sk_buff *skb,
                   int hdr_len)
{
    struct sock_reuseport *reuse;
    struct bpf_prog *prog;
    struct sock *sk2 = NULL;
    u16 socks;

    rcu_read_lock();
    reuse = rcu_dereference(sk->sk_reuseport_cb);

    /* if memory allocation failed or add call is not yet complete */
    if (!reuse)
        goto out;

    prog = rcu_dereference(reuse->prog);
    socks = READ_ONCE(reuse->num_socks);
    if (likely(socks)) {
        smp_rmb();

        if (prog && skb) // 给了用户一个bpf接口可外部控制
            sk2 = run_bpf(reuse, socks, prog, skb, hdr_len);
        else // 取模
            sk2 = reuse->socks[reciprocal_scale(hash, socks)];
    }

out:
    rcu_read_unlock();
    return sk2;
}

正如你所见,即便是如此现代化的实现,其hash结果依然是不一致的,上述代码中,如果reuse->num_socks发生了变化,那么同样的hash值将会被定向到不同的socket上,这显然不是我们想要的。

然而,正如大家都知道的,Linux内核作为通用内核是不会管这种家务事的,那么事情显然需要进程自己来负责,于是这个bpf接口就显得很有必要,灌入一段选择socket的bpf代码即可。

但是仔细观察bpf程序的执行返回值:

static struct sock *run_bpf(struct sock_reuseport *reuse, u16 socks,
                struct bpf_prog *prog, struct sk_buff *skb,
                int hdr_len)
{
    struct sk_buff *nskb = NULL;
    u32 index;

    ...
    if (!pskb_pull(skb, hdr_len)) {
        kfree_skb(nskb);
        return NULL;
    }
    // 注意,返回值只是一个index!!!
    index = bpf_prog_run_save_cb(prog, skb);
    ...
    // 依然是用index取socket
    return reuse->socks[index];
}

我们知道bpf代码是用户灌入的,而socket的index是内核维护的,如何建立二者的关联就是一个问题,显然内核并没有提供什么方便的接口让编程者可以方便快捷地建立二者之间的关联。

那么我们自己加一个:

// get the index of a socket index from a reuseport set
#define SO_GETREUSEINX  XXX

然后在sock_getsockopt里加入下面的代码即可:

    case SO_GETREUSEINX:
        {
            struct sock_reuseport *reuse;
            int i;

            spin_lock_bh(&reuseport_lock);
            reuse = rcu_dereference_protected(sk->sk_reuseport_cb, lockdep_is_held(&reuseport_lock));
            rcu_assign_pointer(sk->sk_reuseport_cb, NULL);

            for (i = 0; i < reuse->num_socks; i++) {
                if (reuse->socks[i] == sk) {
                    v.val = i;
                    break;
                }
            }
            spin_unlock_bh(&reuseport_lock);
        }

有了这么一个小小的改动,使用bpf就可以非常随意了。我们可以在用户态集群的共享内存里维护一张映射表,index在集群建立之初通过调用每一个reuseport socket的SO_GETREUSEINX这个option来获取并初始化,hash算法可以简单地取模即可,按照内核中的算法来,如下所示:

hash值 index(索引,bpf的返回值)
h0 0
h1 1
h2 2

然后编写bpf程序,从hash值里取index。一切顺利,OK。此时,一旦有socket新增了或者退出了,用户态管理进程便需要重新生成这个映射表并且生成bpd注入到内核,更新既有的bpf程序代码。

不多说,都明白。

现在说一下如果不想用bpf程序怎么办?如何实现一个最简单的一致性hash呢?其实也不难,我们看一下当一个socket新增和退出时问题出在哪里,然后见招拆招地去解决它就好了。先看代码:

void reuseport_detach_sock(struct sock *sk)
{
    ...
    for (i = 0; i < reuse->num_socks; i++) {
        if (reuse->socks[i] == sk) {
            // 用最后一个socket补充到这个空位
            reuse->socks[i] = reuse->socks[reuse->num_socks - 1];
            // 递减总量,这是造成取模不一致的根源!!
            reuse->num_socks--;
            ...
            break;
        }
    }
    ...
}

问题似乎很好解决,就是说把下面两行代码改掉即可:

reuse->socks[i] = reuse->socks[reuse->num_socks - 1];
reuse->num_socks--;

改成什么呢?按照一致性hash标准的接管方式来,一旦索引为i的socket退出了,那么由它紧邻的那个socket暂时接管它的位置,同时记录i值,以备新建的补充socket填位:

reuse->socks[i] = reuse->socks[i+1];
//reuse->num_socks--;
reuse->next_slot = i;

此时如果有新的进程建立了一个新的reuseport socket,那么就补充到next_slot这个位置即可:

reuse->socks[reuse->next_slot] = sk;

是不是超级简单?

细心的话你应该能看到一个问题,新增怎么办?,socket挂掉不递减num_socks是可行,那么新增socket不递增可以吗?显然不可以,那么怎么办?非常好办!请注意,我这里实现的是最简版本,而不是最优版本,先用起来再说呗。

解决方案就是,如果你的集群中目前有n个服务器,未来可能会增加,但上限就是2×n,我们不妨直接把num_socks初始化成2×n,然后每隔2个slot填充相同的socket即可,如果有新的socket创建,就随机拆分,即reuse->next_slot以2为单位进行随机,然后随机到那个slot,就按照伙伴系统的算法进行拆分…嗯,棒极了。

显然,不管再怎么好,这也不是内核的标准功能,也许,很多人正希望使用原生的实现呢…于是,一个新的sockopt是必要的,即SO_REUSEPORTHOLD。

reuseport版本5

于是出现了reuseport版本5,正如上节所述。

展望-未来会发生什么

reuseport一直以来都是青睐UDP而不是TCP!

随着http2的逐渐进入视野,几乎全部基于tcp的http1正在加速退出,未来的UDP将大展宏图。

最为先行者,Google已经将QUIC用在了其自家的Chrome浏览器上封装其自家的http流量,这开了一个大口子,就像G家的bbr开了个大口子一样。为什么是UDP?

因为TCP过时了,过时了,过时了,因为TCP过时了!

很多人都知道Google在TCP上的最新进展,那是因为教育的落后,我们付出了大量的时间精力学习复杂的一逼的TCP,却忽略了UDP。TCP是30年前的协议,它也是为30年前的网络场景准备的,然而我们像追星一样追了30年。其实Google的很多TCP优化都是从其在QUIC的试验田里移植过来的,Google可能早就想拥抱UDP了,其对TCP的态度更多的是兼容,而不是优化!TCP到底怎么了?

  • TCP强行打包了握手,按序…强买强卖
  • TCP实现可插拔功能非常不灵活
  • TCP为了节省空间开销付出了时间的代价(30年前的场景)
  • TCP完全基于ACK而全无NAK
  • TCP不得不在信息量极少的情况下实现失败的拥塞控制
  • QUIC的bbr性能比TCP的bbr性能高
  • TCP的脑残粉太多太多了

UDP的优势在于灵活,比如它可以实现松散按序传输,特别是对于音视频类的传输非常棒,偶尔的丢包不会阻碍窗口向前滑行,拥塞控制可以基于ACK+NAK从而精确判断到底发生了什么…

UDP的空间非常大,因为很多TCP的特性都要你自己实现,很多TCP中你不需要的东西你也可以随便抛弃,reuseport只是其中一个可以利用的,但绝不是唯一的一个,相信今后会有更多的基于UDP的可玩且好玩的东西出来。

后记

本来打算去大鹏杨梅坑过个完整的周末,无奈周六上午小小有家长会,只能下午出发了…别说搞IT的加班上瘾,小学老师也是一个加班上瘾的群体…多少个周末都被小小小学的各种活动所破坏,都上一周班了,就不能消停点吗?!

没有更多推荐了,返回首页