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

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

mydocker-network-2.png

本文為從零開始寫 Docker 系列第十七篇,利用 linux 下的 Veth、Bridge、iptables 等等相關技術,構建容器網路模型,為容器插上”網線“。

<!--more-->


完整程式碼見: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

最後,本文僅為個人理解,可能存在一些錯誤或者不完善之處。如果大家發現了任何問題或者有任何建議,一定幫忙指正,一起探討,共同提高。

相關文章