后浪笔记一零二四

在writeDockerByMyself.md文件中,通过Namespace和Cgroup技术实现了容器进程的隔离, 并且通过overlayFS让容器拥有自己的"文件系统",但是这个容器还没有插上“网线”,接下来,我们研究下如何给容器插上“网线”。

假设,一个"Web容器",和一个它要访问的数据库"DB容器",一旦在他们之间定义了一个link,Docker就会在Web容器中,将DB容器的IP地址、端口等信息以环境变量的方式注入进去。

1
2
3
4
5
6
DB_NAME=/web/db
DB_PORT=tcp://172.17.0.5:5432
DB_PORT_5432_TCP=tcp://172.17.0.5:5432
DB_PORT_5432_TCP_PROTO=tcp
DB_PORT_5432_TCP_PORT=5432
DB_PORT_5432_TCP_ADDR=172.17.0.5

而当DB容器发生变化时(比如,镜像更新,被迁移到其他宿主机上等等),这些环境变量的值会由Docker项目自动更新

网络虚拟化技术介绍

  1. Linux虚拟网络设备 linux实际是通过网络设备去操作和使用网卡的,系统装了一个网卡之后会为其生成一个网络设备实例,比如eth0。 而随着网络虚拟化技术的发展,Linux支持创建出虚拟化的设备,可以通过虚拟化设备的组合实现多种多样的功能和网络拓扑。 常见的虚拟化设备有Veth、Bridge、802.1.q VLAN device、TAP,这里主要介绍容器网络需要用到的Veth和Bridge。

Linux Veth Veth是成对出现的虚拟网络设备,发送到Veth一端虚拟设备的请求会从另一端的虚拟网络设备中发出。 在容器的虚拟化场景中,经常会使用Veth连接不同的网络Namespace,如下:

 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
$ # 创建两个网络Namespace
$ ip netns add ns1
$ ip netns add ns2
$ # 创建一对veth
$ ip link add veth0 type veth peer name veth1
$ # 分别将两个Veth移到两个Namespace中
$ ip link set veth0 netns ns1
$ ip link set veth1 netns ns2
$ # 去ns1的namespace中查看网络设备
$ ip netns exec ns1 ip link
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
4: veth0@if3: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/sther 22:e6:a9:bf:b2:fd brd ff:ff:ff:ff:ff:ff link-netnsid 1

$ # 配置每个veth的网络地址和Namespace的路由
$ ip netns exec ns1 ip addr add 172.18.0.2/24 dev veth0
$ ip netns exec ns1 ip link set veth0 up
$ ip netns exec ns2 ip addr add 172.18.0.3/24 dev veth1
$ ip netns exec ns2 ip link set veth1 up
$ ip netns exec ns1 ip route add default via 172.18.0.2 dev veth0
$ ip netns exec ns2 ip route add default via 172.18.0.3 dev veth1
$ # 通过veth一端出去的包,另外一端能够直接接收到
$ ip netns exec ns1 ping -c 1 172.18.0.3
PING 172.18.0.3 (172.18.0.3) 56(84) bytes of data.
64 bytes from 172.18.0.3: icmp_seq=1 ttl=64 time=0.064 ms

--- 172.18.0.3 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.064/0.064/0.064/0.000 ms

从上面的输出中,可以看出,除了loopback的设备以外,就只看到了一个网络设备。 当请求发送到这个虚拟网路设备时,都会原封不动地从另外一个网络Namespace的网络接口中出来。 例如,给两端分别配置不同的地址后,向虚拟网络设备的一端发送请求,就能到达这个虚拟网络设备对应的另一端。

+--------------------------------------------------------------------+
|主机                                                                |
|   +--------------+                             +----------------+  |
|   |ns 1          |                             |            ns2 |  |
|   |          {veth0} ---------------------- {veth1}             |  |
|   +--------------+                             +----------------+  |
|                                                                    |
+--------------------------------------------------------------------+

Linux Bridge Bridge虚拟设备是用来桥接的网络设备,它相当于现实世界中的交换机。可以连接不同的网络设备, 当请求到达Bridge设备时,可以通过报文中的Mac地址进行广播或转发。

+----------------------------------------------------------------+
|主机                                                            |
|                                           +----------------+   |
|                                           |            ns1 |   |
|                                           |                |   |
|                                           +-----{veth1}----+   |
|                                                    |           |
|  +----------------------------------------------{veth0}----+   |
|  |                            br0                          |   |
|  +--{eth0}-------------------------------------------------+   |
+----------------------------------------------------------------+

