Contents

从零开始写 Docker(十七)---容器网络实现(中):为容器插上”网线“

https://img.lixueduan.com/docker/mydocker/cover/mydocker-network-2.png

本文为从零开始写 Docker 系列第十七篇,利用 linux 下的 Veth、Bridge、iptables 等等相关技术,构建容器网络模型,为容器插上”网线“。


完整代码见:https://github.com/lixd/mydocker 欢迎 Star

推荐阅读以下文章对 docker 基本实现有一个大致认识:


开发环境如下:

root@mydocker:~# lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 20.04.2 LTS
Release:	20.04
Codename:	focal
root@mydocker:~# uname -r
5.4.0-74-generic

注意:需要使用 root 用户

1. 概述

前面文章中已经实现了容器的大部分功能,不过还缺少了网络部分。现在我们的容器既不能访问外网也不能访问其他容器。

本篇和下一篇文章则会解决该问题,会实现容器网络相关功能,为我们的容器插上”网线“。

本篇主要在上篇基础上,基于完成剩余工作:

  • mydocker network create/list/delete 命令, 让我们能通过 mydocker 命令实现对容器网络的管理
  • 实现 mydocker run -net,让容器可以加入指定网络

2. Network 实现

基于 IPAM 和 NetworkDriver 实现网络创建、查询、删除、容器加入网络等等功能。

就在之前定义的 Network 对象上增加对应方法即可

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

Driver 注册

暂时使用一个 全局变量 drivers 存储所有的网络驱动。

在 init 方法中进行 driver 注册。

var (
	defaultNetworkPath = "/var/lib/mydocker/network/network/"
	drivers            = map[string]Driver{}
)

func init() {
	// 加载网络驱动
	var bridgeDriver = BridgeNetworkDriver{}
	drivers[bridgeDriver.Name()] = &bridgeDriver

	// 文件不存在则创建
	if _, err := os.Stat(defaultNetworkPath); err != nil {
		if !os.IsNotExist(err) {
			logrus.Errorf("check %s is exist failed,detail:%v", defaultNetworkPath, err)
			return
		}
		if err = os.MkdirAll(defaultNetworkPath, constant.Perm0644); err != nil {
			logrus.Errorf("create %s failed,detail:%v", defaultNetworkPath, err)
			return
		}
	}
}

Network 信息存储

默认将容器网络信息存储在/var/lib/mydocker/network/network/目录。

提供 dump、load、remove 等方法管理文件系统中的网络信息。

func (net *Network) dump(dumpPath string) error {
	// 检查保存的目录是否存在,不存在则创建
	if _, err := os.Stat(dumpPath); err != nil {
		if !os.IsNotExist(err) {
			return err
		}
		if err = os.MkdirAll(dumpPath, constant.Perm0644); err != nil {
			return errors.Wrapf(err, "create network dump path %s failed", dumpPath)
		}
	}
	// 保存的文件名是网络的名字
	netPath := path.Join(dumpPath, net.Name)
	// 打开保存的文件用于写入,后面打开的模式参数分别是存在内容则清空、只写入、不存在则创建
	netFile, err := os.OpenFile(netPath, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, constant.Perm0644)
	if err != nil {
		return errors.Wrapf(err, "open file %s failed", dumpPath)
	}
	defer netFile.Close()

	netJson, err := json.Marshal(net)
	if err != nil {
		return errors.Wrapf(err, "Marshal %v failed", net)
	}

	_, err = netFile.Write(netJson)
	return errors.Wrapf(err, "write %s failed", netJson)
}

func (net *Network) remove(dumpPath string) error {
	// 检查网络对应的配置文件状态,如果文件己经不存在就直接返回
	fullPath := path.Join(dumpPath, net.Name)
	if _, err := os.Stat(fullPath); err != nil {
		if !os.IsNotExist(err) {
			return err
		}
		return nil
	}
	// 否则删除这个网络对应的配置文件
	return os.Remove(fullPath)
}

func (net *Network) load(dumpPath string) error {
	// 打开配置文件
	netConfigFile, err := os.Open(dumpPath)
	if err != nil {
		return err
	}
	defer netConfigFile.Close()
	// 从配置文件中读取网络 配置 json 符串
	netJson := make([]byte, 2000)
	n, err := netConfigFile.Read(netJson)
	if err != nil {
		return err
	}

	err = json.Unmarshal(netJson[:n], net)
	return errors.Wrapf(err, "unmarshal %s failed", netJson[:n])
}

