分类: 网络与安全
2016-12-16 16:03:17
版权声明:本文为博主原创,无版权,未经博主允许可以随意转载,无需注明出处,随意修改或保持可作为原创!
OpenVPN的第一个瓶颈在于tun字符设备是按照一个一个链路层帧的读取和写入的,用户态的OpenVPN进程之所以要两端的link-mtu一致,是因为每次OpenVPN从/dev/net/tun字符设备读取的是一个完整的以太帧,不多也不少,而库接口: ssize_t read(int fd, void *buf, size_t count);中的count则是启动OpenVPN时设置的mtu值,如果OpenVPN收发两端的mtu值设置的不同,比如分别为2000和5000,且tun设备的ifconfig值也不同,分别为3000和5000,由于发送端每次从tun网卡发送5000字节,且发送端的OpenVPN从字符设备中也读5000字节,这是一个链路层帧的长度,然而接收端只接收2000,余下的3000字节将截断,会出错。这种情况在OpenVPN做forward的情况下很难出现,因为发送端tun网卡的数据是从真实网卡forward过来的,真实网卡一般的mtu都是1500。
我们知道了,对于OpenVPN而言,两端的mtu最好设置成一致。在我们不太可能更改OpenVPN前后的真实网络的mtu的情况下,基本上tun都是按照1500左右的数据收发的,虽然完全模拟了网络的行为,然而却和真实的网络环境有着根本的不同,在真实的环境下,数据是直接发往网线的,这基本上是线速度,然而在OpenVPN的环境下,数据通过tun字符设备每次一帧被发往了用户态的OpenVPN进程,然后OpenVPN将数据加密后又通过socket每次一帧通过物理网卡发往对端,这个用户态和内核态的切换就是瓶颈,且还是每次一帧的处理,速率肯定很慢,如果我们能在tun字符设备将数据做缓冲,也就是每次将N个帧一起发往OpenVPN,然后每次N个帧发往对端,用户-内核态的切换额外开销被缓冲抵消,在OpenVPN的socket批量往对端送数据的时候,发送端只是在以大致相同的速率往tun字符设备的队列中填充数据,OpenVPN每次N帧的收发数据。
举个例子:火车运货并且需要在一个站点中转,一般的中转只是将货物从一个火车搬到另一个火车,然后运走,这是很快的,然而OpenVPN的方式是将货物从一列火车搬出火车站,经过有关部门的检测和封装后再进入火车站,搬入另一个火车,如果按照货物一件一件的这么处理(比如站台上没有积压货物的空间),肯定很慢,一般的解决方式是将货物在火车站先积压到一个总的数量,然后统一运出火车站,处理后统一运入火车站运走,在前一批接受处理的时候,第二批正在被积压,没有什么察觉,等到第一批运达了对端,第二批被取走,接受处理,依次类推...关键是货物运出车站-接受处理-运进车站这三个环节消耗太大,消耗在于路上的时间和处理的时间,如果每次处理很多货物,路上的时间和处理时间和总货物的比值就会减小,效率和吞吐量增加,正如我们不可能用万吨货轮每次只运送一个皮箱的原因一样。
我们先实现一个简单的例子,脱离OpenVPN,目的是证明这种“积压”的方式有利于吞吐量和速率的提升。实际上没有必要在tun字符设备的read/write接口上也按照一个链路帧的大小就行io,因为只要skb出了start_xmit函数之后就进入“物理层”了,对于tun而言,物理层就是字符设备和用户态read/write这个字符设备的程序,物理链路如何传输就很自由了,因此tun字符设备这个物理层完全可以批量传输,只要保证到对端写入tun字符设备后,链路层帧是一个接一个写入tun虚拟网卡就可以了。read/write作用于tun字符设备和socket,都是系统调用,系统调用的开销比较大且每次系统调用的开销一定,和操作参数无关,因此需要一种类似mmap的方式将系统调用的次数减少,这样可以增加吞吐量。因此需要针对tun驱动进行相关的修改,不再每次read一个链路层帧,而是尽可能的积累。实际上pci总线上的网卡的tso(tcp segment offload)也是应用了类似的机制减少对pci总线的访问次数的,因为访问总线开销很大,如果tcp被分成了N个小段,那么每发送一个段都要访问pci总线,需要访问N次,于是将一个很大的tcp段直接发往网卡,由网卡来分段,这样访问一次pci总线就够了。理论上分层模型很好,但是那是针对理解问题的,实际应有的时候,不得不破坏这一模型,让网卡处理传输层数据从而提高效率,这又是实用主义获胜的一个例子,正如不存在单纯的cisc或者risc处理器而都是其混合体一样,也如双绞线胜过同轴线一样。
以下对tun网卡的修改仅仅使用tun方式,而不适用于tap方式,如果想支持tap模式,也很容易,修改tun_chr_aio_write中解析包的方式即可。主要修改了两个函数,一个是aio_read,一个是aio_write,内核版本为2.6.32.27:
static ssize_t tun_chr_aio_read(struct kiocb *iocb, struct iovec *iv,
unsigned long count, loff_t pos)
{
struct file *file = iocb->ki_filp;
struct tun_file *tfile = file->private_data;
struct tun_struct *tun = __tun_get(tfile);
DECLARE_WAITQUEUE(wait, current);
struct sk_buff *skb;
ssize_t len, ret = 0;
char __user *buf = iv->iov_base; //取出用户态buf
int len1 = iv->iov_len; //取出用户态buf的长度
int to2 = 0;
int getone = 0;
int result = 0;
if (!tun)
return -EBADFD;
len = iov_length(iv, count);
if (len < 0) {
ret = -EINVAL;
goto out;
}
add_wait_queue(&tun->socket.wait, &wait);
while (len1 > 0) {
current->state = TASK_INTERRUPTIBLE;
if (len1 - dev->mtu < 0) break; //如果剩余的空间不足以容纳一个skb,则返回
if (!(skb=skb_dequeue(&tun->socket.sk->sk_receive_queue))&& !getone) { //起码要返回一个包
if (file->f_flags & O_NONBLOCK) {
ret = -EAGAIN;
break;
}
if (signal_pending(current)) {
ret = -ERESTARTSYS;
break;
}
if (tun->dev->reg_state != NETREG_REGISTERED) {
ret = -EIO;
break;
}
/* Nothing to read, let's sleep */
schedule();
continue;
} else if (skb == NULL && getone){ //只要复制了一个skb,skb为NULL说明取空了队列
break;
}
netif_wake_queue(tun->dev);
iv->iov_base = buf; //将当前的buf指针和长度赋值给iov
iv->iov_len = dev->mtu;
ret = tun_put_user(tun, skb, iv, dev->mtu); //取一个skb
kfree_skb(skb);
result += ret; //推进总长度
buf += ret; //推进用户态缓冲区
len1 -= ret; //减少剩余长度
getone += 1; //增加统计数据
//最后不要break;
}
current->state = TASK_RUNNING;
remove_wait_queue(&tun->socket.wait, &wait);
out:
tun_put(tun);
return result;
}
static ssize_t tun_chr_aio_write(struct kiocb *iocb, const struct iovec *iv,
unsigned long count, loff_t pos)
{
struct file *file = iocb->ki_filp;
struct tun_struct *tun = tun_get(file);
ssize_t result = 0;
char __user *buf = iv->iov_base;
int len = iv->iov_len;
int i = 0;
struct iovec iv2;
int ret = 0;
if (!tun)
return -EBADFD;
while(len>0) {
uint8_t hi = (uint8_t)*(buf+2); //由于只用于tun设备,因此用户态写入字符设备的数据是一个完整的ip数据包或者多个顺序连接在一起的ip数据包,这里通过ip协议头的格式将多个可能的ip数据包解析出来。ip协议头的第三个和第四个字节表示总长度字段,这里先取出第三个字节
uint8_t lo = (uint8_t)*(buf+3); //再取出第四个字节
uint16_t tl = (hi<<8) + lo; //拼装成16位的总长度
iv2.iov_base = buf;
iv2.iov_len = tl; //总长赋值给iov长度,后面按照这个长度来分配skb
ret = tun_get_user(tun, &iv2, iov_length(&iv2, 1),
file->f_flags & O_NONBLOCK);
result += ret; //推进写入总长
buf += ret; //推进写入缓冲区
len -= ret; //减少剩余缓冲区
}
tun_put(tun);
return result;
}
用户态的程序可以选择透明直传,也可以选择解析修改,后者更适合管理的需要,就像OpenVPN那样,在用户态解析出原始IP数据包的信息。此处的使用的是一个simpletun程序,IO框架如下:
while(1) {
int ret;
fd_set rd_set;
FD_ZERO(&rd_set);
FD_SET(tap_fd, &rd_set);
FD_SET(net_fd, &rd_set);
ret = select(100 + 1, &rd_set, NULL, NULL, NULL);
if (ret < 0 && errno == EINTR){
continue;
}
if (ret < 0) {
perror("select()");
exit(1);
}
if(FD_ISSET(tap_fd, &rd_set)) {
nread = cread(tap_fd, buffer, BUFSIZE);
plength = htons(nread);
nwrite = cwrite(net_fd, (char *)&plength, sizeof(plength));
nwrite = cwrite(net_fd, buffer, nread);
}
if(FD_ISSET(net_fd, &rd_set)) {
nread = read_n(net_fd, (char *)&plength, sizeof(plength));
if(nread == 0) {
break;
}
nread = read_n(net_fd, buffer, ntohs(plength));
nwrite = cwrite(tap_fd, buffer, nread);
}
}
客户端和服务器选择BUFSIZE为:
#define BUFSIZE 1500*4
测试命令:ab -k -c 8 -n 500
机器部署:
S0:
eth0:192.168.188.194 mtu 1500 e1000e 1000baseT-FD flow-control
tun0:172.16.0.2 mtu 1500
route:10.0.188.139 dev tun0
S1:
eth0:192.168.188.193 mtu 1500 e1000e 1000baseT-FD flow-control
eth1:10.0.188.193 mtu 1500 e1000e 1000baseT-FD flow-control
tun0:172.16.0.1 mtu 1500
S2:
eth1:10.0.188.139 mtu 1500 e1000e 1000baseT-FD flow-control
route:172.16.0.0 gw 10.0.188.193
测试数据:
使用修改后的tun驱动:
Transfer rate: 111139.88 [Kbytes/sec] received
如果使用原生的tun驱动测试,数据:
Transfer rate: 102512.37 [Kbytes/sec] received
如果不走tun虚拟网卡,物理网卡通过ip_forward裸跑速率:
Transfer rate: 114089.42 [Kbytes/sec] received
影响:
1.如果tun网卡载荷是tcp:
增加了总的吞吐量的同时,也增加了单个tcp包的延迟,因此会影响tcp窗口的滑动,给收发两端造成“路途很远”的假象,从而调整rtt。
2.如果tun网卡载荷是udp:
udp本来就更好支持实时,对丢包倒是不很在意,修改后的tun网卡增加了单包延迟,实时性没有以往好。
3.参数关联性:
有几个参数比较重要,第一是用户态的缓冲区大小,第二是tun网卡的发送队列长度,第三是物理网卡的mtu,这三个参数如何配合以获取马鞍面上的最佳位置(吞吐量和延迟的最佳权衡点),仍需要测试。
更猛的效果:
以上测试并没有体现出修改了tun驱动所带来的革命性速率提升,因此修改OpenVPN代码的开销开来更大且不值得,那是因为这个simpletun是直接转发的,也就是从tun字符设备来了数据就直接发往socket,从socket收到数据直接发往tun字符设备,这样的话积累效应是体现不出来的,如果数据在simpletun应用程序中经历了一些比较耗时的操作,更猛的效果就有了,这是下篇《OpenVPN性能-OpenVPN的第二个瓶颈在ssl加解密》的总结。