CNI设计解读

何为cni?

kubernetes在设计网络方案的时候并没有设计统一的网络方案,只提供了统一的容器网络接口也就是所谓cni,这么做的目的就是为了遵循kubernets的核心理念OutOfTree,简单来讲就是专注于自身核心能力,将其他能力类似csi cni cri交给社区以及领域专家,这样一方面可以降低软件自身使用的复杂度,减小稳定性风险。

flannel cni设计

在一个pod生命周期中,cni主要调用3个方法分别是cmdAdd,cmdDel, cmdCheck,分别代表:创建容器时调用cmdAdd,销毁容器时调用cmdDel, 以及销毁前的检测cmdCheck,但是cmdCheck是在0.4.0之后添加的,对于目前常用的cni版本0.3.1来说并不支持。

整体链路为flnnel-cni->bridge(创建设备)->host-local(申请ip)->bridge(申请到的ip写入到网卡上并配置路由)

大致调用流程图如下:

流程详解

第一部分(kubelet)

创建流程

1. kubelet解析/etc/cni/net.d/10-flannel.conflist文件之后 根据文件里的plugins里的对象逐一执行插件并把结果传递给下一个插件继续执行 最后将结果缓存到/var/lib/cni/cache目录。

/etc/cni/net.d/10-flannel.conflist配置如下 分别调用两个插件:

      a. flannel插件主流程依次调用bridge以及host-local 创建虚拟网卡以及路由

      b.portmap插件主要针对配置hostPort的pod 为该pod通过iptables配置端口映射

{
  "name": "cbr0",
  "cniVersion": "0.3.1",
  "plugins": [
    {
      "type": "flannel",
      "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }
    },
    {
      "type": "portmap",
      "capabilities": {
        "portMappings": true
      }
    }
  ]
}
func (c *CNIConfig) AddNetworkList(ctx context.Context, list *NetworkConfigList, rt *RuntimeConf) (types.Result, error) {
   var err error
   var result types.Result
   for _, net := range list.Plugins {
      result, err = c.addNetwork(ctx, list.Name, list.CNIVersion, net, result, rt)
      if err != nil {
         return nil, err
      }
   }

   if err = setCachedResult(result, list.Name, rt); err != nil {
      return nil, fmt.Errorf("failed to set network %q cached result: %v", list.Name, err)
   }

   return result, nil
}

2. 通过10-flannel.conflist我们可以看到调用的第一个插件为flannel,那么kubelet将插件目录/opt/cni/bin/flannel传递给invoke.ExecPluginWithResult函数.

func (c *CNIConfig) addNetwork(ctx context.Context, name, cniVersion string, net *NetworkConfig, prevResult types.Result, rt *RuntimeConf) (types.Result, error) {
   c.ensureExec()
   pluginPath, err := c.exec.FindInPath(net.Network.Type, c.Path)
   if err != nil {
      return nil, err
   }

   newConf, err := buildOneConfig(name, cniVersion, net, prevResult, rt)
   if err != nil {
      return nil, err
   }

   return invoke.ExecPluginWithResult(ctx, pluginPath, newConf.Bytes, c.args("ADD", rt), c.exec)
}

3. invoke.ExecPluginWithResult函数里调用exec.ExecPlugin并传递参数执行相应的二进制文件

func ExecPluginWithResult(ctx context.Context, pluginPath string, netconf []byte, args CNIArgs, exec Exec) (types.Result, error) {
   if exec == nil {
      exec = defaultExec
   }

   stdoutBytes, err := exec.ExecPlugin(ctx, pluginPath, netconf, args.AsEnv())
   if err != nil {
      return nil, err
   }

   // Plugin must return result in same version as specified in netconf
   versionDecoder := &version.ConfigDecoder{}
   confVersion, err := versionDecoder.Decode(netconf)
   if err != nil {
      return nil, err
   }

   return version.NewResult(confVersion, stdoutBytes)
}

