使用golang理解Linux namespace(六)-Network

上一回合使用了Mount命名空间,并下载了一个很小的linux文件系统alpline作为镜像,然后整体运行起来之后就是一个简单的linux容器。但是实际操作起来会发现,这个容器的网络并没有设置好。因此这里要介绍下和网络相关的命名空间Network

引出问题

回到上次的代码,编译后执行程序,进入到容器进程中,然后执行一些网络相关的命令可以看到

#branch at https://gitee.com/hoyho/ns-proc/tree/mount-5

[email protected]:~/workspace/gons$ go build
[email protected]:~/workspace/gons$ ls
container-fs.go  go.mod  gons  go.sum  ns-proc.go  README  resources
[email protected]:~/workspace/gons$ ./gons
arg0=./gons,
arg0=initFuncName,

>> namespace setup code goes here <<

newRoot:/tmp/ns-proc/rootfs
-[namespace-process]-# ifconfig
-[namespace-process]-# ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
-[namespace-process]-#

当前的容器网络还没设置,更别说其他通信。

饭前小菜

要解决这个问题之前,先要补习下网络基础

使用netns

在正式开始之前,不妨先了解下Linux下的ip命令。 这个命令可以轻松的操作network 命名空间。

比如列出当前的Network命名空间 ip netns list

一般来说,这个结果是空的。除非我们暴露出一些命名空间

比如使用下面的脚本 expose-netns.sh [continer_id]

#!/bin/bash

if [ $# -ne 1 ]; then
    echo "Usage: $0 <container id or name>"
    exit 1
fi

echo "expose container $1 netns"
NETNS=`sudo docker inspect -f '{{.State.Pid}}' $1`

if [ ! -d /var/run/netns ]; then
    mkdir /var/run/netns
fi
if [ -f /var/run/netns/$NETNS ]; then
    rm -rf /var/run/netns/$NETNS
fi

ln -s /proc/$NETNS/ns/net /var/run/netns/$NETNS
echo "done. netns: $NETNS"

echo "============================="
echo "current network namespaces: "
echo "============================="
ip netns

然后list就会看到这个容器的网络命名空间了

或者使用下面命令创建一个网络命名空间 ip netns add <new namespace name> 例如要加一个叫foo的网络命名空间,那相应的命令就是 ip netns add foo

完成后可以list查看: ip netns list

使用veth

创建虚拟网卡对veth 注意,使用ip命令创建出来的虚拟网卡总是成对存在的. 举个🌰: ip link add veth_host type veth peer name veth_container

这样就创建了两个虚拟网卡veth_host和veth_container, 当你删除掉一个的时候,另一个也会被删除

可以把网卡加入到某个命名空间,比如把上面的veth_container加入到foo这个命名空间下 ip link set veth_container netns foo

加入某个命名空间后,指定某个命名空间执行命令,比如在foo这个命名空间下执行ifconfig: ip netns exec foo ifconfig -a

哇似乎命令越来越长了😭

解释下: 首先第一部分ip netns exec 表示要要在某个网络命名空间下执行语句

接下来是指定的命名空间,这里就是foo这个namespace咯

最后剩下的部分ifconfig -a是要在该命名空间下执行的命令,有意思的是,这里要执行的命令其实不限于仅是网络相关的命令,它可以是任何的linux命令。

比如ip netns exec foo echo "hello world"也是可以的。

为什么要介绍这么多ip命令,其实是本次要讲的内容之一就是网络设置。这里限于篇幅不再继续,强烈建议先阅读此文: https://blog.scottlowe.org/2013/09/04/introducing-linux-network-namespaces/

因为本篇文章会用到这些命令。

容器网络简介

如图所示: 容器网络

可以想象我们图中的容器1就是我们演示的ns-proc。

根据上图我们需要设置的步骤可以总结如下: - 在宿主机网络命名空间下创建一个普通的网桥接口brg1(并分配ip) - 创建一对虚拟网卡对(veth pair)veth00----veth01 - 把veth虚拟网口一端附着在brg1网桥接口 - 将veht虚拟网卡另一端 放在ns-proc进程声明的网络命名空间下 - 设置好veth网卡对的流量,配置路由设置

然后ns-proc容器就可以透过veth01-->veth00和宿主建立网络通信。

talk is cheap show me the code

撸起袖子

按照原理,我们需要做的操作似乎还不少的样子。

这个初始化动作我把它抽离出来,写出脚本

设置宿主机上网桥接口名称为brg-demo 设置网桥接口网络为10.10.10.100/255.255.255.0 宿主虚拟网卡名:veth_host 容器端虚拟网卡名:veth_container 容器段虚拟网卡网络:10.10.10.101/255.255.255.0

脚本完整内容如下,使用方法:netsetter.sh [容器进程pid]

#!/usr/bin/env bash

echo 'usage:'$0 '[container_pid]'
echo 'example: sudo bash '$0 '6666'

bridge_nic='brg-demo'
bridge_ip='10.10.10.100/24'

veth_host_ip='10.10.10.100'
veth_container_ip='10.10.10.101/24'
veth_host='veth_host'
veth_container='veth_conta'
pid=$1 #获取传递给脚本的第一个参数

#可能之前有创建过,避免冲突,先移除后添加
ip link del $bridge_nic
ip link add name $bridge_nic address 12:34:56:a1:b2:c3 type bridge

# 添加ip到网桥接口
ip addr add $bridge_ip dev $bridge_nic

ip link set dev $bridge_nic up

#添加虚拟网卡对veth peer
ip link add $veth_host type veth peer name $veth_container

ip link set $veth_host up

#把host端的虚拟网卡附着到个master网卡上(附着在网桥接口)
ip link set $veth_host master $bridge_nic

#虚拟网卡veth另一端移动到容器所在的network namespace
ip link set $veth_container netns $pid


# 暴露容器网络命名空间
NETNS=$pid
if [ ! -d /var/run/netns ]; then
    mkdir /var/run/netns
fi
if [ -f /var/run/netns/$NETNS ]; then
    rm -rf /var/run/netns/$NETNS
fi

ln -s /proc/$NETNS/ns/net /var/run/netns/$NETNS
echo "netns: $NETNS"


# 对容器端虚拟网卡配置网络
ip netns exec $NETNS ip addr add $veth_container_ip dev $veth_container

ip netns exec $NETNS ip link set $veth_container up


ip netns exec $NETNS ip route add default via $veth_host_ip dev $veth_container


# 隐藏容器进程网络命名空间(还原默认设置)
rm -rf /var/run/netns/$NETNS

部分命令饭前小菜环节已经说明,其余的参考脚本注释即可

main函数部分: 首先当然还是传递一系列命名空间的flag,包括我们要的CLONE_NEWNET


func main() {
...

    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWNS |
            syscall.CLONE_NEWUTS |
            syscall.CLONE_NEWIPC |
            syscall.CLONE_NEWPID |
            syscall.CLONE_NEWNET |
            syscall.CLONE_NEWUSER,
        UidMappings: []syscall.SysProcIDMap{
            {
                ContainerID: 0,
                HostID:      os.Getuid(),
                Size:        1,
            },
        },
        GidMappings: []syscall.SysProcIDMap{
            {
                ContainerID: 0,
                HostID:      os.Getgid(),
                Size:        1,
            },
        },
    }

        if err := cmd.Run(); err != nil {
        fmt.Printf("Error running Command - %s\n", err)
        os.Exit(1)
    }
 }

