Skip to content

Latest commit

 

History

History
299 lines (225 loc) · 10.3 KB

flannel原理之udp模式.md

File metadata and controls

299 lines (225 loc) · 10.3 KB

flannel udp模式

flannel 的udp模式是flannel最古老的一种方式,属于隧道技术的最古老实现,据说是flannel最开始开发的时候,linux内核还没有支持vxlan,所以就用了tun虚拟网卡来实现隧道技术,后续linux内核加入vxlan支持之后,flannel实现了vxlan模式,udp模式也成为了一种历史。

从源码层面上,v0.12.0版本和v0.1.0版本在原理上相差并不太大,中间可能会修修补补一些bug,整体而言换汤不换药。

技术原理

有了解openvpn的开发人员应该都知道openvpn采用的也是tun和tap这种虚拟网卡的方式。通过tun和tap设备读取到数据,然后再做一层封装。从而实现虚拟局域网。我在docker网络之tun/tap隧道当中有详细介绍了tun-tap的原理,感兴趣的同学可以去了解下。

tun

flanneld会通过subnet模块监听到子网变化,在handleSubnetEvent当中,会执行路由操作,将路由信息加入内存路由表当中,当收到数据包时,查询内存的路由表,找到下一跳的ip地址和udp端口

源码分析

flannel的udp模式隧道和路由部分的代码采用的是c语言编写,c语言和go语言之间通过unix socket进行通信,实际上这部分代码用go语言也能够实现,但是可能由于历史原因,作者也懒得改了,所以一直沿用c语言的代码,但是这样就需要编译时只能在linux环境下编译,在mac上进行交叉编译会缺少linux头文件。

源码目录

backend/udp
├── cproxy_amd64.go
├── proxy_amd64.c
├── proxy_amd64.h
├── udp_amd64.go
└── udp_network_amd64.go

proxy_amd64.c主要是实现路由操作和隧道技术,也即是在收到子网变化时,最终会使用c语言编写的代码操作路由表,当收到由内核发送的数据包时,会将其封装成udp包发送给对端,在收到对端发送的数据包时,会解封装然后写入到tun设备。

虽然udp模式采用了cgo的方式,但是不存在网上传言的cgo的性能问题,因为无论是udp的读写还是tun的读写,都是在c语言当中实现的。如果读udp在golang当中实现,然后再调用c的函数,那么将会是完全不一样的状态。

udp_amd64.go和udp_network_amd64.go主要实现两个功能,一个是网络注册,一个是bootstrap,将cproxy运行起来,然后循环等待子网变化事件。

整个udp模式处理过程如下:

flannel-udp

路由添加,删除、查找

路由添加、删除s

struct ip_net {
	in_addr_t ip;
	in_addr_t mask;
};

struct route_entry {
	struct ip_net      dst;
	struct sockaddr_in next_hop;
};


static int set_route(struct ip_net dst, struct sockaddr_in *next_hop) {
	size_t i;

	for( i = 0; i < routes_cnt; i++ ) {
		if( dst.ip == routes[i].dst.ip && dst.mask == routes[i].dst.mask ) {
			routes[i].next_hop = *next_hop;
			return 0;
		}
	}

	if( routes_alloc == routes_cnt ) {
		int new_alloc = (routes_alloc ? 2*routes_alloc : 8);
		struct route_entry *new_routes = (struct route_entry *) realloc(routes, new_alloc*sizeof(struct route_entry));
		if( !new_routes )
			return ENOMEM;

		routes = new_routes;
		routes_alloc = new_alloc;
	}

	routes[routes_cnt].dst = dst;
	routes[routes_cnt].next_hop = *next_hop;
	routes_cnt++;

	return 0;
}

static int del_route(struct ip_net dst) {
	size_t i;

	for( i = 0; i < routes_cnt; i++ ) {
		if( dst.ip == routes[i].dst.ip && dst.mask == routes[i].dst.mask ) {
			routes[i] = routes[routes_cnt-1];
			routes_cnt--;
			return 0;
		}
	}

	return ENOENT;
}

flannel的系统路由只有一条,将networkConfig的ip地址段加到系统路由当中,系统路由的目的是为了tun网卡能够读到数据包,除了系统路由外,flannel在内存当中维护一个路由表,内存路由表的目的是在收到数据包时,知道往哪个ip和udp端口发送。

路由查找

static inline int contains(struct ip_net net, in_addr_t ip) {
	return net.ip == (ip & net.mask);
}

static struct sockaddr_in *find_route(in_addr_t dst) {
	size_t i;

	for( i = 0; i < routes_cnt; i++ ) {
		if( contains(routes[i].dst, dst) ) {
			// packets for same dest tend to come in bursts. swap to front make it faster for subsequent ones
			if( i != 0 ) {
				struct route_entry tmp = routes[i];
				routes[i] = routes[0];
				routes[0] = tmp;
			}

			return &routes[0].next_hop;
		}
	}

	return NULL;
}

路由查找过程比较粗糙,只是拿目的ip与路由项目当中的子网掩码进行与运算然后进行比较,然后将该表象移动到路由表第一项,算是一种优化策略。

熟悉Linux静态路由的开发人员可能比较清楚,在匹配到多个路由表项时,linux静态路由匹配到的是子网掩码最大的路由表项,返回最精确的地址。

从flannel的源码上看,flannel明显没有这样处理,但是这种玩法在flannel机制下又是没问题的,因为flannel分配的所有所有子网subnetlen都是一样的,而且还有overlap判断,所有子网都不存在包含的关系。所以采用这种玩法问题也不大。

func (n IP4Net) Overlaps(other IP4Net) bool {
	var mask uint32
	if n.PrefixLen < other.PrefixLen {
		mask = n.Mask()
	} else {
		mask = other.Mask()
	}
	return (uint32(n.IP) & mask) == (uint32(other.IP) & mask)
}

