K8S源码分析-通过CRD看资源的实现

概述 #

自定义资源(CustomResourceDefinitions,简称CRD)是K8S的一个重要功能。K8S中有一些资源属于K8S内部自定义的资源类型,如Pod、ConfigMap等,但是即使有了这些核心的资源类型,还是不能满足所有用户的需求的,因为考虑到不同用户的需求不一样,K8S需要另一种可扩展的方式提供给用户。所以K8S就提供了这种叫做CRD的资源类型,允许用户定义自己的资源类型,同时还享受着K8S平台一系列便利:用户可以使用K8S的API,实现对自定义资源的CRUDW操作。有了这个扩展之后,更多的功能就可以在K8S上展开了,比如istio、knative等都是通过CRD来实现与K8S平台的互通,同时K8S自身的一些核心组件比如controller manager、kubelet、ingress等,本质上也是K8S资源的监听者。

所以在K8S看来,其实一个自定义的资源类型,和用户通过CRD来定义的资源类型,处理起来并没有太多不一样的地方,在本文中通过对定义CRD之后生成的一些文件,来分析在K8S中实现一个资源类型,都需要哪些操作。

如果还不熟悉CRD的创建流程,可以先看看这篇文章:https://www.cn18k.com/2018/04/04/Kubernetes-Deep-Dive-Code-Generation-for-CustomResources/ ,本文中将不讲述如何创建CRD生成代码的流程。

核心数据结构 #

要实现一个K8S资源类型,熟悉K8S的人不难想到会涉及到如下操作。

  • 资源的注册:该资源如何注册到K8S系统中。
  • 不同版本资源的转换:一个资源,如果有多个不同的版本,那么必然涉及到这些版本之间如何进行转换的。
  • 资源数据的序列化:资源的数据最终是存储在etcd中的,但是在应答给客户端时,又有不同的格式输出,如yaml、json等。

在这里,K8S提供了一个核心的scheme数据结构进行这些操作的封装,这个数据结构定义在K8S目录的vendor/k8s.io/apimachinery/pkg/runtime/scheme.go中,它有如下成员。

  • gvkToType map[schema.GroupVersionKind]reflect.Type:根据组类型映射得到该资源的反射类型的map。

  • typeToGVK map[reflect.Type][]schema.GroupVersionKind:根据反射类型得到组类型的map,注意到这里的值是一个数组,也就意味着一个反射类型可能对应多个组类型。

  • unversionedKinds map[string]reflect.Type:

  • fieldLabelConversionFuncs map[string]map[string]FieldLabelConversionFunc:

  • defaulterFuncs map[reflect.Type]func(interface{}):

  • converter *conversion.Converter:转换器,负责同个类型不同版本之间的转换。

可以看到,Scheme结构体中,存储了关于组版本类型与对应资源类型的信息,因此它可以同时很方便的用于以下用途。

  • 根据资源类型得到其组版本信息,这是ObjectTyper接口需要的操作。
  • 进行一个资源在不同组版本之间的转化操作,这是ObjectConvertor接口需要的操作。
  • 根据传入的组版本信息,创建一个对应的资源数据返回,这是ObjectCreater接口需要的操作。
  • 给资源赋予默认值,这是ObjectDefaulter接口需要的操作。

在CRD的生成代码中,首先会生成如下几个全局变量,一般在clientset/versioned/scheme/register.go中:

var Scheme = runtime.NewScheme()
var Codecs = serializer.NewCodecFactory(Scheme)
var ParameterCodec = runtime.NewParameterCodec(Scheme)

其中:

  • Scheme:就是我们前面提到的Scheme结构体,它是最先生成的,因为它是后面其它全局变量的基础。
  • Codecs:进行序列化和反序列化操作的结构体,之所以需要Scheme是因为它实现了前面分析过的几种与类型相关的接口。
  • ParameterCodec:进行不同类型之间转换的操作结构体,用到Scheme也是因为它实现了前面分析过的几种与类型相关的接口。

流程 #

