從零開始寫 Docker(十六)---容器網路實現(上):為容器插上”網線”

探索云原生發表於2024-05-28

mydocker-network-1.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. 概述

前面文章中已經實現了容器的大部分功能,不過還缺少了網路部分。現在我們的容器既不能訪問外網也不能訪問其他容器。

本篇和下一篇文章則會解決該問題,會實現容器網路相關功能,為我們的容器插上”網線“。

本篇主要介紹大致思路以及 IPAM 和 Network Driver 元件的實現過程。

2. 網路模型

Docker 橋接網路

相關內容在這邊文章:Docker教程(十)---揭秘 Docker 網路:手動實現 Docker 橋接網路 中已經有了詳細記錄,感興趣的可以跳轉閱讀。

核心如下:

  • 首先容器就是一個程序,主要利用 Linux Namespace 進行隔離。
  • 那麼,為了跨 Namespace 通訊,就用到了 Veth pair。
  • 然後多個容器都使用 Veth pair 互相連通的話,不好管理,所以加入了 Linux Bridge,所有 veth 一端在容器中,一端直接和 bridge 連線,這樣就好管理多了。
  • 最後容器和外部網路要進行通訊,於是又要用到 iptables 的 NAT 規則進行地址轉換。

接下來我們要做的就是使用 Go 程式碼實現這些功能。

裝置抽象

首先,將 Bridge 和 Veth 這兩個物件進行抽象:網路(Network)網路端點(Endpoint)

Network

網路(Netowrk)中可以有多個容器,在同一個網路裡的容器可以透過這個網路互相通訊。

就像掛載到同一個 Linux Bridge 裝置上的網路裝置一樣, 可以直接透過 Bridge 裝置實現網路互連;連線到同一個網路中的容器也可以透過這個網路和網路中別的容器互連。

網路中會包括這個網路相關的配置,比如網路的容器地址段、網路操作所呼叫的網路驅動等資訊。

type Network struct {
    Name    string     // 網路名
    IPRange *net.IPNet // 地址段
    Driver  string     // 網路驅動名
}

Endpoint

網路端點(Endpoint)是用於連線容器與網路的,保證容器內部與網路的通訊。

將 Linux 中的 veth-pair 一端掛載到容器內部,另一端掛載到 Bridge 上,就能打通容器和宿主機網路的通訊。

網路端點中會包括連線到網路的一些資訊,比如地址、Veth 裝置、埠對映、連線的容器和網路等資訊。

type Endpoint struct {
    ID          string           `json:"id"`
    Device      netlink.Veth     `json:"dev"`
    IPAddress   net.IP           `json:"ip"`
    MacAddress  net.HardwareAddr `json:"mac"`
    Network     *Network
    PortMapping []string
}

Network Driver

**網路驅動(Network Driver) **是一個網路功能中的元件,不同的驅動對網路的建立、連線、銷燬的策略不同,透過在建立網路時指定不同的網路驅動來定義使用哪個驅動做網路的配置。

它的介面定義如下:

type Driver interface {
	Name() string
	Create(subnet string, name string) (*Network, error)
	Delete(name string) error
	Connect(network *Network, endpoint *Endpoint) error
	Disconnect(network Network, endpoint *Endpoint) error
}

IPAM

**IPAM(IP Address Management) **也是網路功能中的一個元件,用於網路 IP 地址的分配和釋放,包括容器的IP地址和網路閘道器的IP地址,它的主要功能如下。

type IPAMer interface {
	Allocate(subnet *net.IPNet) (ip net.IP, err error) // 從指定的 subnet 網段中分配 IP 地址
	Release(subnet *net.IPNet, ipaddr *net.IP) error   //  從指定的 subnet 網段中釋放掉指定的 IP 地址。
}

實現思路

為了給我們的容器插上網線,大致需要做以下工作:

  • 實現 IPAM ,完成對 IP 地址的管理
  • 實現 NetworkDriver,實現對網路的管理
  • 基於 IPAM 和 NetworkDriver 實現 mydocker network create/list/delete 命令, 讓我們能透過 mydocker 命令實現對容器網路的管理
  • 實現 mydocker run -net,讓容器可以加入指定網路

