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

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


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

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

  • 核心原理:深入理解 Docker 核心原理:Namespace、Cgroups 和 Rootfs
  • 基于 namespace 的视图隔离:探索 Linux Namespace:Docker 隔离的神奇背后
  • 基于 cgroups 的资源限制
    • 初探 Linux Cgroups:资源控制的奇妙世界
    • 深入剖析 Linux Cgroups 子系统:资源精细管理
    • Docker 与 Linux Cgroups:资源隔离的魔法之旅
  • 基于 overlayfs 的文件系统:Docker 魔法解密:探索 UnionFS 与 OverlayFS
  • 基于 veth pair、bridge、iptables 等等技术的 Docker 网络:揭秘 Docker 网络:手动实现 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。

流程

具体流程如下图所示:

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命令删除己经创建的网络。

具体流程如下图:

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 这个网络与网络中的其他容器进行通信了。

流程

具体流程图如下:

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 规则

网络拓扑可以参考下图:

Docker Bridge 网络拓扑

可以简单理解为:整个链路使用 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

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

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

数据流向:SNAT --> 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 '^]'.

**【从零开始写 Docker 系列】**持续更新中,搜索公众号【探索云原生】订阅,文章。


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 进行转发。

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

Docker Bridge 网络拓扑

最后再次推荐一下 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

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/684243.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

python语言中变量的作用域

def getpoint&#xff08;&#xff09;&#xff1a; x 10 y 20 return x&#xff0c;y x&#xff0c;y getpoint&#xff08;&#xff09; print &#xff08;x&#xff0c;y&#xff09; 函数内部的x&#xff0c;y和函数外面的x&#xff0c;y是同一组变量吗? 不同的变量…

AG32 MCU+FPGA 使用感受

前言&#xff1a; 笔者35了&#xff0c;10多年前开始玩单片机/FPGA啥的&#xff0c;从现在回想过去&#xff0c;眼下真的是我们国家微电子发展的好时候。各种各样的国产单片机&#xff0c;FPGA啥的&#xff0c;想想本科的时候用的Freescale&#xff0c;后来用的STM32&#xff0…

SpringCloud Gateway基础入门与使用实践总结

官网文档&#xff1a;点击查看官网文档 Cloud全家桶中有个很重要的组件就是网关&#xff0c;在1.x版本中都是采用的Zuul网关。但在2.x版本中&#xff0c;zuul的升级一直跳票&#xff0c;SpringCloud最后自己研发了一个网关替代Zuul&#xff0c;那就是SpringCloud Gateway一句话…

面试(03)————多线程

目录 一、线程和进程的区别&#xff1f; 二、并行和并发的区别&#xff1f; 三、线程创建的方式有哪些&#xff1f; 3.1、继承Thread类 3.2、实现Runnable接口 3.3、实现Callable接口 3.4、线程池 四、Runnable和Callable的区别&#xff1f; 五、在启动线程的时候&am…

git-生成SSH密钥

git-生成SSH密钥 1 打开命令窗口2 操作 1 打开命令窗口 选择"Git Bash Here"&#xff0c;打开Git命令窗口 2 操作 查看当前用户名称 git config user.name配置你的邮箱&#xff0c;“6xxxqq.com” 填写自己的邮箱 git config --global user.email "6xxxqq…

WPF真入门教程32--WPF数字大屏项目实干

1、项目背景 WPF (Windows Presentation Foundation) 是微软的一个框架&#xff0c;用于构建桌面客户端应用程序&#xff0c;它支持富互联网应用程序&#xff08;RIA&#xff09;的开发。在数字大屏应用中&#xff0c;WPF可以用来构建复杂的用户界面&#xff0c;展示庞大的数据…

计算机网络错题答案汇总

王道学习 第1章 计算机网络体系结构 1.1 1.2

Nvidia 目前的市值为 3.01 万亿美元,超过苹果Apple

人工智能的繁荣将英伟达的市值推高到足以使其成为全球第二大最有价值的公司。 英伟达已成为全球第二大最有价值的公司。周三下午&#xff0c;这家芯片制造巨头的市值达到 3.01 万亿美元&#xff0c;领先于苹果公司的 3 万亿美元。 喜好儿网AIGC专区&#xff1a;https://heehe…

独著出书的出版流程是怎样的?

