动手实现一个docker引擎-5-终版:容器网络的构建

学习自《自己动手写Docker》

作者:陈显鹭(花名:遥鹭)-阿里云高级研发工程师

京东购买链接:https://item.jd.com/10033552355433.html

其他链接:

  • https://blog.csdn.net/guizaijianchic/article/details/78444638

往期:

一、容器网络

本节中,我们给docker引擎加上容器网络这关键的一步。

1. 网络虚拟化技术介绍

在Linux中系统安装了一个网卡之后会为其生成一个网络设备实例比如eth0。而随着网络虚拟化技术的发展,Linux支持创建出虚拟化的设备,可以通过虚拟化设备的组合实现多种多样的功能和网络拓扑。常见的虚拟化设备有VethBridge802.1.qVLAN deviceTAP等,这里主要介绍使用的是VethBridge

1.1 ip netns命令

ip 命令因为需要修改系统的网络配置,默认需要 sudo 权限。这篇文章使用 root 用户执行,请不要在生产环境或者重要的系统中用 root 直接执行,以防产生错误。

ip 命令管理的功能很多, 和 network namespace 有关的操作都是在子命令ip netns 下进行的,可以通过 ip netns help 查看所有操作的帮助信息。

默认情况下,使用 ip netns 是没有网络 namespace 的,所以 ip netns ls 命令看不到任何输出。

$ ip netns help
Usage: ip netns list
       ip netns add NAME									# 创建一个新的Network Namespace
       ip netns set NAME NETNSID
       ip [-all] netns delete [NAME]
       ip netns identify [PID]
       ip netns pids NAME
       ip [-all] netns exec [NAME] cmd ...
       ip netns monitor
       ip netns list-id

ip netns 命令创建的 network namespace 会出现在 /var/run/netns/ 目录下,如果需要管理其他不是 ip netns 创建的 network namespace,只要在这个目录下创建一个指向对应 network namespace 文件的链接就行。

有了自己创建的 network namespace,我们还需要看看它里面有哪些东西。对于每个 network namespace 来说,它会有自己独立的网卡、路由表、ARP 表、iptables 等和网络相关的资源。**ip 命令提供了ip netns exec 子命令可以在对应的 network namespace 中执行命令,**比如我们要看一下这个 network namespace 中有哪些网卡。更棒的是,要执行的可以是任何命令,不只是和网络相关的(当然,和网络无关命令执行的结果和在外部执行没有区别)。比如下面例子中,执行bash 命令了之后,后面所有的命令都是在这个 network namespace 中执行的,好处是不用每次执行命令都要把ip netns exec NAME 补全,缺点是你无法清楚知道自己当前所在的 shell,容易混淆。

$ ip netns add ns1
$ ip netns exec ns1 ip addr
$ ip netns exec ns1 bash
$ ip addr
$ exit

ip netns exec 后面跟着 namespace 的名字,比如这里的 ns1,然后是要执行的命令,只要是合法的 shell 命令都能运行,比如上面的 ip addr 或者bash

每个 namespace 在创建的时候会自动创建一个 lo 的 interface,它的作用和 linux 系统中默认看到的lo 一样,都是为了实现 loopback 通信。如果希望 lo 能工作,不要忘记启用它:

$ ip netns exec ns1 ip link set lo up

默认情况下,network namespace 是不能和主机网络,或者其他 network namespace 通信的。

1.2 Linux Veth

有了不同 network namespace 之后,也就有了网络的隔离,但是如果它们之间没有办法通信,也没有实际用处。要把两个网络连接起来,linux 提供了veth pair 。可以把 veth pair 当做是双向的 pipe(管道),从一个方向发送的网络数据,可以直接被另外一端接收到;或者也可以想象成两个 namespace 直接通过一个特殊的虚拟网卡连接起来,可以直接通信。Veth是成对出现的虚拟网络设备,发送到Veth一端虚拟设备的请求会从另一端的虚拟设备中发出。在容器的虚拟化场景中,经常会使用Veth连接不同的网络Namespace。

创建/删除pair

我们可以使用 ip link add type veth 来创建一对 veth pair 出来(会自动生成名字),需要记住的是 veth pair 无法单独存在,删除其中一个,另一个也会自动消失。

$ ip link add type veth
$ ip link

# 两个方向的管道
11: veth0@veth1: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether be:18:f3:5f:8b:cb brd ff:ff:ff:ff:ff:ff
12: veth1@veth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 5a:3d:ce:b4:90:38 brd ff:ff:ff:ff:ff:ff

可以通过命令ip link delete <设备名>删除掉一对pair:

$ ip link delete veth0
$ ip link

创建 veth pair 的时候可以自己指定它们的名字,比如 ip link add vethfoo type veth peer name vethbar 创建出来的两个名字就是 vethfoovethbar 。如果 pair 的一端接口处于Down状态,另一端能自动检测到这个信息,并把自己的状态设置为NO-CARRIER

veth设置namespace

ip link set <设备名> netns <ns名>

连通两个ns示例
# 创建两个网络Namespace
$ sudo ip netns add ns1
$ sudo ip netns add ns2
# 创建一对veth
$ sudo ip link add veth0 type veth peer name veth1
# 分别将两个veth加入两个Namespace中
$ sudo ip link set veth0 netns ns1
$ sudo ip link set veth1 netns ns2
# 进入ns1中查看网络设备
$ sudo 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
5: veth0@if4: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 1e:5f:4c:66:20:28 brd ff:ff:ff:ff:ff:ff link-netnsid 1

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

# 分别配置两个veth的网络地址和默认的路由
$ sudo ip netns exec ns1 ifconfig veth0 172.18.0.2/24
$ sudo ip netns exec ns2 ifconfig veth1 172.18.0.3/24
$ sudo ip netns exec ns1 route add default dev veth0
$ sudo ip netns exec ns2 route add default dev veth1

# 通过veth一端发送的包另一端能够直接收到
# veth0向veth1 ping接受到了包
$ sudo 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.046 ms

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

1.3 Linux Bridge

虽然 veth pair 可以实现两个 network namespace 之间的通信,但是当多个 namespace 需要通信的时候,就无能为力了。讲到多个网络设备通信,我们首先想到的交换机和路由器。因为这里要考虑的只是同个网络,所以只用到交换机的功能。linux 当然也提供了虚拟交换机的功能,我们还是用ip 命令来完成所有的操作。

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

