设备移动性的挑战
1.设备会经常由于小区或模式切换而更改IP地址。
这种地址更新是移动网络的正常行为,不应作为故障或事故看待,因此理应对应用程序透明,应用不应被此类事件打扰,更无责做善后处理。
2.移动设备存在多张3G/4G/2.75G网卡时,希望这些网卡同时收发数据。
由于这些网卡一般属于不同运营商网络,其网络架构又不同,一般要求数据包携带本运营商网卡的IP地址作为源(这一般是为了在该运营商核心网终点处做NAT),因此为了支持多运营商多网卡负载均衡,一个应用程序业务流数据包必然要支持不同的IP地址作为源,不幸的是,即便对于UDP而言,大多数应用也都是只支持单一源(它们会针对UDP socket调bind),以减少服务端的复杂性。
3.经常性的失联
电梯里,高铁上,山区景点盲区,公司的厕所...你会突然失联,然后突然出现!但是应用程序却不希望受到如此的折腾,对于OpenVPN而言,经过测试,一次重连大约要5秒时间,代价是高昂的,重新TLS握手,重新push,...实际上只要你的ping-restart时间足够小,对于信号缺失就会很迅速的被OpenVPN感知,解决方案就是将ping-restart放大,可是你也不知道自己失联多久。
4.RRC相关造成的额外延时
有时候,即便你处在信号很好的地方,也会发现打开一个网页非常慢,然后迅速就会变快,这其实是移动网络的本质,为了节省电量损耗,设备并不是一直和网络保持连接的,而是运行一种和Linux的NOHZ算法一样的机制,在设备长时间没有数据收发的时候,关闭连接,和Linux的NOHZ不同的是,NOHZ状态的脱离时间是明确的,它由下一个timer到期的时间以及时钟之外的任何中断的最小值决定,但是RRC机制却不同,数据什么时候会发送完全取决于神一样的用户,因此当有数据要发送的时候,必须重新接入移动网,协商参数等等,这无疑会消耗时间。这个抖动不是用户应用能解决的,因为这取决于设备厂商的实现以及移动网络的规范,这是一个纯粹的网络问题,因此本文不会涉及过多这方面的内容。
会话层真的太重要了
鉴于TCP/IP栈的抢先进化,其对手便永远失去了机会,因此应用程序一般都是直接接口在传输层协议之上,这是事实!
对于应用开发接口,应用程序的数据收发直接基于一个INET socket,而一个socket的“连接”通过五元组来标识,因此五元组的任何一个元素改变,或者说网络的任何一个事件都会影响到这个对应的socket,socket I/O接口的手册中明确给出了返回值和错误码,而直接调用这些接口的应用程序必须处理这种错误,因此网络事件便直接影响了应用程序!
但是网络事件不应影响应用,比如网络断了不一定让应用程序必须采取善后和重连,也许这只是暂时事件,比如IP地址变了。应用程序要做的就是生成业务数据并发送,它并不需要直接从socket接口获取并处理错误码。应用程序只需要知道发送数据发出了多少即可,即便真有严重事件需要彻底退出,也不该是来自TCP/IP的通知。那么一定需要一个新的层,历史缘故,我称它会话层吧。
OpenVPN的故事
我希望OpenVPN的处理层完全和网络状态脱离,即使客户端的IP地址变了,也能用新的IP地址继续和服务端通信,即使信号全无,一旦有了信号,通信继续进行,也就是说,网络状态不会打扰到OpenVPN进程的处理。为了一步步地满足这个需求,我们看一下OpenVPN目前的行为,两端连通以后,我试着改变客户端的IP地址,结果服务端报错:
Wed Jan 1 00:58:46 2014 us=439027 GET INST BY VIRT: 0e:fe:bc:a3:6f:fe -> zhaoya/192.168.1.197:33512 via 0e:fe:bc:a3:6f:fe
Wed Jan 1 00:58:46 2014 us=439981 zhaoya/192.168.42.197:33512 UDPv4 WRITE [133] to 192.168.1.197:33512: P_DATA_V1 kid=0 DATA len=132
Wed Jan 1 00:58:46 2014 us=822941 TLS State Error: No TLS state for client 192.168.1.199:33512, opcode=6
Wed Jan 1 00:58:46 2014 us=823912 GET INST BY REAL: 192.168.1.199:33512 [failed]
Wed Jan 1 00:58:47 2014 us=197871 MULTI: REAP range 128 -> 144
Wed Jan 1 00:58:47 2014 us=198861 TLS State Error: No TLS state for client 192.168.1.199:33512, opcode=6
Wed Jan 1 00:58:47 2014 us=198887 GET INST BY REAL: 192.168.1.199:33512 [failed]
...
上述原则上将陌生的IP/Port看成了新的TLS session,但是OpenVPN的TLS握手和网络根本就没有关系。它是在BIO上完成的TLS,用Reliable层保证了传输过程的可靠性。但是原谅这个报错吧,代码的初衷可能是防止Dos攻击而不是别的,因为如果没有经过成功的TLS握手,那么一个连接是不可能正常插进来的,否则TLS就该废掉了。现在着手自己的实现。思路是很清晰的,只是在OpenVPN的协议头里面增加一个字段:session ID,服务端用这个session ID识别和区别不同的客户端,不再基于客户端的IP/Port来识别和区别不同的客户端,这样的话,只要客户端发出的OpenVPN的数据包被服务端收到了,且解析出来的session ID可以对应到一个multi_instance,那么这个数据包就是合法的。
因此,OpenVPN的数据收发和底层的网络状态彻底隔离了,只要用OpenVPN协议构造数据包即可,如果网络状况不好,那就发不出去,但是只要网络恢复,就可以发出去,只要发出去被服务端收到,就能识别和解析并对应到某个multi_instance,如果客户端IP地址变化了,只要保持到服务端IP地址的可达性,数据就能发送到服务端,只要能到服务端,服务端就能从OpenVPN协议包中解析出session ID,从而对应到一个multi_instance。
思路有了,也很清晰,那么怎么改呢?
解决问题的步骤
写这篇文章并不是为了表达OpenVPN这个程序如何被用在移动设备上,这个可以写上一本书,本文的主要目的是想展示一种解决问题的方式,我在有了上面的思路后是如何验证其确实可行的呢?我并没有一头扎进那沸腾的代码,去实现最终的方案,比如直接就去修改OpenVPN的协议,而是先将代码写死,瞬间得到一个行或者不行的结论。这个过程要修改最少的代码!为了找到修改何处,还得从上面的报错入手。其在ssl.c的tls_pre_decrypt_lite函数报错,该函数没有任何关于multi_instance的信息,因此我知道在这个tls_pre_decrypt_lite函数调用之前,程序已经进入异常流了,因此就找tls_pre_decrypt_lite的调用代码,在mudp.c中的multi_get_create_instance_udp找到了:
struct multi_instance * multi_get_create_instance_udp (struct multi_context *m) { ... if (mroute_extract_openvpn_sockaddr (&real, &m->top.c2.from.dest, true)) { struct hash_element *he; const uint32_t hv = hash_value (hash, &real); struct hash_bucket *bucket = hash_bucket (hash, hv); hash_bucket_lock (bucket); he = hash_lookup_fast (hash, bucket, &real, hv); if (he) { mi = (struct multi_instance *) he->value; } else { // 找不到multi_instance的异常流处理 if (!m->top.c2.tls_auth_standalone || tls_pre_decrypt_lite (m->top.c2.tls_auth_standalone, &m->top.c2.from, &m->top.c2.buf)) { // 异常流处理 } } ... }
关键是multi_instance没有找到,为什么呢?我发现mroute_extract_openvpn_sockaddr的传入参数real,正是根据接收到的数据包的来源IP和端口初始化的,接下来查询multi_instance哈希表的时候,这个real就是key的值,在客户端的IP地址改变了之后,当然找不到任何value了,就算找到也是冲突链的value,最终返回的为NULL!接下来是关键点,既然是查询哈希表没有查到,并且是由于数据包的源IP/Port改变了没有找到,那么就忽略掉这个查询key,即想办法让这个查询百分百可以找到结果。这个思想是快速解决问题的关键,正是由于找不到key对应的value才失败,如果key能找到value的话要是能成功,问题就转化为了如何让key找到value而这我们已经有办法了,即从buffer里面取key,实际上这是另一个问题,这难道不是一次深刻的执果索因之旅吗?我在高中的物理竞赛中用此法获得了,唉,不提当年勇!这个思想很简单,但用的人不多,很多人都是一开始就修改OpenVPN协议,然后到最后一起调试,对于设计方案通过的研发任务,这是常规做法,但对于预研或极限开发来讲,这万万要不得!你根本就不知道自己的想法在OpenVPN既有框架内是否行得通,怎能一开始就大段改代码呢?R&D没有写成RD是因为它们实际上不是一个部门,起码员工解决问题的思路是不同的,R部门侧重因果推导,执果索因,可行性验证,测试,D部门侧重设计,代码质量,进度控制,项目管理以及各种模型(迭代瀑布...)。
因此就重新定义hash_function以及hash_compare,让其返回定值!背后的思想是固定了hash key和hash compare结果之后,如果此时改变了客户端IP地址而依然不出错,就说明hash查找的过程已经和收到数据包的源IP地址和端口没有关系了,剩下的就是将这个hash key从固定值改为从收到的OpenVPN数据的协议头里面取就可以了。我的新版hash函数如下:
m->hash = hash_init (t->options.real_hash_size, fake_addr_hash_function, fake_addr_compare_function);
其中:
uint32_t fake_addr_hash_function(const void *key, uint32_t iv) { return 0x10101010; } bool fake_addr_compare_function(const void *key1, const void *key2) { return true; }
就改这些即可!服务端已经可以将客户端对应到唯一的那个multi_instance了,并且成功解析封装后的IP报文,可是发现服务端往客户端返回的时候没有通过,我通过192.168.1.199接入OpenVPN成功,然后将OpenVPN客户端的地址改为了192.168.1.197,OpenVPN客户端所在机器长ping服务端的虚拟IP地址,服务端日志打印如下:
Wed Jan 1 00:02:11 2014 us=389812 zhaoya/192.168.1.199:38310 UDPv4 WRITE [77] to 192.168.1.199:38310: P_DATA_V1 kid=0 DATA len=76
发现写入的目标地址还是192.168.1.199,为何没有切换到我新改的地址?我觉得这是一个小问题。我只要找到打印上面日志的位置就好,而这很简单,代码在forward.c的process_outgoing_link函数中,注意以下的代码:
ASSERT (link_socket_actual_defined (c->c2.to_link_addr));
可见,这个to_link_addr是关键,这个值是OpenVPN客户端接入的时候生成的,以后不会变化,我只要将其改为实时更新的即可,就是说,无条件使用上次数据包的from地址,这些都在context_2结构体:
struct context_2 { ... struct link_socket_actual *to_link_addr; /* IP address of remote */ struct link_socket_actual from; /* address of incoming datagram */ ... }
注释很清晰!怎么改呢?可以将使用to_link_addr的地方全部使用&from,当然我不会这么做,因为这只是一个可行性证实,不是正儿八经的改代码,如此鲁莽是不对的,我的做法是添加一段临时代码:
void process_outgoing_link (struct context *c) { struct gc_arena gc = gc_new (); perf_push (PERF_PROC_OUT_LINK); #if 1 // 吐嘈时骂过的,实际上我经常这么玩 { c->c2.to_link_addr = &c->c2.from; } #endif ... }
至此,我认为当初的想法是可行的。事后,我试着在OpenVPN的协议中增加了一个32位的session ID,然后彻底更改了hash function,传入的key就是从BPTR(buf)中取出的这个session ID,并且我把OpenVPN客户端的数量增加到了3个,同样是一致的结果,时间定在了晚上1点35分。如果这是一个下雨的夜晚,我可能会做更多的修改,但是这是一个燥热的初夏之夜!
修改OpenVPN以适应移动性这个想法是我突然想到的,由于近期工作基本和网络无关,且比较杂乱又有严格deadline,我也就不能在工作时间有那么多的闲情雅致,只能选一个下雨的夜晚来折腾。可是雨到底也没来,我也就只能做个半拉子,还有哪些方面没做呢?难道上面说的不就是全部吗?不是全部,很多细节都没有处理,比如客户端改变IP地址后服务端要即使更新该客户端对应的multi_instance中相关数据结构,比如echo机制的ping替换各发各自的ping机制,另外各种restart重连机制也要被替换,移动环境中,很多在非移动环境中发生的导致应用必须重连重置的网络事件都是正常的,因此必须最少化重连重置操作,需要做的就两件事,一是继续发送,二是等!
关于本文的引申
不能杜绝问题的发生,那就忽略掉问题,使其对自身毫无影响。多加一个层就可以隔离问题!你不能让天公不下雨,但你能带上伞或穿上雨衣雨鞋,或者将活动改在室内,再或者像我这样,尽情雨中欢呼...如果你为了不下雨而去研究大气运行原理,研究让云层散开的炮弹,那你就走偏了,虽然最终你可能会成为伟大科学家,但目前,你可能只是因为下雨影响了你的心情,而已。
在定位问题的过程中,千万不要过早扎入代码细节,用最快的方式验证可行性和合理性。然后再细嚼慢咽,要分清主要问题和次要问题,主要问题简化其本质,次要问题模拟其现象,不应该为次要问题浪费大量时间和精力。你不可能一次搞定所有问题,学会模拟当前非本质问题造成的现象是一种技巧技能,能从简化环境中得到真实结论是一种推理技能。
我举一个我早年的反例子。有一次因为文件写入出错,搞了我一个多礼拜没搞定,最终也没搞定,但是却学会了ext2文件系统的数据恢复,八杆子打不着的一个问题,而且不是用debugfs实现,是自己编程哦!对个人好奇心而言,我没什么损失,可是却耽误了进度,其实最可悲的是那些不是好奇心使然的coder,迷迷糊糊就到了内核驱动,然后一无所获或者鼻青脸肿出来...
要问我为何喜欢雨天,也许它能将飘忽在天上的不稳定小结核沉淀,那是胡扯!喜欢雨天是因为,其实我也不知道...
关于模拟不仿真
我这又像是吐槽!有人特别不喜欢模拟,特别喜欢没有意义且又浪费时间的还原真实情景,实际上这是绝对不可能的,你只能尽可能地还原,实际上你也在模拟,你也在仿真。
升华意义的模拟去掉了仿真的内容!而如果你拥有一个分层的模型的话,那你就太幸运了,因为它可以告诉你要模拟什么。一个Web服务不通,首先你会去telnet而不是去查什么Web服务的故障!为了在服务器上实现HTTP请求从哪个网口进来HTTP回应便从同样网口回复这种需求,使用带源的ping即可测试,根本无需搭建什么HTTP服务器,因为这是IP路由的职责,和HTTP无关,也正因为这样抓住主要问题,才可以将这个任务交给不懂HTTP的网络工程师。
提到创新,更需要非仿真的模拟,即模拟次要环境,解决主要问题,对于本文提及的OpenVPN的修改的例子,如果一开始你就想实现一个完备的版本,光看懂代码就能恶心死,然后修改,调试过程肯定耗时又痛苦,最后还不一定行...一定要控制住那些易变的变量,你每次只能操纵一个把手!
有时候,当我异常坚定宣布肯定的结论时,很多人都不爱听,他们宁愿我说一个带点余地的结论,因为他们都知道我是在无法仿真的情况下信口开河的,事实上,科学的思想就是不仿真,只模拟!当然这并不适合软件工程,因为软件更像是社会工程学而不是科学!你永远也不能肯定这个软件没有任何漏洞了,你不能在软件工程中搞光滑平面或者思想实验,正常运行了1000000天的软件可能会下一秒彻底崩溃!受过这样洗脑的软件人整体上都活在紧张的杞人忧天状态,当然不好理解不仿真得出肯定结论的道理了。但即便如此,在解决点上而非工程意义上的问题时,模拟不仿真思想是万万不能丢的!
OpenVPN移动性改造-靠新的session iD而不是IP/Port识别客户端