zoukankan      html  css  js  c++  java
  • Linux3.5内核以后的路由下一跳缓存

    在Linux3.5版本号(包括)之前。存在一个路由cache。这个路由cache的初衷是美好的,可是现实往往是令人遗憾的。下面是陈列得出的两个问题:
    1.面临针对hash算法的ddos问题(描写叙述该问题的文章已经汗牛充栋,不再赘述);
    2.缓存出口设备是p2p设备的路由项会降低性能。

    这些问题本质上是由于路由cache的查找方式和路由表的查找方式互不相容引起的。路由cache必须是精确的元组匹配,因此它必须设计成一维的hash表,而路由表查找算法是最前前缀匹配。因此它能够是多维的。

    路由查找终于会找到路由项。在不考虑策略路由的前提下,我们来看一下把出口设备为p2p设备的路由项塞进路由cache是多么的没有意义。

            p2p设备的邻居集合里仅仅有一个下一跳,那就是它的对端,因此对于p2p设备。甚至都不须要进行邻居绑定的过程。然而假设将这类路由塞进路由cache的话。将会占领巨量的内存,试想假设有10w个IP地址须要通信。源IP集合中相同有10w个IP地址。将有可能会建立100w条路由cache项。极端一点,假设此时系统中仅仅有不多的几条路由表项的话,查找路由表的开销可能会反而低于查找路由cache的开销。特别地。假设路由结果是p2p设备,其实仅仅要想办法cache这唯一的一个条目就可以。这就是一和多的差别,这次,我们发现不光零到一有意义。一到多也相同不可小觑。



            假设系统中有一块以太网卡eth0。由于同一网段会有多个邻居,不同的目标IP地址。其下一跳可能会有所不同,我们不得不cache每个与eth0相关的路由项,然后针对每个数据包进行精确匹配,然而假设系统中有一块p2p网卡,它的邻居仅仅有一个,对于点对点设备而言,其对端逻辑上仅仅有一个设备,它是唯一的且确定的。它是该点对点设备的邻居集合中的唯一一个邻居,因此其实无需进行邻居绑定过程,仅仅要从点对点设备将数据包发出,该数据包就一定会到达唯一的对端,在这样的情况下,假设我们还cache每个与该p2p网卡相关的路由项,意义就不大了,然而,对于Linux的路由cache机制而言,这是无法做的的,由于在查找路由cache以及查找路由表之前。我们无从知道这个数据包就是终于要从一个p2p网卡发送出去的。

            一个解决方式是,假设查找路由表的结果表明其出口设备是p2p设备。则设置一个NOCACHE标志,表示不cache它,待到数据包发送完成即释放,我想这个实现是简单而明了的。本来去年9月份想实现掉它,也是为了我们的一个网关产品能够提高性能。可是后面我离职了,此事也就不了了之,直到近期,我再次面临了此问题。

    然而我有了更好的建议,那就是升级内核到3.6+。只是这是后话,其实,假设你必须维护基于低版本号内核的老产品的话,改动代码就是避不开的,幸运的是,无论是老公司。还是新公司,我与2.6.32版本号的代码打交道已经6年了。

            扩大点说。路由查找这东西确实非常尴尬,能够肯定,一台设备上可能会有数十万条的路由。然而与其相连的邻居集合内的节点数却能够用一个字节来表示,并且大多数节点的邻居可能仅仅有不超过10个!我们消耗了大量的精力,什么cache查询。什么最长前缀匹配。终于就是为了在数十万数量级的大海中捞出几根针,所以说,这一直都是一个比較有挑战性的领域,与TCP加速相比。这个领域更加闭环,它不受其他影响。仅仅有算法本身影响它!其实,不光p2p设备,就连ethX设备。结局也是悲哀的,配置几十条路由。终于的下一跳可能仅仅有五六个,p2p设备仅仅是更加极端一些罢了。对于p2p设备,我们一般这么写路由就可以:
    route add -host/net a.b.c.d/e dev tunlX
    然而对于ethX设备而言,一般来说我们必须写路由:
    route add -host/net a.b.c.d/e gw A.B.C.D
    也就是说,p2p设备直接告知了数据包从设备发出去就可以,然而对于ethX设备(或者全部的广播网络设备以及NBMA设备),必须进行地址解析或者下一跳解析才会知道从哪里发出去。不光如此。路由cache还会对邻居子系统造成影响,简单的说,就是路由项引用邻居。路由项释放之前,邻居不能被释放。即便p2p设备不须要邻居解析。在代码层面也必须特殊处理,不幸的是,Linux内核中并没有看到这样的特殊处理,p2p设备的路由项依旧会塞进路由cache。

            以上就是路由查找的困境。困境在于多对一或者多对少的映射过程,这样的情况下。营造一个精确匹配的cache可能使结局更加悲哀,因此,用一种统一的方式进行调优可能更加符合人之常情。

    Linux3.6以后。去除了路由cache的支持,全部的数据包要想发送出去,必须查找路由表。现在的过程可能会变成下面的逻辑:

    dst=lookup_fib_table(skb);
    dst_nexthop=alloc_entry(dst);
    neigh=bind_neigh(dst_nexthop);
    neigh.output(skb);
    release_entry(dst_nexthop);
    这是一个完美的过程。然而在协议栈的实现层面,出现了新的问题。即alloc/release会带来巨大的内存抖动,我们知道,内存分配与释放是一个必须要在CPU外部完成的事务。它的开销是巨大的。尽管在Linux中有slab cache,可是我们相同也知道。cache是分层的。

    其实,Linux在3.6以后。实现了新的路由cache。不再缓存一个路由项。由于那须要skb的元组精确匹配,而是缓存下一跳,找到这个cache必须经过lookup_fib_table这个例程。

            这是个创举。由于缓存的东西是唯一的,除非发生一些例外!这就破解了解决多对一以及多对少的问题。在找到缓存之前,你必须先查找路由表。而查找完成之后,理论上你已经知道了下一跳,除非一些例外(再次重申!

    )这个新的下一跳缓存仅仅是为了避免内存的分配/释放!伪代码例如以下:

    dst=lookup_fib_table(skb);
    dst_nexthop=lookup_nh_cache(dst);
    if dst_nexthop == NULL;
    then
        dst_nexthop=alloc_entry(dst);
        if dst_nexthop.cache == true;
        then
            insert_into_nh_cache(dst_nexthop);
        endif
    endif
    neigh=bind_neigh(dst_nexthop);
    neigh.output(skb);
    if dst_nexthop.cache == false
    then
        release_entry(dst_nexthop);
    endif
    就这样,路由cache不再缓存整个路由项,而是缓存路由表查找结果的下一跳。



            鉴于一般而言,一个路由项仅仅有一个下一跳。因此这个缓存是极其有意义的。这意味着。在大多数时候,当路由查找的结果是一个确定的dst时。其下一跳缓存会命中。此时便不再须要又一次分配新的dst_nexthop结构体,而是直接使用缓存中的就可以。假设非常不幸,没有命中,那么又一次分配一个dst_nexthop,将其尽可能地插入到下一跳缓存,假设再次非常不幸,没有成功插入,那么设置NOCACHE标志,这意味着该dst_nexthop使用完成后将会被直接释放。

            上述段落说明的是下一跳缓存命中的情况。那么在什么情况下会不命中呢,这非常easy,无非就是在上述的lookup_nh_cache例程中返回NULL的时候,有不多的几种情况会导致其发生。比方某种原因将既有的路由项删除或者更新等。

    这个我随后会通过一个p2p虚拟网卡mtu问题给予说明,在此之前,我还要阐述第二种常见的情形,那就是重定向路由。



            所谓的重定向路由,它会更新本节点路由表的一个路由项条目,要注意的是。这个更新并非永久的,而是暂时的。所以Linux的做法并非直接改动路由表,而是改动下一跳缓存!这个过程是异步的,伪代码例如以下:

    # IP_OUT例程运行IP发送逻辑,它首先会查找标准路由表,然后在下一跳缓存中查找下一跳dst_nexthop,以决定是否又一次分配一个新的dst_nexthop。除非你一開始指定NOCACHE标志。否则差点儿都会在查找下一跳缓存失败进而创建新的dst_nexthop之后将其插入到下一跳缓存,以留给兴许的数据包发送时使用,这样就避免了每次又一次分配/释放新的内存空间。
    func IP_OUT:
        dst=lookup_fib_table(skb);
        dst_nexthop = loopup_redirect_nh(skb.daddr, dst);
        if dst_nexthop == NULL;
        then
            dst_nexthop=lookup_nh_cache(dst);
        endif
        if dst_nexthop == NULL;
        then
            dst_nexthop=alloc_entry(dst);
            if dst_nexthop.cache == true;
            then
                insert_into_nh_cache(dst_nexthop);
            endif
        endif
        neigh=bind_neigh(dst_nexthop);
        neigh.output(skb);
        if dst_nexthop.cache == false
        then
            release_entry(dst_nexthop);
        endif
    endfunc
    
    # IP_ROUTE_REDIRECT例程将创建或者更新一个dst_nexthop,并将其插入到一个链表中,该链表由数据包的目标地址作为查找键。
    func IP_ROUTE_REDIRECT:
        dst=lookup_fib_table(icmp.redirect.daddr);
        dst_nexthop = new_dst_nexthop(dst, icmp.redirect.newnexthop);
        insert_into_redirect_nh(dst_nexthop);
    endfunc

    以上就是3.6以后内核的下一跳缓存逻辑,值得注意。它并没有降低路由查找的开销,而是降低了内存分配/释放的开销。路由查找是绕只是去的。可是路由查找结果是路由项,它和下一跳结构体以及邻居结构体之间还有层次关系,其关系例如以下:
    路由项-下一跳结构体-邻居项
    一个数据包在发送过程中,必须在路由查找结束后绑定一个下一跳结构体,然后绑定一个邻居。路由表仅仅是一个静态表,数据通道没有权限改动它,它仅仅是用来查找。协议栈必须用查找到的路由项信息来构造一个下一跳结构体。这个时候就体现了缓存下一跳的重要性,由于它降低了构造的开销!

            最后,我们能够看一下效果。假设你仅仅是看代码,那么当你看到input或者output路径中的rt_dst_alloc调用时,你可能会非常灰心丧气。可是假设你使用下面的命令看一下实际结果:
    watch -d -n 1 “cat /proc/net/stat/rt_cache”
    的时候。你就会发现,in_slow_tot和out_slow_tot两个字段的计数器添加十分缓慢。甚至停滞!

    这意味着绝大多数的数据包在接收和发送过程中都命中了下一跳cache!假设你发现了异常,也就是说不是这样的情况,它们中的其一或者两者增长的非常快,那么可能是双方面的原因:
    1.你的内核可能没有升级到足够高的版本号
    这意味着你的内核有bug,在3.10的最初版本号中。RT_CACHE_STAT_INC(in_slow_tot);的调用是发生在下列代码之前的:

    if (res.fi) {
        if (!itag) {
            rth = rcu_dereference(FIB_RES_NH(res).nh_rth_input);
            if (rt_cache_valid(rth)) {
                skb_dst_set_noref(skb, &rth->dst);
                err = 0;
                goto out;
            }
            do_cache = true;
        }
    }
    
    rth = rt_dst_alloc(net->loopback_dev,
               IN_DEV_CONF_GET(in_dev, NOPOLICY), false, do_cache);
    ...
    也就是说它遗留了路由cache存在的年代的代码,错误的将下一跳缓存当成了路由cache。仅仅须要将RT_CACHE_STAT_INC(in_slow_tot)移植到rt_dst_alloc之后就可以。


    2.你可能使用了p2p设备。可是并没有正确的设置MTU
    我们知道ipip隧道设备在Linux上是一个虚拟网卡设备,数据包要真正发送出去要经过又一次封装一个IP头部的过程,假设终于是经由ethX发送数据,其MTU默认是1500,假设ipip隧道设备的MTU也是1500或者小于1500减去必要头部开销的话,就到导致又一次更新MTU的操作,而一个下一跳缓存中包括MTU信息,假设MTU须要又一次更新,就意味着下一跳缓存须要更新。

            在一般的物理设备中。这不是问题,由于往往在IP层发送数据前,MTU就是已经确知的。可是对于ipip隧道设备而言,在数据发送的时候,协议栈在实际往隧道发送数据前并不知道终于数据包须要再次封装。因此也就对MTU过大导致数据无法发送这件事不知情,特别是遇到gso,tso这样的情况。事情会更加复杂。此时我们有两个解决方式:
    1).适当调低ipip隧道的MTU值,保证即使经过再次封装,也只是长度过载。这样就不会导致又一次更新MTU进而释放更新下一跳cache。
    2).从代码入手!
    依据代码的rt_cache_valid来看,不要让下一跳缓存的标志变成DST_OBSOLETE_KILL就可以,而这也是和MTU相关的,而在__ip_rt_update_pmtu中。仅仅要保证下一跳缓存的初始mtu不为0就可以。这能够添加一个推断,在rt_dst_alloc之后,初始化rth字段的时候:

    if (dev_out->flags&(IFF_LOOPBACK|IFF_POINTOPOINT))
        rth->mtu = dev_out->mtu;
    else
        rth->mtu = 0;
    经过測试,效果良好!

    BTW,和非常多的安全协议一样。路由表项以及下一跳缓存也使用了版本号号来管理其有效性。仅仅有表项的ID和全局ID一致的时候,才代表该表项有效,这简化了刷新操作。当刷新发生的时候,仅仅须要递增全局版本号号ID就可以。

            现在,能够总结一下了。在Linux3.6以后,路由cache被去除了,取而代之的是下一跳缓存,这里面有非常多的蹊跷,比方有重定向路由的处理等...这主要是有效降低了内存管理的开销而不是查找本身的开销。在此要说一下内存的开销和查找的开销。

    二者并非一个层次的,内存的开销主要跟内存管理数据结构以及体系结构有关,这是一个复杂的范畴,而查找的开销相对简单,仅仅是跟算法的时间空间复杂度以及体系结构相关,然而为什么用查找的开销换内存的开销,这永远是一个无解的哲学问题!

  • 相关阅读:
    printcap
    browser-ua
    PHP 开发 APP 接口 学习笔记与总结
    Java实现 LeetCode 72 编辑距离
    Java实现 LeetCode 72 编辑距离
    Java实现 LeetCode 72 编辑距离
    Java实现 LeetCode 71 简化路径
    Java实现 LeetCode 71 简化路径
    Java实现 LeetCode 71 简化路径
    Java实现 LeetCode70 爬楼梯
  • 原文地址:https://www.cnblogs.com/yangykaifa/p/7091817.html
Copyright © 2011-2022 走看看