创建bridge
$ ip link add br0 type bridge
$ ip link set dev br0 up		# 启动网桥

或者使用brctl工具:

Usage: brctl [commands]
commands:
        addbr           <bridge>                add bridge
        delbr           <bridge>                delete bridge
        addif           <bridge> <device>       add interface to bridge
        delif           <bridge> <device>       delete interface from bridge
        hairpin         <bridge> <port> {on|off}        turn hairpin on/off
        setageing       <bridge> <time>         set ageing time
        setbridgeprio   <bridge> <prio>         set bridge priority
        setfd           <bridge> <time>         set bridge forward delay
        sethello        <bridge> <time>         set hello time
        setmaxage       <bridge> <time>         set max message age
        setpathcost     <bridge> <port> <cost>  set path cost
        setportprio     <bridge> <port> <prio>  set port priority
        show            [ <bridge> ]            show a list of bridges
        showmacs        <bridge>                show a list of mac addrs
        showstp         <bridge>                show bridge stp info
        stp             <bridge> {on|off}       turn stp on/off

需要注意的是,如果想删除一个bridge需要先关闭它

ifconfig <bridge_name> down 然后在brctl delbr <bridge_name>

创建一个Bridge设备来连接Namespace中的网络设备与宿主机上的网络

# 创建veth设备并将一端移入namespace
$ sudo ip netns add ns1															# 创建ns1
$ sudo ip link add veth0 type veth peer name veth1	# 创建pair对
$ sudo ip link set veth1 netns ns1									# 把veth1移入到ns1中

$ apt install bridge-utils
# 创建一个网桥 br0
$ sudo brctl addbr br0
# 挂载网络设备
$ sudo brctl show
$ sudo brctl addif br0 veth0
$ sudo brctl show

现在我们只是完成了上图的构建,下面的Linux路由表将继续完成这个示例。


1.4 Linux路由表

路由表是Linux内核的一个模块,通过定义路由表来决定在某个Net Namespace中包的流向,从而定义请求会到哪个网络设备上。继续用上面的例子:

# 将设备veth0、veth1、br0启动
$ sudo ip link set veth0 up
$ sudo ip link set br0 up
$ sudo ip netns exec ns1 ifconfig veth1 172.18.0.2/24 up
# 分别设置ns1网络空间的路由和宿主机上的路由
# default代表0.0.0.0/0,即在Net Namespace中所有流量都经过veth1的网络设备流出
$ sudo ip netns exec ns1 route add default dev veth1
# 在宿主机上将172.18.0.0/24的网段请求路由到br0的网桥
$ sudo route add -net 172.18.0.0/24 dev br0

通过设置路由,对于IP地址的请求就会被路由到对应的网络设备上,从而实现通信。