例如,创建一个Bridge设备,来连接Namespace中的网络设备和宿主机上的网络

 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
$ # 1. 创建网桥
$ ip link add br0 type bridge
$ # 2. 设置Bridge设备的地址和路由
$ ip addr add 172.31.0.1/24 dev br0
$ # 3. 启动网桥
$ ip link set br0 up
$ # 4. 设置iptables的SNAT规则
$ iptables -t nat -A POSTROUTING -s 172.31.0.0/24 ! -o br0 -j MASQUERADE

$ # 创建Veth设备并将一端(veth123)移入br0
$ ip link add veth0 type veth peer name veth123
$ ip link set veth123 master br0
$ ip link set veth123 up
$ # 将Veth设备的另一端(veth0)移入Namespace
$ ps -ef | grep "test"
root      8138 13652  0 23:19 pts/1    00:00:00 grep --color=auto test
root     22921  6190  0 22:31 pts/0    00:00:00 ./test run -it sh
$ ps -ef | grep -E '22921.*sh'
root      8098 13652  0 23:19 pts/1    00:00:00 grep --color=auto -E 22921.*sh
root     22921  6190  0 22:31 pts/0    00:00:00 ./test run -it sh
root     22926 22921  0 22:31 pts/0    00:00:00 sh
$ ip link set veth0 netns 22926
$ # 进入容器,设置容器里的veth0的ip,并启动它,之后设置路由表
$ nsenter -n --target  22926
$ ip addr add 172.31.0.2/24 dev veth0
$ ip link set veth0 up
$ # Net Namespace中默认本地地址127.0.0.1的"lo"网卡是关闭状态的
$ ip link set lo up
$ # 设置默认路由(注意,网关必须设置为br0的ip)
$ ip route add default via 172.31.0.1 dev veth0

验证宿主机和容器的网络是否是通的:

1
2
3
4
5
$ # 从Namespace中访问宿主机的地址
$ nsenter -n --target  22926
$ ping -c 1 10.0.2.37
$ # 从宿主机访问Namespace中的网络地址
$ ping -c 1 172.31.0.2
  1. Go语言网络库介绍 net库 net库是Go语言内置的库,提供了跨平台支持的网路地址处理,以及各种常见协议的IO支持,比如TCP、UDP、DNS、Unix Socket
  • net.IP: 这个类型定义了IP地址的数据结构,并可以通过ParseIP和String方法将字符串与其转换
  • net.IPNet: 这个类型定义了IP端的数据结构,比如192.168.0.0/16这样的网段,同样可以通过ParseCIDR和String方法与字符串转换

github.com/vishvananda/netlink库 这个库是Go语言中,用来操作网络接口、路由表等配置的库,使用它的调用相当于我们通过IP命令去管理网络接口

github.com/vishvananda/netns库 这个库可以实现和ip netns exec命令同样的功能,进入容器的Net Namespace中去执行网络的配置。

构建容器网络模型

  1. 模型 下图为容器网络的整体模型。从这个模型中,可以抽象出容器网络的两个对象————网络和网络端点
+--------------+  +--------------+  +--------------+
|    容器1     |  |     容器2    |  |     容器3    |
+-----{}-------+  +-------{}-----+  +-------{}-----+
  192.168.1.2       192.168.1.3        192.168.2.2
      |                   |                 |
+-----{}------------------{}-----+  +-------{}-----+
|              网络1             |  |   网络2      |
|         192.168.1.0/24         |  |192.168.2.0/24|
+--------------------------------+  +--------------+

其中,{}表示网络端点

网络 网络是容器的一个集合,在这个网络上的容器可以通过这个网络互相通信,就像挂载到同一个Linux Bridge设备上的网络设备一样, 可以直接通过Bridge设备实现网络互连;连接到同一个网络中的容器也可以通过这个网络和网格中别的容器互连。 网络中会包括这个网络相关的配置,比如网络的容器地址段、网络操作所调用的网络驱动等信息。

1
2
3
4
5
type Network struct {
  Name string   // 网络名
  IpRange *net.IPNet  // 地址段
  Driver string // 网络驱动名
}

网络端点 网络端点是用于连接容器与网络的,保证容器内部与网络的通信。像上一节中用到的Veth设备,一端挂载到容器内部, 另一端挂载到Bridge上,就能保证容器和网络的通信。网络端点中会包括连接到网络的一些信息,比如地址、Veth设备、端口映射、 连接的容器和网络等信息。

