文章目录
背景
网络虚拟化的主要目标就是让报文可以在虚拟机之间进行传输。要实现这个目标,个人总结,需要具备这三个要素:虚拟机、报文收发能力和报文转发能力。下面分别介绍这三个要素在网络虚拟化中的角色和功能。
虚拟机
在物理环境中,主机想要实现以太网(二层网络)帧的收发,需要借助网卡的能力。在发送报文时借助网卡能力将上一层传递来的数据(payload)封装成以太网帧,通过网卡发送出去;在接收报文时借助网卡能力将以太网的帧剥去首部和尾部,然后送交上一层。网络协议栈通常在内核态实现对报文的封装,最终通过网卡处理报文后将其发送或者接收。
在qemu/kvm虚拟化方案中,虚拟机从主机的视角看是一个普通的进程。如果虚拟机实现收发报文,和物理环境有什么不同呢? a. 首先是报文传输的起点和终点不同,物理环境下,报文传输的起点和终点都是主机。虚拟化场景下,报文传输的起点和终点都是虚拟机,虚拟机可以跨主机也可以同主机。 b. 其次是报文的转发媒介不同,物理环境下,二层报文的转发发生在不同主机之间,主机网卡将二层报文发送到交换机,交换机通过mac地址学习建立mac地址到不同端口的映射,实现二层报文在局域网中的传输。相比之下,虚拟化场景中,二层报文转发可以是跨主机的两个进程,也可以是同主机的两个进程。
物理环境下局域网内报文的收发和转发,离不开网卡和交换机。对比之下可以得到结论:虚拟化环境同样也需要组件能够实现类似网卡和交换机的功能,让报文在虚拟机之间可以收发和转发。网卡和交换机是硬件,而虚拟化场景下用软件实现了类似的功能。
报文收发
tun/tap设备是内核向用户态进程提供的能够实现报文收发能力的二层设备。与物理网卡相比,tun设备将报文发送给用户态进程而不是物理媒介,将接收到的报文送达给用户态进程而不是内核态的协议栈。tun设备用在网络虚拟化中,可以为虚拟机进程提供报文的收发能力。
报文转发
tun/tap设备为虚拟机提供了报文收发能力,如果再加上转发能力,报文就可以在虚拟机之前互相传输,网络虚拟化的功能就完备了,该功能可以由openvswitch提供。openvswitch是一个开源组件(以下简称OVS),顾名思义,它具有和物理交换机相同的二层报文转发能力,可以向用户态进程提供报文二层转发能力,除了具有物理交换机的工作模式和能力,通过mac地址学习转发报文,OVS还可以使用安全模式,通过流表匹配转发报文。OVS功能对标的是物理交换机,但不同的是,物理交换机端口另一端通常是局域网内的物理主机,而OVS上的端口的另一端,可以连接到一个tun设备,而一个tun设备为一个进程打开并作报文收发使用,该进程如果是虚拟机QEMU进程,报文就可以完成从OVS的一个端口流向另一个端口并最终到达虚拟机目标。
物理网络简单拓扑图示
虚拟网络简单拓扑图示
基本组件
tun/tap device
基本原理
tun/tap设备是内核向用户空间提供的一个特性,它可以用作组建用户空间网络,就是说,tun/tap设备可以让用户态程序(比如qemu)看到原始的网络包(比如以太网帧或IP报文),而且可以任意处理这些报文。
tun/tanp设备从主机上看是一个和物理网卡类似的接口,拥有mac地址并且也可以配置IP,但它实际上是纯软件的接口,就是说,这个设备只存在于内核空间,没有类似网卡一样的物理组件,也没有所谓地网线
连接到这个网卡。
我们可以把tun/tap设备想象成一个虚拟网卡,当内核想要通过这个虚拟网卡发送数据时,不会将数据通过其连接的网线发送出去(因为它没有),而是将其发送给一个绑定到该网卡的用户态程序,用户态程序绑定tun/tap设备时它会得到一个文件描述符,通过读这个文件描述符的内容,就可以获得内核发送给该虚拟网卡的数据。反之,用户态程序往该文件描述符写入数据,会作为tun/tap设备的输入,也就是网卡的输入,在内核看来,它收到了来自虚拟网卡的数据。
tun/tap设备实际代表内核提供的一个特性,tun设备和tap设备的区别在与,tun设备只能接收和发送IP报文,而tap设备可以接收和发送更底层的以太网帧。用户态程序在创建设备时,通过传入不同的标记用来指导内核创建tun设备还是tap设备。
tun/tap设备创建的虚拟化网卡可以工作在非持久化模式下,非持久化模式下虚拟网卡关联的用户态程序退出了,虚拟化网卡就会被销毁。也可以工作在持久化模式下,即使用户态进程退出,虚拟化网卡还在,下一次用户态进程只要权限正确,还可以继续使用。
物理网卡VS虚拟网卡:
物理网卡连接网线
,收发网络物理层的payload,向上转给内核网络栈或者接收来自内核网络栈的数据,向下传输。
虚拟网卡在内核中是一个纯软件接口,用户空间进程通过创建对应tun设备获得fd,与内核态中的虚拟网卡关联。当内核网络栈要发送数据给虚拟网卡时,它直接其转发给内核对应的虚拟网卡,该虚拟网卡将其发送到其关联的用户空间进程,反之亦然。
基本用法
网卡创建
用户态程序创建虚拟网卡分为两步: a. 打开字符设备/dev/net/tun,获得一个文件描述符 b. 准备字符设备的名字即其它相关信息,放到struct ifreq
中,使用TUNSETIFF
命令字发起ioctl调用,传入ifreq
到内核创建虚拟网卡
以上两步完成后,主机上就可以看到一个和网卡类似的网络接口,可以通过ifconfig查看其配置信息,包括MAC地址,MTU等。
#include <linux/if.h>
#include <linux/if_tun.h>
int tun_alloc(char *dev)
{
struct ifreq ifr;
int fd, err;
/* 获得文件描述符,用户态程序对虚拟网卡的读写通过次描述符完成 */
if( (fd = open("/dev/net/tun", O_RDWR)) < 0 )
return tun_alloc_old(dev);
memset(&ifr, 0, sizeof(ifr));
/* 设置创建设备的标志,IFF_TUN表示创建三层网卡,只能收发IP报文
* IFF_TAP表示创建二层网卡,报文中包含以太网帧头部
*/
/* Flags: IFF_TUN - TUN device (no Ethernet headers)
* IFF_TAP - TAP device
*
* IFF_NO_PI - Do not provide packet information
*/
ifr.ifr_flags = IFF_TUN;
if( *dev )
strncpy(ifr.ifr_name, dev, IFNAMSIZ);
/* 调用TUNSETIFF ioctl命令字,传入ifreq信息,创建虚拟网卡 */
if( (err = ioctl(fd, TUNSETIFF, (void *) &ifr)) < 0 ){
close(fd);
return err;
}
strcpy(dev, ifr.ifr_name);
return fd;
}
持久化
持久化网卡也有两个步骤: a. 首先是设置网卡的所在的用户和组,需要保证下一次用户态进程也用户持久化设置时的用户和组的权限,这一步通过TUNSETOWNER/TUNSETGROUP
命令字实现。 b. 然后使用TUNSETPERSIST
命令字通知内核做持久化设置。
一旦设置完成,虚拟网卡不再随着用户态程序关闭文件描述符而一起被销毁,而是持久化地存在于系统中,当下一次有相同用户和组权限的进程再次创建该网卡时,内核直接返回已经存在的网卡,不再重新创建
/* "delete" is set if the user wants to delete (ie, make nonpersistent)
an existing interface; otherwise, the user is creating a new
interface
用户态程序可以设置网卡持久化,也可以反向操作,禁用其持久化的模式
这些操作通过TUNSETPERSIST命令字完成
*/
if(delete) {
/* remove persistent status */
if(ioctl(tap_fd, TUNSETPERSIST, 0) < 0){
perror("disabling TUNSETPERSIST");
exit(1);
}
printf("Set '%s' nonpersistent\n", ifr.ifr_name);
}
else {
/* emulate behaviour prior to TUNSETGROUP */
if(owner == -1 && group == -1) {
owner = geteuid();
}
/* 设置虚拟化网卡的用户 */
if(owner != -1) {
if(ioctl(tap_fd, TUNSETOWNER, owner) < 0){
perror("TUNSETOWNER");
exit(1);
}
}
/* 设置虚拟化网卡的组 */
if(group != -1) {
if(ioctl(tap_fd, TUNSETGROUP, group) < 0){
perror("TUNSETGROUP");
exit(1);
}
}
/* 使能虚拟网卡持久化 */
if(ioctl(tap_fd, TUNSETPERSIST, 1) < 0){
perror("enabling TUNSETPERSIST");
exit(1);
}
if(brief)
printf("%s\n", ifr.ifr_name);
else {
printf("Set '%s' persistent and owned by", ifr.ifr_name);
if(owner != -1)
printf(" uid %d", owner);
if(group != -1)
printf(" gid %d", group);
printf("\n");
}
}
数据收发
一旦虚拟化网卡创建好之后,用户态程序可以通过文件描述符实现对虚拟化网卡的数据读写
char tun_name[IFNAMSIZ];
/* Connect to the device */
strcpy(tun_name, "tun77");
/* 创建或者连接一个虚拟化网卡tun77,获得对应的文件描述符 */
tun_fd = tun_alloc(tun_name, IFF_TUN | IFF_NO_PI); /* tun interface */
if(tun_fd < 0){
perror("Allocating interface");
exit(1);
}
/* 循环读文件描述符,检查内核是否发送了数据包到虚拟化网卡 */
/* Now read data coming from the kernel */
while(1) {
/* Note that "buffer" should be at least the MTU size of the interface, eg 1500 bytes */
nread = read(tun_fd,buffer,sizeof(buffer));
if(nread < 0) {
perror("Reading from interface");
close(tun_fd);
exit(1);
}
/* Do whatever with the data */
printf("Read %d bytes from device %s\n", nread, tun_name);
}
openvswitch
基本原理
openvswitch是一个软件定义的交换机,它不仅有交换机的二层功能,可以将tun/tap设备纳管作为其接口,实现端口流量转发;还提供vlan隔离、路由等三层功能,vlan隔离的子网之间,通过ovs的路由可以让网络包到达指定子网的port。
OVS是OpenFlow协议交换机的一种软件实现方式,因此它遵循OpenFlow协议。OpenFlow定义其交换机的所有转发都通过流表来实现,OVS也遵循这个规则,因此OVS所有转发包括二层转发和三层路由都通过流表实现。
基本用法
ovs有两种工作模式,一种是NORMAL的工作模式,这种模式下ovs和传统交换机的行为类似,ovs桥被创建后默认会创建一条流表,这条流表会匹配所有ovs上的包,让其执行NORMAL动作,也就是按照传统交换机的方式工作:
NORMAL方式工作的ovs只是兼容传统交换机,ovs的另一种工作方式是流表方式,这种工作方式下ovs的所有行为都用流表来控制。在流表方式下,ovs的端口转发和IP路由都由流表来决定。一条简单的流表就能实现传统交换机的MAC地址学习,IP路由等功能。
ovs的常见使用场景如下,SDN控制器负责下发流表到ovs,ovs根据基于流表对自身的行为做决策,ovs和控制器之间通过OpenFlow协议传输流表信息:
端口转发
在ovs桥中,实现端口转发非常简单,只需要一条命令,现在假设我们的ovs桥br0上有两个端口vnet0和vnet1,vnet0是一个tun/tap设备,被用来当作虚机的一张网卡,作为一个port口加入到br0中,vnet1同样,是另一个虚机的网卡,它们在虚机的配置如下,手动设置了IP,vnet0 IP地址10.10.10.11,vnet1 IP地址10.10.10.12,现在我们要将目标IP地址为10.10.10.12的arp报文从vnet1口发出,实现端口转发,只需要添加一条流表项(ovs-ofctl add-flow br0 "priority=100,dl_type=0x0806,arp_tpa=10.10.10.12 action=output:2"
),如下图,该流表项识别ovs桥中的arp请求报文,将其转发到端口2(vnet1在的port号):
打印br0上的流表项,各字段含义解释如下:
cookie: 用于识别一组流表项,可以方便地进行批量流表操作,控制器通常会使用该字段,如果不设置默认为0
duration: 记录流表项在ovs桥上的存活时间,秒为单位
table: 流表项所在的流表
n_packets: 统计该流表项匹配的网络包个数
n_bytes: 统计该流表项匹配的网络包字节数
idle_age: 上一次流表项匹配成功到现在的时间,秒为单位
prority: 优先级,同一个流表内,数字越大优先级越高,高优先级的流表项优先被匹配到
arp: 同dl_type = 0x0806,表示L2层协议的payload为ARP包。dl_type即data link type,2层协议
arp_tpa: ARP是以太网的payload,当dl_type(ether_type)为0x0806时,arp_tpa表示ARP请求的目标IP地址
action: 如果有网络包与该流表匹配,将其从端口2发送出去
IP路由
ovs桥上基于IP的路由实际上就是识别以太网帧的payload是IP报文,然后将其转发到相应的端口,也只需要一条流表项(ovs-ofctl add-flow br0 "priority=100,dl_type=0x0800,nw_dst=10.10.10.12 action=output:2"
)就可以完成,其本质和端口转发类似,但它实现三层报文的端口转发,打印流表:
解释其中两个字段:
ip: 同dl_type = 0x0800,表示L2层协议的payload为IP包
nw_dst: IP协议中目的端的IP地址
这一条流表项也很简单,如果网络包是目的地址是10.10.10.12的IP报文,将其转发到端口2,如果端口2关联的tun/tap设备由虚机使用,那么这个IP报文就会被虚机接收,ovs桥的流量就转发到虚机。
测试验证
报文收发
我们通过一个示例演示tun/tap设备如何实现报文的接收,步骤如下:
通过openvpn工具创建一个持久化模式的tun设备tun77,配置IP地址10.0.0.1,当dst IP是10.0.0.1的icmp包到达内核时,内核datapath会将其转发到tun77设备
openvpn --mktun --dev tun77 --user hyman
ip link set tun77 up
ip addr add 10.0.0.1/24 dev tun77
ping 10.0.0.1
此时ping包被回复,但通过tcpdump抓取tun77设备上,发现没有流量,原因是内核在处理目的端是10.0.0.1的icmp包时,发现没有必要将icmp包转发给tun77设备,可以直接自己代替tun77设备做出icmp回复。因为tun77在内核中本来就是纯软件模拟的。只有当内核发现自己处理的网络包需要发送到tun77设备关联的网线
(也就是用户态程序),才会将网络包转发给tun77。
编写一个操作tun/tap设备的程序 ,打开tun77设备并循环读,打印其内容,如果tun77上有流量,将会被打印出来,代码如下:
int main(int argc, char *argv[]) {
int tun_fd;
unsigned int nread;
char tun_name[IFNAMSIZ];
char buffer[BUFSIZE];
int i;
/* 打开使用openvpn工具创建的tun/tap设备tun77 */
/* Connect to the device */
strcpy(tun_name, "tun77");
tun_fd = tun_alloc(tun_name, IFF_TUN | IFF_NO_PI); /* tun interface */
if(tun_fd < 0){
perror("Allocating interface");
exit(1);
}
/* 读取并打印其内容 */
/* Now read data coming from the kernel */
while(1) {
/* Note that "buffer" should be at least the MTU size of the interface, eg 1500 bytes */
nread = read(tun_fd,buffer,sizeof(buffer));
if(nread < 0) {
perror("Reading from interface");
close(tun_fd);
exit(1);
}
/* Do whatever with the data */
printf("\nRead %d bytes from device %s\n Raw data:\n", nread, tun_name);
for (i = 0; i < nread; i++) {
printf("%x", buffer[i]);
}
}
return(0);
}
gcc tunclient.c -o tunclient
./tunclient
主机上重开一个终端运行ping命令,测试10.0.0.2的连通性
我们主机上并没有10.0.0.2的网络设备,肯定无法连通,但根据tun/tap设备的原理,当内核在处理网络包时,发现发送到10.0.0.0/24网络的包需要通过tun77设备路由,而10.0.0.2属于10.0.0.0/24网段,因此会将icmp包转发给tun77处理,这时,我们可以在tun77设备上抓到icmp的报文
ping 10.0.0.2
另外,我的主机上如果不使用tunclient
程序读取tun77设备上的数据,也无法通过tcpdump抓到去往10.0.0.2的icmp,怀疑是只有当内核检查到tun/tap设备存在关联的用户态程序时(作为虚拟网卡连接的网线
),才会将icmp包转发给tun77设备,因为如果没有用户态程序,转发到tun77设备的icmp报文得不到处理,转发也就没有意义。
主机路由信息:
tcpdump抓取到的icmp报文: - tunclient
程序读取到的tun77设备的帧内容:
报文转发
我们知道qemu就是使用的tun/tap设备作为的虚拟机的网卡,同时使用ovs桥用来实现虚拟机间的报文转发,模型如下:
我们用虚拟机来验证报文转发,每一步操作之后,截图然后解释。首先使用libvirt创建两个虚拟机并将tun/tap设备作为虚拟机的网卡,桥接在br0桥上,虚机网卡的xml配置如下:
虚机1网卡配置:
<interface type='bridge'>
<mac address='24:42:54:20:50:46'/>
<source bridge='br0'/>
<virtualport type='openvswitch'/>
<model type='virtio'/>
</interface>
虚机2网卡配置:
<interface type='bridge'>
<mac address='24:42:53:21:52:4e'/>
<source bridge='br0'/>
<virtualport type='openvswitch'/>
<model type='virtio'/>
</interface>
qemu命令行中关于虚机网卡的配置如下:
查看qemu进程打开的文件描述符,其中一个是tun/tap设备,对应的fd就是qemu进程打开/dev/net/tun字符设备返回的描述符,用于实现用户态的报文收发,可以和qemu命令行中的fd可以对应起来:
虚机启动后ovs桥拓扑如下:
vnet0是一个tun/tap设备,被用来当作虚机的一张网卡,作为一个port口加入到br0中,vnet1是另一个虚机的网卡,它们在虚机内的配置如下,vnet0 IP地址10.10.10.11,vnet1 IP地址10.10.10.12,我们注意到arp缓存表中没有内容:
查看tun/tap设备在ovs桥中的端口号,vnet0为对应端口1,vnet1对应端口2
ovs桥创建后会默认添加一条匹配任何网络包的流表项,该流表项的动作是NORMAL,也就是让ovs桥执行传统交换机的动作,我们删除这条流表,开始做测验:
在不添加任何流表的情况下,虚机1内部ping虚机2,主机侧分别对两个虚机网卡抓取arp报文如下:
可以看到只有虚机1的网卡有arp报文的请求包,报文出vnet0网卡之后就被ovs桥丢弃了,vnet1上没有任何arp包,我们想要添加一条流表项,让目标IP地址是10.10.10.12的arp包匹配该流表项,行为是转发到vnet1对应的端口2:
再次抓取两个虚机网卡的arp报文,发现vnet0上仍然是arp请求报文,但vnet1上不只有arp请求报文,还有arp应答报文:
vnet1上有arp应答报文,说明虚机2不仅收到了arp请求的包,还发现请求报文中目标IP地址和自己的IP地址匹配,发送了arp应答报文,同时还将虚机1的IP地址和MAC映射关系保存在了自己的arp缓存表中,整个流程如下图所示:
我们能确认arp的应答包从虚机2的网卡vnet1出来之后,被ovs桥丢弃了,并没有转发到虚机1网卡对应的端口,再添加一条流表让目标IP地址是10.10.10.11的arp包匹配该流表项,行为是转发到vnet0对应的端口1,如下图所示:
可以看到添加流表之后,虚机1网卡上收到了arp应答包并流向虚机1内部,虚机1的协议栈处理后将IP地址和MAC的映射保存在了arp的缓存表里面,自此虚机1和虚机2都建立了对方IP地址到MAC的映射,因此可以实现L2层的局域网通信:
这里对流表中arp_tpa进行一些说明,它对应的是arp报文中的目标IP地址字段,如下图所示:
我们将虚机1和虚机2的网络配置在了同一个网段(10.10.10/24),期望它们能正常通信,但再次从虚机1内部ping虚机2还是无法ping通,我们知道ping包是icmp包,因此在主机上分别对虚机的两张网卡抓icmp包:
可以看到,虚机1的网卡上有icmp请求包,虚机2的网卡上没有,说明ovs桥将其丢弃了,现在我们添加一条流表,作用是将目标地址是10.10.10.12的icmp请求包转发到虚机2网卡的端口上,如下图所示:
icmp作为IP报文的payload,有多种类型的报文,其中最常见的就是icmp请求包和应答包,当IP报文的协议字段为1时,表示icmp报文,如下图所示:
icmp的请求包code字段为0,对应流表项中的icmp_code字段,类型字段为8,对应流表项中的icmp_type。对比icmp包格式我们可以更加理解流表的含义。
添加完这条流表后,可以看到虚机1网卡流量仍然是icmp请求包,但虚机2网卡流量处理icmp的请求包,还有icmp的应答包:
通过抓包可以判断虚机2回复的icmp应答包从vnet1网卡出来后,被ovs桥丢弃了,我们需要添加一条流表让发送到虚机1的icmp包从vnet0的端口出去,如下图所示:
可以看到,一旦我们的流表添加完成,虚机1ping虚机2的包立即有了回复,从主机侧分别对两个虚机的网卡抓包,可以发现有icmp包。
自此我们通过添加流表让虚机1能够向虚机2发送icmp请求包并接收到icmp应答,理论上这只是单向ping通,虚机2到虚机1的icmp请求包仍然会被丢弃,因为我们的流表项明确写到只有发往虚机2的icmp请求包才会被转发到vnet1的端口,虚机2发往虚机1的icmp包和虚机1发往虚机2的应答包不会匹配到任何流表,照样会被丢弃,我们登陆虚机2ping虚机1,并在主机侧抓取icmp包,如下图所示:
果然,发往虚机1的icmp请求包从虚机2的网卡出来后就被丢弃,因此虚机1网卡上没有icmp包的流量,和前面类似,我们需要添加流表让发往虚机1的请求包从vnet0出去,让发往虚机2的应答包从vnet1出去:
自此我们添加的流表实现了端口转发,并允许虚机1和虚机2互相发送icmp报文,其它类型的ip报文仍然会被丢弃,我们登陆虚机1测验从虚机1ssh到虚机2,如下:
可以看到虚机1的网卡上有IP报文,并且是tcp报文的第一次握手sync包,而虚机2的网卡上没有IP报文,因此需要我们增加流表项让去往虚机2的IP报文从vnet1的端口流出:
当完成流表的添加之后,再次从虚机1ssh到虚机2,发现可以工作了。自此我们通过手动添加流表让虚机1和虚机可以实现IP通信了,从上面的操作也可以看到,手动的添加流表是个很复杂的过程,实际的虚拟化网络产品中还需要一个更抽象的上层软件专门用来做这些事情,这就是SDN控制器,它会监听ovs的端口状态并根据用户的网络配置,自动下发能够实现该配置的流表项。
vlan隔离