# 查看宿主机的IP
$ ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.59.2  netmask 255.255.192.0  broadcast 172.17.63.255
        ether 00:16:3e:0e:2d:b8  txqueuelen 1000  (Ethernet)
        RX packets 12874  bytes 4077159 (4.0 MB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 10088  bytes 2003522 (2.0 MB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
# 从Namespace中访问宿主机的地址
$ sudo ip netns exec ns1 ping -c 3 172.17.59.2

PING 172.17.59.2 (172.17.59.2) 56(84) bytes of data.
64 bytes from 172.17.59.2: icmp_seq=1 ttl=64 time=0.049 ms
64 bytes from 172.17.59.2: icmp_seq=2 ttl=64 time=0.064 ms
64 bytes from 172.17.59.2: icmp_seq=3 ttl=64 time=0.063 ms

--- 172.17.59.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2038ms
rtt min/avg/max/mdev = 0.049/0.058/0.064/0.011 ms

# 从宿主机访问Namespace的网络地址
$ ping -c 3 172.18.0.2

PING 172.18.0.2 (172.18.0.2) 56(84) bytes of data.
64 bytes from 172.18.0.2: icmp_seq=1 ttl=64 time=0.040 ms
64 bytes from 172.18.0.2: icmp_seq=2 ttl=64 time=0.041 ms
64 bytes from 172.18.0.2: icmp_seq=3 ttl=64 time=0.056 ms

--- 172.18.0.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2041ms
rtt min/avg/max/mdev = 0.040/0.045/0.056/0.010 ms

1.5 Linux iptables

iptables是对linux内核的netfilter模块进行操作和展示的工具,用来管理包的流动和传送。它定义了一套链式处理的结构,在网络包传输的各个阶段可以使用不同的策略对包进行加工、传送或者丢弃。在容器的虚拟化技术中,经常会使用两种策略MASQUERADEDNAT,用于容器和宿主机外部的网络通信

MASQUERADE

该策略可以将请求包中的原地址转化成为一个网络设备的地址。例如上一节的172.18.0.2, 这个网络包虽然可以从宿主机上路由到br0的网桥,但是到达宿主机外部后是不知道如何路由到这个IP地址的(也就是说,对于宿主机的容器只有宿主机本身可以访问,外部其他机器无法访问)。所以如果请求外部地址的话,需要先通过MASQUERADE策略将这个IP转换为宿主机出口网卡的IP

# 打开Ip转发
$ sudo sysctl -w net.ipv4.conf.all.forwarding=1
net.ipv4.conf.all.forwarding = 1
# 对Namespace中发出的包添加网络地址的转换
$ sudo iptables -t nat -A POSTROUTING -s 172.18.0.0/24 -o eth0 -j MASQUERADE

在Namespace请求宿主机外部地址时,将Namespace中的源地址转换成宿主机的地址作为源地址,就可以在Namespace中访问宿主机外的网络了。

DNAT

该策略用于网络地址的转换,不过是更换目标地址,经常用于将内部网络地址的端口映射到外部去。比如,上面的例子中Namespace隔离的容器需要为除了宿主机的外部提供服务时,就可以使用这个策略:

$ sudo iptables -t nat -A PREROUTING -p tcp -m tcp --dport 80 -j DNAT --to-destination 172.18.0.2:80

这样就可以将宿主机上的80端口的TCP请求转发到Namespace中地址172.18.0.2:80中了。

2. Go语言网络库

2.1 net库

  • net.IP: 定义了IP地址的数据结构,并可以通过ParseIPString方法转换

    IP在底层来说就是一个长度为4的字节数组

    const (
    	IPv4len = 4
    	IPv6len = 16
    )
    type IP []byte
    
  • net.IPNet:定义了IP段(如192.168.0.0/16)的数据结构,同样可以转换

    其实就是IP+掩码来实现表示一个网段

    type IPNet struct {
    	IP   IP     // network number
    	Mask IPMask // network mask
    }
    

    func ParseCIDR

    func ParseCIDR(s string) (IP, *IPNet, error)
    

    ParseCIDR将s作为一个CIDR(无类型域间路由)的IP地址和掩码字符串,如"192.168.100.1/24"或"2001:DB8::/48",解析并返回IP地址和IP网络,参见RFC 4632RFC 4291

    本函数会返回IP地址和该IP所在的网络和掩码。例如,ParseCIDR(“192.168.100.1/16”)会返回IP地址192.168.100.1和IP网络192.168.0.0/16。

2.2 netlink库和netns库

github.com/vishvananda/netlink是go语言的操作网络接口、路由表等配置的库,使用它等于我们通过IP命令取管理网络接口

github.com/vishvananda/netns是go语言进出Net Namespace的库,通过这个库可以让netlink库中配置网络接口的代码在某个容器的Net NS中执行

3. 网络模型

整体的网络模型结构图如下:

1. 网络

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

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

2. 网络端点

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

type Endpoint struct {
	ID          string           `json:"id"`           // ID
	Device      netlink.Veth     `json:"dev"`          // Veth设备
	IpAddress   net.IP           `json:"ip"`           // IP地址
	MacAddress  net.HardwareAddr `json:"mac"`          // mac地址
	PortMapping []string         `json:"port_mapping"` // 端口映射
	Network     *Network         // 网络
}

网络端点的信息传输需要靠网络功能的两个组件配合完成,这两个组件分别是网络驱动和IPAM:

3. 网络驱动

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

type NetworkDriver 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 // 从网络中移除容器的网络端点
}

4. IPAM

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

  • IPAM.Allocate(subnet *net.IPNet): 从指定的subnet网段中分配IP地址
  • IPAM.Release(subnet net.IPNet, padder net.IP): 从指定的subnet网段中释放指定的IP地址

4. Version17-实现容器网络

此版本是最终版本,包含了所有容器网络的修改代码,但是每一个步骤/功能还是单独叙述。

本节代码改动:

add container network

(https://github.com/xwjahahahaha/myDocker/commit/e8c9ab3d40a8e4432aae1437042e0be19a318c9a)

1. 创建网络

创建网络中将实现通过network create命令创建一个容器网络:

./mydocker network create --subnet 192.168.0.0/24 --driver bridge testbridgenet

通过bridge的网络驱动创建一个网络,网段是192.168.0.0/24,网络驱动是Bridge

整体思路流程如下:

IPAM负责通过传入的IP网段去分配一个可用的IP地址给容器和网络的网关,比如网络的网段是192.168.0.0/16,那么获取到的IP地址就属于这个网段,然后用于分配给容器的连接端点,保证网络中的容器IP不会冲突.

Network Driver是用于网络的管理的,例如在创建网络时完成网络的初始化动作以及在容器启动时完成网络端点配置,像Bridge的驱动对应的动作就是创建Linux Bridge和挂载Veth设备。

IPAM和网络驱动设备Bridge都是后面要实现的模块,后面会详细说,这里可以不用深入

首先,我们添加network子命令,然后在其上又添加create子命令,并设置了flag

var networkCommand = &cobra.Command{
	Use:   "network",
	Short: "container network commands",
	Long:  "container network commands",
	Run: func(cmd *cobra.Command, args []string) {

	},
}

var networkCreate = &cobra.Command{
	Use:   "create [network_name]",
	Short: "create a container network",
	Long:  "create a container network",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		// 加载网络配置信息
		if err := network.Init(); err != nil {
			return err
		}
		// 创建网络
		if err := network.CreateNetwork(driver, subnet, args[0]); err != nil {
			return fmt.Errorf("create network error: %+v", err)
		}
		return nil
	},
}
networkCommand.AddCommand(networkCreate, networkList, networkRemove)
networkCreate.Flags().StringVarP(&driver, "driver", "", "bridge", "network driver")
networkCreate.Flags().StringVarP(&subnet, "subnet", "", "", "subnet cidr")
networkCreate.MarkFlagRequired("driver")
networkCreate.MarkFlagRequired("subnet")

容器网络类似于之前的容器信息containerInfo的做法,我们将每个网络保存到/var/run/mydocker/network/network/<网络名>的文件中,获取时就访问这些文件即可。

在运行的内存中我们需要加载这些网络在networks中:

var (
	defaultNetworkPath = "/var/run/mydocker/network/network/"		// 默认存储位置
	drivers            = map[string]NetworkDriver{}							// 网络驱动映射
	networks           = map[string]*Network{}									// 所有网络映射
)

首先我们编写网络初始化代码Init, 初始化函数就做两件事:

  • 加载网络的驱动到内存Map即drivers (我们的实现中,只有一个网络驱动,那么就是网桥Bridge)
  • 扫描网络默认存储位置,加载所有网络到内存networks
// Init 从网络配置的目录中加载所有的网络配置信息到networks字典中
func Init() error {
	// 加载网络驱动
	var bridgeDriver = BridgeNetworkDriver{}
	drivers[bridgeDriver.Name()] = &bridgeDriver
	// 判断网络的配置目录是否存在,不存在则创建
	if _, err := os.Stat(defaultNetworkPath); err != nil {
		if os.IsNotExist(err) {
			if err := os.MkdirAll(defaultNetworkPath, 0644); err != nil {
				log.Log.Error(err)
				return err
			}
		} else {
			log.Log.Error(err)
			return err
		}
	}
	// 检查网络配置目录中的所有文件
	if err := filepath.Walk(defaultNetworkPath, func(nwPath string, info fs.FileInfo, err error) error {
		// 如果是目录则跳过
		if info.IsDir() {
			return nil
		}
		// 加载文件名作为网络名
		_, nwName := path.Split(nwPath)
		nw := &Network{
			Name: nwName,
		}
		// 调用Network.load方法加载网络配置信息
		if err := nw.load(nwPath); err != nil {
			log.Log.Error(err)
			return err
		}
		// 将网络配置信息加入到networks字典中
		networks[nwName] = nw
		return nil
	}); err != nil {
		return err
	}
	return nil
}

然后,我们调用创建网络函数:

func CreateNetwork(driver, subnet, name string) error {
	// ParseCIDR的功能是将网段的字符串转换为net.IPNet对象
	_, cidr, _ := net.ParseCIDR(subnet)
	// 通过IPAM分配网关IP,获取到网段中第一个IP作为网关的IP
	gatewayIp, err := ipAllocator.Allocate(cidr)
	if err != nil {
		log.Log.Error(err)
		return err
	}
	// 重置IP
	cidr.IP = gatewayIp

	// 调用指定的网络驱动创建网络,这里的drivers字典是各个网络驱动的示例字典,通过调用网络驱动的Create方法创建网络
	nw, err := drivers[driver].Create(cidr.String(), name)
	if err != nil {
		log.Log.Error(err)
		return err
	}
	// 保存网络信息,将网络信息保存在文件系统中,以便查询和在网络上连接网络端点
	return nw.dump(defaultNetworkPath)
}

调用网络驱动创建网络,创建后,将创建成功的网络保存到文件系统中。

网络驱动-bridge

我们首先定义了一个网络驱动的接口,并定义了如下的方法(之前也说过):

// NetworkDriver 网络驱动
type NetworkDriver 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 // 从网络中移除容器的网络端点
}