1
2
3
4
5
6
7
8
type Endpoint struct {
  ID   string  `json:"id"`
  Device netlink.Veth  `json:"dev"`
  IPAddress net.IP     `json:"ip"`
  MacAddress net.HardwareAddr `json:"mac"`
  PortMapping []string `json:"portmapping"`
  Network   *Network
}

而网络端点的信息传输需要靠网络功能的两个组件配合完成,这两个组件分别为网络驱动和IPAM,具体介绍如下

网络驱动 网络驱动(Network Driver)是一个网络功能中的组件,不同的驱动对网络的创建、连接、销毁的策略不同,通过在创建网络时指定不同 的网络驱动来定义使用哪个驱动做网络的配置。它的接口定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type NewworkDriver interface {
  // 驱动名
  Name()    string
  // 创建网络
  Create(subnet string, name string) (*Network, error)
  // 删除网络
  Delete(network Network) error
  // 连接容器网络端点到网络
  Connect(network *Network, endpoint *Endpoint) error
  // 从网络上移除容器网络端点
  Disconnect(network *Network, endpoint *Endpoint) error
}

IPAM IPAM也是网络功能中的一个组件,用于网络IP地址的分配和释放,包括容器的IP地址和网络网关的IP地址。 它的主要功能如下:

  1. IPAM.Allocate(subnet *net.IPNet)从指定的subnet网段中分配IP地址。
  2. IPAM.Release(subnet net.IPNet, ipaddr net.IP)从指定的subnet网段中释放掉指定的IP地址。

容器地址分配(ipam)

  1. bitmap算法介绍 bitmap算法,也叫位图算法,在大规模连续且少状态的数据处理中有很高的效率,比如要用到的IP地址分配。 在网段中,某个IP地址有两种状态,1表示已经被分配了,0表示还未被分配,那么一个IP地址的状态就可以用一位来表示, 并且通过这一位相对基本位的偏移也能够迅速定位到数据所在的位。 例如,下图中的内存IP地址位的列表,通过地址相对于192.168.0.0/24的偏移找到所在的位,然后通过位中的值是0还是1去管理地址分配的信息。
   192.168.0.0/24 网段
   +---+
0  | 0 | 192.168.0.1/24 未分配
   +---+
1  | 1 | 192.168.0.2/24 已分配
   +---+
2  | 0 | 192.168.0.3/24 未分配
   +---+
3  | 0 | 192.168.0.4/24 未分配
   +---+
    ...
    ...
   +---+
253| 1 | 192.168.0.256/24 已分配
   +---+
254| 0 | 192.168.0.255/24 未分配
   +---+

通过位图的方式实现IP地址的管理,在获取IP地址时,遍历这个数组,找到值为0的数组项的偏移, 然后通过偏移和网段的配置计算出分配的IP地址,并将这个数组项置为1,表明IP地址已经被分配。在释放时,原理也是一样的。

  1. 数据结构定义
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const ipamDefaultAllocatorPath="/var/run/mydocker/network/ipam/subnet.json"
// 存放IP地址分配信息
type IPAM struct {                 
        // 分配文件存放位置
        SubnetAllocatorPath string 
        // 网段和位图算法的数组map,key是网段,value是分配的位图数组
        Subnets *map[string]string 
}
// 初始化一个IPAM的对象,默认使用"/var/run/mydocker/network/ipam/subnet.json"作为分配信息存储位置
var ipAllocator = &IPAM{           
        SubnetAllocatorPath: ipamDefaultAllocatorPath,
} 

注意:在这个定义中,为了代码实现简单和易于阅读,使用string中的一个字符表示一个状态位,实际上可以采用一位表示一个 是否分配的状态位,这样消耗的资源会更低。

通过将分配的信息序列化成json文件或将json文件以反序列化的方式保存和读取网段分配的信息,如下:

 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
// 加载网段地址分配信息
func (ipam *IPAM) load() error {
        // 通过os.Stat函数检查存储文件状态,如果不存在,则说明之前没有分配,则不需要加载
        if _, err := os.Stat(ipam.SubnetAllocatorPath); err != nil {
                if os.IsNotExist(err) {
                        return nil
                } else {
                        return err
                }
        }
        // 打开并读取存储文件
        subnetConfigFile, err := os.Open(ipam.SubnetAllocatorPath)
        defer subnetConfigFile.Close()
        if err != nil {
                return err
        }
        subnetJson := make([]byte, 2000)
        n, err := subnetConfigFile.Read(subnetJson)
        if err != nil {
                return err
        }

        // 将文件中的内容反序列化出IP的分配信息
        err = json.Unmarshal(subnetJson[:n], ipam.Subnets)
        if err != nil {
                fmt.Printf("Error dump allocation info, %v\n", err)
                return err
        }
        return nil
}

