背景

最近一段时间一直在折腾 Kubernetes 的 Calico 网络方案,所以对这块还算比较熟悉。说实话,Calico 项目名字不错,但是代码质量感觉跟 Kubernetes 比起来那就差远了。不过社区的 member 还算不错,很多问题解决和回复都挺快的。

有时间写篇文章讲讲大概的架构,今天就先讲点细节。

代码版本

其中,cni-plugin 项目是一个采用标准 CNI 接口的插件层,将编译出两个二进制文件 calicocalico-ipam(默认在每个 Kubernetes 节点的 /opt/cni/bin 目录下),关于 IP 的分配最终是用 calico-ipam 这个可执行文件,而 calico-ipam 具体的 IP 分配逻辑由库 libcalico-go 实现。

libcalico-go 是 Calico 项目的公共库,实现了对底层存储(etcd/k8s)的操作,IP 的具体分配等。

总体逻辑

当在 Kubernetes 创建一个新的容器是,kubelet 服务会通过标准的 CNI 接口调用 Calico 的 CNI 插件,从而创建为容器创建一个 veth-pair 类型的网卡,并为每个容器写入路由表信息

节点上的路由表通过 bird 组件以 BGP 的形式广播到其他邻居上,其他节点在收到路由条目后在进一步聚合路由写入到自身节点上。

容器创建时

Calico 中为某个节点分配 IP Block 的环节发生在 CNI 插件环节,主要逻辑为:

kubelet 通过调用 CNI 插件的 /opt/cni/bin/calico 来创建容器网络:

cni/k8s/k8s.go

1
2
3
...
ipamResult, err := ipam.ExecAdd(conf.IPAM.Type, args.StdinData)
...

这段代码最终又调用了 /opt/cni/bin/calico-ipam 这个 CNI 插件,这部分的代码在cni/k8s/calico-ipam.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
...
// 如果不是指定 IP 分配
if ipamArgs.IP != nil {
    ...
    err := calicoClient.IPAM().AssignIP(assignArgs)
    ...
} else {
    ...
    assignedV4, assignedV6, err := calicoClient.IPAM().AutoAssign(assignArgs)
    ...
}
...

我们目前线上都不会指定 IP 分配,所以将调用 AutoAssign() 的逻辑。

AutoAssign() 的逻辑在 libcalico-go/lib/client/ipam.go,最终实现在 autoAssign()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
...
func (c ipams) autoAssign(num int, handleID *string, attrs map[string]string, pools []net.IPNet, version ipVersion, host string) ([]net.IP, error) {

    // 优先分配 host-affine 的地址块
    // 这一步会从 etcd v2 中读取 /calico/ipam/v2/host/<host>/ipv4/block 对应的 key
    // 其中 affBlocks 是一个 list,即有可能一个 host 对应多个 block
    affBlocks, err := c.blockReaderWriter.getAffineBlocks(host, version, pools)
    ...
    // 创建一个 ips list,将分配的 num 个 IP 放入这个 list 中
    ips := []net.IP{}
    for len(ips) < num {
        // 如果可分配
        if len(affBlocks) == 0 {
            log.Infof("Ran out of existing affine blocks for host '%s'", host)
            break
        }
        // 依次取地址块 list 中的成员来分配 IP
        cidr := affBlocks[0]
        affBlocks = affBlocks[1:]
        // 从对应 cidr 中分配 IP
        ips, _ = c.assignFromExistingBlock(cidr, num, handleID, attrs, host, true)
        log.Debugf("Block '%s' provided addresses: %v", cidr.String(), ips)
    }
    
    // 获取 Calico IPAM 的配置
    // Calico IPAM 有两个全局配置项目(二者之中只能有一个为 true):
    // StrictAffinity: 严格的一个 host 对应一个地址块,如果地址块耗尽不再分配新的地址
    // AutoAllocateBlocks: 自动分配地址块,如果基于 host affine 的地址块耗尽,将分配新的地址块
    // 这部分配置没有对外暴露,只能通过人工配置对应 etcd key 值或者编程调用相关接口来进行配置
    // 相关讨论可参考 issue: https://github.com/projectcalico/calico/issues/1577
    // 直接设置 etcd key:/calico/ipam/v2/config/ => "{\"strict_affinity\":true}"
    config, err := c.GetIPAMConfig()
    
    ...
    if config.AutoAllocateBlocks == true {
        // 如果还有需要分配的 IP
        rem := num - len(ips)
        retries := ipamEtcdRetries
        ...
        for rem > 0 && retries > 0 {
            ...
            // 申请一块 host-affine 块
            // 新的 host-affine 块是有随机化算法从全局可用 ippool 中生成
            b, err := c.blockReaderWriter.claimNewAffineBlock(host, version, pools, *config)
            ...
            // 在新的块里分配 IP
            newIPs, err := c.assignFromExistingBlock(*b, rem, handleID, attrs, host, config.StrictAffinity)
            ...
        }
    }
    ...
    // 如果还有 IP 需要分配且 StrictAffinity 不为 true
    // 默认不会走到这一步逻辑,因为 StrictAffinity 默认为 false
    rem := num - len(ips)
    if config.StrictAffinity != true && rem != 0 {
        ...
        // 全局遍历当前可用的 IPPool,目前我们全局只有一个 IPPool
        allPools, err := c.client.IPPools().List(api.IPPoolMetadata{})
        ...
        for _, p := range allPools.Items {
            // 如果 IPPool 可被使用,加入到 pools 列表
            if !p.Spec.Disabled {
                pools = append(pools, p.Metadata.CIDR)
            }
        }
        
        // 从符合要求的 IP 块中,遍历每个 pool
        // 用当前宿主机 hostname 在这个 pool 下用随机化算法为其生成一个新的 block
        // 然后从新生成的 block 中分配 IP
        for _, p := range pools {
            newBlock := randomBlockGenerator(p, host)
            for rem > 0 {
                blockCIDR := newBlock()
                ...
                // 这某个 pool 的随机块中分配 IP
                // 此时不管对应 block 是否被其他 host 使用,也不会将该 block 对当前 host 绑定
                newIPs, err := c.assignFromExistingBlock(*blockCIDR, rem, handleID, attrs, host, false)
                ...
            }
        }
    }
}
...