但是使用cmd.Run() 来运行 reexec命令有个缺点,就是这玩意除非出错,不然它不会停下来,直至进程完成。

在这种场景是有问题的,之前呢是因为我们只会在这个run()里面进行设置,包括clone()已经文件系统初始化等操作。但是现在,我们出来要在新进程里面进行设置,在宿主上也需要设置。也就是说现在不能使用阻塞式的cmd.Run()。我们这里需要同时设置的设计。

那几可以改成cmd.Start()cmd.Wait(),这样在调用了cmd.Start()之后程序不会阻塞,仍然可以执行宿主机上的网络设置脚本。cmd.Start()会阻塞进程,直到cmd命令完成退出。

这就是这样要采用的方式,clone新命名空间,初始化文件系统等操作放在cmd中,在cmd.Start()后他们会在后台完成,同时,不会阻塞当前程序执行,程序会同时调用netsetter.sh进行网络设置,此时执行netsetter.sh设置脚本其实还是在宿主机上的namespaces上完成的。

请看下面:

...

if err := cmd.Start(); err != nil {
        fmt.Printf("Error starting the reexec.Command - %s\n", err)
        os.Exit(1)
    }

    // run netsetgo using default args

    pid := fmt.Sprintf("%d", cmd.Process.Pid)

    //netsetCmd := exec.Command("whoami" ) //see current user , my result is ubuntu not root
    netsetCmd := exec.Command("sudo",netsetPath, pid) //
    var out bytes.Buffer
    var stderr bytes.Buffer
    netsetCmd.Stdout = &out
    netsetCmd.Stderr = &stderr

    if err := netsetCmd.Start(); err != nil {
        fmt.Printf("Error running netsetg:%s, stderr:%s, stdout:%s",fmt.Sprint(err),stderr.String(),out.String())
        os.Exit(1)
    }
    fmt.Printf("run netsetter: stdout:%s \n",out.String())

    if err := cmd.Wait(); err != nil {
        fmt.Printf("Error waiting for the reexec.Command - %s\n", err)
        os.Exit(1)
    }

