CNI插件编写框架分析

概述

在《CNI, From A Developer's Perspective》一文中,我们已经对CNI有了较为深入的了解。我们知道,容器网络功能的实现最终是通过CNI插件来完成的。每个CNI插件本质上就是一个可执行文件,而CNI的执行流程无非就是从容器管理系统和配置文件获取配置信息,然后将这些信息以环境变量和标准输入的形式传输给插件,再运行插件完成具体的容器网络配置,最后将配置结果通过标志输出返回。

在我们对CNI的各种插件做了一个初步的浏览之后,我们会发现,虽然各个CNI插件实现容器网络的方式是多种多样的,但是它们编写的套路基本是一致的。其中一定会存在三个函数:main(),cmdAdd(),cmdDel()。接着我们回想一下《CNI, From A Developer's Perspective》一文中的描述,CNI其实就只有两个基本操作ADD和DEL,前者用于加入容器网络,后者用于从容器网络中退出。由此,通过上述三个函数,再加上一些合理的联想,我们也就不难勾勒出插件的执行流程了。当CNI插件被调用时,首先进入main函数,main函数会对环境变量和标准输入中的配置信息进行解析,接着根据解析得到的操作方式(ADD或DEL),转入具体的执行函数完成网络的配置工作。如果是ADD操作,则调用cmdAdd()函数,反之,如果是DEL操作,则调用cmdDel()函数。从宏观角度来看,CNI插件的实现框架是就是这样简单清晰。下面我们就以CNI官方插件库的bridge插件为例,深入上述三个函数的源码,来进一步说明CNI插件应该如何实现的。

(bridge插件源码链接:https://github.com/containernetworking/plugins/tree/master/plugins/main/bridge)

 

main函数

1、main函数非常简单,仅仅只是调用了skel.PluginMain这个函数,并且将函数cmdAdd和cmdDel以及支持插件支持的CNI版本作为参数传递给它。

func main() {
	skel.PluginMain(cmdAdd, cmdDel, version.All)
}

  

2、PluginMain函数是一个包裹函数,它直接对PluginMainWithError进行调用,当有错误发生的时候,会将错误以json的形式输出到标准输出,并退出插件的执行。

func PluginMain(cmdAdd, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo) {
	if e := PluginMainWithError(cmdAdd, cmdDel, versionInfo); e != nil {
		if err := e.Print(); err != nil {
			log.Print("Error writing error JSON to stdout: ", err)
		}
		os.Exit(1)
	}
}

  

3、PluginMainWithError函数也非常简单,其实就是用环境变量,标准输入输出构造了一个dispatcher结构,再执行其中的pluginMain方法而已。

func PluginMainWithError(cmdAdd, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo) *types.Error {
	return (&dispatcher{
		Getenv: os.Getenv,
		Stdin:  os.Stdin,
		Stdout: os.Stdout,
		Stderr: os.Stderr,
	}).pluginMain(cmdAdd, cmdDel, versionInfo)
}

  

dispatcher结构如下所示:

type dispatcher struct {
	Getenv func(string) string
	Stdin  io.Reader
	Stdout io.Writer
	Stderr io.Writer

	ConfVersionDecoder version.ConfigDecoder
	VersionReconciler  version.Reconciler
}

  

4、接着dispatcher结构的pluginMain方法执行具体的操作。该函数的操作分为如下两步:

  • 首先调用cmd, cmdArgs, err := t.getCmdArgsFromEnv()从环境变量和标准输入中解析出操作信息cmd和配置信息cmdArgs
  • 接着根据操作信息cmd的不同,调用checkVersionAndCall(),该函数会首先从标准输入中获取配置信息中的CNI版本,再和之前main函数中指定的插件支持的CNI版本信息进行比对。如果版本匹配,则调用相应的回调函数cmdAdd或cmdDel并以cmdArgs作为参数,否则,返回错误
func (t *dispatcher) pluginMain(cmdAdd, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo) *types.Error {
	cmd, cmdArgs, err := t.getCmdArgsFromEnv()
        .....
	switch cmd {
	case "ADD":
		err = t.checkVersionAndCall(cmdArgs, versionInfo, cmdAdd)
	case "DEL":
                ......
	}
        ......
}

  

5、下面我们来看看dispatcher的getCmdArgsFromEnv()方法是如何从环境变量和标准输入中获取配置信息的。首先来看一下cmdArgs的具体结构:

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

  

分析了上述结构之后我们可以发现CmdArgs中的内容和《CNI, From A Developer's Perspective》中描述的从容器管理系统中获取的运行时系统基本是一致的,而已知这些参数是通过环境变量传递给插件的。因此,不难想象,getCmdArgsFromEnv()所做的工作就是从环境变量中提取出配置信息用以填充CmdArgs,再将容器网络的配置信息,也就是标准输入中的内容,存入StdinData字段。具体代码如下所示:

func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, error) {
	var cmd, contID, netns, ifName, args, path string

	vars := []struct {
		name      string
		val       *string
		reqForCmd reqForCmdEntry
	}{
		{
			"CNI_COMMAND",
			&cmd,
			reqForCmdEntry{
				"ADD": true,
				"DEL": true,
			},
		},
                ....
		{
			"CNI_NETNS",
			&netns,
			reqForCmdEntry{
				"ADD": true,
				"DEL": false,
			},
		},
                ....
	}

	argsMissing := false
	for _, v := range vars {
		*v.val = t.Getenv(v.name)
		if *v.val == "" {
			if v.reqForCmd[cmd] || v.name == "CNI_COMMAND" {
				fmt.Fprintf(t.Stderr, "%v env variable missing\n", v.name)
				argsMissing = true
			}
		}
	}

	if argsMissing {
		return "", nil, fmt.Errorf("required env variables missing")
	}

	stdinData, err := ioutil.ReadAll(t.Stdin)
	if err != nil {
		return "", nil, fmt.Errorf("error reading from stdin: %v", err)
	}

	cmdArgs := &CmdArgs{
		ContainerID: contID,
		Netns:       netns,
		IfName:      ifName,
		Args:        args,
		Path:        path,
		StdinData:   stdinData,
	}
	return cmd, cmdArgs, nil
}

  