我们定义了一个网络驱动实例Bridge,并实现了这个接口。

对于drivers[driver].Create函数,Bridge的实现如下:

func (d *BridgeNetworkDriver) Create(subnet string, name string) (*Network, error) {
	// 获取网段字符串的网关IP地址和网络IP段
	ip, ipRange, err := net.ParseCIDR(subnet)
	if err != nil {
		log.Log.Error(err)
		return nil, err
	}
	ipRange.IP = ip
	// 初始化网络对象
	n := &Network{
		Name: name,
		IpRange: ipRange,
		Driver: d.Name(),
	}
	// 初始化配置Linux Bridge
	if err := d.initBridge(n); err != nil {
		log.Log.Error(err)
		return nil, err
	}
	// 返回配置好的网络
	return n, nil
}

主要实现两个功能:

  • 创建一个网络Network对象
  • 初始化一个网桥

可以看出的是,每一个新创建的网络都会创建一个同名的网桥来实现网络的连接

初始化一个网桥initbridge分为四部,我们主要实现采用了netlink包的功能,效果与之前直接用命令行一样的效果。

func (d *BridgeNetworkDriver) initBridge(n *Network) error {
	// 1. 创建Bridge虚拟设备
	bridgeName := n.Name
	if err := createBridgeInterface(bridgeName); err != nil {
		return fmt.Errorf(" Error add bridge: %s, Error: %v", bridgeName, err)
	}
	// 2. 设置Bridge设备的地址和路由
	gatewayIp := *n.IpRange
	gatewayIp.IP = n.IpRange.IP
	if err := setInterfaceIP(bridgeName, gatewayIp.String()); err != nil {
		return fmt.Errorf(" Error assigning address: %s on bridge: %s with an error of: %v", gatewayIp, bridgeName, err)
	}
	// 3. 启动Bridge设备
	if err := setInterfaceUP(bridgeName); err != nil {
		return fmt.Errorf(" Error set bridge up: %s, Error: %v", bridgeName, err)
	}

	// 4. 设置iptables的SNAT规则
	if err := setupIPTables(bridgeName, n.IpRange); err != nil {
		return fmt.Errorf(" Error setting iptables for %s: %v", bridgeName, err)
	}
	return nil
}
// createBridgeInterface 创建一个Bridge网络驱动/虚拟设备
func createBridgeInterface(bridgeName string) error {
	// 先检查是否存在同名的Bridge设备
	iface, err := net.InterfaceByName(bridgeName)
	if iface != nil || err == nil || !strings.Contains(err.Error(), "no such network interface") {
		return fmt.Errorf(" Bridge interface %s exist!", bridgeName)
	}
	// 初始化一个netlink的link基础对象,link的名字就是bridge的名字
	la := netlink.NewLinkAttrs()
	la.Name = bridgeName
	// 使用刚才创建的Link的属性创建netlink的Bridge对象
	br := &netlink.Bridge{ LinkAttrs: la }
	// 调用netlink的LinkAdd方法,创建Bridge虚拟网络设备
	if err := netlink.LinkAdd(br); err != nil {
		return fmt.Errorf(" Bridge creation failed for bridge %s: %v", bridgeName, err)
	}
	return nil
}

// setInterfaceIP 设置Bridge设备的地址和路由
func setInterfaceIP(name string, rawIP string) error {
	// 通过netlink的LinkByName方法找到需要设置的网络接口也就是刚刚创建的Bridge
	iface, err := netlink.LinkByName(name)
	if err != nil {
		return fmt.Errorf(" error get interface: %v", err)
	}
	// netlink.ParseIPNet是对net.ParseCIDR的封装,返回的值ipNet中既包含了网段的信息(192.168.0.0/24)也包含了原始的IP地址(192.168.0.1)
	ipNet, err := netlink.ParseIPNet(rawIP)
	if err != nil {
		return err
	}
	// 通过netlink.AddrAdd给网络接口配置地址,等价于 ip addr add xxxx命令
	// 同时如果配置了地址所在的网段信息,例如192.168.0.0/24, 还会配置路由表192.168.0.0/24转发到这个bridge上
	addr := &netlink.Addr{
		IPNet:       ipNet,
		Label:       "",
		Flags:       0,
		Scope:       0,
	}
	return netlink.AddrAdd(iface, addr)
}

// setInterfaceUP 设置网络接口(bridge)启动为Up状态
func setInterfaceUP(interfaceName string) error {
	iface, err := netlink.LinkByName(interfaceName)
	if err != nil {
		return fmt.Errorf(" error get interface: %v", err)
	}
	// 通过netlink的LinkSetUp方法设置接口状态为Up状态
	if err := netlink.LinkSetUp(iface); err != nil {
		return fmt.Errorf(" Error enabing interface for %s: %v", interfaceName, err)
	}
	return nil
}

