深度剖析Kubernetes动态准入控制之Initializers

时间:2022-05-10
本文章向大家介绍深度剖析Kubernetes动态准入控制之Initializers,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

Author: xidianwangtao@gmail.com

Admission Controll的最佳配置

配置过kube-apiserver的同学一定记得这个配置--admission-control或者--admission-control-config-file,你可以在这里顺序的配置你想要的准入控制器,默认是AlwaysAdmit

  • 在Kubernetes 1.9中,所有允许的控制器列表如已经支持多达32个:
    • AlwaysAdmit,
    • AlwaysDeny,
    • AlwaysPullImages,
    • DefaultStorageClass,
    • DefaultTolerationSeconds,
    • DenyEscalatingExec,
    • DenyExecOnPrivileged,
    • EventRateLimit,
    • ExtendedResourceToleration,
    • ImagePolicyWebhook,
    • InitialResources,
    • Initializers,
    • LimitPodHardAntiAffinityTopology,
    • LimitRanger,
    • MutatingAdmissionWebhook,
    • NamespaceAutoProvision,
    • NamespaceExists,
    • NamespaceLifecycle,
    • NodeRestriction,
    • OwnerReferencesPermissionEnforcement,
    • PVCProtection,
    • PersistentVolumeClaimResize,
    • PersistentVolumeLabel,
    • PodNodeSelector,
    • PodPreset,
    • PodSecurityPolicy,
    • PodTolerationRestriction,
    • Priority,
    • ResourceQuota,
    • SecurityContextDeny,
    • ServiceAccount,
    • ValidatingAdmissionWebhook

注意,在我写这博客的时候Dynamic Admission Controll官方文档还没来得及更新到1.9对应内容,官方文档中还是写的GenericAdmissionWebhook,实际上Webhook类已经分为MutatingAdmissionWebhook和ValidatingAdmissionWebhook了,而没有GenericAdmissionWebhook这一项,其实它就是ValidatingAdmissionWebhook在Kubernetes 1.9后作的rename而已。

这么多的准入控制器,如果你并不想去了解那么多(虽然我不推荐你这么做,每一项的具体含义请参考admission-controllers官方文档),没关系,Kubernetes也有推荐项给你。

  • 如果你使用Kubernetes 1.6 ~ 1.8,官方推荐配置如下: --admission-control=NamespaceLifecycle,LimitRanger,ServiceAccount,PersistentVolumeLabel,DefaultStorageClass,ResourceQuota,DefaultTolerationSeconds
  • 如果你使用Kubernetes 1.9,官方推荐配置如下: --admission-control=NamespaceLifecycle,LimitRanger,ServiceAccount,PersistentVolumeLabel,DefaultStorageClass,ValidatingAdmissionWebhook,ResourceQuota,DefaultTolerationSeconds,MutatingAdmissionWebhook

再次强调一点,--admission-control配置的控制器列表是有顺序的,越靠前的越先执行,一旦某个控制器返回的结果是reject的,那么整个准入控制阶段立刻结束,所以这里的配置顺序也是有讲究的,配置顺序不好,会导致性能会差些。

built-in准入控制的缺陷

即便Kubernetes提供了这么多的准入控制器,也不可能满足所有企业的需求,因此Kubernetes提供了三个Dynamic Admission Controller:

  • Initializers(Alpha, Default disable in 1.9)
  • MutatingAdmissionWebhook(Belta, Default enable in 1.9)
  • ValidatingAdmissionWebhook(Alpha in 1.8, Belta in 1.9, Default enable in 1.9)

这三个Dynamic Admission Controller都是为了解决其他内置插件化准入控制器的两个缺陷:

  • 在kube-apiserver编译时打包进去的,如果有定制化修改,需要重新编译kube-apiserver。
  • 如果需要修改--admission-controll中的控制器列表(包括顺序),都需要重启kube-apiserver。
    • 如果你没做Kubernetes Master HA,会导致Kubernetes Master中断服务;
    • 如果你做了Kubernetes Master HA,就完全没问题了吗?当然也不完全是,服务不会中断,但是存在一段时间会存在不同的kube-apiserver有不同的--admission-controll配置,导致同样的请求如果分发到不一样配置的kube-apiserver,就不能做到幂等性了。当然,这好像影响也并不大。

Initializers工作机制

Initializers有什么用

我们什么时候需要用Initializers呢?当集群管理员需要强制对某些请求或者所有请求都进行校验或者修改的时候,就可以考虑使用Initializers。

  • 通过Initializers,你可以给每个即将创建的Pod都插入一个SideCar容器。
  • 通过Initializers,给所有Pod都插入一个带有测试数据的volume用于业务测试。
  • 通过Initializers,检查Secret的长度是否满足要求,以此来保证密码的复杂度,如果不满足就拒绝create pod请求。

另外我之前思考的关于Harbor镜像安全的问题:在多租户环境中,某个用户在某个Node上pull了一个带有敏感数据的镜像并且启动为Pod了。此时,另外一个用户只要知道这个image name,并且设置imagePullPolicy为IfNotPresent,那么这个用户的Pod就可能会被调度到这个节点(如果scheduler配置了ImageLocalityPriority priority policy,非默认配置,但在经常会配置,以提高pod启动速度),然后就把别人的敏感镜像跑起来了,这在公有云中是不可被接受的。