虽然getCmdArgsFromEnv()要完成的工作非常简单,但仔细分析代码之后,我们可以发现它的实现非常精巧。首先,它定义了一系列想要获取的参数,例如cmd,contID,netns等等。之后再定义了一个匿名结构的数组,匿名结构中包含了环境变量的名字,一个字符串指针(把该环境变量对应的参数赋给它,例如cmd对应CNI_COMMAND)以及一个reqForCmdEntry类型的成员reqForCmd。类型reqForCmdEntry其实是一个map,它在这里的作用是定义该环境变量是否为对应操作所必须的。例如,上文中的环境变量"CNI_NETNS",对于"ADD"操作为true,而对于"DEL"操作则为false,这说明在"ADD"操作时,该环境变量不能为空,否则会报错,但是在"DEL"操作时则无所谓。最后,遍历该数组进行参数的提取即可。

到此为止,main函数的任务完成。总的来说它做了三件事情:1、CNI版本检查,2、提取配置参数构建cmdArgs,3、调用对应的回调函数,cmdAdd或者cmdDel。

 

cmdAdd函数

1、如下所示cmdAdd函数一般分为三个步骤执行:

  • 首先调用函数conf, err := loadNetConf(args.StdinData)(注:loadNetConf是插件自定义的,各个插件都不一样),从标准输入,也就是参数args.StdinData中获取容器网络配置信息
  • 接着根据具体的配置信息进行网络的配置工作
  • 最后,调用函数types.PrintResult(result, conf.CNIVersion)输出配置结果
func cmdAdd(args *skel.CmdArgs) error {
	n, cniVersion, err := loadNetConf(args.StdinData)
        ......
        return PrintResult(result, cniVersion)
}

  

2、接着我们对loadNetConf函数进行分析。因为每个CNI插件配置容器网络的方式各有不同,因此它们所需的配置信息一般也是不同的,除了大家共有的信息被包含在types.NetConf结构中,每个插件还定义了自己所需的字段。例如,对于bridge插件,它用于存储配置信息的结构如下所示:

type NetConf struct {
	types.NetConf
	BrName       string `json:"bridge"`
	IsGW         bool   `json:"isGateway"`
	IsDefaultGW  bool   `json:"isDefaultGateway"`
	ForceAddress bool   `json:"forceAddress"`
	IPMasq       bool   `json:"ipMasq"`
	MTU          int    `json:"mtu"`
	HairpinMode  bool   `json:"hairpinMode"`
	PromiscMode  bool   `json:"promiscMode"`
}

  

