OpenVPN多实例优化的思考过程

1.sss

当构建组件之间的关系已经错综复杂到接近于一张完全图的时候,就要换一个思路了,或者你需要重构整个系统,或者你将重新实现一个。

2.TAP网卡和TUN网卡

2.1.TAP的优势

1.方便组网


可以把所有的OpenVPN节点,包括服务端和客户端看作是一台巨大的三层交换机,所有的TAP虚拟网卡组成一个虚拟的内部以太网,如果在某个节点,你将
物理网卡和TAP网卡Bridge在了一起,那么针对该物理网卡连接的网段,执行二层转发,如果没有进行这种Bridge,则执行三层转发。在下图模式
下,只要你在每台设备上开启了ARP
Proxy,并且所有的TAP网卡和物理网卡都Bridge在了一起,整个网络就全通了,无疑这是一种极端技巧性的方式:


剂猛药的最大效果就是,缩短了Net1/2/3之间的距离!本来它们之间可能通过N多跳才能到达,结果呢,现在通过TAP模式的OpenVPN将它们连接
在了一起,在不存在Br0的情况下就好像Net1/2/3中间只间隔一个三层设备,在存在Br0的情况下,更加猛,就好像你在一个以太网内叠加了多个不同
的IP网段,想通吗?简单,如果所有的机器上都配置一条force onlink的路由的话,一切都不在话下了。
       就此打住,点到为止。

2.管理方便

使用TAP网卡,你就像在管理一个局域网,以太网太方便管理了,有很多现成的工具和方案。

2.2.TAP的问题

1.Android的问题

Android明确说不支持TAP模式的网卡,难道是怕广播问题?不得而知,起码现在不支持。这就限制了TAP模式的OpenVPN在Android终端的使用,不过我已经有办法了,那就是在Android终端做一个TUN到TAP的适配器,前面的文章有谈及。

2.广播问题


想搞掉整个局域网怎么做,那就是抢占别人的IP然后发送免费ARP咯,抢IP的技术太多了,在TAP网卡群组成的虚拟以太网中,你也可以这么做,事实上你
还知道,OpenVPN服务端的IP地址是本段的第一个。搞瘫网络的第二个方法就是造就环路然后发广播,广播,广播,广播...

2.3.TUN的优势和问题

1.TUN的优势

一直以来我理解的TUN模式的OpenVPN优势比较不太明显,它封装的数据比TAP模式封装的数据少一个以太头,它采用点到点模式,无需链路层地址解析,事实上,没有链路层的协议只能采用点到点的模式。

2.TUN的问题

TUN
模式的OpenVPN组网比较复杂,不太适合网到网之间的连通。因为TUN模式的OpenVPN在服务端是认证IP地址而不是MAC地址,而网到网之间的
通信也是IP通信,因此要完成TUN模式的网到网通信,必须要服务端认识IP数据报的源IP地址才行,要做到这一点,就必须配置复杂的iroute,即内
部路由。
       虽然使用TUN网卡再加点脑汁也能实现超猛的组网逻辑,但是不像TAP网卡那么直观。在OpenVPN中使用TUN还有一个问题,那就是一个VPN节点会占掉两个IP地址,而这将限制server模式下接入客户端的数量。

2.4.先入为主的观念


直以来,我对TAP有一种偏好,因此就想在所有的场景中都使用TAP,即便Android等系统明确不支持TAP,宁可在Android上适配一个以太层
也不使用TUN。步入正文前我要先扯一段历史观,也算是近期的一些读后感吧。先入为主这种观念也许是必然而然的,也是所有文明发展的宿命,即过于早熟的东
西最容易被超越。我在高坂正尧的《文明衰亡论》中总结出了一个结论,那就是发达的文明(在本文中等价于观念)必然衰亡。原因是这样的。

文明的早期都是纯洁的,平等的,在共同的努力和纪律中奋发的,只要这样,才会进步,才会高尚。以上即内因,推及外因,也必含有利因素,你处在发展中而远非
发达,故而旁人不会盯着你,不会和你过不去,你只需学习它们的长处,而丝毫不会被它们的短处影响。然则一旦发达壮大,事情将有质的变化,过量的财富引发分
配问题,引发不均,引发权力不等,思想转攻而守,守何?既得利益也!发达状态好似站在高压水龙头上头冲浪,一切因素互为因果,只要一处崩坏或者做非善意的
变化,所有的一切顷刻崩塌,即便内因不变,外部环境的短处逐渐牵扯进来也会造成崩塌,美国在发展时期中国市场的变化对它没有影响,可是现在,它却需要时刻
关注中国这个巨大的市场。此也正如《安娜.卡列尼娜》最开始的那句”幸福的婚姻都是想似的,不幸的婚姻各有各的不幸“所一致。
       因此,发达等于僵化,也或者趋于僵化,只因为没法变化,只要旁人稍作努力便会超越之。罗马的崩塌,威尼斯的衰败,中国的早熟,皆如此。

所以,千万不要让一个观念在你的脑子里呆得太久。我最开始用TAP模式的OpenVPN加以Bridge以及DNAT完成了OpenVPN服务端多实例,
运行良好,所以往后只要涉及多实例就会想到它,毕竟付出的不是一个雨夜,成就的也不止百行代码,节省的更是数百的人日。如今遇到了各种的问题,我不会想是
不是TAP模式需要变化了一下了,而是把所有的问题归结为如何使其向TAP模式适配!事实上,只要采用TUN模式,大部分的问题都将不是问题了。

3.OpenVPN多实例的困境