三个参数如下示例:

 pluginPath: /opt/cni/bin/flannel
  NetConf: types.NetConf{
    CNIVersion: "0.3.1",
    Name: "cbr0",
    Type: "flannel",
    IPAM: types.IPAM{
      Type: "",
    },
    DNS: types.DNS{
      Nameservers: nil,
      Domain: "",
      Search: nil,
      Options: nil,
    },
  },
  Delegate: map[string]interface {}{
    "hairpinMode": true,
    "isDefaultGateway": true,
  },
}
args.AsEnv():  设置CNI_COMMAND,CNI_CONTAINERID,CNI_NETNS,CNI_ARGS,CNI_IFNAME,CNI_PATH几个环境变量给到插件
二进制程序通过PluginMain里的getCmdArgsFromEnv函数将环境变量解析成CmdArgs之后传递给cmdAdd,cmdDel,cmdCheck

4. 所有插件执行完成之后调用setCachedResult将结果写入到/var/lib/cni/cache/results目录下

删除流程

1.类似于创建流程,整体流程如下:

TearDownPod -> plugin.deleteFromNetwork -> cniNet.DelNetworkList -> delNetwork -> invoke.ExecPluginWithoutResult

从这里我们看到基本的流程和创建流程类似 唯一不一致的地方是我们在c.args("DEL", rt) 传入的是DEL而不是ADD。也就是说我们在这里判断我们执行插件的函数是cmdAdd还是cmdDel

2. 最后删除/var/lib/cni/cache/results下的对应文件,/var/lib/cni/cache/results的目的是为了提供cmdcheck用于检测是否合规

第二部分(flannel-cni)

        flannel-cni总共实现了两个方法cmdAdd以及cmdDel,通过PluginMain注册cmdAdd,cmdDel两个方法,并在PluginMain里讲传递过来的参数解析成cmdargs传递给cmdAdd,cmdDel其实现的能力如下:

cmdAdd:

1. 通过loadFlannelNetConf解析cmdargs里的StdinData,内容为NetConf结构体,并配置SubnetFile(/run/flannel/subnet.env)以及DataDir(/var/lib/cni/flannel)

2. 通过loadFlannelSubnetEnv解析由flannel生成的/run/flannel/subnet.env文件

3. 将/run/flannel/subnet.env里面的参数 渲染到由loadFlannelNetConf生成的出来的结构体中,也就是hairpinMode(发夹模式 可以让数据流量从同一个点位进出)以及isDefaultGateway(是否生成pod容器的默认网关)两个参数:

types.NetConf{
    CNIVersion: "0.3.1",
    Name: "cbr0",
    Type: "bridge",
    Mtu: "1450",
    HairpinMode: true,
    IpMasq: false,
    IsDefaultGateway: true,
    IsGateway: true,
    IPAM: types.IPAM{
      "type":   "host-local",
      "subnet": 10.244.0.1/24,
      "routes": []types.Route{
        types.Route{
         Dst: 10.244.0.0/16,
        },
      },
    }
}

 4. delegateAdd通过type字段找到后续需要执行的插件名称 并通过saveScratchNetConf方法将以上结果保存到/var/lib/cni/flannel目录下并在invoke.DelegateAddDelegate里通过type字段来判断执行下一个插件的名称(bridge),并将之前的结果以及args.env传递给下一个插件(bridge),该结果通过下一个插件的StdinData获取到.

func delegateAdd(cid, dataDir string, netconf map[string]interface{}) error {
   netconfBytes, err := json.Marshal(netconf)
   if err != nil {
      return fmt.Errorf("error serializing delegate netconf: %v", err)
   }

   // save the rendered netconf for cmdDel
   if err = saveScratchNetConf(cid, dataDir, netconfBytes); err != nil {
      return err
   }

   result, err := invoke.DelegateAdd(netconf["type"].(string), netconfBytes)
   if err != nil {
      return err
   }

   return result.Print()
}

cmdDEL:

1.通过loadFlannelNetConf解析cmdargs里的StdinData,内容为NetConf结构体,并配置SubnetFile(/run/flannel/subnet.env)以及DataDir(/var/lib/cni/flannel)

2. 通过CNI_ARGS获取到容器的id 通过consumeScratchNetConf函数读取/var/lib/cni/flannel/$containerId里面的配置类似,读取之后移除该文件。

# cat 0e39bc1c61f18e1af93bc6f455097fcfe04872d590c313eccf4d3a4f7de224d2
{"cniVersion":"0.3.1","hairpinMode":true,"ipMasq":false,"ipam":{"routes":[{"dst":"10.220.0.0/16"}],"subnet":"10.220.9.0/24","type":"host-local"},"isDefaultGateway":true,"isGateway":true,"mtu":1450,"name":"cbr0","type":"bridge"}