// 存储网段信息分配信息
func (ipam *IPAM) dump() error {
        // 检查存储文件所在文件夹是否存在,如果不存在则创建,path.Split函数能够分隔目录和文件
        ipamConfigFileDir, _ := path.Split(ipam.SubnetAllocatorPath)
        if _, err := os.Stat(ipamConfigFileDir); err != nil {
                if os.IsNotExist(err) {
                        // 创建文件夹,os.MkdirAll相当于mkdir -p <dir>命令
                        os.MkdirAll(ipamConfigFileDir, 0644)
                } else {
                        return err
                }
        }
        // 打开存储文件,os.O_TRUNC表示如果存在则清空,os.O_CREATE表示如果不存在则创建
        subnetConfigFile, err := os.OpenFile(ipam.SubnetAllocatorPath, os.O_TRUNC | os.O_WRONLY | os.O_CREATE, 0644)
        defer subnetConfigFile.Close()
        if err != nil {
                return err
        }
        // 序列化ipam对象到json串
        ipamConfigJson, err := json.Marshal(ipam.Subnets)
        if err != nil {
                return err
        }
        // 将序列化后的json串写入到配置文件中
        _, err = subnetConfigFile.Write(ipamConfigJson)
        if err != nil {
                return err
        }

        return nil
}
  1. 地址分配的实现 下面来介绍如何通过位图算法分配IP地址。IPAM.Allocate函数用来实现在网段中分配一个可用的IP地址,并将IP地址分配信息记录到文件中,流程如下。
 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
// 在网段中分配一个可用的IP地址
func (ipam *IPAM) Allocate(subnet *net.IPNet) (ip net.IP, err error) {
        // 存放网段中地址分配信息的数组
        ipam.Subnets = &map[string]string{}

        // 从文件中加载已经分配的网段信息
        err = ipam.load()
        if err != nil {
                fmt.Printf("Error dump allocation info, %v\n", err)
        }

        _, subnet, _ = net.ParseCIDR(subnet.String())
        // net.IPNet.Mask.Size()函数会返回网段前面的固定位置的长度和网段的子网掩码的总长度
        one, size := subnet.Mask.Size()

        // 如果之前没有分配过这个网段,则初始化网段的分配配置
        if _, exist := (*ipam.Subnets)[subnet.String()]; !exist {
                // 用"0"填满这个网段的配置,1 << uint8(size - one)表示这个网段中有多少个可用地址
                // "size - one"是子网掩码后面的网络位数,2^(size - one)表示网段中的可用IP数(等价于 1<<uint8(size - one))
                (*ipam.Subnets)[subnet.String()] = strings.Repeat("0", 1 << uint8(size - one))
        }

        // 遍历网段的位图数组
        for c := range((*ipam.Subnets)[subnet.String()]) {
                // 找到数组中为"0"的项和数组序号,即可以分配的IP
                if (*ipam.Subnets)[subnet.String()][c] == '0' {
                        // 设置这个为"0"的序号值为"1",即分配这个IP
                        ipalloc := []byte((*ipam.Subnets)[subnet.String()])
                        // Go的字符串,创建之后就不能修改,所以通过转换成byte数组,修改后再转换成字符串赋值
                        ipalloc[c] = '1'
                        (*ipam.Subnets)[subnet.String()] = string(ipalloc)
                        // 这里的IP为初始IP,比如对于网段192.168.0.0/16,这里就是192.168.0.0
                        ip = subnet.IP

                        /*
                        通过网段的IP与上面的偏移相加计算出分配的IP地址,由于IP地址是uint的一个数组,
                        需要通过数组中的每一项加所需要的值,比如网段是172.16.0.0/12,数组序号是65555,
                        那么在[172,16,0,0]上依次加[uint8(65555 >> 24)]、uint8(65555 >> 16)、
                        uint8(65555 >> 8)、uint(65555 >> 0)], 即[0, 1, 0, 19],那么获得的IP就是172.17.0.19
                        */
                        for t := uint(4); t > 0; t-=1 {
                                []byte(ip)[4-t] += uint8(c >> ((t - 1) * 8))
                        }
                        // 由于此处IP是从1开始分配的,所以最后再加上1,最终得到分配的IP是172.17.0.20
                        ip[3]+=1
                        break
                }
        }
        // 通过调用dump()将分配结果保存到文件中
        ipam.dump()
        return
}
  1. 地址释放的实现 下面介绍如何通过位图算法释放IP地址。IPAM.Release函数用来实现将已分配的IP地址释放掉,参数是网段及要释放的IP地址, 并将释放掉之后的网段分配信息保存到文件中。
 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
