注: 本文采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。知识共享许可协议

本文,我们将用go语言,结合CNI项目提供的go工具库,来开发一个接近真实更加复杂的插件。它和CNI的标准插件bridge类似,会为我们在宿主机上创建一个bridge,并把容器连接到bridge上。

第二个插件:simple-bridge

Kubernetes网络篇——自己动手写CNI插件(上)一文里,我们通过一个简单的例子演示了用shell脚本写的CNI插件,并成功地和rkt实现了互动。接下来,我们再用go语言来写一个更接近真实的CNI插件。

应该说,使用go来编写CNI插件是最顺理成章的。因为,CNI的GitHub项目提供了基于go语言的工具库,再加上go社区本身就有很多工具库,开发人员可以对Linux的网络功能进行各种丰富的控制。

我们知道,通常CNI插件会在容器启动和删除的时候被调用。在容器启动时,插件会为容器分配相应的网络资源,比如:网络连接,IP地址等。在容器删除时,插件会回收之前所创建的这些网络资源。我们即将要实现的这个插实际上是一个简化版的bridge插件,它会在容器启动时为我们在宿主机上创建一个bridge,并把容器连接到bridge上,最后再为容器分配好IP地址。

准备工作

使用go语言开发需要准备好go的开发环境,因为等一下要用rkt来验证我们开发的插件,所以和之前一样,我会继续使用Debian环境。由于go环境的安装不是本文的关注点,所以这里就不展开了。

为了测试我们开发的插件,还需要一个网络配置文件:

{
    "cniVersion": "0.4.0",
    "name": "lab-cni-mybr-net",
    "type": "simple-bridge",
    "bridge": "lab-cni-mybr",
    "ip": "10.15.30.100/24"
}

这个文件非常简单,除了几个标准属性,如:CNI版本号,网络名称,插件类型(simple-bridge)外,还有两个特定于插件的参数。其中,bridge是宿主机上的bridge名称,ip是veth pair位于容器一端的IP地址。我们把配置文件取名为lab-cni-mybr-net.conf,存到/etc/rkt/net.d目录下。

代码实现

下面我们就一起来看一下插件的代码实现。

主体结构

首先来看一下代码的主体结构:

Package main

Import (
  ... ...
)

func cmdAdd(args *skel.CmdArgs) error {
  ... ...
}

func cmdCheck(args *skel.CmdArgs) error {
  return nil
}

func cmdDel(args *skel.CmdArgs) error {
  return nil
}

func main() {
  skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.All, bv.BuildString("simple-bridge"))
}

每一个go实现的CNI插件都具备如上的代码结构,除了用import指令把需要的依赖引入进来以外,还需要实现三个函数,分别对应CNI的三个命令:

  • cmdAdd
  • cmdCheck
  • cmdDel

然后在main函数里,通过CNI工具库提供的skel.PluginMain函数,把这三个函数注册进来。skel.PluginMain的倒数第二个参数用来指定插件所支持的CNI规范的版本,这里我们选择的是所有版本;最后一个参数通过调用工具类生成一段简单的帮助文本,如果在命令行直接执行插件的可执行文件,就会看到这段文本。

另外,为了能在代码里访问到插件所需的环境变量,以及通过标准输入设备传入的配置文件,我们使用了skel提供的数据类型CmdArgs

type CmdArgs struct {
    ContainerID string
    Netns string
    IfName string
    Args string
    Path string
    StdinData []byte
}

可以看到,CmdArgs里大部分属性都分别对应相应的环境变量,最后一个属性代表通过标准输入设备传入的一个字节数组,对应于我们的网络配置文件。

我们的插件实现了CNI里的ADD命令,主要完成下面几个步骤:

  • 读取网络定义的配置文件
  • 配置宿主机上的bridge
  • 创建veth pair,并把宿主机一端连到bridge上
  • 为容器端的网络接口分配指定的IP地址

接下来,我们将针对上面几个步骤,逐一进行讲解。

第一步:读取配置文件

为了从配置文件里读取网络配置,我们定义了一个struct类型的数据结构。因为只关心网络配置文件里的bridgeip,所以这里只定义了两个属性:

type NetConf struct {
  BrName    string `json:"bridge"`
  IPAddress string `json:"ip"`
}

然后,我们在函数loadNetConf里实现了读取配置文件的逻辑:

func loadNetConf(bytes []byte) (*NetConf, error) {
  n := &NetConf{}
  if err := json.Unmarshal(bytes, n); err != nil {
    return nil, err
  }
  return n, nil
}

这里,我们先创建了一个NetConf类型的变量n,然后调用json.Unmarshal函数,把来自标准输入设备的字节数据读入了这个变量。

