美文网首页
Flannel和Calico网络模式笔记

Flannel和Calico网络模式笔记

作者: Teddy_b | 来源:发表于2024-01-08 14:47 被阅读0次

Falnnel

UDP模式

引用极客时间-张磊深入剖析k8s的图

image.png
  • 按照容器内的路由表,同一主机上的容器之间发送数据包时,如到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的图

image.png
  • 容器的数据包也是先路由到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地址,这样就可以经过宿主机的路由器发送出去了,实现跨网段的传送

参考

相关文章

网友评论

      本文标题:Flannel和Calico网络模式笔记

      本文链接:https://www.haomeiwen.com/subject/whlbndtx.html