3. 将从/var/lib/cni/flannel/$containerId读取出来的内容 发给下一个插件(bridge)的CmdDel函数

第三部分(bridge cni)

cmdAdd部分

1. loadNetConf函数作用是读取flannel cni传递过来的StdinData 并设置BrName参数为cni0 组合成新的NetConf对象

2. 通过setupBridge函数里的ensureBridge函数创建名称为cni0的bridge虚拟桥接网卡

3. 通过GetNS获取该pod容器所在的网络名称空间id,args.Netns类似pid所在的net文件类似/proc/18649/ns/net

4. 通过setupVeth在改网络namespace里创建veth网卡对,网络namespace端为eth0 宿主机端为veth***(该名称由RandomVethName函数生成,并将veth加入到宿主机端namespace下。之后配置宿主机端端veth网卡hairpin mode。

5. 之后通过ipam.ExecAdd往下ipam(IP地址管理器)里获取ip地址,这里ipam类型为host-local,host-local的插件的规则从第四部分详解。

6. 获取到ip的结构为:

{
   "ip4": {
     "ip": "10.244.0.2",
     "gateway": "10.244.0.1"
   },
   "dns": {}
}

7. 通过calcGatewy来获取网关详情 这里主要以IsDefaultGW参数来判断是否给pod容器加默认网关

8. 之后在ConfigureIface函数里进入到上述的网络namespace配置eth0网卡的ip地址并根据calcGatewy的结果增加默认路由

9. 之后在ensureBridgeAddr函数里配置cni0网卡的ip地址,网卡mac地址,并在enableIPForward开启ipv4转发(/proc/sys/net/ipv4/ip_forward)。

10.之后在SetupIPMasq函数里配置地址伪装的iptables规则

CmdDEL部分

1. 通过loadNetConf读取flannel插件传递的过来的StdinData

2. 将flannel插件传递的过来的StdinData传递给IPAM插件去释放分配给该pod的ip地址,这里的IPAM从StdinData里可以看到 调用的是host-local插件。

3. 切换到该容器的network namespace之后删除虚拟网卡信息,并且如果开启了地址伪装功能,删除对应的iptables规则

第四部分(host-local cni)

cmdAdd部分

1.通过LoadIPAMConfig函数 将bridge传过来的StdinData以及args.env生成IPAMConfig对象

2. 之后通过allocator.Get根据传递过来的range进行ip地址分配,这里也可以指定ip地址分配,如果指定ip地址那么会校验ip地址是否合规,如果合规将分配的地址存储到本地磁盘下(/var/lib/cni/networks/cbr0/)以ip为文件名称 内容为容器id,并把最后获取到的ip地址存储到/var/lib/cni/networks/cbr0/last_reserved_ip.0,这么做的目的是保证分配的地址不冲突。

3. 迭代器GetIter的主要逻辑是基于LastReservedIP(这个数据保存在一个文件中)和ip range,找到下一个可分配的IP并返回,获取规则是避免LastReservedIP里的ip 在LastReservedIP的基础上+1 作为分配的ip 当然也会通过range检测分配的ip是否在合规范围内。

4. 核心函数GetIter 生成出迭代器对象,并通过lastReservedIP来配置cur这个参数的值,Next函数根据cur的值来判断下一个可分配的ip地址

5.之后通过Reserve方法将ip地址和容器id进行绑定存储到/var/lib/cni/networks/cbr0/并更新last_reserved_ip.0文件,最后将结果返回给bridge插件。

func (a *IPAllocator) GetIter() (*RangeIter, error) {
   iter := RangeIter{
      rangeset: a.rangeset,
   }

   // Round-robin by trying to allocate from the last reserved IP + 1
   startFromLastReservedIP := false

   // We might get a last reserved IP that is wrong if the range indexes changed.
   // This is not critical, we just lose round-robin this one time.
   lastReservedIP, err := a.store.LastReservedIP(a.rangeID)
   if err != nil && !os.IsNotExist(err) {
      log.Printf("Error retrieving last reserved ip: %v", err)
   } else if lastReservedIP != nil {
      startFromLastReservedIP = a.rangeset.Contains(lastReservedIP)
   }

   // Find the range in the set with this IP
   if startFromLastReservedIP {
      for i, r := range *a.rangeset {
         if r.Contains(lastReservedIP) {
            iter.rangeIdx = i
            iter.startRange = i

            // We advance the cursor on every Next(), so the first call
            // to next() will return lastReservedIP + 1
            iter.cur = lastReservedIP
            break
         }
      }
   } else {
      iter.rangeIdx = 0
      iter.startRange = 0
      iter.startIP = (*a.rangeset)[0].RangeStart
   }
   return &iter, nil
}
func (i *RangeIter) Next() (*net.IPNet, net.IP) {
   r := (*i.rangeset)[i.rangeIdx]

   // If this is the first time iterating and we're not starting in the middle
   // of the range, then start at rangeStart, which is inclusive
   if i.cur == nil {
      i.cur = r.RangeStart
      i.startIP = i.cur
      if i.cur.Equal(r.Gateway) {
         return i.Next()
      }
      return &net.IPNet{IP: i.cur, Mask: r.Subnet.Mask}, r.Gateway
   }

   // If we've reached the end of this range, we need to advance the range
   // RangeEnd is inclusive as well
   if i.cur.Equal(r.RangeEnd) {
      i.rangeIdx += 1
      i.rangeIdx %= len(*i.rangeset)
      r = (*i.rangeset)[i.rangeIdx]

      i.cur = r.RangeStart
   } else {
      i.cur = ip.NextIP(i.cur)
   }

   if i.startIP == nil {
      i.startIP = i.cur
   } else if i.rangeIdx == i.startRange && i.cur.Equal(i.startIP) {
      // IF we've looped back to where we started, give up
      return nil, nil
   }

   if i.cur.Equal(r.Gateway) {
      return i.Next()
   }

   return &net.IPNet{IP: i.cur, Mask: r.Subnet.Mask}, r.Gateway
}

cmdDel部分

1. 通过LoadIPAMConfig读取bridge传递的StdinData内容

2. disk.New出生后存储,默认的目录是/var/lib/cni/networks/{name},后续通过store操作数据

3. 通过ipAllocator.Release释放ip 并通过ReleaseByID函数循环读取/var/lib/cni/networks/cbr0目录下的文件,如果文件内容匹配容器id那么就移除该文件。

func (s *Store) ReleaseByID(id string) error {
   err := filepath.Walk(s.dataDir, func(path string, info os.FileInfo, err error) error {
      if err != nil || info.IsDir() {
         return nil
      }
      data, err := ioutil.ReadFile(path)
      if err != nil {
         return nil
      }
      if strings.TrimSpace(string(data)) == strings.TrimSpace(id) {
         if err := os.Remove(path); err != nil {
            return nil
         }
      }
      return nil
   })
   return err
}

第五部分(portmap cni)

cmdAdd部分

最后flannel-cni返回给kubelet插件的结构体如下 并把结果放到prevResult里交给portmap插件 配置案例如下:

{
        "name": "cbr0",
        "cniVersion": "0.3.1",
        "runtimeConfig": {
                "portMappings": [{
                        "hostPort": 801,
                        "containerPort": 80,
                        "protocol": "tcp"
                }]
        },
        "prevResult": {
                "cniVersion": "1.0.0",
                "interfaces": [{
                        "name": "eth0",
                        "sandbox": "/proc/20202/ns/net"
                }],
                "ips": [{
                        "interface": 2,
                        "address": "10.244.0.136/24",
                        "version": "4"
                }],
                "routes": [{
                        "dst": "0.0.0.0/0",
                        "gw": "10.244.0.1"
                }],
                "dns": {}
        }
}

2. parseConfig函数解析上面的结构体以及接口名称通常为eth0,在这里主要判断结构体内容是否合规 是否需要进行端口映射

3. 通过forwardPorts生成并在宿主机加入iptables规则映射规则DNT以及SNAT默认情况下地址伪装是需要配置snat规则,这里配置dnat主要是为了对需要地址伪装的流量进行打标记

cmdDEL部分

1. 通过parseConfig解析StdinData以及ifname

2. 获取args.ContainerID

3. 通过unforwardPorts删除对应的iptables端口映射规则dnat以及snat

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值