1.sss
当构建组件之间的关系已经错综复杂到接近于一张完全图的时候,就要换一个思路了,或者你需要重构整个系统,或者你将重新实现一个。
2.TAP网卡和TUN网卡
2.1.TAP的优势
1.方便组网
你
可以把所有的Open×××节点,包括服务端和客户端看作是一台巨大的三层交换机,所有的TAP虚拟网卡组成一个虚拟的内部以太网,如果在某个节点,你将
物理网卡和TAP网卡Bridge在了一起,那么针对该物理网卡连接的网段,执行二层转发,如果没有进行这种Bridge,则执行三层转发。在下图模式
下,只要你在每台设备上开启了ARP
Proxy,并且所有的TAP网卡和物理网卡都Bridge在了一起,整个网络就全通了,无疑这是一种极端技巧性的方式:
此
剂猛药的最大效果就是,缩短了Net1/2/3之间的距离!本来它们之间可能通过N多跳才能到达,结果呢,现在通过TAP模式的Open×××将它们连接
在了一起,在不存在Br0的情况下就好像Net1/2/3中间只间隔一个三层设备,在存在Br0的情况下,更加猛,就好像你在一个以太网内叠加了多个不同
的IP网段,想通吗?简单,如果所有的机器上都配置一条force onlink的路由的话,一切都不在话下了。
就此打住,点到为止。
2.管理方便
使用TAP网卡,你就像在管理一个局域网,以太网太方便管理了,有很多现成的工具和方案。
2.2.TAP的问题
1.Android的问题
Android明确说不支持TAP模式的网卡,难道是怕广播问题?不得而知,起码现在不支持。这就限制了TAP模式的Open×××在Android终端的使用,不过我已经有办法了,那就是在Android终端做一个TUN到TAP的适配器,前面的文章有谈及。
2.广播问题
你
想搞掉整个局域网怎么做,那就是抢占别人的IP然后发送免费ARP咯,抢IP的技术太多了,在TAP网卡群组成的虚拟以太网中,你也可以这么做,事实上你
还知道,Open×××服务端的IP地址是本段的第一个。搞瘫网络的第二个方法就是造就环路然后发广播,广播,广播,广播...
2.3.TUN的优势和问题
1.TUN的优势
一直以来我理解的TUN模式的Open×××优势比较不太明显,它封装的数据比TAP模式封装的数据少一个以太头,它采用点到点模式,无需链路层地址解析,事实上,没有链路层的协议只能采用点到点的模式。
2.TUN的问题
TUN
模式的Open×××组网比较复杂,不太适合网到网之间的连通。因为TUN模式的Open×××在服务端是认证IP地址而不是MAC地址,而网到网之间的
通信也是IP通信,因此要完成TUN模式的网到网通信,必须要服务端认识IP数据报的源IP地址才行,要做到这一点,就必须配置复杂的iroute,即内
部路由。
虽然使用TUN网卡再加点脑汁也能实现超猛的组网逻辑,但是不像TAP网卡那么直观。在Open×××中使用TUN还有一个问题,那就是一个×××节点会占掉两个IP地址,而这将限制server模式下接入客户端的数量。
2.4.先入为主的观念
一
直以来,我对TAP有一种偏好,因此就想在所有的场景中都使用TAP,即便Android等系统明确不支持TAP,宁可在Android上适配一个以太层
也不使用TUN。步入正文前我要先扯一段历史观,也算是近期的一些读后感吧。先入为主这种观念也许是必然而然的,也是所有文明发展的宿命,即过于早熟的东
西最容易被超越。我在高坂正尧的《文明衰亡论》中总结出了一个结论,那就是发达的文明(在本文中等价于观念)必然衰亡。原因是这样的。
文明的早期都是纯洁的,平等的,在共同的努力和纪律中奋发的,只要这样,才会进步,才会高尚。以上即内因,推及外因,也必含有利因素,你处在发展中而远非
发达,故而旁人不会盯着你,不会和你过不去,你只需学习它们的长处,而丝毫不会被它们的短处影响。然则一旦发达壮大,事情将有质的变化,过量的财富引发分
配问题,引发不均,引发权力不等,思想转攻而守,守何?既得利益也!发达状态好似站在高压水龙头上头冲浪,一切因素互为因果,只要一处崩坏或者做非善意的
变化,所有的一切顷刻崩塌,即便内因不变,外部环境的短处逐渐牵扯进来也会造成崩塌,美国在发展时期中国市场的变化对它没有影响,可是现在,它却需要时刻
关注中国这个巨大的市场。此也正如《安娜.卡列尼娜》最开始的那句”幸福的婚姻都是想似的,不幸的婚姻各有各的不幸“所一致。
因此,发达等于僵化,也或者趋于僵化,只因为没法变化,只要旁人稍作努力便会超越之。罗马的崩塌,威尼斯的衰败,中国的早熟,皆如此。
所以,千万不要让一个观念在你的脑子里呆得太久。我最开始用TAP模式的Open×××加以Bridge以及DNAT完成了Open×××服务端多实例,
运行良好,所以往后只要涉及多实例就会想到它,毕竟付出的不是一个雨夜,成就的也不止百行代码,节省的更是数百的人日。如今遇到了各种的问题,我不会想是
不是TAP模式需要变化了一下了,而是把所有的问题归结为如何使其向TAP模式适配!事实上,只要采用TUN模式,大部分的问题都将不是问题了。
3.Open×××多实例的困境
Open×××
不支持多实例意味着如果你选择其运行在多处理器的设备上,将是一种巨额投资的浪费,因此Open×××在高大上服务器上一直不被看好。这不是
Open×××社区的错,这是自己的错。但是困境在于你如何着手去做这件事。我为此事困惑了3年,终究没有修成正果,然而收获总是有的,一有想法我就会分
享在博客,非工作QQ空间,论坛甚至非工作的微信朋友圈,得到了不少的批评和意见,总之在带给别人思路的同时,自己也在成长,这样的过程还将继续,前路还
很漫长...
从我最开始接触Open×××,一直到现在,Open×××始终没有发展成一个巨型的像Apache那样的存在(being),而始终是一个功能单一的×××隧道建立者(actor)。但是这并不意味着你只能用它来构建功能单一的×××隧道,只是说一切都必须你自己来做。
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
借
用这个特性,不需要开发任何模块就能实现在Open×××服务端多实例之间的负载均衡。然而问题在于你如何去维护具体的映射和实际的Open×××实例之
间的关系,这是一点典型的80/20问题,80%的框架性的问题有了解决方案,可是剩下的这20%的iptables规则与Open×××实例之间的关系
维护方面却可以把整个系统搞成一团乱麻。
虽然iptables可以将在一个连续的端口群中选择一个,但是第一,这个选择只能是随机的,不能有其它的调度策略,第二,你怎么保证它选择的那个端口一
定有进程与之bind,要解决这第二点问题,用户态的monitor服务将会非常复杂,第一个问题我认为不修改内核模块是无法解决的。
不管怎么,用是可以用的,但这绝不是一个产品级的解决方案。
3.1.2.借用以太网广播的bridge
接
下来看如何管理多个Open×××实例产生的多个TAP网卡的问题。我的目标是让必须通过Open×××加密发送的流量可以被路由到正确的TAP网卡中。
显而易见,每一个通过Open×××传输的数据流都唯一得和一个Open×××服务端实例关联,进而唯一得和某一个TAP网卡关联,问题在于,通过何种机
制可以让数据包在多个TAP虚拟网卡选出正确的那个。
将不同的Open×××实例关联的TAP网卡划分到不同的子网是一种方案,但是使用TAP的优势之一不就是可以营造一个虚拟的以太网而受益吗?因此我希望
将所有的TAP网卡Bridge成一块虚拟的网桥。创意在此涌现。实际上,根本就不用做任何工作,只要将所有的TAP网卡Bridge起来,让
Bridge接管所有的TAP网卡本来的那相同的IP地址,同时清除被Bridge的TAP网卡的IP地址,此时,每一个TAP网卡就退化成了整个
Bridge的一个端口,针对特定下一跳的ARP回应和Bridge的端口学习机制就会自动地学习到哪个目标地址该发往哪个TAP网卡。
但是你不觉得这完全是捡来的便宜吗?只要有任何一个条件不满足,TAP网卡就不能这么玩。不管怎么,用是可以用的,但这绝不是一个产品级的解决方案。
3.1.3.拼凑出来的巧合
没
有做任何的开发工作就既能满足在bind多端口的多个Open×××实例间负载均衡,又可以有效管理TAP网卡和Open×××实例之间的关系,这绝对是
拼凑出来的巧合,正是这个巧合把我拽进了TAP的深渊而不可自拔,不过毫不自夸的说,这也是一种能力,可以抄起身边能找到的任何家伙就上,知道拿起什么工
具能做什么事。不可否认,想到这两个借用可以印证我简历上曾经的那两个精通:精通Netfilter/iptables,精通Linux网络。不过我使用
那份简历的时候,可能还真的不是很懂细节,但是绝对知道怎么使用这些玩意儿,不过时刻了解自己的局限,并努力弥补,善莫大焉。后来我就慢慢地学习细节了。
要说明的是,深入细节前你必须会用它,否则就会迷失于细节。
同时,重要的不是你懂什么以及感悟到了什么,而是你能用这些完成什么事情,一开始甚至你都可以不懂细节,但是你得知道如何组装元素,这就是人和其它高等动
物的区别。黑猩猩懂得使用木棍,但它们不会用木棍去逮狼...事实上,人类几千年的文明都是建立在不懂细节的组装之上的。人类数万年前就会用火了,但是火
的本质百年前才被揭晓。
3.2.侦听多端口的外部调度多实例
(略)
4.豁然开朗的TUN多实例
曾
经,我为TUN模式的Open×××设计了一个多实例模型,即将多个实例产生的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规则和
Open×××进程,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.观感-组件化与集中化的博弈
到底应该组合功能单一的小组件还是编写一个大模块?这需要深思熟虑!
对于我要的Open×××负载均衡模块,我希望它是专门用于此目的的,对于已有的Linux LVS,它太大了,用于Open×××有点喧宾夺主的意味,在此要记住的乃是我做负载均衡的目的仅仅是弥补Open×××不支持多处理器的这个缺陷,并不 是要做一个通用模块。如前所述,如果用iptables的DNAT实现的话,又太松散,很难集中控制。对此,我决定做一个内核模块来专门实现针对目前 Open×××的多实例负载均衡!
方案确定是令人愉悦的,但方案的最终设计却不得不斟酌,我的想法是让数据包绕过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的端口,进程当前的连接数(即目前有多少Open×××客户端连接于其上)。如果在启动Open×××之前加载内核模块LB_×××.ko的时
候,会生成该目录和文件,如果一个Open×××启动,其up脚本中有下面一行:
echo +add $ovpn_name $ovpn_pid $local_port
之后,如果有一个Open×××客户端接入,那么在client-connect脚本中,会有如下一行:
echo +$ovpn_pid
这意味着这个Open×××实例的负载又多了一个。 对于数据结构,我将每一个Open×××实例归到以下的内核数据结构中:
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_×××模块中,维护两个全局结构,一个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中。这给了我启发。
对于多个Open×××实例的负载均衡实际上就是为一个连接选择一个Open×××实例侦听的端口,当然如果使用Linux
3.10+的内核,已经可以实现针对bind同一IP/Port的UDP
socket的random负载均衡,但是对于低版本的内核,由于REUSEPORT名不副实,你还得让不同的Open×××实例bind不同的端口。
具体来讲就是将到达同一Open×××端口的数据流负载到不同的目标端口,本质上就是做一个针对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之前实现来自Open×××客户端的正向包的目标端口转换:
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之后实现回到Open×××客户端的反向包的源端口恢复:
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。我依然启动多个Open×××进程bind不同的端口,然后在这几个端口中按照负载均衡算法(随机或者按照当前连接
数)选择一个,赋予一个流的头包并保存在conntrack中,不需要转换skb中的端口,直接为skb的sk字段赋值即可!
我们来看一下这个做法有什么意义,它完全绕过了Linux协议栈的第4层定位逻辑,只需要针对NEW状态的一个流的第一个数据包进行一次负载均衡计算定位
一个port,然后进行一次__udp4_lib_lookup查找,之后保存在conntrack结构体中,同一个流的后续的数据包可以直接取用这个
socket,完全省去了__udp4_lib_lookup的过程!不过值得注意的是,由于没有针对数据包本身进行任何修改,建议Open×××客户端
要使用nobind参数随机选取源端口,否则很可能多个连接会被归并到一个conntrack结构体从而总是负载了一个Open×××实例中。具体的端口
定位逻辑代码如下:
__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的引用计数是一件很烦人的工作。但是根本的原因是:我并没有说不用它。
×××隧道建立方面的多实例负载均衡已经解决,下面看一下数据流在TUN虚拟网卡间如何分发。
4.5.TUN网卡不能bridge
当
最初得到TAP模式Open×××多实例方案的时候,兴奋了一阵子,因为那纯粹是空手套白狼,毕竟什么都不是自己开发的,靠两个完美的借用完成了设计。借
用Brdige对ARP的广播以及对ARP回应的端口学习完成了在多个TAP网卡中选择一个的任务,借用random
DNAT以及ip_conntrack完成了×××连接在多个Open×××服务端实例上负载均衡。也许正是这两个如此廉价的借用才让我如此痴迷于TAP
模式,期待廉价的午餐再次滴落。
在Android上将TUN适配成TAP并不难,难的是如何以及以什么理由来促成这件事。做产品不是写诗拍电影,有时缺一些创意反而会更好,创意应该付诸
设计,而不应付诸实现。换句话说,实现中创意是不好的,创意应该在设计阶段终结。不能被自己的感情因素左右技术实现。因此既然Android不能支持
TAP,那么一群Android设备的接入,何必不用TUN模式的Open×××服务端呢?不是不能用,而是怕困难难以克服。什么困难呢?TUN网卡不能
Bridge,又难以Bonding,因此TUN网卡群就不好像TAP网卡群那样对外呈现出一块网卡了...但是这个问题貌似必须解决。
4.5.1.何必非要展现出一块虚拟网卡
提
出一个问题比解决它更重要,引申一点就是,如果提出了一个问题,怎么证明这个问题是有意义的呢?事实上不能证明。在解决某个问题遇到困难的时候,停下来问
一下这个问题有没有意义是必要的。TUN模式的网卡群难以合并成一块虚拟的网卡,不管是bridge还是Bonding,即便可以Bonding,还是难
以管理,你不得不在Open×××的up/down脚本中去ifenslave。那么反问一下,为何非要将所有TUN网卡合并到一个虚拟的网卡呢?到底是
什么原因让我非这样做不可呢?
答案是模糊的,因为根本就没有非如此不可的必要因素。部分原因只是因为我习惯了在TAP模式下时将多块TAP网卡合成一块,而之所以会这么做,根本原因在
于TAP网卡是模拟以太网的,而不管是Bridge还是Bonding都是专门针对以太网的。到此为止,一切都明了了,我一直都在死胡同里面,事实上,我
一直都妄想将以太网的特性应用在TUN网卡上,以图它能给我带来一些利益。事实上,TUN网卡群完全可以独立呈现在系统中,比如我启动了5个
Open×××进程,那么TUN网卡就是tun0~tun4一共5块。
4.5.2.多实例多网段
在得到根本没有必要在多个
Open×××的TUN网卡之间建立任何关联这个让人清爽的事实后,下一步就是划分子网了。如果我规划了130.130.0.0/16这个大网段给所有的
m个Open×××实例,那么对于每一个Open×××,只需要给它划分总容量的1/m大小的网段即可了,还可以根据Open×××实例的不同权值给与加
权分割子网。如此一来,m个Open×××服务端在启动了自己的TUN网卡后,会把自己的子网的网段路由加入到系统路由表,从某个Open×××实例过来
的IP数据流在返回的时候,可以自动通过路由来寻址到正确的TUN网卡群中的一个,从而经过它来的时候那个Open×××实例加密后返回。
但是还有更猛的方案。
4.5.3.多实例单网段
这
并不是一个显而易见的方案,需要一番思考以及对Linux的IP路由以及ip
conntrack非常熟悉才能理解。简单讲就是所有的m个Open×××实例共享一个IP网段,比如130.130.0.0/16,那个所有的
Open×××服务端实例的TUN网卡的IP地址均是130.130.0.1,只要在所有的Open×××服务端的client-connect脚本中为
每一个Open×××客户端(不管它连接到了哪个Open×××服务端实例)在全局池里面分配一个不重复的IP即可。
这怎么可能?Open×××服务端的所有实例的TUN网卡的IP地址不明显冲突了吗?是的,是冲突了。地址冲突带来的是直连路由的冲突。在以太网上,同一
机器的多个网卡地址冲突还可能导致流量的截获或者ARP混乱等。然而,忘掉以太网吧,我们现在面临的是点对点的TUN网卡群,对于TUN网卡,第一,它不
需要链路层地址解析,其次,它根本就不需要链路层封装。因此只要保证一个数据流从哪个TUN网卡进来,该数据流的返回流量从哪个TUN网卡出去即可。而从
哪个TUN网卡进来是远端的Open×××客户端决定的,由此看来,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; }