3. IPAM 實現

IPAM 主要管理 IP 的分配以及釋放,因此需要找個地方儲存哪些 IP 分配了,哪些 IP 可用。

由於對每個 IP 來說只存在已分配、可用兩種狀態,因此容器想到使用 bitmap 來儲存。

bitmap

bitmap 在大規模連續且少狀態的資料處理中有很高的效率,比如要用到的 IP 地址分配。

一個網段中的某個 IP 地址有兩種狀態:

  • 1 表示已經被分配了,
  • 0表示還未被分配;

那麼一個 IP 地址的狀態就可以用一位來表示, 並且透過這位相對基礎位的偏移也能夠迅速定位到資料所在的位。

透過點陣圖的方式實現 IP 地址的管理也比較簡單:

  • 分配 IP:在獲取 IP 地址時,遍歷每一項,找到值為 0 的項的偏移,然後透過偏移和網段的配置計算出分配的 IP 地址,並將該位置元素置為 1,表明 IP 地址已經被分配。
  • 釋放 IP:根據 IP 和網段配置計算出偏移,然後將該位置元素置為 0,表示該 IP 地址可用。

資料結構定義

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

type IPAM struct {
    SubnetAllocatorPath string             // 分配檔案存放位置
    Subnets             *map[string]string // 網段和點陣圖演算法的陣列 map, key 是網段, value 是分配的點陣圖陣列
}

// 初始化一個IPAM的物件,預設使用/var/lib/mydocker/network/ipam/subnet.json作為分配資訊儲存位置
var ipAllocator = &IPAM{
    SubnetAllocatorPath: ipamDefaultAllocatorPath,
}

整個定義比較簡單,整個 IPAM 物件包括一個 SubnetAllocatorPath 欄位用於存放資料的持久化位置,一個 Subnets 欄位記錄每一個網段中 IP 的分配情況。

注意:在這個定義中,為了程式碼實現簡單和易於閱讀,使用一個字元表示一個狀態位,實際上可以採用一位表示一個是否分配的狀態位,這樣資源會有更低的消耗。

配置資訊持久化

透過將分配資訊序列化成 json 檔案或將 json 檔案以反序列化的方式儲存和讀取網段分配的資訊到記憶體。

讀取檔案資料到記憶體:

// load 載入網段地址分配資訊
func (ipam *IPAM) load() error {
	// 檢查儲存檔案狀態,如果不存在,則說明之前沒有分配,則不需要載入
	if _, err := os.Stat(ipam.SubnetAllocatorPath); err != nil {
		if !os.IsNotExist(err) {
			return err
		}
		return nil
	}
	// 讀取檔案,載入配置資訊
	subnetConfigFile, err := os.Open(ipam.SubnetAllocatorPath)
	if err != nil {
		return err
	}
	defer subnetConfigFile.Close()
	subnetJson := make([]byte, 2000)
	n, err := subnetConfigFile.Read(subnetJson)
	if err != nil {
		return errors.Wrap(err, "read subnet config file error")
	}
	err = json.Unmarshal(subnetJson[:n], ipam.Subnets)
	return errors.Wrap(err, "err dump allocation info")
}

將記憶體中的資料持久化到檔案:

// dump 儲存網段地址分配資訊
func (ipam *IPAM) dump() error {
	ipamConfigFileDir, _ := path.Split(ipam.SubnetAllocatorPath)
	if _, err := os.Stat(ipamConfigFileDir); err != nil {
		if !os.IsNotExist(err) {
			return err
		}
		if err = os.MkdirAll(ipamConfigFileDir, constant.Perm0644); err != nil {
			return err
		}
	}
	// 開啟儲存檔案 O_TRUNC 表示如果存在則消空, os O_CREATE 表示如果不存在則建立
	subnetConfigFile, err := os.OpenFile(ipam.SubnetAllocatorPath, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, constant.Perm0644)
	if err != nil {
		return err
	}
	defer subnetConfigFile.Close()
	ipamConfigJson, err := json.Marshal(ipam.Subnets)
	if err != nil {
		return err
	}
	_, err = subnetConfigFile.Write(ipamConfigJson)
	return err
}

Allocate