OpenVPN
不支持多实例意味着如果你选择其运行在多处理器的设备上,将是一种巨额投资的浪费,因此OpenVPN在高大上服务器上一直不被看好。这不是
OpenVPN社区的错,这是自己的错。但是困境在于你如何着手去做这件事。我为此事困惑了3年,终究没有修成正果,然而收获总是有的,一有想法我就会分
享在博客,非工作QQ空间,论坛甚至非工作的微信朋友圈,得到了不少的批评和意见,总之在带给别人思路的同时,自己也在成长,这样的过程还将继续,前路还
很漫长...
       从我最开始接触OpenVPN,一直到现在,OpenVPN始终没有发展成一个巨型的像Apache那样的存在(being),而始终是一个功能单一的VPN隧道建立者(actor)。但是这并不意味着你只能用它来构建功能单一的VPN隧道,只是说一切都必须你自己来做。

3.1.基于网桥和DNAT的TAP多实例

3.1.1.借用iptables的random DNAT

Linux
的NAT是工作在ip
conntrack的基础之上的,这就意味着,只会针对一个数据流的第一个数据包进行NAT匹配动作,最终确定NAT的结果,然后将该结果保存在
conntrack结构体中(其实就是很简单的在conntrack被condirm之前修改了reply方向的tuple而已,这是一项创举)。这个流
程的效果就是一个数据流的每一个数据包的转换规则都是相同的,即Linux的iptables配置的NAT是针对数据流的。
       鉴于上述的Linux NAT特征,如果我配置一个random的DNAT,就能起到将到达同一端口的数据流分发到不同端口的目标:
iptables -t nat -A PREROUTING .... -j DNAT --random --to-destination $local:12345-12355

用这个特性,不需要开发任何模块就能实现在OpenVPN服务端多实例之间的负载均衡。然而问题在于你如何去维护具体的映射和实际的OpenVPN实例之
间的关系,这是一点典型的80/20问题,80%的框架性的问题有了解决方案,可是剩下的这20%的iptables规则与OpenVPN实例之间的关系
维护方面却可以把整个系统搞成一团乱麻。
      
虽然iptables可以将在一个连续的端口群中选择一个,但是第一,这个选择只能是随机的,不能有其它的调度策略,第二,你怎么保证它选择的那个端口一
定有进程与之bind,要解决这第二点问题,用户态的monitor服务将会非常复杂,第一个问题我认为不修改内核模块是无法解决的。
       不管怎么,用是可以用的,但这绝不是一个产品级的解决方案。

3.1.2.借用以太网广播的bridge


下来看如何管理多个OpenVPN实例产生的多个TAP网卡的问题。我的目标是让必须通过OpenVPN加密发送的流量可以被路由到正确的TAP网卡中。
显而易见,每一个通过OpenVPN传输的数据流都唯一得和一个OpenVPN服务端实例关联,进而唯一得和某一个TAP网卡关联,问题在于,通过何种机
制可以让数据包在多个TAP虚拟网卡选出正确的那个。
      
将不同的OpenVPN实例关联的TAP网卡划分到不同的子网是一种方案,但是使用TAP的优势之一不就是可以营造一个虚拟的以太网而受益吗?因此我希望
将所有的TAP网卡Bridge成一块虚拟的网桥。创意在此涌现。实际上,根本就不用做任何工作,只要将所有的TAP网卡Bridge起来,让
Bridge接管所有的TAP网卡本来的那相同的IP地址,同时清除被Bridge的TAP网卡的IP地址,此时,每一个TAP网卡就退化成了整个
Bridge的一个端口,针对特定下一跳的ARP回应和Bridge的端口学习机制就会自动地学习到哪个目标地址该发往哪个TAP网卡。
       但是你不觉得这完全是捡来的便宜吗?只要有任何一个条件不满足,TAP网卡就不能这么玩。不管怎么,用是可以用的,但这绝不是一个产品级的解决方案。

3.1.3.拼凑出来的巧合


有做任何的开发工作就既能满足在bind多端口的多个OpenVPN实例间负载均衡,又可以有效管理TAP网卡和OpenVPN实例之间的关系,这绝对是
拼凑出来的巧合,正是这个巧合把我拽进了TAP的深渊而不可自拔,不过毫不自夸的说,这也是一种能力,可以抄起身边能找到的任何家伙就上,知道拿起什么工
具能做什么事。不可否认,想到这两个借用可以印证我简历上曾经的那两个精通:精通Netfilter/iptables,精通Linux网络。不过我使用
那份简历的时候,可能还真的不是很懂细节,但是绝对知道怎么使用这些玩意儿,不过时刻了解自己的局限,并努力弥补,善莫大焉。后来我就慢慢地学习细节了。
要说明的是,深入细节前你必须会用它,否则就会迷失于细节。
      
同时,重要的不是你懂什么以及感悟到了什么,而是你能用这些完成什么事情,一开始甚至你都可以不懂细节,但是你得知道如何组装元素,这就是人和其它高等动
物的区别。黑猩猩懂得使用木棍,但它们不会用木棍去逮狼...事实上,人类几千年的文明都是建立在不懂细节的组装之上的。人类数万年前就会用火了,但是火
的本质百年前才被揭晓。

3.2.侦听多端口的外部调度多实例

(略)

4.豁然开朗的TUN多实例