第二步:配置bridge

我们把对bridge的配置封装到了setupBridge函数里,它的输入参数就是前面调用loadNetConf的返回结果,也就是我们的网络配置信息。下面是函数的实现逻辑,这里主要利用了netlink包对bridge进行操作:

func setupBridge(n *NetConf) (*netlink.Bridge, error) {
  br := &netlink.Bridge{                          
    LinkAttrs: netlink.LinkAttrs{
      Name: n.BrName,
      MTU:  1500,
      TxQLen: -1,
    },
  }

  err := netlink.LinkAdd(br)                      
  if err != nil && err != syscall.EEXIST {
    return nil, err
  }

  br, err = bridgeByName(n.BrName)                
  if err != nil {
    return nil, err
  }

  if err := netlink.LinkSetUp(br); err != nil {   
    return nil, err
  }

  return br, nil
}

行①:创建netlink.Bridge对象,传入在配置文件里定义的bridge属性;

行②:调用netlink.LinkAdd函数,传入netlink.Bridge对象,把bridge创建出来,相当于执行ip link add命令。如果创建失败时返回syscall.EEXIST,表示这个bridge已经存在了,这种情况我们认为是正常的;

行③:调用bridgeByName函数,重新读取一次新建的bridge,用于验证其是否被正确创建出来了;

行④:如果一切正常,则调用netlink.LinkSetUp函数,把bridge启动起来,相当于执行ip link set ... up命令;

下面是bridgeByName函数的实现逻辑:

func bridgeByName(name string) (*netlink.Bridge, error) {
  l, err := netlink.LinkByName(name)  
  if err != nil {
    return nil, err
  }
  br, ok := l.(*netlink.Bridge)       
  if !ok {
    return nil, fmt.Errorf("%q already exists but is not a bridge", name)
  }
  return br, nil
}

行①:调用netlink.LinkByName函数,传入bridge的名称,重新读取一次新建的bridge;

行②:尝试把读取出来的对象转换成netlink.Bridge类型,如果失败,就表示读出来的对象不是一个bridge,于是返回错误;

第三步:配置veth pair

创建veth pair的逻辑被封装到了setupVeth函数里。它包含如下几个参数:

  • netns:容器对应的network namespace,类型为NetNS,属于CNI提供的工具包ns里的一个数据结构;
  • br:位于宿主机上的bridge;
  • ifName:veth pair位于容器一端的网络接口名;
  • ipAddress:容器端网络接口的IP地址;

下面来看一下函数的实现逻辑:

func setupVeth(netns ns.NetNS, br *netlink.Bridge, ifName string, ipAddress string) error {
  hostIface := &current.Interface{}                           

  err := netns.Do(func(hostNS ns.NetNS) error {               
    hostVeth, containerVeth, err := 
      ip.SetupVeth(ifName, 1500, hostNS)                      
    if err != nil {
      return err
    }
    hostIface.Name = hostVeth.Name                            

    return nil
  })
  if err != nil {
    return err
  }

  hostVeth, err := netlink.LinkByName(hostIface.Name)         
  if err != nil {
    return err
  }

  if err := netlink.LinkSetMaster(hostVeth, br); err != nil { 
    return err
  }

  return nil
}

行①:这里用到了CNI提供的types/current包,Interface是一个struct类型的数据结构,对应一个网络接口。我们用它来保存veth pair位于宿主机一端的网络接口信息,以方便后面的调用;

行②:这里的netns就是容器的network namespace,对应类型为NetNSNetNS有一个特殊的函数Do,它接受一个函数闭包作为参数,而我们设置veth pair的逻辑就在这个闭包里;

对veth pair的配置是在容器的network namespace,即netns下进行的。在go里,每个操作系统线程都可能对应不同的network namespace,但由于go线程调度机制的特点,在我们的代码逻辑开始执行的时候,并不能够保证当前的network namespace就一定是容器所对应的那个,而Do函数就是用来解决这一问题的:

它通过调用runtime.LockOSThread()函数,锁定了执行闭包的当前线程。Do函数在执行闭包之前,会先保存当前线程的network namespace,也就是宿主机对应的namespace,并作为参数传入闭包。然后在闭包执行结束时,把当前network namespace恢复成宿主机对应的namespace。

行③:这里我们调用了SetupVeth函数,该函数位于CNI提供的ip包里。利用它,我们在容器里创建了veth pair,把容器端的网络接口名设置为ifName,并把宿主机一端的网络接口“移入”了宿主机的namespace里,即:hostNS

行④:ip.SetupVeth的返回结果里包含了宿主机一端的网络接口信息。这里,我们把接口名称保存到了前面行①处定义的hostIface里,以方便后面使用;