当前路由表项有目的地址是172.20.10.0/24和172.20.0.0/16两项 收到数据包目的地址是172.20.10.5 此时匹配到两条路由,静态路由的策略是返回子网掩码最大,也就是172.20.10.0/24 flannel会返回路由表当中最先匹配到的项目,所以下一跳可能每次都会不一样。

cproxy的实现

cproxy模拟了linux当中的路由选择过程,并且处理了ip头的ttl,icmp等细节问题

主流程

backend/udp/proxy_amd64.c
void run_proxy(int tun, int sock, int ctl, in_addr_t tun_ip, size_t tun_mtu, int log_errors) {
	char *buf;
	struct pollfd fds[PFD_CNT] = {
		{
			.fd = tun,
			.events = POLLIN
		},
		{
			.fd = sock,
			.events = POLLIN
		},
		{
			.fd = ctl,
			.events = POLLIN
		},
	};

	exit_flag = 0;
	tun_addr = tun_ip;
	log_enabled = log_errors;

	buf = (char *) malloc(tun_mtu);
	if( !buf ) {
		log_error("Failed to allocate %d byte buffer\n", tun_mtu);
		exit(1);
	}

	fcntl(tun, F_SETFL, O_NONBLOCK);

	while( !exit_flag ) {
		int nfds = poll(fds, PFD_CNT, -1), activity;
		if( nfds < 0 ) {
			if( errno == EINTR )
				continue;

			log_error("Poll failed: %s\n", strerror(errno));
			exit(1);
		}

		if( fds[PFD_CTL].revents & POLLIN )
			process_cmd(ctl);

		if( fds[PFD_TUN].revents & POLLIN || fds[PFD_SOCK].revents & POLLIN )
			do {
				activity = 0;
				activity += tun_to_udp(tun, sock, buf, tun_mtu);
				activity += udp_to_tun(sock, tun, buf, tun_mtu);

				/* As long as tun or udp is readable bypass poll().
				 * We'll just occasionally get EAGAIN on an unreadable fd which
				 * is cheaper than the poll() call, the rest of the time the
				 * read/recvfrom call moves data which poll() never does for us.
				 *
				 * This is at the expense of the ctl socket, a counter could be
				 * used to place an upper bound on how long we may neglect ctl.
				 */
			} while( activity );
	}

	free(buf);
}


在run_proxy当中,会监听三个文件描述符,tun网卡fd,udp套接字fd,unixsocket fd。

从unixsocket fd当中,读取到子网变化的cmd,然后调用process_cmd进行处理。

从tun网卡当中读取ip包信息,然后调用tun_to_udp进行路由模拟以及隧道封装。

从udp套接字当中读取其他主机封装过来的数据包,然后调用udp_to_tun写入tun网卡。

在实现当中,如果sock和tun两个描述符只要有一个就绪,就会同时处理tun_to_udp和udp_to_tun,我个人猜测是为了防止使用多线程。

tun_to_udp


static int tun_to_udp(int tun, int sock, char *buf, size_t buflen) {
	struct iphdr *iph;
	struct sockaddr_in *next_hop;

	ssize_t pktlen = tun_recv_packet(tun, buf, buflen);
	if( pktlen < 0 )
		return 0;
	
	iph = (struct iphdr *)buf;

	next_hop = find_route((in_addr_t) iph->daddr);
	if( !next_hop ) {
		send_net_unreachable(tun, buf);
		goto _active;
	}

	if( !decrement_ttl(iph) ) {
		/* TTL went to 0, discard.
		 * TODO: send back ICMP Time Exceeded
		 */
		goto _active;
	}

	sock_send_packet(sock, buf, pktlen, next_hop);
_active:
	return 1;
}

tun_to_udp实现非常直观,从tun网卡读去ip包,获取到目的地址,然后查路由,获取到下一跳的ip地址和端口,然后通过udp包把这个ip包发到对端去。

这里作者还实现了两个细节,一个是路由查找失败的时候回复ICMP消息,另外一个是将ttl递减,需要注意的是ttl减1的过程需要重新计算checksum,会带来额外的cpu消耗,我个人在cframe的实现过程当中这两个方面都没有处理,目前观察问题不大。

udp_to_tun udp_to_tun是tun_to_udp的逆向过程,将收到的对端udp包解封装,然后再写入到tun文件描述符

static int udp_to_tun(int sock, int tun, char *buf, size_t buflen) {
	struct iphdr *iph;

	ssize_t pktlen = sock_recv_packet(sock, buf, buflen);
	if( pktlen < 0 )
		return 0;

	iph = (struct iphdr *)buf;

	if( !decrement_ttl(iph) ) {
		/* TTL went to 0, discard.
		 * TODO: send back ICMP Time Exceeded
		 */
		goto _active;
	}

	tun_send_packet(tun, buf, pktlen);
_active:
	return 1;
}

同样也做了ttl递减,出发check_sum计算。

注意: 如果前后两次backendType不一样,也就是改了配置了,路由可能需要手动操作 比如上一次使用了host-gw模式,路由已经加到了物理网卡当中 下一次切换成udp模式,物理网卡当中的路由还保留,那么需要先手动删了这一路由。

收获

对我而言flannel的udp模式是最好理解的,因为tun网卡接触的比较多,在cframegtun当中都广泛应用了tun网卡,除了ttl递减和icmp消息之外,和flannel的实现是完全一致的。

flannel的udp模式需要两次进入内核协议栈,第一次是容器发包时,从tun网卡发出,第二次是隧道封装时,从物理网卡发出。而且每次还处理了ttl,需要重新计算checksum,因此可能会造成速度以及cpu占用问题。