经,我为TUN模式的OpenVPN设计了一个多实例模型,即将多个实例产生的TUN网卡做成一个Bonding网卡,然后将Bonding网卡配置成
Broadcast模式,这就是说每一个数据包都会往所有的TUN网卡上复制一份,那岂不是做了很多无用功?非也!我的意思是既然无法或者说很难在
Bonding层面做到“从哪个TUN进来,那么回包就从哪个TUN网卡返回”,那么就往所有的TUN都广播一份,由TUN网卡本身来决定是自己继续处理
呢,还是直接丢弃,为此我想到了TAP模式网卡的filter机制,还修改了TUN驱动,请看《绑定多个TAP网卡与绑定多个TUN网卡-附带TUN/TAP适配》,然而那是中毒太深的缘故,我现在已经放弃了那种方法。本节我将给出新方法从头到尾的思路。

在给出思路以及方案之前,我首先要肯定的是Broadcast模式的Bonding让TUN网卡自行抉择这个思想的创造性。这个思想非常棒,通过广播,每
个工作节点都会得到一份数据,然后由节点自身决定是否要处理,这种方式的负载均衡省去了中心调度节点的开销,避免了中心瓶颈和单点故障,事实
上,iptables的CLUSTERIP target就是这种思想的直接体现,细节请manual。

4.1.iptables已经成了一团乱麻


用iptables完成了太多的东西,如今整个系统中到处充斥着iptables规则,我已经理不清它们之间的关系了。我用iptables实现负载均
衡,用它做NAT,甚至是双向静态NAT,我用它来为数据包打不同的mark,以便实施policy
routing...总之,它成了类似bash那样的黏合剂。我定义了太多的自定义链,水平却远不如无线路由器厂商。诸多的iptables规则维护起来
复杂又低效,牵一发而动全身。就拿我用random
DNAT来做负载均衡来讲,我不得不不断monitor所有的进程,哪个挂掉之后还必须侦听同一个端口迅速将其拉起来。iptables规则和
OpenVPN进程,monitor进程以及内核之间没有任何接口,完全靠“蛛丝马迹”来互相通信,比如如果你知道Linux的某个藏得很深的特性,你就
能做某件事,如果不知道,就做不了。结果就是,整个系统就我一个人能全部搞定,因为系统完全是靠脆弱的技巧构建的,即便是我自己,时隔多年再见它的时候,
也会一头雾水后拍案惊奇。难道没有文档吗?没有,什么也没有,因为根本没法写,所有的东西都是易变的。
      
是时候改变这一切了。iptables的功能在manual中都有,凡是不在其中的,就不要硬用iptables来凑合。诚然,使用iptables技巧
性模拟负载均衡可以完成任务,但是那不是常规的做法,真正需要做的是去开发一个模块而不是勉强拼凑一些组件。

4.2.过分的UNIX哲学

最近在看《大教堂与集市
(绝对值得一读,除了怎么写代码,它什么都讲),Raymond非常前卫,极端且谦虚。他一直崇尚小工具,但是觉得一旦系统的复杂性超过一定限度,就要集
中控制。我虽然不是在说开发模式,但是同样的讨论也可以用在UNIX哲学上。我也一样,一直都喜欢用小的组件组装复杂的系统。不想开发大的C程序,而更喜
欢用C写小功能组件,然后用bash将其组合起来,甚至用iptables将其组合起来,反正只要不用编译的那种所见即所得的就成。

最终,我虽然不用C编程,然而却陷入了更麻烦的编程过程。事实上,编程的过程就是一个逻辑与流程的整理过程,和所使用的语言半毛钱关系都没有。虽然我避免
了使用switch-case,goto,do-while来编程,但是却要使用while-do-done,iptables -N,iptables
-F...一切更复杂了。

我总是觉得用脚本粘合小模块是一件低成本高收益的事,因为功能单一的小模块越多,它们的排列组合越多,可以构建的功能越丰富,重用度越高...可是我忽
略了组件间的沟通成本,当组件互连成一个接近完全图的蜘蛛网时,组合小组件相对于编写大程序的优势就不再了,组件之间的关系成了大程序本身!总之,不要用
粘合剂实现复杂逻辑,组件之间尽量不要双向依赖!这也许就是bash简单单向管道的妙处吧,这也许就是bash不支持复合数据结构的原因吧。

4.3.观感-组件化与集中化的博弈

到底应该组合功能单一的小组件还是编写一个大模块?这需要深思熟虑!

对于我要的OpenVPN负载均衡模块,我希望它是专门用于此目的的,对于已有的Linux
LVS,它太大了,用于OpenVPN有点喧宾夺主的意味,在此要记住的乃是我做负载均衡的目的仅仅是弥补OpenVPN不支持多处理器的这个缺陷,并不
是要做一个通用模块。如前所述,如果用iptables的DNAT实现的话,又太松散,很难集中控制。对此,我决定做一个内核模块来专门实现针对目前
OpenVPN的多实例负载均衡!

方案确定是令人愉悦的,但方案的最终设计却不得不斟酌,我的想法是让数据包绕过Linux标准协议栈实现在传输层的按端口寻socket的过程,如下图所示:

在Linux 3.10+的内核中对于UDP而言我们遇到了福音,因为它天然就支持了reuseport的负载均衡,和我上图一致!但是,我现在还在用2.6.32!
       上图是一个基本的框图,最终我的配置界面如下:
/**
*    proc
*    `-- lb_vpn
*    `-- node_info
*
*    node_info:
*    NAME        PID     PORT    WEIGHT
*    instance1   1234   61195     3  
*    instance2   2234   61197     8  
*    .....
*    up:                 echo +add $name $pid $port
*    client_connect:     echo +$pid
*    client_disconnect:  echo -$pid
*    down:                echo +del $name $pid $port
*/