同时提供了一个 loadNetwork 方法,扫描/var/lib/mydocker/network/network/目录,并加载所有 Network 数据到内存中,便于使用。

// LoadFromFile 读取 defaultNetworkPath 目录下的 Network 信息存放到内存中,便于使用
func loadNetwork() (map[string]*Network, error) {
	networks := map[string]*Network{}

	// 检查网络配置目录中的所有文件,并执行第二个参数中的函数指针去处理目录下的每一个文件
	err := filepath.Walk(defaultNetworkPath, func(netPath string, info os.FileInfo, err error) error {
		// 如果是目录则跳过
		if info.IsDir() {
			return nil
		}
		// if strings.HasSuffix(netPath, "/") {
		// 	return nil
		// }
		//  加载文件名作为网络名
		_, netName := path.Split(netPath)
		net := &Network{
			Name: netName,
		}
		// 调用前面介绍的 Network.load 方法加载网络的配置信息
		if err = net.load(netPath); err != nil {
			logrus.Errorf("error load network: %s", err)
		}
		// 将网络的配置信息加入到 networks 字典中
		networks[netName] = net
		return nil
	})
	return networks, err
}

3. mydocker network 命令

mydocker network 命令一共需要实现 3 个 子命令:

  • create:创建网络
  • list:查看当前所有网络信息
  • remove:删除网络

Create Command

我们要是实现的通过mydocker network create命令创建一个容器网络,就像下面这样:

mydocker network create --subset 192.168.0.0/24 --deive bridge testbr

通过 Bridge 网络驱动创建一个名为 testbr 的网络,网段则是 192.168.0.0/24。

流程

具体流程如下图所示:

https://img.lixueduan.com/docker/mydocker//network-create.png

上图中的 IPAM 和 Network Driver 两个组件就是我们前面实现的。

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

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

分为以下几步:

  • 首先是使用 IPAM 分配 IP
  • 然后根据 driver 找到对应的 NetworkDriver 并创建网络
  • 最后将网络信息保存到文件

代码实现

首先增加一个 create 子命令,用于需要指定 driver 和 subnet 以及网络名称。

var networkCommand = cli.Command{
	Name:  "network",
	Usage: "container network commands",
	Subcommands: []cli.Command{
		{
			Name:  "create",
			Usage: "create a container network",
			Flags: []cli.Flag{
				cli.StringFlag{
					Name:  "driver",
					Usage: "network driver",
				},
				cli.StringFlag{
					Name:  "subnet",
					Usage: "subnet cidr",
				},
			},
			Action: func(context *cli.Context) error {
				if len(context.Args()) < 1 {
					return fmt.Errorf("missing network name")
				}
				driver := context.String("driver")
				subnet := context.String("subnet")
				name := context.Args()[0]
				
				err := network.CreateNetwork(driver, subnet, name)
				if err != nil {
					return fmt.Errorf("create network error: %+v", err)
				}
				return nil
			},
		}
  }

核心实现 在 CreateNetwork 方法中,具体如下:

// CreateNetwork 根据不同 driver 创建 Network
func CreateNetwork(driver, subnet, name string) error {
	// 将网段的字符串转换成net. IPNet的对象
	_, cidr, _ := net.ParseCIDR(subnet)
	// 通过IPAM分配网关IP,获取到网段中第一个IP作为网关的IP
	ip, err := ipAllocator.Allocate(cidr)
	if err != nil {
		return err
	}
	cidr.IP = ip
	// 调用指定的网络驱动创建网络,这里的 drivers 字典是各个网络驱动的实例字典 通过调用网络驱动
	// Create 方法创建网络,后面会以 Bridge 驱动为例介绍它的实现
	net, err := drivers[driver].Create(cidr.String(), name)
	if err != nil {
		return err
	}
	// 保存网络信息,将网络的信息保存在文件系统中,以便查询和在网络上连接网络端点
	return net.dump(defaultNetworkPath)
}

List Command

通过 mydocker network list命令显示当前创建了哪些网络。

扫描网络配置的目录/var/lib/mydocker/network/network/拿到所有的网络配置信息并打印即可。

增加一个 list 子命令

var networkCommand = cli.Command{
	Name:  "network",
	Usage: "container network commands",
	Subcommands: []cli.Command{
		{
			Name:  "list",
			Usage: "list container network",
			Action: func(context *cli.Context) error {
				network.ListNetwork()
				return nil
			},
		}
  }

ListNetwork 方法具体实现:

// ListNetwork 打印出当前全部 Network 信息
func ListNetwork() {
	networks, err := loadNetwork()
	if err != nil {
		logrus.Errorf("load network from file failed,detail: %v", err)
		return
	}
	// 通过tabwriter库把信息打印到屏幕上
	w := tabwriter.NewWriter(os.Stdout, 12, 1, 3, ' ', 0)
	fmt.Fprint(w, "NAME\tIpRange\tDriver\n")
	for _, net := range networks {
		fmt.Fprintf(w, "%s\t%s\t%s\n",
			net.Name,
			net.IPRange.String(),
			net.Driver,
		)
	}
	if err = w.Flush(); err != nil {
		logrus.Errorf("Flush error %v", err)
		return
	}
}

Remove Command

流程

通过使用命令 mydocker network remove命令删除己经创建的网络。

具体流程如下图:

https://img.lixueduan.com/docker/mydocker/network-delete.png

分为以下几个步骤:

  • 1)先调用 IPAM 去释放网络所占用的网关 IP
  • 2)然后调用网络驱动去删除该网络创建的一些设备与配置
  • 3)最终从网络配置目录中删除网络对应的配置文

