Flannel和Calico网络模式笔记
Falnnel
UDP模式
引用极客时间-张磊深入剖析k8s的图
- 按照容器内的路由表,同一主机上的容器之间发送数据包时,如到
172.17.0.4
会发往网关0.0.0.0
,即不需要网关,通过二层直接路由,数据包直接发往容器的ech0网卡
/ # route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
0.0.0.0 172.17.0.1 0.0.0.0 UG 0 0 0 eth0
172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 eth0
-
由于veth设置连接host命名空间和容器命名空间,容器1上的数据包会立马出现在veth设备的另一端
-
在主机上的
brctl
命令可以看到veth
设置docker0
网桥的从设备,因此veth设备不具备处理数据包的资格,都需要交给网桥处理,此时网桥扮演二层交换机的角色,通过ARP拿到目标容器IP的MAC地址,然后两个容器即可正常通信了
docker@ubuntu:~$ brctl show
bridge name bridge id STP enabled interfaces
docker0 8000.0242cbadb428 no vethadc8dc2
vethd2a5b9e
-
如果发往另一主机的数据包,如到
172.17.2.4
,按照容器内的路由表,由于不在172.16.0.0
网段下了,因此会发往网关172.17.0.1
,有容器的ech0网卡发出 -
此时的网关
172.17.0.1
对应的就是docker0
的IP地址,容器1先通过ARP协议询问网关172.17.0.1
的MAC地址,如果数据包的目的 MAC 地址为网桥本身,并且网桥有设置了 IP 地址的话,那该数据包即被认为是收到发往创建网桥那台主机的数据包,此数据包将不会转发到任何设备,而是直接交给上层(三层)协议栈去处理。 -
数据包达到三层协议栈后, 将会根据宿主机的路由表来转发包,而flanneld进程会再宿主机上维护一条路由信息,即所有
172.17.0.0/16
这个网段的数据包都发到flannel0设备
$ route -n
172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 flannel0
-
而flannel0设备是一个tun设备,设备上的内核数据包都会经文件句柄
/dev/net/tun
发送到用户态进程,而flanneld进程监听了这个文件句柄,因此可以完全收到这个设备上的数据包 -
flanneld进程收到这个数据包后,会将数据包包装成一个UDP包,UDP端口默认是8285,UDP是应用层协议,还需要再套上IP头,IP就是flanneld所在的宿主机IP,而目标IP是通过etcd保存着的,etcd中记录了每个容器网段再哪个目标宿主机上,这样就能拿到目标IP和端口
$ etcdctl get /coreos.com/network/subnets/172.17.2.0-24
{"PublicIP":"105.168.0.3"}
-
UDP包装完成后,再通过文件句柄
/dev/net/tun
将数据包发送到flannel0设备,重新进入到了网络协议栈 -
由于再容器的IP包外又包了一层UDP包,所以flannel0设备的MTU会比宿主机的小28字节,对应的是
udp header 8 byte + ip header 20 byte
-
此时目标IP已经变成了宿主机可识别的IP了,可以直接通过宿主机网络发送出去了
-
目标IP收到UDP包后,将UDP包丢给目标主机的flanneld进程,然后目标主机解包,去掉UDP头部后,剩下的就是原始两个容器之间的IP数据包
-
原始的容器IP数据包再通过flannel0设备进入目标IP的网络协议栈,然后由docker0设备转发给目标容器
缺点:性能差,由用户态进程进程UDP封包操作,需要经历用户(容器)->内核->用户(flanneld)->内核 多次状态转换
源码解析
创建flannel0设备
func OpenTun(name string) (*os.File, string, error) {
// 以读写模式打开文件 /dev/net/tun
tun, err := os.OpenFile(tunDevice, os.O_RDWR, 0)
if err != nil {
return nil, "", err
}
var ifr ifreqFlags
copy(ifr.IfrnName[:len(ifr.IfrnName)-1], []byte(name+"\000"))
ifr.IfruFlags = syscall.IFF_TUN | syscall.IFF_NO_PI
// 系统调用ioctl,此时会创建一个tun设备出来
// 相当于执行了:ip tuntap add dev flannel0 mod tun
err = ioctl(int(tun.Fd()), syscall.TUNSETIFF, uintptr(unsafe.Pointer(&ifr)))
if err != nil {
return nil, "", err
}
ifname := fromZeroTerm(ifr.IfrnName[:ifnameSize])
return tun, ifname, nil
}
配置flannel0设备
// 找到网卡设备
// 相当于执行了: ip link show | grep tun
iface, err := netlink.LinkByName(ifname)
// 为网卡添加IP地址
// 相当于执行了: ip addr add 10.1.1.0/32 dev flannel0
netlink.AddrAdd(iface, &netlink.Addr{IPNet: ipnLocal.ToIPNet(), Label: ""})
// 设置MTU
// 相当于执行了: ifconfig flannel0 mtu 1472
netlink.LinkSetMTU(iface, mtu)
// 设置网卡UP
// 相当于执行了: ip link set flannel0 up
netlink.LinkSetUp(iface)
// 添加路由
// 相当于执行了: ip route add 10.1.1.0/16 dev flannel0
netlink.RouteAdd(&netlink.Route{
LinkIndex: iface.Attrs().Index,
Scope: netlink.SCOPE_UNIVERSE,
Dst: ipn.Network().ToIPNet(),
})
开启UDP和文件句柄/dev/net/tun
监听,flannel中通过调用C函数实现中的poll函数实现同时监听多个文件句柄
void run_proxy(int tun, int sock, int ctl, in_addr_t tun_ip, size_t tun_mtu, int log_errors) {
char *buf;
// pollfd 定义在 poll.h 中,用来监控一组文件句柄
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;
// 分配 flannel0网卡MTU大小的内存空间
buf = (char *) malloc(tun_mtu);
if( !buf ) {
log_error("Failed to allocate %d byte buffer\n", tun_mtu);
exit(1);
}
// 将 /dev/net/tun 文件描述符设置为非阻塞
// 因为下面代码是先调用 tun_to_udp(),如果是UDP先收到数据包,而tun是阻塞的,那么会一直阻塞在tun_to_udp()方法里
fcntl(tun, F_SETFL, O_NONBLOCK);
while( !exit_flag ) {
// 通过 poll() 监听文件描述符是否有可读事件,指定了永不超时,poll()会一直挂起
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);
}
// ctl2 文件句柄上有可读事件,对应的是有节点加入/删除,需要添加/移除路由信息
if( fds[PFD_CTL].revents & POLLIN )
process_cmd(ctl);
// flannel0网卡或UDP文件句柄有可读事件,对应的是有网络包需要转发
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);
}
-
这里一方面是监听udp和tun设备文件句柄,产生了事件,就意味着又数据包需要转发,因此会执行包的转发逻辑
-
另一方面还监听了etcd,通过watch etcd中
/coreos.com/network/subnets
这个key的变化,再集群节点加入或者删除时,再节点上创建或者删除路由规则,即ip route add 10.1.2.0/16 next hop 192.168.89.130 next hop port 8285
,相当于配置flannel0设备的路由
VxLAN模式
VxLAN是Linux内核默认提供的能力,可以再内核完成UDP封包,因此较UDP方式性能更好
在学习这种模式之前,可以先了解下VxLAN是什么?https://icyfenix.cn/immutable-infrastructure/network/linux-vnet.html
引用极客时间-张磊深入剖析k8s的图
-
容器的数据包也是先路由到flannel.1设备,这种模式下创建的设备名称叫flannel.1(不是flannel0了)
-
flannel.1是VTEP设备,同时flanneld进程会维护宿主机上的路由规则,即发往目标容器网段为
10.1.16.0/24
的,都经过flannel.1设备发送到网关10.1.16.0
,这个网关对应的就是目标宿主机上的flannel.1设备的IP地址
$ route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
...
10.1.16.0 10.1.16.0 255.255.255.0 UG 0 0 0 flannel.1
-
这样就找到了目标VTEP设备,但是宿主机上是没有到
10.1.16.0
网关的路由信息的,因此这个包还是不能从宿主机上发出 -
VxLAN模式下,是工作在三层之上的二层数据帧,集群内所有的VTEP设备组成一个二层vLAN,二层设备之间通过MAC地址通信,所以VTEP设备会给容器IP包包装一层目标VTEP设备的MAC,这个MAC地址从哪里来呢,也是flanneld进程维护的,在节点加入时静态写入的ARP信息
$ ip neigh show dev flannel.1
10.1.16.0 lladdr 5e:f8:4f:00:e3:37 PERMANENT
-
包装了目标VTEP设备的MAC地址后,再包装VxLAN头部和UDP头部,其中VxLAN头部中的VID指定为1,这个flannel.1设备中的
1
是一致的 -
然后再经宿主机发出这个UDP包,而目标宿主机的IP地址也是flanneld进程维护的,会记录目标VTEP设备的MAC地址和目标宿主机IP的映射关系
$ bridge fdb show flannel.1 | grep 5e:f8:4f:00:e3:37
5e:f8:4f:00:e3:37 dev flannel.1 dst 10.168.0.3 self permanent
- 目标IP的MAC地址通过ARP学习即可完成,至此, 容器IP数据包才能从宿主机发出
源码解析
VxLAN模式会watch节点的加入和删除事件,实现方式和UDP模式基本一致
func (nw *network) Run(ctx context.Context) {
go func() {
subnet.WatchLeases(ctx, nw.subnetMgr, nw.SubnetLease, events)
log.V(1).Info("WatchLeases exited")
wg.Done()
}()
for {
evtBatch, ok := <-events
nw.handleSubnetEvents(evtBatch)
}
}
收到节点接入的事件后
-
首先会添加静态ARP记录,对应的就是上面VTEP网关地址对应的MAC信息
-
然后再添加FDB记录,对应的就是上面VTEP设备MAC地址到目标IP的记录
-
最后是到目标VTEP设备网关的路由信息
switch event.Type {
case subnet.EventAdded:
if event.Lease.EnableIPv4 {
else {
if err := nw.dev.AddARP(neighbor{IP: sn.IP, MAC: net.HardwareAddr(vxlanAttrs.VtepMAC)}); err != nil {
if err := nw.dev.AddFDB(neighbor{IP: attrs.PublicIP, MAC: net.HardwareAddr(vxlanAttrs.VtepMAC)}); err != nil {
if err := netlink.RouteReplace(&vxlanRoute); err != nil {
}
Host-GW模式
前面两种模式都是通过隧道方式,将容器的数据包包装成宿主机的数据包后发出,因此对宿主机的网络没具体要求,只要能够连通即可
host-gw模式则不一样,它是一种纯靠路由转发的方案
这种模式下flanneld进程只需要维护宿主机的路由信息即可
$ ip route
...
10.244.1.0/24 via 10.168.0.3 dev eth0
即目标容器IP在网段10.244.1.0/24
在这个范围内的,直接通过宿主机的网卡发出,并且下一跳地址是10.168.0.3,即这个网段对应的目标宿主机IP
由于这个容器数据包是直接通过宿主机网卡发出去的,所以性能相对最好
同样也是因为这里设置了下一跳地址,所以对宿主机的网络要求更高了,需要宿主机之间是二层可达的,即属于同一个vlan
因为如果宿主机之间二层不可达,这个直接指定的下一跳地址将无法获取到MAC地址,进而无法发出去
(宿主机之间二层不可达时,原本可以通过指定下一跳路由器来到达目标IP的,现在改了下一跳地址了,所以现在不可达了)
源码分析
这种方式的实现相对更简单,只需要watch etcd中节点的加入和删除,然后添加将目标节点作为网关,目标容器网段作为dst即可
func (be *HostgwBackend) RegisterNetwork(ctx context.Context, wg *sync.WaitGroup, config *subnet.Config) (backend.Network, error) {
if config.EnableIPv4 {
attrs.PublicIP = ip.FromIP(be.extIface.ExtAddr)
n.GetRoute = func(lease *subnet.Lease) *netlink.Route {
return &netlink.Route{
Dst: lease.Subnet.ToIPNet(),
Gw: lease.Attrs.PublicIP.ToIP(),
LinkIndex: n.LinkIndex,
}
}
}
Calico
Calico和上面flannel的host-gw模式类似,不过Calico不自己维护路由规则了,而是依靠BGP协议(边界网关协议)来传递节点间的路由规则
同时Calico也不会创建网桥设备了,取而代之的是每个容器的路由规则都会写入宿主机,cali5863f3 和 容器内的 eth0
组成veth pair
10.233.2.3 dev cali5863f3 scope link
跨主机的容器IP通过网段形式也写入宿主机,这点和host-gw模式是一致的,不同的是这个路由消息是通过BGP协议交换的,BGP协议是Linux内核支持的
10.244.1.0/24 via 10.168.0.3 dev eth0
这里的路由规则也指定了下一跳,因此也需要宿主机之间是二层可达的
对于宿主机之间二层不可达的,Calico也提供了隧道模式,Calico中使用的不是VxLAN了,而是IP tunnel(IP隧道),这种隧道会在原始IP包上再包装一层IP包,外层的IP包指定为宿主机的IP到目标IP,即IPv4 in IPv4
经过包装后,这个IP包源地址变成了宿主机IP,然后到目标IP地址,这样就可以经过宿主机的路由器发送出去了,实现跨网段的传送