proc下面创建一个lb_vpn目录,然后里面有一个node_info的可读写文件,如果你读它,展现出来的就是4个列:进程名,进程ID,进程
bind的端口,进程当前的连接数(即目前有多少OpenVPN客户端连接于其上)。如果在启动OpenVPN之前加载内核模块LB_VPN.ko的时
候,会生成该目录和文件,如果一个OpenVPN启动,其up脚本中有下面一行:
echo +add $ovpn_name $ovpn_pid $local_port
之后,如果有一个OpenVPN客户端接入,那么在client-connect脚本中,会有如下一行:
echo +$ovpn_pid
这意味着这个OpenVPN实例的负载又多了一个。 对于数据结构,我将每一个OpenVPN实例归到以下的内核数据结构中:

struct lb_node {
    struct list_head *list;
    struct heap_node *node;
    pid_t pid;
    __be16 port;
    unsigned int weight;
};

其中的list是一个线性的链表节点,用于随机取端口,而node则是一个排过序的堆节点,用于寻找weight最小的节点,关键就
看采用哪种算法了,对于client-connect中echo到node_info中的那一句,实际上就是递增了对应lb_node的weight值而
已。在内核的LB_VPN模块中,维护两个全局结构,一个list_head,一个heap,其中heap按照weight值进行插入。这种双重甚至多重
容器的链接在内核中很常见,每一种方式针对特定目的进行优化,比如vm_area_struct中就有两种链接方式:

struct vm_area_struct *vm_next, *vm_prev;   //用于遍历
struct rb_node vm_rb;                       //用于查找

4.4.突破NAT的实现

感谢翔叔,是翔叔自己实现了类似LVS的代码,也许是因为翔叔年纪大了,曾经搞过银河计算机的翔叔玩Linux依然威力不减当年。

叔的实现实际上是一个NAT,只是他老人家没有使用Netfilter,即没有在HOOK点上进行NAT,而是直接写在了ip_rcv中。这给了我启发。
对于多个OpenVPN实例的负载均衡实际上就是为一个连接选择一个OpenVPN实例侦听的端口,当然如果使用Linux
3.10+的内核,已经可以实现针对bind同一IP/Port的UDP
socket的random负载均衡,但是对于低版本的内核,由于REUSEPORT名不副实,你还得让不同的OpenVPN实例bind不同的端口。
      
具体来讲就是将到达同一OpenVPN端口的数据流负载到不同的目标端口,本质上就是做一个针对destination
port的端口转换。我在想在哪里做它会比较好,其实利用DNAT功能修改PREROUTING上的NAT实现会更加省力,但是更进一步,既然已经不准备
使用标准的DNAT(那是为iptables精心设计的HOOK点)了,还不如在INPUT这个HOOK上做,这样只针对到达本地的流量去判断是否需要转
换。注意,我们要放掉一切关于标准DNAT实现的固定思路,比如只能在路由前做DNAT之类的想法。在哪里都可以做DNAT,不但翔叔做到了,实际上
Cisco的做法也和Linux的iptables的不一致,不得不说,PREROUTING上做DNAT,POSTROUTING上做SNAT,这只是
为iptables而设计的,如果不用iptables了,那么你就自由实现吧。翔叔提供了思路和部分代码,但是另外一部分代码我准备重用
Netfilter的,因此我还是在Netfilter的框架内做HOOK函数。
       但是,我不能使用Netfilter为NAT准备的API,比如nf_nat_packet,nf_nat_setup_info之类的,因为那些API的实现中,明确限制了针对iptables的NAT用法,比如以下这段:

NF_CT_ASSERT(par->hooknum == NF_INET_PRE_ROUTING ||
             par->hooknum == NF_INET_LOCAL_OUT);

于是我不得不重新封装这些API,去掉这些FXXXING assert!然而冷静下来就会有更简单的做法,不就是转换一个目标端口嘛,何必这么复杂,自己实现难道不更简单吗?事实上,翔叔的成果可以直接用!在列出HOOK函数之前,看一下端口转换的实现:

int nf_lb_assign_port(struct sk_buff *skb, __be16 port, int dir, __be16 *savedptr)
{
    __be16 *portptr;
    __be32 ipaddr;
    struct iphdr *iph = (struct iphdr *)(skb->data + 0);
    unsigned int hdroff = iph->ihl*4;
    if (iph->protocol == IPPROTO_UDP) {
        struct udphdr *hdr;
        hdr = (struct udphdr *)(skb->data + hdroff);
        if (!skb_make_writable(skb, hdroff + sizeof(*hdr))){
            return 0;
        }
        /* 正向包的目标端口转换 */
        if (dir == IP_CT_DIR_ORIGINAL) {
            portptr = &hdr->dest;
            /* 如果不需要转换,则返回 */
            if (port == *portptr) {
                return 0;
            }
            ipaddr = iph->daddr;
        }
        /* 返回包的源端口恢复 */
        else {
            portptr = &hdr->source;
            ipaddr = iph->saddr;
        }
        if (hdr->check || skb->ip_summed == CHECKSUM_PARTIAL) {
            inet_proto_csum_replace4(&hdr->check, skb, ipaddr, ipaddr, 1);
            inet_proto_csum_replace2(&hdr->check, skb, *portptr, port, 0);
            if (!hdr->check) {
                hdr->check = CSUM_MANGLED_0;
            }
       }
    } else if (iph->protocol == IPPROTO_TCP) {
        //TODO
        return 0;
    } else {
        return 0;
    }
    *savedptr = *portptr;
    *portptr = port;

    return 1;
}

事实上,我没有用NAT模块的任何东西,无非就是简单的转换一个端口,转换后重新计算一下校验和即可。把下面的HOOK函数挂在INPUT点的conntrack confirm之前实现来自OpenVPN客户端的正向包的目标端口转换:

static unsigned int socket_balance_in (unsigned int hooknum,
                                      struct sk_buff *skb,
                                      const struct net_device *in,
                                      const struct net_device *out,
                                      int (*okfn)(struct sk_buff *))
{
    struct nf_conn *ct;
    enum ip_conntrack_info ctinfo;
    struct nf_conn_counter *acct;
    struct nf_conn_priv *dst_info;
    const struct iphdr *iph = ip_hdr(skb);
    __be16 real_port, dummy;
    __be16 *portptr;
    int dir;

    if (iph->protocol != IPPROTO_UDP &&
            iph->protocol != IPPROTO_TCP) {
        return NF_ACCEPT;
    }

    ct = nf_ct_get(skb, &ctinfo);
    if (!ct || ct == &nf_conntrack_untracked)
        return NF_ACCEPT;
    acct = nf_conn_acct_find(ct);
    if (acct) {
        dir = CTINFO2DIR(ctinfo);
        if (dir == IP_CT_DIR_REPLY) {
            return NF_ACCEPT;
        }
        dst_info = (struct nf_conn_priv *)acct;
        real_port = dst_info->nport;
        portptr = &dummy;
        /* 仅针对一个流的头包去找一个合适的端口,保存在conntrack中,
         * 后续的包直接取出来用,保证同一个流被负载到一个特定的端口
         **/
        if (ctinfo == IP_CT_NEW) {
            unsigned int ok;
            /* 仅仅针对特定的端口进行负载均衡分发 */
            ok = check_policy(skb);
            if (!ok) {
                return NF_ACCEPT;
            }
            /* 找到一个特定的目标端口,保存,并保留原始端口 */
            real_port = find_port();
            portptr = &(dst_info->oport);
            dst_info->nport = real_port;
        }
        if (real_port == 0) {
            return NF_ACCEPT;
        }
        /* 实施目标端口转换 */
        if (!nf_lb_assign_port(skb, real_port, dir, portptr)) {
            *portptr = 0;
            dst_info->nport = 0;
            return NF_ACCEPT;
        }
        /* 如果转换成功,别忘了同时转换conntrack的tuple */
        if (ctinfo == IP_CT_NEW && !nf_ct_is_confirmed(ct)) {
            ct->tuplehash[IP_CT_DIR_REPLY].tuple.src.u.udp.port = real_port;
        }
    }
    return NF_ACCEPT;
}

以上的代码没有任何创造性,就是按部就班。唯一的创意来自conntrack的tuple管理,你只能在conntrack还是
NEW状态(肯定是正向)且还未confirm的时候转换了IP地址或者端口,转换后将反向的tuple更改一下即可,其它的什么都不需要做!把下面的
HOOK函数挂在OUTPUT点的conntrack之后实现回到OpenVPN客户端的反向包的源端口恢复:

static unsigned int socket_balance_out (unsigned int hooknum,
                                      struct sk_buff *skb,
                                      const struct net_device *in,
                                      const struct net_device *out,
                                      int (*okfn)(struct sk_buff *))
{
    struct nf_conn *ct;
    enum ip_conntrack_info ctinfo;
    struct nf_conn_counter *acct;
    struct nf_conn_priv *dst_info;
    const struct iphdr *iph = ip_hdr(skb);
    __be16 real_port, dummy;
    int dir;

    if (iph->protocol != IPPROTO_UDP &&
            iph->protocol != IPPROTO_TCP) {
        return NF_ACCEPT;
    }

    ct = nf_ct_get(skb, &ctinfo);
    if (!ct || ct == &nf_conntrack_untracked)
        return NF_ACCEPT;
    acct = nf_conn_acct_find(ct);
    if (acct) {
        dir = CTINFO2DIR(ctinfo);
        /* 仅针对返回包做端口恢复 */
        if (dir == IP_CT_DIR_ORIGINAL) {
            return NF_ACCEPT;
        }
        dst_info = (struct nf_conn_priv *)acct;
        /* 取出保存的原始端口 */
        real_port = dst_info->oport;
        if (real_port == 0) {
            return NF_ACCEPT;
        }
        if (!nf_lb_assign_port(skb, real_port, dir, &dummy)) {
            return NF_ACCEPT;
        }
    }
    return NF_ACCEPT;
}

4.4.1.直接Assign一个socket

看了tproxy的代码之后,就冒出一个想法:所谓的传
输层端口其实就是为了定位socket用的,如果能直接赋予skb一个socket,端口就无所谓了,比如一个UDP数据包的目标端口是1234,这个
1234的作用就是为了定位一个UDP
socket,那如果我事先用另外一种方式找了一个socket赋予这个数据包,这个1234就没有用了,是不是这样子呢?我们来看一下代
码,__udp4_lib_rcv是Linux的UDP接收函数,其中定位socket的那句是:

sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest, udptable);
static inline struct sock *__udp4_lib_lookup_skb(struct sk_buff *skb,
                         __be16 sport, __be16 dport,
                         struct udp_table *udptable)
{
    struct sock *sk;
    const struct iphdr *iph = ip_hdr(skb);

    if (unlikely(sk = skb_steal_sock(skb)))
        return sk;
    else
        return __udp4_lib_lookup(dev_net(skb_dst(skb)->dev), iph->saddr, sport,
                     iph->daddr, dport, inet_iif(skb),
                     udptable);
}