行⑤:通过调用netlink.LinkByName函数,并传入在行④处获取到的宿主机一端的网络接口名,我们拿到了宿主机一端的网络接口对象hostVeth

行⑥:通过调用netlink.LinkSetMaster函数,并传入hostVeth,我们把veth pair的宿主机一端连到了bridge上;

第四步:分配IP地址

下面我们来为容器端的网络接口分配IP地址。这部分逻辑,被放在了传入netns.Do的那个闭包里,ip.SetupVeth调用的后面:

  ipv4Addr, ipv4Net, err := net.ParseCIDR(ipAddress)if err != nil {
    return err
  }
  ipv4Net.IP = ipv4Addr
  addr := &netlink.Addr{IPNet: ipv4Net}link, err := netlink.LinkByName(containerVeth.Name)if err != nil {
    return err
  }

  if err = netlink.AddrAdd(link, addr); err != nil {return err
  }

行①:通过调用net.ParseCIDR函数,我们对传入setupVethipAddress进行解析,并生成ipv4Addripv4Net对象,分别对应IP地址和所在网段。这里的ipAddress就是前面在网络配置文件里定义的ip属性;

行②:根据前面的解析结果,构造一个netlink.Addr对象,对应容器端网络接口的IP地址;

行③:调用netlink.LinkByName函数,传入containerVeth.Name,获得容器端的网络接口对象。这里的containerVeth是前面调用ip.SetupVeth时返回的;

行④:调用netlink.AddrAdd函数,分别传入行③处获得的容器端网络接口和行②处构造的IP地址对象,完成IP地址的真正分配;

把所有代码串起来

前面我们讲了每一步的实现逻辑,接下来就可以把这些逻辑在cmdAdd函数里串起来了:

func cmdAdd(args *skel.CmdArgs) error {
  n, err := loadNetConf(args.StdinData)     
  if err != nil {
    return err
  }

  br, err := setupBridge(n)                 
  if err != nil {
    return err
  }

  netns, err := ns.GetNS(args.Netns)        
  if err != nil {
    return err
  }
  defer netns.Close()

  if err := setupVeth(netns, br,            
    args.IfName, n.IPAddress); err != nil {
    return err
  }

  result := current.Result{}
  return result.Print()
}

可以看到,这里的行①,②,④分别对应的就是我们前面提到的四个步骤。而行③是为调用setupVeth所做的准备,它利用ns.GetNS函数获取到当前容器的network namespace,并作为参数传入了setupVeth里。

最后,前面已经讨论过,为了确保线程不会在我们对network namespace进行操作期间被切换,从而导致namespace被切换,我们在init函数里加入了对runtime.LockOSThread()的调用,init函数会在main函数执行之前被自动调用:

func init() {
  runtime.LockOSThread()
}

代码运行

在运行插件以前,我们需要先把代码编译成可执行文件。为了成功编译,可以在项目所在目录下调用go get命令,把相关的依赖下载下来:

$ go get -d -v ./...

然后执行go build把插件代码编译成二进制文件:

$ go build simple-bridge.go

在当前目录下找到编译生成的可执行文件simple-bridge,然后把它拷贝到/etc/rkt/net.d目录下。再执行下面命令启动一个新的容器,并指定--net参数的值为lab-cni-mybr-net

$ rkt run --insecure-options=image --interactive --net=lab-cni-mybr-net docker://busybox

如果一切正常,我们会进入到busybox容器内部,执行ip addr show命令:

/ # ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue 
    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
3: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue 
    link/ether 86:c8:3c:a7:23:23 brd ff:ff:ff:ff:ff:ff
    inet 10.15.30.100/24 brd 10.15.30.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::84c8:3cff:fea7:2323/64 scope link 
       valid_lft forever preferred_lft forever

可以看到,作为veth pair在容器一端的网络接口eth0,它的IP地址已经被成功设置成我们指定的值了(10.15.30.100)。

输入Ctrl + ]]]退回到宿主机后,再执行ip addr show

$ ip addr show
... ...
5: lab-cni-mybr: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 0a:67:c4:8f:93:12 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::6437:83ff:fe38:507b/64 scope link 
       valid_lft forever preferred_lft forever
8: veth95dc17da: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master lab-cni-mybr state UP group default 
    link/ether 0a:67:c4:8f:93:12 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::867:c4ff:fe8f:9312/64 scope link 
       valid_lft forever preferred_lft forever

可以看到,宿主机上多了一个名为lab-cni-mybr的bridge,还有作为veth pair在宿主机一端的网络接口veth95dc17da。

留下评论

您的电子邮箱地址并不会被展示。请填写标记为必须的字段。 *

正在加载...