這部分為 Allocate 方法的實現,比較簡單

  • 1)從檔案中載入 IPAM 資料

  • 2)根據子網資訊在 map 中找到儲存 IP 分配資訊的字串

  • 3)遍歷字串找到其中為 0 的元素,並根據偏移按照演算法計算得到本次分配的 IP

  • 4)把對應位置置 1 並寫回檔案

// Allocate 在網段中分配一個可用的 IP 地址
func (ipam *IPAM) Allocate(subnet *net.IPNet) (ip net.IP, err error) {
	// 存放網段中地址分配資訊的陣列
	ipam.Subnets = &map[string]string{}

	// 從檔案中載入已經分配的網段資訊
	err = ipam.load()
	if err != nil {
		return nil, errors.Wrap(err, "load subnet allocation info error")
	}
	// net.IPNet.Mask.Size函式會返回網段的子網掩碼的總長度和網段前面的固定位的長度
	// 比如“127.0.0.0/8”網段的子網掩碼是“255.0.0.0”
	// 那麼subnet.Mask.Size()的返回值就是前面255所對應的位數和總位數,即8和24
	_, subnet, _ = net.ParseCIDR(subnet.String())
	one, size := subnet.Mask.Size()
	// 如果之前沒有分配過這個網段,則初始化網段的分配配置
	if _, exist := (*ipam.Subnets)[subnet.String()]; !exist {
		// /用“0”填滿這個網段的配置,uint8(size - one )表示這個網段中有多少個可用地址
		// size - one是子網掩碼後面的網路位數,2^(size - one)表示網段中的可用IP數
		// 而2^(size - one)等價於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已經分配過了
			// Go 的字串,建立之後就不能修改 所以透過轉換成 byte 陣列,修改後再轉換成字串賦值
			ipalloc := []byte((*ipam.Subnets)[subnet.String()])
			ipalloc[c] = '1'
			(*ipam.Subnets)[subnet.String()] = string(ipalloc)
			// 這裡的 subnet.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)、uint8(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開始分配的(0被閘道器佔了),所以最後再加1,最終得到分配的IP 172.17.0.20
			ip[3] += 1
			break
		}
	}
	// 最後呼叫dump將分配結果儲存到檔案中
	err = ipam.dump()
	if err != nil {
		log.Error("Allocate:dump ipam error", err)
	}
	return
}

Release

釋放則和分配相反,根據 IP 計算出對應的點陣圖陣列索引位置並將其置 0,然後儲存到檔案中。

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 {
		return errors.Wrap(err, "load subnet allocation info error")
	}
	// 和分配一樣的演算法,反過來根據IP找到點陣圖陣列中的對應索引位置
	c := 0
	releaseIP := ipaddr.To4()
	releaseIP[3] -= 1
	for t := uint(4); t > 0; t -= 1 {
		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)

	// 最後呼叫dump將分配結果儲存到檔案中
	err = ipam.dump()
	if err != nil {
		log.Error("Allocate:dump ipam error", err)
	}
	return nil
}

測試

透過兩個單元測試來測試網段中 IP 的分配和釋放。

func TestAllocate(t *testing.T) {
    _, ipNet, _ := net.ParseCIDR("192.168.0.1/24")
    ip, err := ipAllocator.Allocate(ipNet)
    if err != nil {
       t.Fatal(err)
    }
    t.Logf("alloc ip: %v", ip)
}

func TestRelease(t *testing.T) {
    ip, ipNet, _ := net.ParseCIDR("192.168.0.1/24")
    err := ipAllocator.Release(ipNet, &ip)
    if err != nil {
       t.Fatal(err)
    }
}

Allocate

先執行分配,看一下:

root@mydocker:~/feat-network-1/mydocker/network# go test -v -run TestAllocate
=== RUN   TestAllocate
    ipam_test.go:14: alloc ip: 192.168.0.1
--- PASS: TestAllocate (0.00s)
PASS
ok  	mydocker/network	0.006s

檢視以下儲存的檔案是否正常:

root@mydocker:~/feat-network-1/mydocker/network# cat /var/lib/mydocker/network/ipam/subnet.json
{"192.168.0.0/24":"1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}

可以看到網段的第 1 位被置為了 1,說明我們的分配功能是ok的。

Release

測試一下釋放剛才分配的IP

root@mydocker:~/feat-network-1/mydocker/network# go test -v -run TestRelease
=== RUN   TestRelease
--- PASS: TestRelease (0.00s)
PASS
ok  	mydocker/network	0.005s

再次檢視檔案

root@mydocker:~/feat-network-1/mydocker/network# cat /var/lib/mydocker/network/ipam/subnet.json
{"192.168.0.0/24":"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}

可以看到網段對應的第 1 位已經被重新置為 0 了,說明釋放功能也是 ok 的。

4. NetworkDriver 實現

大致實現

這裡實現簡單的橋接網路作為容器的網路驅動,因此:

  • Create:建立 Bridge 裝置
  • Delete:刪除 Bridge 裝置
  • Connect:將 veth 關聯到網橋
  • Disconnect:將 veth 從網橋解綁

當然,除了 Bridge 裝置外還有其他一些配置,,這篇文章Docker教程(十)---揭秘 Docker 網路:手動實現 Docker 橋接網路 有詳細資訊,這裡就不在重複贅述。

文章中網路管理大致包括以下幾條命令:

# 建立網橋
sudo brctl addbr br0
# 為bridge分配IP地址,啟用上線
sudo ip addr add 172.18.0.1/24 dev br0
sudo ip link set br0 up
# 配置 nat 規則讓容器可以訪問外網
sudo iptables -t nat -A POSTROUTING -s 172.18.0.0/24 ! -o br0 -j MASQUERADE

我們的網路驅動要做的事情就是把上述命令用 Go 實現,需要用到以下幾個庫

  • net 庫是 Go 語言內建的庫,提供了跨平臺支援的網路地址處理,以及各種常見協議的IO支援,比如TCP、UDP、DNS、Unix Socket等。
  • netlink庫 是Go 語言的操作網路介面、路由表等配置的庫 ,使用它的呼叫相當於我們透過 IP 命令去管理網路介面。
  • netns庫 就是 Go 語言版的ip netns exec 命令實現。透過這個庫可以讓 netlink 庫中配置網路介面的程式碼在某個容器的 Net amespace 中執行。

實現前面定義的 Driver 介面即可。

type BridgeNetworkDriver struct {
}

func (d *BridgeNetworkDriver) Name() string {
	return "bridge"
}

func (d *BridgeNetworkDriver) Create(subnet string, name string) (*Network, error) {
	return nil, err
}

// Delete 刪除網路
func (d *BridgeNetworkDriver) Delete(network Network) error {
	return nil
}

func (d *BridgeNetworkDriver) Disconnect(network Network, endpoint *Endpoint) error {
	return nil
}

Create

根據子網資訊建立 Bridge 裝置並初始化。

func (d *BridgeNetworkDriver) Create(subnet string, name string) (*Network, error) {
    ip, ipRange, _ := net.ParseCIDR(subnet)
    ipRange.IP = ip
    n := &Network{
       Name:    name,
       IPRange: ipRange,
       Driver:  d.Name(),
    }
    err := d.initBridge(n)
    if err != nil {
       return nil, errors.Wrapf(err, "Failed to create bridge network")
    }
    return n, err
}

核心在 initBridge 中,具體如下:

func (d *BridgeNetworkDriver) initBridge(n *Network) error {
    bridgeName := n.Name
    // 1)建立 Bridge 虛擬裝置
    if err := createBridgeInterface(bridgeName); err != nil {
       return errors.Wrapf(err, "Failed to create bridge %s", bridgeName)
    }

    // 2)設定 Bridge 裝置地址和路由
    gatewayIP := *n.IPRange
    gatewayIP.IP = n.IPRange.IP

    if err := setInterfaceIP(bridgeName, gatewayIP.String()); err != nil {
       return errors.Wrapf(err, "Error set bridge ip: %s on bridge: %s", gatewayIP.String(), bridgeName)
    }
    // 3)啟動 Bridge 裝置
    if err := setInterfaceUP(bridgeName); err != nil {
       return errors.Wrapf(err, "Failed to set %s up", bridgeName)
    }

    // 4)設定 iptables SNAT 規則
    if err := setupIPTables(bridgeName, n.IPRange); err != nil {
       return errors.Wrapf(err, "Failed to set up iptables for %s", bridgeName)
    }

    return nil
}

建立 bridge 虛擬裝置

這部分主要實現下面 ip link add x這個命令,建立一個 Bridge 裝置。

// createBridgeInterface 建立Bridge裝置
func createBridgeInterface(bridgeName string) error {
    // 先檢查是否己經存在了這個同名的Bridge裝置
    _, err := net.InterfaceByName(bridgeName)
    // 如果已經存在或者報錯則返回建立錯
    // errNoSuchInterface這個錯誤未匯出也沒提供判斷方法,只能判斷字串了。。
    if err == nil || !strings.Contains(err.Error(), "no such network interface") {
       return err
    }

    // create *netlink.Bridge object
    la := netlink.NewLinkAttrs()
    la.Name = bridgeName
    // 使用剛才建立的Link的屬性創netlink Bridge物件
    br := &netlink.Bridge{LinkAttrs: la}
    // 呼叫 net link Linkadd 方法,創 Bridge 虛擬網路裝置
    // netlink.LinkAdd 方法是用來建立虛擬網路裝置的,相當於 ip link add xxxx
    if err = netlink.LinkAdd(br); err != nil {
       return errors.Wrapf(err, "create bridge %s error", bridgeName)
    }
    return nil
}

設定 Bridge 裝置的地址和路由

這部分主要實現下面 ip addr add xxx這個命令,為 Bridge 裝置分為 IP 地址以及路由表配置。

func setInterfaceIP(name string, rawIP string) error {
   retries := 2
   var iface netlink.Link
   var err error
   for i := 0; i < retries; i++ {
      // 透過LinkByName方法找到需要設定的網路介面
      iface, err = netlink.LinkByName(name)
      if err == nil {
         break
      }
      log.Debugf("error retrieving new bridge netlink link [ %s ]... retrying", name)
      time.Sleep(2 * time.Second)
   }
   if err != nil {
      return errors.Wrap(err, "abandoning retrieving the new bridge link from netlink, Run [ ip link ] to troubleshoot")
   }
   // 由於 netlink.ParseIPNet 是對 net.ParseCIDR一個封裝,因此可以將 net.PareCIDR中返回的IP進行整合
   // 返回值中的 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 xxx命令
   // 同時如果配置了地址所在網段的資訊,例如 192.168.0.0/24
   // 還會配置路由表 192.168.0.0/24 轉發到這 testbridge 的網路介面上
   addr := &netlink.Addr{IPNet: ipNet}
   return netlink.AddrAdd(iface, addr)
}

啟動 Bridge 裝置

這部分主要實現下面 ip link set xxx up這個命令,啟動 Bridge 裝置。

func setInterfaceUP(interfaceName string) error {
    link, err := netlink.LinkByName(interfaceName)
    if err != nil {
       return errors.Wrapf(err, "error retrieving a link named [ %s ]:", link.Attrs().Name)
    }
    // 等價於 ip link set xxx up 命令
    if err = netlink.LinkSetUp(link); err != nil {
       return errors.Wrapf(err, "nabling interface for %s", interfaceName)
    }
    return nil
}

設定 iptabels 規則

最後則是設定 iptables 規則實現 SNAT,便於容器訪問外部網路。

$ iptables -t nat -A POSTROUTING -s 172.18.0.0/24 -o eth0 -j MASQUERADE
# 語法:iptables -t nat -A POSTROUTING -s {subnet} -o {deviceName} -j MASQUERADE
// setupIPTables 設定 iptables 對應 bridge MASQUERADE 規則
func setupIPTables(bridgeName string, subnet *net.IPNet) error {
    // 拼接命令
    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.Errorf("iptables Output, %v", output)
    }
    return err
}

透過直接執行 iptables 命令,建立 SNAT 規則,只要是從這個網橋上出來的包,都會對其做源 IP 的轉換,保證了容器經過宿主機訪問到宿主機外部網路請求的包轉換成機器的 IP,從而能正確的送達和接收。

Delete

刪除就比較簡單,刪除對應名稱的 Bridge 裝置即可。

// Delete 刪除網路
func (d *BridgeNetworkDriver) Delete(network Network) error {
    // 根據名字找到對應的Bridge裝置
    br, err := netlink.LinkByName(network.Name)
    if err != nil {
       return err
    }
    // 刪除網路對應的 Linux Bridge 裝置
    return netlink.LinkDel(br)
}

Connect

connect 則是將 Endpoint 連線到當前指定網路。

類似於使用以下命令將 veth 裝置新增到網橋裝置上。

sudo brctl addif br0 veth1

實現如下:

// Connect 連線一個網路和網路端點
func (d *BridgeNetworkDriver) Connect(network *Network, endpoint *Endpoint) error {
	bridgeName := network.Name
	// 透過介面名獲取到 Linux Bridge 介面的物件和介面屬性
	br, err := netlink.LinkByName(bridgeName)
	if err != nil {
		return err
	}
	// 建立 Veth 介面的配置
	la := netlink.NewLinkAttrs()
	// 由於 Linux 介面名的限制,取 endpointID 的前
	la.Name = endpoint.ID[:5]
	// 透過設定 Veth 介面 master 屬性,設定這個Veth的一端掛載到網路對應的 Linux Bridge
	la.MasterIndex = br.Attrs().Index
	// 建立 Veth 物件,透過 PeerNarne 配置 Veth 另外 端的介面名
	// 配置 Veth 另外 端的名字 cif {endpoint ID 的前 位}
	endpoint.Device = netlink.Veth{
		LinkAttrs: la,
		PeerName:  "cif-" + endpoint.ID[:5],
	}
	// 呼叫netlink的LinkAdd方法建立出這個Veth介面
	// 因為上面指定了link的MasterIndex是網路對應的Linux Bridge
	// 所以Veth的一端就已經掛載到了網路對應的LinuxBridge.上
	if err = netlink.LinkAdd(&endpoint.Device); err != nil {
		return fmt.Errorf("error Add Endpoint Device: %v", err)
	}
	// 呼叫netlink的LinkSetUp方法,設定Veth啟動
	// 相當於ip link set xxx up命令
	if err = netlink.LinkSetUp(&endpoint.Device); err != nil {
		return fmt.Errorf("error Add Endpoint Device: %v", err)
	}
	return nil
}

Disconnct

Disconnect 就是把 veth 從 Bridge 上解綁,比較少用到,暫不實現。

func (d *BridgeNetworkDriver) Disconnect(network Network, endpoint *Endpoint) error {
	return nil
}

測試

同樣先透過幾個簡單的單元測試來測試一下功能是否正常。

var testName = "testbridge"

func TestBridgeCreate(t *testing.T) {
	d := BridgeNetworkDriver{}
	n, err := d.Create("192.168.0.1/24", testName)
	if err != nil {
		t.Fatal(err)
	}
	t.Logf("create network :%v", n)
}

func TestBridgeDelete(t *testing.T) {
	d := BridgeNetworkDriver{}
	err := d.Delete(testName)
	if err != nil {
		t.Fatal(err)
	}
	t.Logf("delete network :%v", testName)
}

func TestBridgeConnect(t *testing.T) {
	ep := Endpoint{
		ID: "testcontainer",
	}

	n := Network{
		Name: testName,
	}

	d := BridgeNetworkDriver{}
	err := d.Connect(&n, &ep)
	if err != nil {
		t.Fatal(err)
	}
}

func TestBridgeDisconnect(t *testing.T) {
	ep := Endpoint{
		ID: "testcontainer",
	}

	n := Network{
		Name: testName,
	}

	d := BridgeNetworkDriver{}
	err := d.Disconnect(n, &ep)
	if err != nil {
		t.Fatal(err)
	}
}

Create

root@mydocker:~/feat-network-1/mydocker/network# go test -v -run TestBridgeCreate
=== RUN   TestBridgeCreate
    bridge_driver_test.go:15: create network :&{testbridge 192.168.0.1/24 bridge}
--- PASS: TestBridgeCreate (1.80s)
PASS
ok  	mydocker/network	1.804s

然後檢視是否真正建立出了網橋

root@mydocker:~/feat-network-1/mydocker/network# ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ens3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether fa:16:3e:58:62:ef brd ff:ff:ff:ff:ff:ff
3: testbridge: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether b6:f9:fe:f3:f7:16 brd ff:ff:ff:ff:ff:ff

可以看到,第三個就是我們剛建立出的 testbridge 網橋,說明 create 是正常的。

Delete

root@mydocker:~/feat-network-1/mydocker/network# go test -v -run TestBridgeDelete
=== RUN   TestBridgeDelete
    bridge_driver_test.go:24: delete network :testbridge
--- PASS: TestBridgeDelete (0.02s)
PASS
ok  	mydocker/network	0.019s

檢查是否真正刪除了

root@mydocker:~/feat-network-1/mydocker/network# ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ens3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether fa:16:3e:58:62:ef brd ff:ff:ff:ff:ff:ff

testbridge 網橋已經不存在了,說明 Delete 也是正常的。

Connect

需要先建立網橋,在進行繫結測試:

root@mydocker:~/feat-network-1/mydocker/network# go test -v -run TestBridgeConnect
=== RUN   TestBridgeConnect
--- PASS: TestBridgeConnect (0.10s)
PASS
ok  	mydocker/network	0.104s

檢視是否新建了 veth 並繫結到該網橋上了

root@mydocker:~/feat-network-1/mydocker/network# ip link show type veth
5: cif-testc@testc: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 2a:fb:68:92:7e:59 brd ff:ff:ff:ff:ff:ff
6: testc@cif-testc: <NO-CARRIER,BROADCAST,MULTICAST,UP,M-DOWN> mtu 1500 qdisc noqueue master testbridge state LOWERLAYERDOWN mode DEFAULT group default qlen 1000
    link/ether 06:d6:04:62:13:eb brd ff:ff:ff:ff:ff:ff

可以看到,確實建立出了指定的 veth 裝置(testc),由於只取了名稱前 5 位,因此為 testc。

根據 master testbridge 屬性可以知道,該 veth 關聯到了前面建立的 testbridge 網橋上。

說明 Connect 方法是正常的。

Disconnect

需要先繫結後再測試解綁。

root@mydocker:~/feat-network-1/mydocker/network# go test -v -run TestBridgeDisconnect
=== RUN   TestBridgeDisconnect
--- PASS: TestBridgeDisconnect (0.01s)
PASS
ok  	mydocker/network	0.013s

檢視是否接觸繫結

root@mydocker:~/feat-network-1/mydocker/network# ip link show type veth
5: cif-testc@testc: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 2a:fb:68:92:7e:59 brd ff:ff:ff:ff:ff:ff
6: testc@cif-testc: <NO-CARRIER,BROADCAST,MULTICAST,UP,M-DOWN> mtu 1500 qdisc noqueue state LOWERLAYERDOWN mode DEFAULT group default qlen 1000
    link/ether 06:d6:04:62:13:eb brd ff:ff:ff:ff:ff:ff

可以看到,之前的 master testbridge 屬性不見了,說明解綁成功。

5. 小結

本章實現了容器網路的前置工作,包括:

  • 負載 IP 管理的 IPAM 元件
  • 以及網路管理的 NetworkDriver 元件。

下一篇會在此基礎上,實現容器網路,包括:

  • mydocker network create/delete 命令,實現網路管理
  • mydocker run -net 引數,將容器加入到指定網路中

最後再次推薦一下 Docker教程(十)---揭秘 Docker 網路:手動實現 Docker 橋接網路


【從零開始寫 Docker 系列】持續更新中,搜尋公眾號【探索雲原生】訂閱,閱讀更多文章。



完整程式碼見:https://github.com/lixd/mydocker
歡迎關注~

相關程式碼見 feat-network-1 分支,測試指令碼如下:

# 克隆程式碼
git clone -b feat-run-e https://github.com/lixd/mydocker.git
cd mydocker
# 進入 Network 目錄
cd network
# 執行測試
go test -v -run TestAllocate
# 檢視結果
cat /var/lib/mydocker/network/ipam/subnet.json

相關文章