看一下skb_steal_sock这一句,它的含义正是,如果skb已经关联了一个sk,那么就直接返回它,否则再去按照UDP的
4元组来查找。从这里我们可以看出,定位socket的方式不止按协议4元组查找这一种!那么我们用什么来定位socket呢?答案还是使用
__udp4_lib_lookup。我依然启动多个OpenVPN进程bind不同的端口,然后在这几个端口中按照负载均衡算法(随机或者按照当前连接
数)选择一个,赋予一个流的头包并保存在conntrack中,不需要转换skb中的端口,直接为skb的sk字段赋值即可!
      
我们来看一下这个做法有什么意义,它完全绕过了Linux协议栈的第4层定位逻辑,只需要针对NEW状态的一个流的第一个数据包进行一次负载均衡计算定位
一个port,然后进行一次__udp4_lib_lookup查找,之后保存在conntrack结构体中,同一个流的后续的数据包可以直接取用这个
socket,完全省去了__udp4_lib_lookup的过程!不过值得注意的是,由于没有针对数据包本身进行任何修改,建议OpenVPN客户端
要使用nobind参数随机选取源端口,否则很可能多个连接会被归并到一个conntrack结构体从而总是负载了一个OpenVPN实例中。具体的端口
定位逻辑代码如下:

__be16 find_port()
{  
    int i = 0;
    static unsigned int inner_index = 0;
    struct lb_node *lb = NULL;
    struct list_head *l;
    index = random32()%curr_count;
    read_lock(&lb_list_lock);
    list_for_each(l, &lb_list) {
        i++;
        if (i == index) {
            __be16 port;
            lb = list_entry(l, struct lb_node, list);
            port = lb->port;
            //TODO check port
            break;
        }
    }
    read_unlock(&lb_list_lock);
    return lb->port;
}

然后再调用__udp4_lib_lookup即可:

__be16 port = find_port();
// 一般不会用到uh->source
sk = __udp4_lib_lookup_skb(skb, uh->source, port, udptable);
skb->sk = sk;

这么好的办法,为何我不用呢?难道没有翔叔罩着?是啊。但是另外的原因是,维护socket的引用计数是一件很烦人的工作。但是根本的原因是:我并没有说不用它。

VPN隧道建立方面的多实例负载均衡已经解决,下面看一下数据流在TUN虚拟网卡间如何分发。

4.5.TUN网卡不能bridge


最初得到TAP模式OpenVPN多实例方案的时候,兴奋了一阵子,因为那纯粹是空手套白狼,毕竟什么都不是自己开发的,靠两个完美的借用完成了设计。借
用Brdige对ARP的广播以及对ARP回应的端口学习完成了在多个TAP网卡中选择一个的任务,借用random
DNAT以及ip_conntrack完成了VPN连接在多个OpenVPN服务端实例上负载均衡。也许正是这两个如此廉价的借用才让我如此痴迷于TAP
模式,期待廉价的午餐再次滴落。
      
在Android上将TUN适配成TAP并不难,难的是如何以及以什么理由来促成这件事。做产品不是写诗拍电影,有时缺一些创意反而会更好,创意应该付诸
设计,而不应付诸实现。换句话说,实现中创意是不好的,创意应该在设计阶段终结。不能被自己的感情因素左右技术实现。因此既然Android不能支持
TAP,那么一群Android设备的接入,何必不用TUN模式的OpenVPN服务端呢?不是不能用,而是怕困难难以克服。什么困难呢?TUN网卡不能
Bridge,又难以Bonding,因此TUN网卡群就不好像TAP网卡群那样对外呈现出一块网卡了...但是这个问题貌似必须解决。

4.5.1.何必非要展现出一块虚拟网卡


出一个问题比解决它更重要,引申一点就是,如果提出了一个问题,怎么证明这个问题是有意义的呢?事实上不能证明。在解决某个问题遇到困难的时候,停下来问
一下这个问题有没有意义是必要的。TUN模式的网卡群难以合并成一块虚拟的网卡,不管是bridge还是Bonding,即便可以Bonding,还是难
以管理,你不得不在OpenVPN的up/down脚本中去ifenslave。那么反问一下,为何非要将所有TUN网卡合并到一个虚拟的网卡呢?到底是
什么原因让我非这样做不可呢?
      
答案是模糊的,因为根本就没有非如此不可的必要因素。部分原因只是因为我习惯了在TAP模式下时将多块TAP网卡合成一块,而之所以会这么做,根本原因在
于TAP网卡是模拟以太网的,而不管是Bridge还是Bonding都是专门针对以太网的。到此为止,一切都明了了,我一直都在死胡同里面,事实上,我
一直都妄想将以太网的特性应用在TUN网卡上,以图它能给我带来一些利益。事实上,TUN网卡群完全可以独立呈现在系统中,比如我启动了5个
OpenVPN进程,那么TUN网卡就是tun0~tun4一共5块。

4.5.2.多实例多网段

在得到根本没有必要在多个
OpenVPN的TUN网卡之间建立任何关联这个让人清爽的事实后,下一步就是划分子网了。如果我规划了130.130.0.0/16这个大网段给所有的
m个OpenVPN实例,那么对于每一个OpenVPN,只需要给它划分总容量的1/m大小的网段即可了,还可以根据OpenVPN实例的不同权值给与加
权分割子网。如此一来,m个OpenVPN服务端在启动了自己的TUN网卡后,会把自己的子网的网段路由加入到系统路由表,从某个OpenVPN实例过来
的IP数据流在返回的时候,可以自动通过路由来寻址到正确的TUN网卡群中的一个,从而经过它来的时候那个OpenVPN实例加密后返回。
       但是还有更猛的方案。

4.5.3.多实例单网段