代码实现

var networkCommand = cli.Command{
	Name:  "network",
	Usage: "container network commands",
	Subcommands: []cli.Command{
		{
			Name:  "remove",
			Usage: "remove container network",
			Action: func(context *cli.Context) error {
				if len(context.Args()) < 1 {
					return fmt.Errorf("missing network name")
				}
				err := network.DeleteNetwork(context.Args()[0])
				if err != nil {
					return fmt.Errorf("remove network error: %+v", err)
				}
				return nil
			},
		},
	},
}

核心实现在 DeleteNetwork 中,具体如下:

// DeleteNetwork 根据名字删除 Network
func DeleteNetwork(networkName string) error {
	networks, err := loadNetwork()
	if err != nil {
		return errors.WithMessage(err, "load network from file failed")
	}
	// 网络不存在直接返回一个error
	net, ok := networks[networkName]
	if !ok {
		return fmt.Errorf("no Such Network: %s", networkName)
	}
	// 调用IPAM的实例ipAllocator释放网络网关的IP
	if err = ipAllocator.Release(net.IPRange, &net.IPRange.IP); err != nil {
		return errors.Wrap(err, "remove Network gateway ip failed")
	}
	// 调用网络驱动删除网络创建的设备与配置 后面会以 Bridge 驱动删除网络为例子介绍如何实现网络驱动删除网络
	if err = drivers[net.Driver].Delete(net.Name); err != nil {
		return errors.Wrap(err, "remove Network DriverError failed")
	}
	// 最后从网络的配直目录中删除该网络对应的配置文件
	return net.remove(defaultNetworkPath)
}

4. mydocker run -net

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

mydocker run -it -p 80:80 --net testbridgenet xxxx

这样创建出的容器便可以通过 testbridgenet 这个网络与网络中的其他容器进行通信了。

流程

具体流程图如下:

https://img.lixueduan.com/docker/mydocker/network-connect.png

在调用创建容器时指定网络,

  • 首先会调用IPAM,通过网络中定义的网段找到未分配的 IP 分配给容器
  • 然后创建容器的网络端点,并调用这个网络的网络驱动连接网络与网络端点,最终完成网络端点的连接和配置。比如在 Bridge 驱动中就会将Veth 设备挂载到 Linux Bridge 网桥上。
  • 最后则是配置端口映射,让用户访问宿主机某端口就能访问到容器中的端口

代码实现

首先先 run 命令增加 net 和 p flag 接收网络和端口映射信息,具体如下:

var runCommand = cli.Command{
	Name: "run",
	Usage: `Create a container with namespace and cgroups limit
			mydocker run -it [command]
			mydocker run -d -name [containerName] [imageName] [command]`,
	Flags: []cli.Flag{
    // 省略...
		cli.StringFlag{
			Name:  "net",
			Usage: "container network,e.g. -net testbr",
		},
		cli.StringSliceFlag{
			Name:  "p",
			Usage: "port mapping,e.g. -p 8080:80 -p 30336:3306",
		},
	},

	Action: func(context *cli.Context) error {
		// 省略...
		network := context.String("net")
		portMapping := context.StringSlice("p")

		Run(tty, cmdArray, envSlice, resConf, volume, containerName, imageName,network,portMapping)
		return nil
	},
}

然后 Run 方法中增加网络配置逻辑

func Run(tty bool, comArray, envSlice []string, res *subsystems.ResourceConfig, volume, containerName, imageName string,
	net string, portMapping []string) {
	containerId := container.GenerateContainerID() // 生成 10 位容器 id

	// 省略....

	// 创建cgroup manager, 并通过调用set和apply设置资源限制并使限制在容器上生效
	cgroupManager := cgroups.NewCgroupManager("mydocker-cgroup")
	defer cgroupManager.Destroy()
	_ = cgroupManager.Set(res)
	_ = cgroupManager.Apply(parent.Process.Pid, res)

  // 如果指定了网络信息则进行配置
	if net != "" {
		// config container network
		containerInfo := &container.Info{
			Id:          containerId,
			Pid:         strconv.Itoa(parent.Process.Pid),
			Name:        containerName,
			PortMapping: portMapping,
		}
		if err = network.Connect(net, containerInfo); err != nil {
			log.Errorf("Error Connect Network %v", err)
			return
		}
	}

  // 省略....
}

核心实现在 Connect 方法,完整代码如下:

// Connect 连接容器到之前创建的网络 mydocker run -net testnet -p 8080:80 xxxx
func Connect(networkName string, info *container.Info) error {
	networks, err := loadNetwork()
	if err != nil {
		return errors.WithMessage(err, "load network from file failed")
	}
	// 从networks字典中取到容器连接的网络的信息,networks字典中保存了当前己经创建的网络
	network, ok := networks[networkName]
	if !ok {
		return fmt.Errorf("no Such Network: %s", networkName)
	}

	// 分配容器IP地址
	ip, err := ipAllocator.Allocate(network.IPRange)
	if err != nil {
		return errors.Wrapf(err, "allocate ip")
	}

	// 创建网络端点
	ep := &Endpoint{
		ID:          fmt.Sprintf("%s-%s", info.Id, networkName),
		IPAddress:   ip,
		Network:     network,
		PortMapping: info.PortMapping,
	}
	// 调用网络驱动挂载和配置网络端点
	if err = drivers[network.Driver].Connect(network, ep); err != nil {
		return err
	}
	// 到容器的namespace配置容器网络设备IP地址
	if err = configEndpointIpAddressAndRoute(ep, info); err != nil {
		return err
	}
	// 配置端口映射信息,例如 mydocker run -p 8080:80
	return configPortMapping(ep)
}

实现和 Docker教程(十)—揭秘 Docker 网络:手动实现 Docker 桥接网络 中手动操作的步骤类似

  • IPAM 分配 IP
  • 创建 veth 设备,一端移动到容器 network namespace
  • 设置 IP 并启动
  • 宿主机添加 iptables 规则,实现端口转发