我们如何解决这个问题呢?在私有云中,会通过DevOps平台做好权限的控制,用户只能选择自己的app进行部署,并不能指定别人的镜像名称。在Kubernetes层面,有办法解决这个问题吗?嗯,利用Initializers就能很好解决(幸运的是,Kubernetes已经提供了AlwaysPullImages这个Admission Controller),所有用户创建的Pod请求,都经过你的Initializers进行检查和修改,强制修改Pod ImagePullPolicy为Always即可。

如何启用Initializers

  • 前面提到,需要在每个kube-apiserver实例(考虑到Kubernetes Master HA)中--admission-controll中添加Initializers
  • 另外,还需要在每个kube-apiserver实例的--runtime-config中添加admissionregistration.k8s.io/v1alpha1

Initializers的工作原理

  • 首先部署你自己写的Initializers controller。这个controller通过watch你想要的resource type,捕获后对这些resource的POST请求做修改。我们以envoy-initializer为例:
apiVersion: apps/v1beta1
kind: Deployment
metadata:
  initializers:
    pending: []
  labels:
    app: envoy-initializer
  name: envoy-initializer
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: envoy-initializer
      name: envoy-initializer
    spec:
      containers:
        - name: envoy-initializer
          image: gcr.io/hightowerlabs/envoy-initializer:0.0.1
          imagePullPolicy: Always
          args:
            - "-annotation=initializer.kubernetes.io/envoy"
            - "-require-annotation=true"

部署envoy-initializer时,千万要注意设置metadata.initializers.pending为空,防止envoy-initializer的部署被自己stuck了。

  • 然后你要创建你的initializerConfigurationAPI Object, 比如你想通过Initializers给每个之后创建的Deployment注入一个envoy proxy sidecar容器:
apiVersion: admissionregistration.k8s.io/v1alpha1
kind: InitializerConfiguration
metadata:
  name: envoy
initializers:
  - name: envoy.initializer.kubernetes.io
    rules:
      - apiGroups:
          - "*"
        apiVersions:
          - "*"
        resources:
          - deployments
  • initializerConfiguration创建后,你需要等待几秒,然后再通过Deployment部署你的应用,这个时候对应的Initializers就会自动append到Deployment的metadata.initializers.pending数组中,以上面的example为例,就是附加metadata.initializers.pending[0]=envoy.initializer.kubernetes.io
apiVersion: apps/v1beta1
kind: Deployment
metadata:
  annotations:
    "initializer.kubernetes.io/envoy": "true"
  labels:
    app: helloworld
    envoy: "true"
  name: helloworld-with-annotation
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: helloworld
        envoy: "true"
      name: helloworld-with-annotation
    spec:
      containers:
        - name: helloworld
          image: gcr.io/hightowerlabs/helloworld:0.0.1
          imagePullPolicy: Always
          args:
            - "-http=127.0.0.1:8080"

注意:metadata.initializers.pending不为null的时候,默认是无法通过api获取到该deployment object的,因此Initializers controller list&wath 对象的时候需要在request url中添加参数?includeUninitialized=true

  • 然后这一创建Deployment对象的event被你自定义的Initializers controller捕获到了,Initializers controller就按照你的逻辑对该Deployment进行修改,比如注入sidecar container和volume等,并且会从对象的metadata.initializers.pending中删除掉自己对应的Initializers controller。
  • 如果有多个Initializers映射到这个对象, 那么就会串行的按照上面的逻辑处理。因此如果是不需要对Object做修改操作的Admission Controller,建议通过webhook的方式处理(并行的),那样性能会更高。initializers的串行方式注定性能会低,所以最好不要创建多的initializers。
  • 当该Object的metadata.initializers.pending为null的时候,就认为已经完成初始化流程,接下来scheduler和controller-managers管理的controllers就能看到这些Object,继续后面的调度和自动驾驶逻辑。

注意:当你通过kubectl或者rest api提交创建对象请求的时候,如果这个对象有相应的Initializers,那么这个对象会保持uninitialized状态,需要要等待Initializers Controllers执行完对应的逻辑后才会返回,并且有个超时时间为30s。

Initializers注意事项

基于上面对Initializers工作机制的理解,我们发现它也有缺陷或者注意事项:

  • 如果你部署的Initializers Controllers不能正常工作了或者性能很低,在高并发场景下会导致大量的相关对象停留在uninitialized状态,无法进行后续的调度。这可能会影响你的业务,比如你使用了HPA对相关Deployment对象进行弹性扩容,当负债上来的时候,你的Initializers Controllers不能正常工作了,会导致你的应用不能弹性伸缩,后果可想而知!所以写一个高性能的稳定的Initializers Controllers是你必须的技能。
  • 目前Initializers准入控制仍属于Alpha,你懂得。
  • 你部署的Initializers Controllers是如此重要,所以建议你给它部署在kube-system或者单独的一个namespace中,给他分配足够的ResourceQuota和LimitRanger,以保障它的稳定性。
  • 如果你有多个Initializers Controllers关联到某类resource,那么每次创建resource的时候,生成的metadata.initializers.pending数组元素顺序可能是不一样的,所以建议这些Initializers Controllers不应该有相互依赖。
  • 再次强调一下,部署你的Initializers Controllers时,千万要注意设置metadata.initializers.pending为空,防止Initializers Controllers的部署被自己stuck了。