并不是一个显而易见的方案,需要一番思考以及对Linux的IP路由以及ip
conntrack非常熟悉才能理解。简单讲就是所有的m个OpenVPN实例共享一个IP网段,比如130.130.0.0/16,那个所有的
OpenVPN服务端实例的TUN网卡的IP地址均是130.130.0.1,只要在所有的OpenVPN服务端的client-connect脚本中为
每一个OpenVPN客户端(不管它连接到了哪个OpenVPN服务端实例)在全局池里面分配一个不重复的IP即可。
      
这怎么可能?OpenVPN服务端的所有实例的TUN网卡的IP地址不明显冲突了吗?是的,是冲突了。地址冲突带来的是直连路由的冲突。在以太网上,同一
机器的多个网卡地址冲突还可能导致流量的截获或者ARP混乱等。然而,忘掉以太网吧,我们现在面临的是点对点的TUN网卡群,对于TUN网卡,第一,它不
需要链路层地址解析,其次,它根本就不需要链路层封装。因此只要保证一个数据流从哪个TUN网卡进来,该数据流的返回流量从哪个TUN网卡出去即可。而从
哪个TUN网卡进来是远端的OpenVPN客户端决定的,由此看来,TUN模式下只要能将一个流的正向进入的TUN网卡记录在流本身,返回数据就可以直接
取出该TUN网卡调用xmit发送了,幸好它是不需要封装链路层的帧头。
       TUN网卡不需要封装帧头从而可以直接调用dev_queue_xmit发送是很有意思的,真是失之东隅,收之桑榆啊。不得不承认,这个特点又是一次空手套白狼的借用!
       实施起来非常容易,只要你知道如何在ip_conntrack结构体中记录信息即可,而这在我的另一篇文章《如何扩展Linux的ip_conntrack中被详细描述 
       代码很容易,直接将下面的HOOK函数挂在PREROUTING的conntrack优先级之后即可:

static unsigned int ipv4_conntrack_setdst (unsigned int hooknum,
                                      struct sk_buff *skb,
                                      const struct net_device *in,
                                      const struct net_device *out,
                                      int (*okfn)(struct sk_buff *))
{
    struct nf_conn *ct;
    enum ip_conntrack_info ctinfo;
    struct nf_conn_counter *acct;
    struct nf_conn_priv *dst_info;
    ct = nf_ct_get(skb, &ctinfo);
    if (!ct || ct == &nf_conntrack_untracked)
        return NF_ACCEPT;
    acct = nf_conn_acct_find(ct);
    if (acct) {
        struct net_device *dev;
        int dir = CTINFO2DIR(ctinfo);
        dst_info = (struct nf_conn_priv *)acct;
        /* 仅仅针对NEW状态的数据流头包保存TUN设备到conntrack中 */
        if (dir == IP_CT_DIR_ORIGINAL && ctinfo == IP_CT_NEW) {
            dev = skb->dev;
            /* 仅仅“借用”不需要封装链路层的网卡采用快速转发 */
            if (dev &&
                   (dev->flags & (IFF_NOARP | IFF_POINTOPOINT))) {
                dst_info->dev_out = skb->dev;
            }
        }
        /* 如果是反方向的回包,直接跳过路由查询进行快速转发
         * 类似的思想还可用于conntrack保存路由项,直接调用
         * dst->output的话即使是需要封装链路层也无所谓
         */
        else if (dir == IP_CT_DIR_REPLY) {
            dev = dst_info->dev_out;
            if (dev && dev != skb->dev &&
                   (dev->flags & (IFF_NOARP | IFF_POINTOPOINT))) {
                return xmit_packet(skb, dev);
            }
        }
    }
    return NF_ACCEPT;
}

如果也需要针对本机,那就将下面的HOOK函数挂在OUTPUT的conntrack之后:

static unsigned int ipv4_conntrack_setdst_local (unsigned int hooknum,
                                      struct sk_buff *skb,
                                      const struct net_device *in,
                                      const struct net_device *out,
                                      int (*okfn)(struct sk_buff *))
{
    struct nf_conn *ct;
    enum ip_conntrack_info ctinfo;
    struct nf_conn_counter *acct;
    struct nf_conn_priv *dst_info;
    ct = nf_ct_get(skb, &ctinfo);
    if (!ct || ct == &nf_conntrack_untracked)
        return NF_ACCEPT;
    acct = nf_conn_acct_find(ct);
    if (acct) {
        struct net_device *dev;
        int dir = CTINFO2DIR(ctinfo);
        dst_info = (struct nf_conn_priv *)acct;
        if (dir == IP_CT_DIR_ORIGINAL) {
            return NF_ACCEPT;
        } else if (dir == IP_CT_DIR_REPLY) {
            dev = dst_info->dev_out;
            if (dev &&
                   (dev->flags & (IFF_NOARP | IFF_POINTOPOINT))) {
                struct iphdr *iph = (struct iphdr *)(skb->data + 0);
                return xmit_packet(skb, dev);
            }
        }
    }
    return NF_ACCEPT;
}

xmit函数很简单:

static unsigned int xmit_packet(struct sk_buff *skb,
                                struct net_device *dev)
{
    if (dev &&
        (dev->flags & (IFF_NOARP | IFF_POINTOPOINT))) {
        skb->dev = dev;
        dev_queue_xmit(skb);
        return NF_STOLEN;
    }
    return NF_ACCEPT;
}

4.5.4.优化-连接跟踪记录TUN设备

4.5.5.优化-PF_RING替代TAP/TUN

4.5.6.优化-Direct Path From Intel82599 To OpenVPN

5.Bomb,the boss chair

OpenVPN多实例优化的思考过程

