何为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