// configEndpointIpAddressAndRoute 配置容器网络端点的地址和路由
func configEndpointIpAddressAndRoute(ep *Endpoint, info *container.Info) error {
	// 根据名字找到对应Veth设备
	peerLink, err := netlink.LinkByName(ep.Device.PeerName)
	if err != nil {
		return fmt.Errorf("fail config endpoint: %v", err)
	}
	// 将容器的网络端点加入到容器的网络空间中
	// 并使这个函数下面的操作都在这个网络空间中进行
	// 执行完函数后,恢复为默认的网络空间,具体实现下面再做介绍

	defer enterContainerNetNS(&peerLink, info)()
	// 获取到容器的IP地址及网段,用于配置容器内部接口地址
	// 比如容器IP是192.168.1.2, 而网络的网段是192.168.1.0/24
	// 那么这里产出的IP字符串就是192.168.1.2/24,用于容器内Veth端点配置

	interfaceIP := *ep.Network.IPRange
	interfaceIP.IP = ep.IPAddress
	// 设置容器内Veth端点的IP
	if err = setInterfaceIP(ep.Device.PeerName, interfaceIP.String()); err != nil {
		return fmt.Errorf("%v,%s", ep.Network, err)
	}
	// 启动容器内的Veth端点
	if err = setInterfaceUP(ep.Device.PeerName); err != nil {
		return err
	}
	// Net Namespace 中默认本地地址 127 的勺。”网卡是关闭状态的
	// 启动它以保证容器访问自己的请求
	if err = setInterfaceUP("lo"); err != nil {
		return err
	}
	// 设置容器内的外部请求都通过容器内的Veth端点访问
	// 0.0.0.0/0的网段,表示所有的IP地址段
	_, cidr, _ := net.ParseCIDR("0.0.0.0/0")
	// 构建要添加的路由数据,包括网络设备、网关IP及目的网段
	// 相当于route add -net 0.0.0.0/0 gw (Bridge网桥地址) dev (容器内的Veth端点设备)

	defaultRoute := &netlink.Route{
		LinkIndex: peerLink.Attrs().Index,
		Gw:        ep.Network.IPRange.IP,
		Dst:       cidr,
	}
	// 调用netlink的RouteAdd,添加路由到容器的网络空间
	// RouteAdd 函数相当于route add 命令
	if err = netlink.RouteAdd(defaultRoute); err != nil {
		return err
	}

	return nil
}

// configPortMapping 配置端口映射
func configPortMapping(ep *Endpoint) error {
	var err error
	// 遍历容器端口映射列表
	for _, pm := range ep.PortMapping {
		// 分割成宿主机的端口和容器的端口
		portMapping := strings.Split(pm, ":")
		if len(portMapping) != 2 {
			logrus.Errorf("port mapping format error, %v", pm)
			continue
		}
		// 由于iptables没有Go语言版本的实现,所以采用exec.Command的方式直接调用命令配置
		// 在iptables的PREROUTING中添加DNAT规则
		// 将宿主机的端口请求转发到容器的地址和端口上
		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, " ")...)
		logrus.Infoln("配置端口映射 cmd:", cmd.String())
		// 执行iptables命令,添加端口映射转发规则
		output, err := cmd.Output()
		if err != nil {
			logrus.Errorf("iptables Output, %v", output)
			continue
		}
	}
	return err
}

至此,容器网络相关改造就算是完成了。

实际上还有一些收尾工作需要处理,比如

  • 容器停止后需要删除对应的 veth 设备,有端口映射的还需要删除对应的 Iptables 规则等
  • 容器信息需要新增网络相关信息,ps 命令也需要调整
  • ….

本篇主要实现容器网络功能,让大家加深这块的理解,后续的收尾工作就不赘述了。

5. 测试

测试以下几个场景:

  • 容器与容器互联:主要检测 veth 设备 + bridge 设备 + 路由配置
  • 宿主机访问容器:同上
  • 容器访问外部网络:主要检测 veth 设备 + bridge 设备 + 路由配置+ SNAT 规则
  • 外部机器访问容器,端口映射:主要检测 veth 设备 + bridge 设备 + 路由配置+ DNAT 规则

网络拓扑可以参考下图:

https://img.lixueduan.com/docker/network/docker-bridge-network.png

可以简单理解为:整个链路使用 veth 设备和 bridge 设备构成,数据流向则有路由规则 SNAT、DNAT 等规则控制。

首先,创建一个网络,用于让容器挂载。

需要注意,这个网段不能和宿主机的网段冲突,否则可能无法正常使用。

root@mydocker:~/feat-network-2/mydocker# go build .
root@mydocker:~/feat-network-2/mydocker# ./mydocker network create --driver bridge --subnet 10.0.0.1/24 testbridge
root@mydocker:~/feat-network-2/mydocker# ./mydocker network list
NAME         IpRange       Driver
testbridge   10.0.0.1/24   bridge

容器与容器互联

数据流向大致为:容器 1 veth –> bridge –> 容器 2 veth。

开启两个终端,分别执行以下命令,在testbridge网络上启动两个容器。

./mydocker run -it -net testbridge busybox sh

查看容器1的IP地址:

/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
15: cif-86416@if16: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue qlen 1000
    link/ether fe:44:02:8a:07:1f brd ff:ff:ff:ff:ff:ff
    inet 10.0.0.2/24 brd 10.0.0.255 scope global cif-86416
       valid_lft forever preferred_lft forever
    inet6 fe80::fc44:2ff:fe8a:71f/64 scope link
       valid_lft forever preferred_lft forever