时间: 2024-10-10 12:46:44

OpenVPN多实例优化的思考过程的相关文章

对SQL语句优化的思考

软件在研发的过程中自始至终都在留意着系统的可扩展性,但与此同时也在关注着系统的性能,SQL语句作为系统性能的一环不容忽视,从今天开始结合开发的经验,谈一下我对SQL语句优化的理解和认知: 1.在联合查询语句中做到小表驱动大表: 联合查询是常用到的一种查询方式,左连接.右连接.内连接等等时不时地被应用在查询语句中,然而在这一过程中如果能判明各表的数据量,那就再好不过了,在这种情况下from后面应该紧跟数据量小的表,为什么?呵呵呵,比如a表有1000条数据,b表有20条数据,使用左连接进行联合查询如

Java 类的实例变量初始化的过程 静态块、非静态块、构造函数的加载顺序

Java 类的实例变量初始化的过程 静态块.非静态块.构造函数的加载顺序 先看一道Java面试题: 1 public class Baset { 2 private String baseName = "base"; 3 // 构造方法 4 public Baset() { 5 callName(); 6 } 7 // 成员方法 8 public void callName() { 9 // TODO Auto-generated method stub 10 System.out.p

hdu6078[优化递推过程] 2017多校4

/*hdu6078[优化递推过程] 2017多校4*/ #include <bits/stdc++.h> using namespace std; typedef long long LL; const LL MOD = 998244353LL; int T, m, n, a[2005], b[2005]; LL sum[2005][3], dp[2005][3]; void solve() { LL ans = 0; for (int i = 1; i <= n; i++) { LL

我帮网友解决问题的思考过程

还是和平常一样,我习惯在各个群里穿梭,一来了解最新的技术,二来收集各种各样的资料,知识点,以便提高自己,三来从中娱乐,获得快乐,四来交一些志同道合的朋友,朋友多了路好走,五来其他.就这样我发现一个群友在群里面提出这样的一个问题. 其实我最喜欢这样的问题了,数据库嘛,我擅长的,一方面复习,一方面算是帮助他人吧!获得认可.刚看到这个问题我还看走眼了以为它的需求错了,我的回答是这样的.是我错了! 我知道自己错了,当然就要改了,我得承认.上面已经承认了. 接下来我就建表,加数据,然后模拟了! 表格和数据

Android app 性能优化的思考--性能卡顿不好的原因在哪?

说到 Android 系统手机,大部分人的印象是用了一段时间就变得有点卡顿,有些程序在运行期间莫名其妙的出现崩溃,打开系统文件夹一看,发现多了很多文件,然后用手机管家 APP 不断地进行清理优化 ,才感觉运行速度稍微提高了点,就算手机在各种性能跑分软件面前分数遥遥领先,还是感觉无论有多大的内存空间都远远不够用.相信每个使用 Android 系统的用户都有过以上类似经历,确实,Android 系统在流畅性方面不如 IOS 系统,为何呢,明明在看手机硬件配置上时,Android 设备都不会输于 IO

【开卷有益】记录一次高并发下的死锁解决思考过程

开卷有益,好久没写博客了,最近工作也挺忙的. 死锁距离我不遥远,终于还是在高并发时被我碰到了. DeadLock Found! 尽管编程风格中会尽量避免死锁,但是还是被我碰上了.文章可能看不出来我在做什么事情,只是记录自己的一个排除死锁的过程. 事情起源于两个联动的缓存+redis+异步数据库读写操作. 事务中的这句出现死锁: DELETE FROM table WHERE FROM key = 'helloworld' 当初的思考解除死锁的思路如下: 1)分析死锁模型,假设1,2 两个线程,假

makefile实例(3)-多个文件实例优化

我们先看一下make是如何工作的在默认的方式下,也就是我们只输入make命令.那么,1.make会在当前目录下找名字叫“Makefile”或“makefile”的文件.2.如果找到,它会找文件中的第一个目标文件(target),在上面的例子中,他会找到“edit”这个文件,并把这个文件作为最终的目标文件.3.如果edit文件不存在,或是edit所依赖的后面的 .o 文件的文件修改时间要比edit这个文件新,那么,他就会执行后面所定义的命令来生成edit这个文件.4.如果edit所依赖的.o文件也

【机房重构】——上下机之思考过程

做上下机的时候,刚开始没有头绪的.总觉得下机好麻烦,还要有好多计算.后来有一个小想法,想在界面动态显示消费时间,于是下面的思考就出现了. 原思路: 以上就是我最初的思路,知道要用策略模式,但是不知道怎么去写啊,怎么办?先把功能实现了再说策略模式吧! 当与同学交流后发现,我的所有更新都是在下机之后更新的.这样做会出现两种问题: 1.程序故障以及断电故障:会出现数据丢失更新问题: 2.当查询上机记录或上机状态的时候,消费时间以及消费金额,余额等是没有发生变化的,不能及时查询上机动态. 这就是延迟更新

提高系统性能——对SQL语句优化的思考

软件在研发的过程中自始至终都在留意着系统的可扩展性.但与此同一时候也在关注着系统的性能,SQL语句作为系统性能的一环不容忽视.从今天開始结合开发的经验,谈一下我对SQL语句优化的理解和认知: 1.在联合查询语句中做到小表驱动大表: 联合查询是经常使用到的一种查询方式,左连接.右连接.内连接等等时不时地被应用在查询语句中,然而在这一过程中假设能判明各表的数据量,那就再好只是了,在这样的情况下from后面应该紧跟数据量小的表.为什么?呵呵呵,比方a表有1000条数据,b表有20条数据.使用左连接进行