其中,claimNewAffineBlock()libcalico-go/lib/client/ipam_block_reader_writer.go 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func (rw blockReaderWriter) claimNewAffineBlock(
	host string, version ipVersion, givenPools []cnet.IPNet, config IPAMConfig) (*cnet.IPNet, error) {
    ...
    // 如果指定 pools 进行分配,线上都不会指定 pools 进行分配
    if len(givenPools) != 0 {
    } else {
        // 获取全局所有可用的 ippool
        allPools, err := rw.Client.IPPools().List(api.IPPoolMetadata{})
        if err != nil {
            ...
        }
        // 选出满足需求的可用 pool(没有被 disable)放入 pools 列表中
        for _, p := range allPools.Items {
            if !p.Spec.Disabled && version.Number == p.Metadata.CIDR.Version() {
                pools = append(pools, p.Metadata.CIDR)
            }
        }
    }
    
    ...
    // 遍历 pools 列表
    for _, pool := range pools {
        // randomBlockGenerator() 生成一个函数,每次调用该函数将随机产生一个 block
        // 遍历每次生成的 block,查看是否已被使用,如果未被使用,则返回对应的 block
        blocks := randomBlockGenerator(pool, host)
        for subnet := blocks(); subnet != nil; subnet = blocks() {
            // 生成一个 etcd v2 的 key:/calico/ipam/v2/assignment/ipv4/block/<cidr>
            key := model.BlockKey{CIDR: *subnet}
            // 从 etcd 中读一下这个 key
            _, err := rw.client.Backend.Get(key)
            // 如果发生错误
            if err != nil {
                // 如果 etcd 没有这个 key,则说明对应的 block 还未被使用
                if _, ok := err.(errors.ErrorResourceDoesNotExist); ok {
                    // 生成对应数据结构,并在 etcd 中写入对应的配置信息
                    err = rw.claimBlockAffinity(*subnet, host, config)
                    return subnet, err
                }
            } else {
                ...
            }
        }
    }
    ...
}

综上所述,如果根据默认配置,即 StrictAffinity: false, AutoAllocateBlocks: true,则有如下行为(3 个大的条件分支):

1. 如果已有主机名相关地址块,则先从主机名相关地址块进行分配

优先从 etcd 中寻找 host-affine 的地址块列表,如果获取的列表不为空,则从列表中逐个分配所需 IP(即前一个 IP 块分配完了但还需要更多 IP 就从列表中的下一个 block 中继续分配,依此类推);

2. 如果找不到已有主机名相关地址块或者地址仍不够用,根据配置决定是否分配新的主机名相关地址块

如果发生以下两种情况:

  • 获取到 host-affine 地址块为空(比如首次启动节点);

  • 步骤 1 中的 block 中可分配的 IP 仍然不够用;

此时将根据 IPAM 的配置(AutoAllocateBlocks 是否为 true,默认为 true)来决定是否分配一个新的 host-affine 地址块。

在分配新的地址块的操作中(claimNewAffineBlock()),会从全局可用的 ippool 中分配新的主机相关地址块,再从新分配的主机相关地址块中分配 IP。

如果我们人为设定 StrictAffinity: true, AutoAllocateBlocks: false,则此时首次分配地址块会出错,因为 AutoAllocateBlocks 被禁止且没有分配过 host affine 地址块。

3. 如果地址仍然不够用,根据配置决定是否遍历全局可用 ippools 并从中随机产生一个 block 进行分配(不论 block 是否已经被其他 host 使用)

根据配置(StrictAffinity 是否为 false,默认为 true),遍历全局可用 ippools,并从每个 pool 中随机生成地址块,此时不管对应 block 是否被其他 host 使用,也不会将该 block 对当前 host 绑定,就从该 block 中分配 IP。迭代进行,直到所需满足所需 IP 数量

当以上 3 个条件都不满足时,分配 IP 出错

容器销毁时

当容器销毁时,IP 将会被回收,且下一次分配的 IP 依然是上一次地址块中已分配的 IP 的下一个(可以观察到是从小到大)。

什么时候 host affine 地址块会被回收呢 ?这段代码在 libcalico-go/lib/client/ipam.go

1
2
3
func (c ipams) RemoveIPAMHost(host string) error {
    ...
}

默认这个函数在容器销毁时不会被调用,但是在节点被删除时,即 libcalico-go/lib/client/node.go

1
2
3
4
5
func (h *nodes) Delete(metadata api.NodeMetadata) error {
    ...
    err = h.c.IPAM().RemoveIPAMHost(metadata.Name)
    ...
}

即当使用 calicoctl 这类工具删除节点时将会显式删除地址块。

是否满足主机与对应地址块一直绑定

根据上文的分析,如果一个地址块依旧被分配完(64 个 pod),那么默认的策略将分配新的地址块,此时将破坏这种映射关系。如果强行禁止这种自动分配逻辑,又将导致节点首次分配 IP 出错(因为禁止自动分配地址块),不过此处我认为是个 bug。