可以看到,地址是,10.0.0.2。

然后去另一个容器中尝试连接这个地址。

/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
17: cif-12201@if18: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue qlen 1000
    link/ether 42:da:b3:b8:6f:6a brd ff:ff:ff:ff:ff:ff
    inet 10.0.0.3/24 brd 10.0.0.255 scope global cif-12201
       valid_lft forever preferred_lft forever
    inet6 fe80::40da:b3ff:feb8:6f6a/64 scope link
       valid_lft forever preferred_lft forever

另一个容器地址是 10.0.0.3。

另外容器中都会自动添加以下路由:

/ # ip r
default via 10.0.0.1 dev cif-81260
10.0.0.0/24 dev cif-81260 scope link  src 10.0.0.3

因此,整个数据流向应该是没什么问题的。

ping 一下看看

/ # ping 10.0.0.2 -c 4
PING 10.0.0.2 (10.0.0.2): 56 data bytes
64 bytes from 10.0.0.2: seq=0 ttl=64 time=0.387 ms
64 bytes from 10.0.0.2: seq=1 ttl=64 time=0.115 ms
64 bytes from 10.0.0.2: seq=2 ttl=64 time=0.129 ms
64 bytes from 10.0.0.2: seq=3 ttl=64 time=0.090 ms

--- 10.0.0.2 ping statistics ---
4 packets transmitted, 4 packets received, 0% packet loss
round-trip min/avg/max = 0.090/0.180/0.387 ms

由以上结果可以看到,两个容器可以通过这个网络互相连通。

宿主机访问容器

数据流向:bridge –> 容器 veth。

由于创建网络(bridge)时会在宿主机上添加下面这样的路由规则

root@mydocker:~/feat-network-2/mydocker# ip r
10.0.0.0/24 dev testbridge proto kernel scope link src 10.0.0.1

因此,宿主机上访问容器最终会交给 Bridge 设备然后进入到容器中。

试一下

root@mydocker:~/feat-network-2/mydocker# ping 10.0.0.5 -c 4
PING 10.0.0.5 (10.0.0.5) 56(84) bytes of data.
64 bytes from 10.0.0.5: icmp_seq=1 ttl=64 time=0.474 ms
64 bytes from 10.0.0.5: icmp_seq=2 ttl=64 time=0.099 ms
64 bytes from 10.0.0.5: icmp_seq=3 ttl=64 time=0.119 ms
64 bytes from 10.0.0.5: icmp_seq=4 ttl=64 time=0.114 ms

--- 10.0.0.5 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3032ms
rtt min/avg/max/mdev = 0.099/0.201/0.474/0.157 ms

一切正常。

容器访问外部网络

数据流向大概是这样的:veth -> bridge -> eth0 -> public net。

数据包使用 veth 从容器中跑到宿主机 Bridge,然后使用宿主机网卡 eth0(或者其他网卡)发送出去。

为了让数据包能够正常回来,因此需要进行 SNAT,把数据包原地址从容器 IP 改成宿主机 IP,对应 Iptables 规则是这样的:

iptables -t nat -A POSTROUTING -s 10.0.0.1/24 -o testbridge -j MASQUERADE

在代码实现上,创建网络(bridge) 时我们就添加了该规则,因此正常情况下容器时可以直接访问外网的。

测试一下

/ # ping 114.114.114.114 -c 4
PING 114.114.114.114 (114.114.114.114): 56 data bytes
64 bytes from 114.114.114.114: seq=0 ttl=88 time=19.744 ms
64 bytes from 114.114.114.114: seq=1 ttl=69 time=19.639 ms
64 bytes from 114.114.114.114: seq=2 ttl=69 time=19.517 ms
64 bytes from 114.114.114.114: seq=3 ttl=84 time=19.620 ms

--- 114.114.114.114 ping statistics ---
4 packets transmitted, 4 packets received, 0% packet loss
round-trip min/avg/max = 19.517/19.630/19.744 ms

能 ping 通则说明可以访问外部网络,如果 ping 没反应,则需要检查宿主机配置。

1)需要宿主机进行转发功能