func (ipam *IPAM) Release(subnet *net.IPNet, ipaddr *net.IP) error {
        ipam.Subnets = &map[string]string{}
        // 从文件中加载网段的分配信息
        _, subnet, _ = net.ParseCIDR(subnet.String())

        err := ipam.load()
        if err != nil {
                fmt.Printf("Error dump allocation info, %v\n", err)
        }

        // 计算IP地址在网段位图数组中的索引位置
        c := 0
        // 将IP地址转换成4个字节的表示方式
        releaseIP := ipaddr.To4()
        // 由于IP是从1开始分配的,所以转换成索引应减1
        releaseIP[3]-=1
        for t := uint(4); t > 0; t-=1 {
                // 与分配IP相反,释放IP获得索引的方式是IP地址的每一位相减之后分别左移将对应的数值加到索引上。
                c += int(releaseIP[t-1] - subnet.IP[t-1]) << ((4-t) * 8)
        }

        // 将分配的位图数组中索引位置的值置为0
        ipalloc := []byte((*ipam.Subnets)[subnet.String()])
        ipalloc[c] = '0'
        (*ipam.Subnets)[subnet.String()] = string(ipalloc)

        // 保存释放掉IP之后的网段IP分配信息
        ipam.dump()
        return nil
}
  1. 测试 通过两个单元测试来测试网段中IP的分配和释放,如下。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func TestAllocate(t *testing.T) {
        // 在192.168.0.0/24 网段中分配IP
        _, ipnet, _ := net.ParseCIDR("192.168.0.1/24")
        ip, _ := ipAllocator.Allocate(ipnet)
        t.Logf("alloc ip: %v", ip)
}

func TestRelease(t *testing.T) {
        // 在192.168.0.0/24 网段中释放刚才分配的192.168.0.1的IP
        ip, ipnet, _ := net.ParseCIDR("192.168.0.1/24")
        ipAllocator.Release(ipnet, &ip)
}
  1. 跨主机容器网络的IPAM(一致性KV-store) 一致性KV-store的KV是指key-value,相当于写代码时常用到的map类型中的key-value,而KV-store就是存储这种key-value对应关系的一种数据库。比如,一个容器网络的网段为192.168.0.0/30,在上面介绍IPAM的时候,存储的这个地址分配信息是"192.168.0.0/30",“0000"用来代表这个网段的4个IP都未被分配,“192.168.0.0/30"就可以作为网段的key,而value是分配信息的位图"0000”。前面通过json对象的方式将key-value存到了文件中,现在为了让多个宿主机都可以同时访问到IP的分配信息,就可以存储到中心化的一致性KV-store中,保证每个宿主机对分配信息的访问。

而当多个宿主机同时访问时,就会引起并发的问题,比如在两个宿主机上同时启动容器,就会同时分配IP地址,这样就可能会分配到同样的IP地址,那么一致性KV-store的一致性就派上用场了。通过一致性KV-store,可以保证每个宿主机上KV信息的强一致性,不会出现并发时分配到同样IP地址的问题。通常会用如下两种方式实现节点一致。

  • 全局锁,很多一致性KV-store的实现支持对某一个key或者路径锁的方法,拿到这个锁的客户端才能修改对应的数据,比如在分配IP之前先拿存放分配信息对应的key锁,分配完成并写入后再释放锁。这样就能保证同一时间只有一个宿主机在分配地址,确保地址不重复。

  • Compare And Swap,有的一致性KV-store会实现Compare And Swap(CAS)的原子方法。通过CAS方法,每个宿主机在写入IP分配的信息时,会判断分配过程中这个IP分配信息的key是不是被修改过。如果被修改过,则重新获取信息并重新分配IP后再重试。这种方式能够保证在同一时间只有一个宿主机上分配地址成功,同样可以保证地址不重复。

常见的一致性KV-store有etcd、consul、zookeeper等,都可以用来实现跨主机网络的IP地址分配需求。


本文发表于 0001-01-01,最后修改于 0001-01-01。

本站永久域名「 jiavvc.top 」,也可搜索「 后浪笔记一零二四 」找到我。


上一篇 « 下一篇 »

赞赏支持

请我吃鸡腿 =^_^=

i ysf

云闪付

i wechat

微信

推荐阅读

Big Image