有了前面的核心数据准备,可以继续展开CRD核心流程的分析了。

在clientset/versioned/scheme/register.go中,init函数里会进行如下操作。

  • AddToGroupVersion添加该CRD组版本类型到scheme中,在这个函数中,会将一系列的资源与这个组版本挂钩:
// AddToGroupVersion registers common meta types into schemas.
func AddToGroupVersion(scheme *runtime.Scheme, groupVersion schema.GroupVersion) {
	scheme.AddKnownTypeWithName(groupVersion.WithKind(WatchEventKind), &WatchEvent{})
	scheme.AddKnownTypeWithName(
		schema.GroupVersion{Group: groupVersion.Group, Version: runtime.APIVersionInternal}.WithKind(WatchEventKind),
		&InternalEvent{},
	)
	// Supports legacy code paths, most callers should use metav1.ParameterCodec for now
	scheme.AddKnownTypes(groupVersion,
		&ListOptions{},
		&ExportOptions{},
		&GetOptions{},
		&DeleteOptions{},
	)
	scheme.AddConversionFuncs(
		Convert_versioned_Event_to_watch_Event,
		Convert_versioned_InternalEvent_to_versioned_Event,
		Convert_watch_Event_to_versioned_Event,
		Convert_versioned_Event_to_versioned_InternalEvent,
	)

	// Register Unversioned types under their own special group
	scheme.AddUnversionedTypes(Unversioned,
		&Status{},
		&APIVersions{},
		&APIGroupList{},
		&APIGroup{},
		&APIResourceList{},
	)

	// register manually. This usually goes through the SchemeBuilder, which we cannot use here.
	AddConversionFuncs(scheme)
	RegisterDefaults(scheme)
}

从上面代码可以看出,任何一个CRD,都进行了以下的注册:

  1. 注册了与组版本类型相关的WatchEvent事件,注册了WatchEvent相关的内部事件InternalEvent,同时在后面通过调用AddConversionFuncs注册了与这两个事件相关的转换函数。

  2. 注册了与组版本类型相关的操作选项:ListOptions、ExportOptions、GetOptions、DeleteOptions、CreateOptions、UpdateOptions。

  3. 注册了与组版本类型无关的类型:Status、APIVersions、APIGroupList、APIGroup、APIResourceList。

  • 调用AddToScheme函数,将自定义的资源注册到Scheme中,这些类型都是最初使用者在types.go文件中自定义的资源类型,这些类型一定继承自metav1.TypeMeta。

使用Codecs #

接下来先来看看K8S代码中是怎么使用上前面的Codecs全局变量的。

在生成的CRD相关代码中,会将与客户端REST请求相关参数的参数保存到rest.Config结构体中:

func setConfigDefaults(config *rest.Config) error {
	gv := v1.SchemeGroupVersion
	config.GroupVersion = &gv
	config.APIPath = "/apis"
	config.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs}

	if config.UserAgent == "" {
		config.UserAgent = rest.DefaultKubernetesUserAgent()
	}

	return nil
}

在这里可以看到:

  1. CRD的api路径都是"apis"开头的。
  2. Codecs最终保存到config.NegotiatedSerializer中了。

最终,这里保存下来的config.NegotiatedSerializer会存入到Serializers结构体中的Encoder和Decoder成员中,而这两个成员最终在与客户端的REST请求中起作用,比如encoder成员在request.go的Body函数中对用户数据进行编码:

	case runtime.Object:
		// callers may pass typed interface pointers, therefore we must check nil with reflection
		if reflect.ValueOf(t).IsNil() {
			return r
		}
		data, err := runtime.Encode(r.serializers.Encoder, t)
		if err != nil {
			r.err = err
			return r
		}
		glogBody("Request Body", data)
		r.body = bytes.NewReader(data)
		r.SetHeader("Content-Type", r.content.ContentType)

至于Decoder这里就不继续阐述了,可以自行搜索,简而言之都是与用户的REST请求、应答相关的地方派上用场。

使用ParameterCodec #

ParameterCodec用在用户请求的VersionedParams中。