# 检查是否开启
root@mydocker:~/feat-network-2/mydocker# sysctl net.ipv4.conf.all.forwarding
net.ipv4.conf.all.forwarding = 0 # 为 0 则说明未开启
# 执行以下命令设置为 1 开启转发功能
sysctl net.ipv4.conf.all.forwarding=1

2)检查 iptables FORWARD 规则

$ iptables -t filter -L 
FORWARD Chain FORWARD (policy ACCEPT) target     prot opt source               destination 

一般缺省策略都是 ACCEPT,不需要改动,如果如果缺省策略是DROP,需要设置为ACCEPT

iptables -t filter -P FORWARD ACCEPT

开启后基本上就可以正常运行了。

容器映射端口到宿主机上供外部访问

数据流向:DNAT –> Bridge -> Veth。

访问宿主机 IP:8080 SNAT 后变成容器 IP:80,然后进入到 Bridge,接着进入容器内 Veth。

对应的 DNAT 就是下面这条 Iptables 规则

iptables -t nat -A PREROUTING ! -i testbridge -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.0.0.2:80

通过 mydocker run -p 8080:80 的方式将容器中的 80 端口映射到宿主机 8080 端口。

./mydocker run -it -p 8080:80 -net testbridge busybox sh
# 容器中使用nc命令监听 80 端口
/ # nc -lp 80

然后访问宿主机的 8080 端口,看是否能转发到容器中。

注意:需要到和宿主机同一个局域网的远程主机上访问,而且要使用 宿主机 IP 进行访问。

[root@kc-jumper ~]# telnet 192.168.10.144 8080
Trying 192.168.10.144...
Connected to 192.168.10.144.
Escape character is '^]'.

连上则说明是可以的。

因为只在 PREROUTING 链上做了 DNAT,因此需要同局域网的其他主机访问才行,要当前宿主机访问则需要再 OUTPUT 链也做 DNAT。

因为访问本地服务不会走 PREROUTING、INPUT 链,直接走的是 OUTPUT 链,因此要在 OUTPUT 链也增加 DNAT。

iptables 规则如下:

iptables -t nat -A OUTPUT -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.0.0.2:80

添加后就可以连上了

root@mydocker:~/feat-network-2/mydocker# telnet 192.168.10.144 8080
Trying 192.168.10.144...
Connected to 192.168.10.144.
Escape character is '^]'.

6. 小结

本章实现了容器网络模型构建,为容器插上了“网线”,实现了容器访问外网以及容器间访问。

包括以下工作:

  • 负载 IP 管理的 IPAM 组件

  • 以及网络管理的 NetworkDriver 组件。

  • mydocker network create/delete 命令,实现网络管理

  • mydocker run -net 参数,将容器加入到指定网络中

实际上是使用 Linux Veth、Bridge、Iptables 等相关技术实现,具体原理:

  • 首先容器 就是一个进程,主要利用 Linux Namespace 进行网络隔离。
  • 为了跨 Namespace 通信,就用到了 Veth pair。
  • 然后多个容器都使用 Veth pair 互相连通的话,不好管理,所以加入了 Linux Bridge,所有 veth 一端在容器中,一端直接和 bridge 连接,这样就好管理多了。
  • 接着容器和外部网络要进行通信,于是又要用到 iptables 的 NAT 规则进行地址转换。
  • 最后宿主机和容器端口映射也需要使用 Iptables 进行转发。

现在再看一下这个网络拓扑应该就比较清晰了:

https://img.lixueduan.com/docker/network/docker-bridge-network.png

最后再次推荐一下 Docker教程(十)—揭秘 Docker 网络:手动实现 Docker 桥接网络,可以和本文对照着看。


完整代码见:https://github.com/lixd/mydocker 欢迎关注~

相关代码见 feat-network-2 分支,测试脚本如下:

需要提前在 /var/lib/mydocker/image 目录准备好 busybox.tar 文件,具体见第四篇第二节。

# 克隆代码
git clone -b feat-network-2 https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依赖并编译
go mod tidy
go build .
# 测试 
# 创建网络
./mydocker network create --driver bridge --subnet 10.0.0.1/24 testbridge
# 指定网络创建容器
./mydocker run -it -net testbridge busybox sh

最后,本文仅为个人理解,可能存在一些错误或者不完善之处。如果大家发现了任何问题或者有任何建议,一定帮忙指正,一起探讨,共同提高。