独著出书的出版流程一般包括以下几个步骤&#xff1a; 1. 准备书稿&#xff1a;确保书稿内容完整、准确&#xff0c;并符合出版社的要求。 2. 选择出版社&#xff1a;根据书稿的主题和内容&#xff0c;选择合适的出版社。可以考虑出版社的专业性、声誉和出版范围等因素。 3.…

2.1.4 采用配置类与注解方式使用MyBatis

实战概述&#xff1a;采用配置类与注解方式使用MyBatis 创建MyBatis配置类 在net.huawei.mybatis.config包中创建MyBatisConfig类&#xff0c;用于配置MyBatis核心组件&#xff0c;包括数据源、事务工厂和环境设置。 配置数据源和事务 使用PooledDataSource配置MySQL数据库连接…

Vue3【四】使用Vue2的写法写一个新的组件子组件和根组件

Vue3【四】使用Vue2的写法写一个新的组件 Vue3【四】使用Vue2的写法写一个新的组件 Vue3是向下兼容的&#xff0c;所有可以使用Vue的选项式写法 运行截图 目录结构 文件源码 App.vue <template><div class"app"><h1>你好世界! 我是App根组件<…

Java Web学习笔记13——JSON

JavaScript自定义对象 定义格式&#xff1a; <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><title>Js-对…

SCT53600TVB具有反向电流保护的理想二极管控制器

4.7V至65V工作范围 – 65V反向额定电压 用于外部N沟道MOSFET的电荷泵 20mV正向压降调节 12V栅极驱动电压 带启用输入 驱动高侧外部N沟道MOSFET 1μA关断电流&#xff08;EN低&#xff09; 60μA工作静态电流&#xff08;EN高&#xff09; 2.3-A峰值…

为什么要做与运算?网关如何和ip做与运算?

在计算机网络中&#xff0c;“与运算”是一个基本而重要的概念&#xff0c;尤其在IP地址和子网掩码的处理中起着关键作用。本文将解释为什么要进行与运算&#xff0c;以及网关如何和IP地址进行与运算。 为什么要做与运算&#xff1f; 1. 确定网络地址 与运算&#xff08;AND…

常规操作-ArcGIS常用标注技巧

常规操作-ArcGIS常用标注技巧 1、简单的"&“符号&#xff1a;多字段表达只需要用”&"符号&#xff0c;多个字段之间需要空格&#xff0c;空格符号需要加双引号。 表达式为&#xff1a; [字段] & " " & [字段] 2、“VBnewline"应用…

MySQL深分页,limit 100000,10 优化

文章目录 一、limit深分页为什么会变慢二、优化方案2.1 通过子查询优化&#xff08;覆盖索引&#xff09;回顾B树结构覆盖索引把条件转移到主键索引树 2.2 INNER JOIN 延迟关联2.3 标签记录法&#xff08;要求id是有序的&#xff09;2.4 使用between...and... 我们日常做分页需…

debrief 中用到的计算方法

//累加时长方法 function calculateCumulativeSum(array, key) { return array.reduce((accumulator, currentValue, currentIndex) > { if (currentIndex 0) { accumulator[currentIndex] currentValue[key]; } else { accu…

python-15(Xpath)

1.XPath 1.1.什么是XPath XPath&#xff08;XML Path Language&#xff09;是一种用于在XML文档中定位和选择节点的语言。它是W3C&#xff08;World Wide Web Consortium&#xff09;定义的一种标准查询语言&#xff0c;广泛用于解析和操作XML文档&#xff0c;也可以操作html…

TensorBoard在pytorch训练过程中如何使用,及数据读取问题解决方法

TensorBoard 模块导入日志记录文件的创建训练中如何写入数据如何提取保存的数据调用TensorBoard面板可能会遇到的问题 模块导入 首先从torch中导入tensorboard的SummaryWriter日志记录模块 from torch.utils.tensorboard import SummaryWriter然后导入要用到的os库&#xff0…

STAT313 Chap5 多变量核密度估计带宽选择(下)

本文原创笔记&#xff0c;禁止转载。有问题可私信付费找我咨询。 直接插入法的基本步骤 步骤1&#xff1a;计算初始估计&#xff08;Pilot Estimate&#xff09; 选择一个初始的带宽矩阵 G \mathbf{G} G 和核函数 L L L 。计算初始密度估计&#xff1a; f ~ ( x , G ) 1 …