看起来还行吧,就是使用cmd.start()后台设置容器进程的同时调用了另外一个netsetCmd来执行网络设置任务。

但是,第一部分cmd.Start()会执行init()这个函数,以及后续的一系列容器初始化的操作:比如mountProc(),pivotRoot(),直到nsRun(),这个函数就会直接启动新进程sh。这个时候的执行情况是和netsetCmd执行并行的,因此我们在nsRun()启动新进程之前要确保网络已经准备好。要在网卡设置完毕再启动容器进程sh。

解决办法是添加个网络模块,进行网络检测,检测完毕再启动容器进程:

network.go

...

func waitNetwork() error {
    maxWait := time.Second * 60
    checkInterval := time.Second
    timeStarted := time.Now()

    for {
        fmt.Printf("status: waiting network ...\n")
        interfaces, err := net.Interfaces()
        if err != nil {
            return err
        }

        if len(interfaces) > 1 {
            return nil
        }

        if time.Since(timeStarted) > maxWait {
            return fmt.Errorf("Timeout after %s waiting for network", maxWait)
        }

        time.Sleep(checkInterval)
    }
}

相应地,以key:initFuncName注册在内存中的匿名函数,调用这个网络检测函数:


//call waitNetwork() before nsRun()

func init() {

    fmt.Printf("arg0=%s,\n",os.Args[0])

    reexec.Register("initFuncName", func() {
        fmt.Printf("\n>> namespace setup code goes here <<\n\n")

        newRoot := os.Args[1]

        if err := mountProc(newRoot); err != nil {
            fmt.Printf("Error mounting /proc - %s\n", err)
            os.Exit(1)
        }

        fmt.Printf("newRoot:%s \n",newRoot)
        if err := pivotRoot(newRoot); err != nil {
            fmt.Printf("Error running pivot_root - %s\n", err)
            os.Exit(1)
        }


        if err := waitNetwork(); err != nil {
            fmt.Printf("Error waiting for network - %s\n", err)
            os.Exit(1)
        }


        nsRun() //calling clone() to create new process goes here
    })

    if reexec.Init() {
        os.Exit(0)
    }
}


验证网络

使用新的代码重新编译并且执行结果如下:

[email protected]:~/workspace/gons$ ls
container-fs.go  go.mod  go.sum  netsetter.sh  network.go  ns-proc.go  README  resources
[email protected]:~/workspace/gons$ go build -v
github.com/hoyho/gons
[email protected]:~/workspace/gons$ ./gons
arg0=./gons,
arg0=initFuncName,

>> namespace setup code goes here <<

newRoot:/tmp/ns-proc/rootfs
status: waiting network ...
run netsetter: stdout:
status: waiting network ...
-[namespace-process]-# ifconfig
veth_conta Link encap:Ethernet  HWaddr 86:77:53:53:50:62
          inet addr:10.10.10.101  Bcast:0.0.0.0  Mask:255.255.255.0
          inet6 addr: fe80::8477:53ff:fe53:5062/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:648 (648.0 B)

-[namespace-process]-# ping 10.10.10.100
PING 10.10.10.100 (10.10.10.100): 56 data bytes
64 bytes from 10.10.10.100: seq=0 ttl=64 time=0.102 ms
64 bytes from 10.10.10.100: seq=1 ttl=64 time=0.085 ms
-[namespace-process]-#

看到输出 status: waiting network ... run netsetter: stdout: status: waiting network ...

说明网络设置部分和容器init函数是并行执行的,并且大约两秒后检测到网卡设置完毕就启动了容器进程sh,此时在容器sh进程下执行ifconfig就可以看到虚拟网卡veth_conta,已经分配了容器一个网卡并配了ip:10.10.10.101

虚拟网卡对的另一端为veth_host,ip是:10.10.10.100 这时候在容器内ping这个ip是通的,这就说明容器网络连接已经建立好了。

源码获取

由于代码也越写越长了,完整的工程代码可以访问git仓库获取,鉴于国内网络,暂时先托管国内gitee仓库

更上一层楼

其实还有好多东西可以做,比如容器互联,iptable设置等等。后续有时间再补一补这一块。

接下来做什么

截止目前,本系列用go编写简单容器来演示linux namespace已经囊括 User, Mount, Pid和Network等多个命名空间。 剩下的大概就UTS和cgroup还没展开, 下篇预计会讲解这部分。

ref: https://blog.scottlowe.org/2013/09/04/introducing-linux-network-namespaces/

https://arthurchiao.github.io/blog/play-with-container-network-if/