而loadNetConf函数所做的操作也非常简单,就是调用json.Unmarshal(bytes, n)函数将配置信息从标准输入的字节流中解码到一个NetConf结构,具体代码如下:

func loadNetConf(bytes []byte) (*NetConf, string, error) {
	n := &NetConf{
		BrName: defaultBrName,
	}
	if err := json.Unmarshal(bytes, n); err != nil {
		return nil, "", fmt.Errorf("failed to load netconf: %v", err)
	}
	return n, n.CNIVersion, nil
}

  

3、最后,我们对配置结果的输出进行分析。由于不同的CNI版本要求的输出结果的内容是不太一样的,因此这部分内容其实是比较复杂的。下面我们就进入PrintResult函数一探究竟。

func PrintResult(result Result, version string) error {
	newResult, err := result.GetAsVersion(version)
	if err != nil {
		return err
	}
	return newResult.Print()
}

  

从上面的代码中我们可以看出,该函数就做了两件事,一件是调用newResult, err := result.GetAsVersion(version),根据指定的版本信息,进行结果信息的版本转换。第二件就是调用newResult.Print()将结果信息输出到标准输出。

事实上,Result如下所示,是一个interface类型。每个版本的CNI都是定义了自己的Result结构的,而这些结构都是满足Result接口的。

// Result is an interface that provides the result of plugin execution
type Result interface {
	// The highest CNI specification result verison the result supports
	// without having to convert
	Version() string

	// Returns the result converted into the requested CNI specification
	// result version, or an error if conversion failed
	GetAsVersion(version string) (Result, error)

	// Prints the result in JSON format to stdout
	Print() error

	// Returns a JSON string representation of the result
	String() string
}

  

而其中的GetAsVersion()方法则用于将当前版本的CNI Result信息转化到对应的CNI Result信息。我们来举个具体的例子,应该就很清晰了。

func (r *Result) GetAsVersion(version string) (types.Result, error) {
	switch version {
	case "0.3.0", ImplementedSpecVersion:
		r.CNIVersion = version
		return r, nil
	case types020.SupportedVersions[0], types020.SupportedVersions[1], types020.SupportedVersions[2]:
		return r.convertTo020()
	}
	return nil, fmt.Errorf("cannot convert version 0.3.x to %q", version)
}

  

假设现在我们的result的版本0.3.0, 但是插件要求返回的result版本是0.2.0的,根据上文中的代码,显然此时我们会调用r.convertTo020()函数进行转换,如下所示:

// Convert to the older 0.2.0 CNI spec Result type
func (r *Result) convertTo020() (*types020.Result, error) {
	oldResult := &types020.Result{
		CNIVersion: types020.ImplementedSpecVersion,
		DNS:        r.DNS,
	}

	for _, ip := range r.IPs {
		// Only convert the first IP address of each version as 0.2.0
		// and earlier cannot handle multiple IP addresses
               ......
	}

	for _, route := range r.Routes {
               ......
	}
        ......
	return oldResult, nil
}

  

该函数所做的操作,简单来说,就是定义了相应版本具体的Result结构,然后用当前版本的Result结构中的信息进行填充,从而完成Result版本的转化。

而Print方法对于各个版本的Result都是一样的,都是将Result进行json编码后,输出到标准输出而已。

到此为止,cmdAdd函数操作完成。

 

cmdDel函数

cmdDel和cmdAdd的执行结构是类似的,而且一般比cmdAdd还简单一些。同样,cmdDel先从args.Stdin中获取网络的配置信息,接着再进行相应的清理工作。最后,与cmdAdd不同的是,cmdDel不需要对结果进行输出,直接返回错误信息即可。

因为cmdDel和cmdAdd从结构层面来看是类似的,因此就不再赘述了。

 

结语

上文对CNI插件的执行框架进行了比较深入的分析。总的来说,一般插件的执行就是三部分内容:1、解析配置信息,2、执行具体的网络配置ADD或DEL,3、对于ADD操作还需输出结果。整体来说,架构还是非常简洁清晰的。

如果你有任何新的容器网络方案,希望通过本文的阅读可以让你迅速地编写出对应的CNI插件。

posted on 2017-09-13 15:02  姚灯灯!  阅读(2310)  评论(0编辑  收藏  举报

导航