如何开发一个自定义的Initializers

...
type config struct {
	Containers []corev1.Container
	Volumes    []corev1.Volume
}

func main() {
	...
	// Watch uninitialized Deployments in all namespaces.
	restClient := clientset.AppsV1beta1().RESTClient()
	watchlist := cache.NewListWatchFromClient(restClient, "deployments", corev1.NamespaceAll, fields.Everything())

	// Wrap the returned watchlist to workaround the inability to include
	// the `IncludeUninitialized` list option when setting up watch clients.
	includeUninitializedWatchlist := &cache.ListWatch{
		ListFunc: func(options metav1.ListOptions) (runtime.Object, error) {
			options.IncludeUninitialized = true
			return watchlist.List(options)
		},
		WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {
			options.IncludeUninitialized = true
			return watchlist.Watch(options)
		},
	}

	resyncPeriod := 30 * time.Second

	_, controller := cache.NewInformer(includeUninitializedWatchlist, &v1beta1.Deployment{}, resyncPeriod,
		cache.ResourceEventHandlerFuncs{
			AddFunc: func(obj interface{}) {
				err := initializeDeployment(obj.(*v1beta1.Deployment), c, clientset)
				if err != nil {
					log.Println(err)
				}
			},
		},
	)

	stop := make(chan struct{})
	go controller.Run(stop)

	signalChan := make(chan os.Signal, 1)
	signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
	<-signalChan

	log.Println("Shutdown signal received, exiting...")
	close(stop)
}

func initializeDeployment(deployment *v1beta1.Deployment, c *config, clientset *kubernetes.Clientset) error {
	if deployment.ObjectMeta.GetInitializers() != nil {
		pendingInitializers := deployment.ObjectMeta.GetInitializers().Pending

		if initializerName == pendingInitializers[0].Name {
			log.Printf("Initializing deployment: %s", deployment.Name)

			o, err := runtime.NewScheme().DeepCopy(deployment)
			if err != nil {
				return err
			}
			initializedDeployment := o.(*v1beta1.Deployment)

			// Remove self from the list of pending Initializers while preserving ordering.
			if len(pendingInitializers) == 1 {
				initializedDeployment.ObjectMeta.Initializers = nil
			} else {
				initializedDeployment.ObjectMeta.Initializers.Pending = append(pendingInitializers[:0], pendingInitializers[1:]...)
			}

			if requireAnnotation {
				a := deployment.ObjectMeta.GetAnnotations()
				_, ok := a[annotation]
				if !ok {
					log.Printf("Required '%s' annotation missing; skipping envoy container injection", annotation)
					_, err = clientset.AppsV1beta1().Deployments(deployment.Namespace).Update(initializedDeployment)
					if err != nil {
						return err
					}
					return nil
				}
			}

			// Modify the Deployment's Pod template to include the Envoy container
			// and configuration volume. Then patch the original deployment.
			initializedDeployment.Spec.Template.Spec.Containers = append(deployment.Spec.Template.Spec.Containers, c.Containers...)
			initializedDeployment.Spec.Template.Spec.Volumes = append(deployment.Spec.Template.Spec.Volumes, c.Volumes...)

			oldData, err := json.Marshal(deployment)
			if err != nil {
				return err
			}

			newData, err := json.Marshal(initializedDeployment)
			if err != nil {
				return err
			}

			patchBytes, err := strategicpatch.CreateTwoWayMergePatch(oldData, newData, v1beta1.Deployment{})
			if err != nil {
				return err
			}

			_, err = clientset.AppsV1beta1().Deployments(deployment.Namespace).Patch(deployment.Name, types.StrategicMergePatchType, patchBytes)
			if err != nil {
				return err
			}
		}
	}

	return nil
}

func configmapToConfig(configmap *corev1.ConfigMap) (*config, error) {
	var c config
	err := yaml.Unmarshal([]byte(configmap.Data["config"]), &c)
	if err != nil {
		return nil, err
	}
	return &c, nil
}

Kubernetes 1.9对Initializers的增强

  • kubectl annotate, apply, edit-last-applied, delete, describe, edit, get, label, set命令可以增加--include-uninitialized来对uninitialized进行操作;
  • Initializers的启用不需要手动配置feature gate,admission controll中配置后会自动添加到feature gate中;
  • Initializer名称至少包含两个.,分隔成至少3段;
  • Fixes an initializer bug where update requests which had an empty pending initializers list were erroneously rejected.

总结

相信你已经对Kubernetes Initializers的工作机制和使用注意事项已经有所了解了,后续我会对Kubernetes Initializers的代码进行走读分析,然后再对MutatingAdmissionWebhook和ValidatingAdmissionWebhook进行工作机制和代码分析。