// 设置iptable对应bridge的MASQUERADE规则
func setupIPTables(bridgeName string, subnet *net.IPNet) error {
	// 由于go没有直接操作iptables的库,所以直接使用命令
	iptablesCmd := fmt.Sprintf("-t nat -A POSTROUTING -s %s ! -o %s -j MASQUERADE", subnet.String(), bridgeName)
	cmd := exec.Command("iptables", strings.Split(iptablesCmd, " ")...)
	output, err := cmd.Output()
	if err != nil {
		log.Log.Errorf("iptables Output, %v", output)
	}
	return nil
}

我们给网桥分配了IP设置了网关路由,然后将其启动Up,实现了MASQUERADE规则后面容器连接上这个网桥后就可以以主机的IP向外部访问了。

IPAM

上面创建网络中还有一个关键的一环,那就是分配Ip地址的IPAM

我们通过bitmap-位图算法实现了网络Ip的分配:

image-20211117160056527

其核心思路是利用了偏移量可以迅速定位数据所在的位置(ip地址), 然后分配信息

先看一下其数据结构:

const ipamDefaultAllocatorPath = "/var/run/mydocker/network/ipam/subnet.json"

type IPAM struct {
	SubnetAllocatorPath string             // 分配文件存放的位置
	Subnets             map[string]string // 网段和位图算法的数组map:key是网段,value是分配的位图字符串(使用字符串的一个字符标识一个状态位)
}

位图是用一个字符串表示,所有网段与其位图的关系用一个Map来表示,并且持久化的存储到SubnetAllocatorPath中,用于下次运行的加载。

下面就是位图算法的详细介绍:

func (ipam *IPAM) Allocate(subnet *net.IPNet) (ip net.IP, err error) {
	// 存放网段中地址分配信息的map
	ipam.Subnets = make(map[string]string)
	// 从文件中加载已经分配的网段信息
	if err := ipam.load(); err != nil {
		log.Log.Error(err)
	}
	// 这里重新生成一个子网段实例,因为传递的是指针,为了避免影响
	_, subnet, _ = net.ParseCIDR(subnet.String())
	// subnet.Mask.Size() 函数会返回网段前面的固定位1的长度以及后面0位的长度
	// 例如: 127.0.0.0/8 子网掩码:255.0.0.0 subnet.Mask.Size()返回8和32
	one, size := subnet.Mask.Size()
	ipAddr := subnet.String()
	// 如果之前没有分配过这个网段,则初始化网段的分配配置
	if _, has := ipam.Subnets[ipAddr]; !has {
		// 1 << uint8(zero) 表示 2^(zero) 表示 剩余的可分配的IP数量,后面的位数全部用0填满
		ipam.Subnets[ipAddr] = strings.Repeat("0", 1<<uint8(size - one))
	}
	var AlloIP net.IP
	// 遍历网段的位图数组
	for c := range ipam.Subnets[ipAddr] {
		if ipam.Subnets[ipAddr][c] == '0' {
			// 设置这个为'0'的序号值为'1',即表示分配这个IP
			// 转换字符串为字节数组进行修改
			ipalloc := []byte(ipam.Subnets[ipAddr])
			ipalloc[c] = '1'
			ipam.Subnets[ipAddr] = string(ipalloc)
			// 获取该网段的IP,比如对于网段192.168.0.0/16,这里就是192.168.0.0
			first_ip := subnet.IP
			// ip地址是一个uint[4]的数组,例如172.16.0.0就是[172, 16, 0, 0]
			// 需要通过数组中每一项加所需要的值, 对于当前序号,例如65535
			// 每一位加的计算就是[uint8(65535>>24), uint8(65535>>16), uint8(65535>>8), uint8(65535>>0)]
			for t := uint(4); t > 0; t -= 1 {
				[]byte(first_ip)[4-t] += uint8(c >> ((t - 1) * 8))
			}
			// 由于这里从1开始分配,所以再加1
			first_ip[3] += 1
			AlloIP = first_ip
			break
		}
	}
	if err := ipam.dump(); err != nil {
		return nil, err
	}
	return AlloIP, nil
}

有了IPAM的位图算法,我们就可以实现为网桥、容器快速的分配IP的功能

2. 展示网络列表

展示网络列表的实现思路很简单,就是读取上一节我们为每个网络保存的信息文件加载到内存,然后遍历输出。这里不再贴出代码,配合上一节我们进行效果的演示:

$ ./mydocker network create --driver bridge --subnet 192.168.0.0/24 testBridge
$ ./mydocker network list
$ ip link show dev testBridge
$ ip addr show dev testBridge		# 查看地址配置与路由配置
NAME         IpRange          Driver
testBridge   192.168.0.1/24   bridge


22: testBridge: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether 06:43:d8:d6:65:2f brd ff:ff:ff:ff:ff:ff

22: testBridge: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ether 06:43:d8:d6:65:2f brd ff:ff:ff:ff:ff:ff
    inet 192.168.0.1/24 brd 192.168.0.255 scope global testBridge
       valid_lft forever preferred_lft forever

可以看到输出的效果,我们的网络已经创建完毕

目前,我们可以通过如下的命令手动删除掉一个网络:

$ rm -rf /var/run/mydocker/network/*
$ ifconfig testBridge down
$ brctl delbr testBridge

3. 创建容器并连接网络

通过运行容器时指定--net的参数,指定容器启动时连接的网络。

./mydocker run -t -p 80:80 --net testbridgenet xxxx
实现

实现的流程图如下所示:

同样的首先调用IPAM,通过网络中定义的网段找到未分配的IP给容器,然后创建网络端点(Veth),并调用这个网络驱动连接网络与容器中的网络端点,最终实现网络端点的连接和配置。

同样的我们在run命令上添加两个flag: netport分别表示容器运行时目标连接的网络与端口映射设置,我们将这两个值传入到Run函数:

func Run(...){
  ...
  // 如果需要则连接网络
  if NetWorkName != "" {
    // 初始化网络
    if err := network.Init(); err != nil {
      log.Log.Error(err)
      return
    }
    // 将容器连接到目标网络
    if err := network.Connect(NetWorkName, containerInfo); err != nil {
      log.Log.Errorf("Error Connect Network %v", err)
      return
    }
  }
  ...
}	

同样初始化所有网络的配置到内存,然后执行Connect连接:

容器连接网络一共要执行以下几部:

  1. 判断提供的网络名是否有效
  2. 调用IPAM获取容器自己的一个可用IP
  3. 创建网络端点
  4. 调用网络驱动连接和配置网络端点
  5. 进入容器配置容器网络设备的IP地址和路由
  6. 配置端口映射
func Connect(networkName string, cinfo *container.ContainerInfo) error {
	// 从networks字典中获取容器连接的网络信息,networks字典中保存了当前已经创建的网络
	network, ok := networks[networkName]
	if !ok {
		err := fmt.Errorf(" No Such Network: %s", networkName)
		log.Log.Error(err)
		return err
	}
	// 通过调用IPAM从网络的网段中获取可用的IP作为容器IP地址
	ip, err := ipAllocator.Allocate(network.IpRange)
	if err != nil {
		log.Log.Error(err)
		return err
	}
	// 创建网络端点
	ep := &Endpoint{
		ID:          fmt.Sprintf("%s-%s", cinfo.Id, networkName),
		IpAddress:   ip,
		PortMapping: cinfo.PortMapping,
		Network:     network,
	}
	// 调用网络驱动的Connect方法连接和配置网络端点
	if err := drivers[network.Driver].Connect(network, ep); err != nil {
		log.Log.Error(err)
		return err
	}
	// 进入到容器的网络Namespace配置容器网络设备的IP地址和路由
	if err := configEndpointIpAddressAndRoute(ep, cinfo); err != nil {
		log.Log.Error(err)
		return err
	}
	// 配置容器到宿主机的端口映射
	return configPortMapping(ep)
}

主要的后面三步主要是实现以下功能:

首先对于 drivers[network.Driver].Connect函数:

其主要实现的功能是(图中的前三步):

  1. 创建Veth对,容器的一端命名为cif-{endpoint ID前5位}而另一端Bridge需要连接的一端设置为{endpoint ID前5位}
  2. 让Bridge连接一端(将Veth的一端挂载在网桥上),并让Veth启动
func (d *BridgeNetworkDriver) Connect(network *Network, endpoint *Endpoint) error {
	// 网络名即Linux Bridge的设备名
	bridgeName := network.Name
	// 通过netlink找到对应的设备
	br, err := netlink.LinkByName(bridgeName)
	if err != nil {
		return fmt.Errorf(" error get interface: %v", err)
	}

	// 创建Veth接口的配置
	la := netlink.NewLinkAttrs()
	// 由于Linux接口名的限制,所以名字取前5位
	la.Name = endpoint.ID[:5]
	// 通过设置Veth接口的master属性,设置这个Veth的一端挂载到网络对应的Linux Bridge上
	la.MasterIndex = br.Attrs().Index

	// 创建Veth对象,通过PeerName配置Veth另一端的接口名cif-{endpoint ID前5位}
	endpoint.Device = netlink.Veth{
		LinkAttrs: la,
		PeerName: "cif-" + endpoint.ID[:5],
	}

	// 调用netlink的LinkAdd方法创建出这个Veth接口
	// 因为上面已经指定了link的MasterIndex是网络接口Bridge,所以一端即已经挂载在Bridge上了
	if err := netlink.LinkAdd(&endpoint.Device); err != nil {
		return fmt.Errorf(" Error Add Endpoint Device: %v", err)
	}
	// 调用netlink的LinkSetUp方法设置Veth启动
	if err := netlink.LinkSetUp(&endpoint.Device); err != nil {
		return fmt.Errorf(" Error set Endpoint Device up: %v", err)
	}
	return nil
}

然后对于configEndpointIpAddressAndRoute函数:

其主要实现的功能是(图中的4、5两步):

  1. 给容器内的一端Veth设置IP、路由并启动(顺便启动了容器内的lo设备)
func configEndpointIpAddressAndRoute(ep *Endpoint, cinfo *container.ContainerInfo) error {
	// 获取网络端点中的Veth的另一端
	peerLink, err := netlink.LinkByName(ep.Device.PeerName)
	if err != nil {
		return fmt.Errorf("fail config endpoint: %v", err)
	}
	// 将容器的网络端点加入到容器的网络空间中,并使这个函数下面的操作都在这个网络空间中进行,执行完函数后,恢复为默认的网络空间
	defer enterContainerNetns(&peerLink, cinfo)()
	// 获取到容器的IP地址以及网段,用于配置容器内部接口地址
	interfaceIP := *ep.Network.IpRange
	interfaceIP.IP = ep.IpAddress
	// 调用setInterfaceIp函数设置容器内Veth端点的IP
	if err := setInterfaceIP(ep.Device.PeerName, interfaceIP.String()); err != nil {
		return fmt.Errorf("NetWork : %v, err : %s", ep.Network, err)
	}
	// 启动容器内的Veth端点
	if err := setInterfaceUP(ep.Device.PeerName); err != nil {
		return err
	}
	// Net Namespace中默认本地地址127.0.0.1的lo网卡是关闭状态的,启动以保证容器访问自己的请求
	if err := setInterfaceUP("lo"); err != nil {
		return err
	}
	// 设置容器内的外部请求都通过容器内的Veth端点访问
	// 0.0.0.0/0的网段,表示所有的IP地址段
	_, cidr, _ := net.ParseCIDR("0.0.0.0/0")
	// 构建要添加的路由数据,包括网络设备,网关IP以及目的网段
	defaultRoute := &netlink.Route{
		LinkIndex: peerLink.Attrs().Index,
		Gw:        ep.Network.IpRange.IP,
		Dst:       cidr,
	}
	if err := netlink.RouteAdd(defaultRoute); err != nil {
		return err
	}
	return nil
}

此函数的实现最为重要的是defer enterContainerNetns(&peerLink, cinfo)()延迟函数的实现:

其返回一个函数,所以需要注意的是在返回函数之前的代码都是先执行的,即使前面有一个defer,defer的作用只限于返回的函数。

前面执行的函数的作用就是让我们的线程进入到容器的Net Namespace通过/proc/[pid]/ns/net这样的文件方式实现,并且需要锁定线程不让其被其他调用。在此函数执行之后的操作都是在容器内进行,最后defer函数执行的时候才会退出容器Net NS在宿主机上继续执行。

// enterContainerNetns 进入容器内部并配置veth
// 锁定当前程序执行的线程,防止goroutine别调度到其他线程,离开目标网络空间
// 返回一个函数指针,执行这个返回函数才会退出容器的网络空间,回到宿主机的网络空间
func enterContainerNetns(enLink *netlink.Link, cinfo *container.ContainerInfo) func() {
	// 找到容器的Net Namespace
	// 通过/proc/[pid]/ns/net文件的文件描述符可以来操作Net Namepspace
	// pid就是containerInfo中的容器在宿主机上映射的进程ID
	f, err := os.OpenFile(fmt.Sprintf("/proc/%s/ns/net", cinfo.Pid), os.O_RDONLY, 0)
	if err != nil {
		log.Log.Error(err)
	}
	// 获取文件描述符
	nsFD := f.Fd()
	// 锁定当前线程
	runtime.LockOSThread()
	// 修改网络端点Veth的另一端,将其移动到容器的Net namespace中
	if err := netlink.LinkSetNsFd(*enLink, int(nsFD)); err != nil {
		log.Log.Error(err)
	}
	// 通过netns.Get方法获得当前网络的Net Namespace,以便后面从容器的Net Namespace中退出回到当前netns中
	origins, err := netns.Get()
	if err != nil {
		log.Log.Error(err)
	}
	// 调用netns.Set方法,将当前进程加入容器的Net Namespace
	if err := netns.Set(netns.NsHandle(nsFD)); err != nil {
		log.Log.Error(err)
	}
	// 返回之前的Net Namespace函数
	// 在容器的网络空间中,执行完容器配置之后调用此函数就可以回到原来的Net Namespace
	return func() {
		// 恢复上面获取到的Net Namespace
		if err := netns.Set(origins); err != nil {
			log.Log.Error(err)
		}
		// 关闭Namespace文件
		origins.Close()
		// 取消线程锁定
		runtime.UnlockOSThread()
		// 关闭Namespace文件
		f.Close()
	}
}

最后,我们实现端口的映射步骤:

func configPortMapping(ep *Endpoint) error {
	// 遍历容器端口映射列表
	for _, pm := range ep.PortMapping {
		portMapping := strings.Split(pm, ":")
		if len(portMapping) != 2 {
			log.Log.Errorf("port mapping format error, %v", pm)
			continue
		}
		// 使用命令行实现iptable
		iptablesCmd := fmt.Sprintf("-t nat -A PREROUTING -p tcp -m tcp --dport %s -j DNAT --to-destination %s:%s",
			portMapping[0], ep.IpAddress.String(), portMapping[1])
		cmd := exec.Command("iptables", strings.Split(iptablesCmd, " ")...)
		output, err := cmd.Output()
		if err != nil {
			logrus.Errorf("iptables Output, %v", output)
			continue
		}
	}
	return nil
}

和之前命令行执行的一样。

测试

至此,我们就实现了创建容器并连接网络的整体步骤,下面进行测试:

需要注意的是,我们采用Bridge实现容器之间搭建网桥,但是需要我们事先将宿主机的iptables FORWARD 链(转发链)默认策略设置为ACCEPT,因为我们是在宿主机上本地路由转发,所以一定要设置允许通过才能够在容器之间ping通。

(本问题来自于https://github.com/xianlubird/mydocker/issues/52)

查看自己的FORWARD的策略:iptables-save -t filter

# Generated by iptables-save v1.6.1 on Thu Nov 18 10:20:44 2021
*filter
:INPUT ACCEPT [203737:17173141]
:FORWARD ACCEPT [159:10562]								# 注意这一行
:OUTPUT ACCEPT [185503:38665815]
:DOCKER - [0:0]
:DOCKER-ISOLATION-STAGE-1 - [0:0]
:DOCKER-ISOLATION-STAGE-2 - [0:0]
:DOCKER-USER - [0:0]
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN
-A DOCKER-USER -j RETURN
COMMIT

如果是DROP那么我们需要通过iptables -P FORWARD ACCEPT设置为允许。

下面进行测试:

# 创建一个网络
$ ./mydocker network create --driver bridge --subnet 172.18.10.0/24 testBridge
$ ./mydocker network list
NAME         IpRange          Driver
testBridge   172.18.10.1/24   bridge

启动第一个容器:

$ ./mydocker run -t --net testBridge sh
$ ifconfig

cif-3c66c Link encap:Ethernet  HWaddr D2:28:B9:5F:6C:C0  
          inet addr:172.18.10.8  Bcast:172.18.10.255  Mask:255.255.255.0
          inet6 addr: fe80::d028:b9ff:fe5f:6cc0/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:8 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:0 (0.0 B)  TX bytes:656 (656.0 B)

lo        Link encap:Local Loopback  
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

可以看到其创建了一个虚拟网卡名为cif-3c66c(后五位为容器ID前5位)并且设置了IP处于UP状态

启动第二个容器:

$ ./mydocker run -t --net testBridge sh
cif-cc36d Link encap:Ethernet  HWaddr FA:29:B8:AE:30:54  
          inet addr:172.18.10.9  Bcast:172.18.10.255  Mask:255.255.255.0
          inet6 addr: fe80::f829:b8ff:feae:3054/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:5 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:0 (0.0 B)  TX bytes:426 (426.0 B)

lo        Link encap:Local Loopback  
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

可以看到同样的实现了ip地址的分配并且没有重复

现在我们先测试容器间能不能ping通:

在容器1向容器2ping:

$ ping 172.18.10.9 
PING 172.18.10.9 (172.18.10.9): 56 data bytes
64 bytes from 172.18.10.9: seq=0 ttl=64 time=0.125 ms
64 bytes from 172.18.10.9: seq=1 ttl=64 time=0.110 ms
64 bytes from 172.18.10.9: seq=2 ttl=64 time=0.102 ms
64 bytes from 172.18.10.9: seq=3 ttl=64 time=0.096 ms
64 bytes from 172.18.10.9: seq=4 ttl=64 time=0.091 ms
64 bytes from 172.18.10.9: seq=5 ttl=64 time=0.091 ms
64 bytes from 172.18.10.9: seq=6 ttl=64 time=0.106 ms
64 bytes from 172.18.10.9: seq=7 ttl=64 time=0.104 ms
64 bytes from 172.18.10.9: seq=8 ttl=64 time=0.094 ms
64 bytes from 172.18.10.9: seq=9 ttl=64 time=0.085 ms
64 bytes from 172.18.10.9: seq=10 ttl=64 time=0.099 ms
64 bytes from 172.18.10.9: seq=11 ttl=64 time=0.103 ms

在容器2向容器1ping:

$ ping 172.18.10.8
PING 172.18.10.8 (172.18.10.8): 56 data bytes
64 bytes from 172.18.10.8: seq=0 ttl=64 time=0.112 ms
64 bytes from 172.18.10.8: seq=1 ttl=64 time=0.095 ms
64 bytes from 172.18.10.8: seq=2 ttl=64 time=0.092 ms
64 bytes from 172.18.10.8: seq=3 ttl=64 time=0.091 ms
^[[B64 bytes from 172.18.10.8: seq=4 ttl=64 time=0.079 ms
64 bytes from 172.18.10.8: seq=5 ttl=64 time=0.121 ms

可以看出容器之间实现了互通

下面再测试访问外网:

因为没有设置域名服务,所以我们直接测试ping百度的IP:202.108.22.5

$ ping 202.108.22.5
PING 202.108.22.5 (202.108.22.5): 56 data bytes
64 bytes from 202.108.22.5: seq=0 ttl=48 time=24.121 ms
64 bytes from 202.108.22.5: seq=1 ttl=48 time=24.169 ms
64 bytes from 202.108.22.5: seq=2 ttl=48 time=24.147 ms
64 bytes from 202.108.22.5: seq=3 ttl=48 time=24.165 ms
64 bytes from 202.108.22.5: seq=4 ttl=48 time=24.182 ms
64 bytes from 202.108.22.5: seq=5 ttl=48 time=24.199 ms

最后我们测试一下端口转发:

启动容器:

# 我们设置端口转发规则
$ ./mydocker run -t -p 8888:8888 --net testBridge sh
$ nc -lp 8888

在宿主机上(访问宿主机的IP):

$ telnet 47.103.203.133 8888
Trying 47.103.203.133...
Connected to 47.103.203.133.
Escape character is '^]'.
hello world									# 发送消息

image-20211118104156782

aOFpfl

至此我们就实现了容器的网络构建

4. 删除网络

实现的思路如下:

同样的定义删除网络的命令:

var networkRemove = &cobra.Command{
	Use:   "remove [network_name]",
	Short: "remove container network",
	Long:  "remove container network",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		if err := network.Init(); err != nil {
			return err
		}
		if err := network.DeleteNetwork(args[0]); err != nil {
			return err
		}
		return nil
	},
}
func DeleteNetwork(networkName string) error {
	// 查找网络是否存在
	nw, ok := networks[networkName]
	if !ok {
		err := fmt.Errorf(" No Such Network: %s", networkName)
		log.Log.Error(err)
		return err
	}
	// 调用IPAM的实例释放网络网关的IP
	if err := ipAllocator.Release(nw.IpRange, &nw.IpRange.IP); err != nil {
		return fmt.Errorf(" Error Remove Network gateway ip: %s", err)
	}
	// 调用网络驱动删除网络创建的设备与配置
	if err := drivers[nw.Driver].Delete(nw); err != nil {
		return fmt.Errorf(" Error Remove Network DriverError: %s", err)
	}
	// 从网络的配置目录中删除该网络对应的配置文件
	return nw.remove(defaultNetworkPath)
}

核心还是看一下Release函数:

// Release 释放一个IP
func (ipam *IPAM) Release(subnet *net.IPNet, ipaddr *net.IP) error {
	ipam.Subnets = make(map[string]string)
	_, subnet, _ = net.ParseCIDR(subnet.String())
	// 从文件中加载网段的分配信息
	if err := ipam.load(); err != nil {
		log.Log.Error(err)
		return 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地址的每一位相减之后分别左移将对应的数值加到索引上
		// *8是IP的每一个小分段都是8位,等于扩大相应的倍数
		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信息
	if err := ipam.dump(); err != nil {
		return err
	}
	return nil
}

其实也就是Allocate的反向操作。

func (d *BridgeNetworkDriver) Delete(network *Network) error {
	// 网络名即Linux Bridge的设备名
	bridgeName := network.Name
	// 通过netlink找到对应的设备
	iface, err := netlink.LinkByName(bridgeName)
	if err != nil {
		return fmt.Errorf(" error get interface: %v", err)
	}
	// 删除网络对应的Linux Bridge设备
	return netlink.LinkDel(iface)
}

下面进行测试:

$ ./mydocker network create --driver bridge --subnet 172.18.10.0/24 testBridge
$ ./mydocker network remove testBridge
$ ./mydocker network list

image-20211118112151193

5. 总结与展望

myDocker is a simple container runtime implementation.
The purpose of this project is to learn how docker works and how to write a docker by ourselves
Enjoy it, just for fun.

Usage:
  myDocker [flags]
  myDocker [command]

Available Commands:
  commit      commit a container into image
  completion  generate the autocompletion script for the specified shell
  exec        exec a command into container
  help        Help about any command
  init        Init container process run user's process in container.Do not call it outside.
  logs        print logs of a container
  network     container network commands
  ps          list all the containers
  rm          remove a container
  run         Create a container with namespace and cgroups limit: myDocker run -t [command]
  stop        stop a container

Flags:
  -h, --help   help for myDocker

Use "myDocker [command] --help" for more information about a command.
container network commands

Usage:
  myDocker network [flags]
  myDocker network [command]

Available Commands:
  create      create a container network
  list        list container network
  remove      remove container network

Flags:
  -h, --help   help for network

对于我们自己实现的Docker引擎,我们实现了如下的功能:

  • 容器的交互式与后台运行run

  • 容器的数据卷持久化存储功能-v

  • 容器的镜像打包commit

  • 容器的进入exec、停止容器stop、删除容器rm

  • 容器的日志logs、容器列表查看ps

  • 容器网络network:IPAM位图地址分配、Bridge网络驱动、容器网络创建、删除、列表查看

还未实现的功能以及展望:

  • 跨主机容器网络:对于IPAM的位图存储,如果不同的宿主机之间则需要位图存储文件的一致性同步,否则会出现两个宿主机上的容器IP相同的问题。可以使用一执性KV-Store解决

至此简单版本的runC容器引擎的所有实现就已经完成了。项目的地址在:

https://github.com/xwjahahahaha/myDocker

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

xxx_undefined

谢谢请博主吃糖

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值