给operator添加webhook功能【保姆级】

1,265 阅读4分钟

简介

准入控制器是在对象持久化之前用于对 Kubernetes API Server 的请求进行拦截的代码段,在请求经过身份验证和授权之后放行通过。准入控制器可能正在 validating、mutating 或者都在执行,Mutating 控制器可以修改他们处理的资源对象,Validating 控制器不会,如果任何一个阶段中的任何控制器拒绝了请求,则会立即拒绝整个请求,并将错误返回给最终的用户。

k8s-api-request-lifecycle.png

时序图如下所示:

webhook1.png

如果我们想为CRD实现admission webhook,我们唯一要做的就是实现Defaulter和(或)Validator接口。

Kubebuilder会我们处理剩余的工作:

  • 创建webhook服务器
  • 确保服务器已添加到管理器中
  • 为webhook创建处理程序
  • 使用服务器中的路径注册每个处理程序

设计实战场景

这里我们直接在手摸手教你使用kubebuilder开发operator中实战的app-operator项目上来扩展功能。

这里我们扩展两个功能:

1、假设用户的CR中没有设置总QPS,我们可以通过webhook来设置一个默认值,比如1300;

2、为了保护系统,给单个Pod的QPS设置上限,比如1000,如果用户的CR中singlePodsQPS的值超过1000,webhook进行拦截,创建资源对象失败。

部署证书管理器

和controller类似,webhook既可以在kubernetes环境中运行,也可以在kubernetes环境外运行,如果webhook在kuberneetes环境之外运行,需要将证书放在所在的环境中,默认路径:

/tmp/k8s-webhook-server/serving-certs/tls.{crt,key}

这里选择是将webhook部署在kubernetes环境中,使用cert-manager

来管理证书。

直接使用下面命令安装cert-manager组件即可:

$ kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.9.1/cert-manager.yaml

命令执行成功后,会自动创建cert-manager命名空间,以及rbac、pod、service等资源:

$ kubectl get all -n cert-manager
NAME                                           READY   STATUS    RESTARTS   AGE
pod/cert-manager-6544c44c6b-gf6nk              1/1     Running   0          20h
pod/cert-manager-cainjector-5687864d5f-22dzw   1/1     Running   0          20h
pod/cert-manager-webhook-785bb86798-nh2gc      1/1     Running   0          20h

NAME                           TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
service/cert-manager           ClusterIP   10.109.226.106   <none>        9402/TCP   20h
service/cert-manager-webhook   ClusterIP   10.106.116.190   <none>        443/TCP    20h

NAME                                      READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/cert-manager              1/1     1            1           20h
deployment.apps/cert-manager-cainjector   1/1     1            1           20h
deployment.apps/cert-manager-webhook      1/1     1            1           20h

NAME                                                 DESIRED   CURRENT   READY   AGE
replicaset.apps/cert-manager-6544c44c6b              1         1         1       20h
replicaset.apps/cert-manager-cainjector-5687864d5f   1         1         1       20h
replicaset.apps/cert-manager-webhook-785bb86798      1         1         1       20h

开发webhook

进入app-operator工程下,在终端执行以下命令创建webhook:

$ kubebuilder create webhook \
--group elasticweb \
--version v1 \
--kind ElasticWeb \
--defaulting \
--programmatic-validation

上述命令执行完毕,kubebuilder会为我们创建webhook的处理程序。

首先来查看main.go文件,不难发现,kuberbuilder为我们新增了如下代码:

if err = (&elasticwebv1.ElasticWeb{}).SetupWebhookWithManager(mgr); err != nil {
		setupLog.Error(err, "unable to create webhook", "webhook", "ElasticWeb")
		os.Exit(1)
	}

同时不难发现,在api/v1下,新增了elasticweb_webhook.gowebhook_suite_test.go两个文件,同时在config目录下,也新增了webhook的相关配置。

这里我们最为关心的就是elasticweb_webhook.go这个文件了,因为我们的主要逻辑都是要在这里实现。

配置

在进行编写业务逻辑之前,我们需要对config/default/kustomization.yaml文件做一些修改,原文件中对webhook的配置部分都是注释掉的,我们要做的是就是启用这些配置。

这些配置分别是- ../webhook,- ../certmanager,- manager_webhook_patch.yaml,- webhookcainjection_patch.yaml以及vars下的全部内容。

# config/default/kustomization.yaml

# Adds namespace to all resources.
namespace: elasticweb-system

# Value of this field is prepended to the
# names of all resources, e.g. a deployment named
# "wordpress" becomes "alices-wordpress".
# Note that it should also match with the prefix (text before '-') of the namespace
# field above.
namePrefix: elasticweb-

# Labels to add to all resources and selectors.
#commonLabels:
#  someName: someValue

bases:
- ../crd
- ../rbac
- ../manager
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
# crd/kustomization.yaml
- ../webhook
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
- ../certmanager
# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
#- ../prometheus

patchesStrategicMerge:
# Protect the /metrics endpoint by putting it behind auth.
# If you want your controller-manager to expose the /metrics
# endpoint w/o any authn/z, please comment the following line.
- manager_auth_proxy_patch.yaml

# Mount the controller config file for loading manager configurations
# through a ComponentConfig type
#- manager_config_patch.yaml

# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
# crd/kustomization.yaml
- manager_webhook_patch.yaml

# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'.
# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks.
# 'CERTMANAGER' needs to be enabled to use ca injection
- webhookcainjection_patch.yaml

# the following config is for teaching kustomize how to do var substitution
vars:
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR
  objref:
    kind: Certificate
    group: cert-manager.io
    version: v1
    name: serving-cert # this name should match the one in certificate.yaml
  fieldref:
    fieldpath: metadata.namespace
- name: CERTIFICATE_NAME
  objref:
    kind: Certificate
    group: cert-manager.io
    version: v1
    name: serving-cert # this name should match the one in certificate.yaml
- name: SERVICE_NAMESPACE # namespace of the service
  objref:
    kind: Service
    version: v1
    name: webhook-service
  fieldref:
    fieldpath: metadata.namespace
- name: SERVICE_NAME
  objref:
    kind: Service
    version: v1
    name: webhook-service

编码

一切就绪后,现在开始实现业务逻辑。

打开api/v1/elasticweb_webhook.go文件。

新增依赖:

apierrors "k8s.io/apimachinery/pkg/api/errors"

首先我们来实现Default方法。

Default就是来判断CR中的TotalQPS的值是否为空,如果为空值,那么就给它设置个默认值:

// Default implements webhook.Defaulter so a webhook will be registered for the type
func (r *ElasticWeb) Default() {
	elasticweblog.Info("default", "name", r.Name)

	// TODO(user): fill in your defaulting logic.
	// 如果创建的时候没有输入总QPS,就设置个默认值
	if r.Spec.TotalQPS == nil {
		r.Spec.TotalQPS = new(int32)
		*r.Spec.TotalQPS = 1300
		elasticweblog.Info("a. TotalQPS is nil,set default value now", "totalQPS", *r.Spec.TotalQPS)
	} else {
		elasticweblog.Info("b. TotalQPS exists", "TotalQPS", r.Spec.TotalQPS)
	}
}

接下来我们实现验证功能,由于这里我们需要验证CreateUpdate,它们使用同一套逻辑,为了代码简洁以及复用性,我们封装一个validateElasticWeb方法。

我们主要是验证singlePodsQPS是否大于1000,如果是,则直接拦截。

func (r *ElasticWeb) validateElasticWeb() error {
	var allErrs field.ErrorList

	if *r.Spec.SinglePodsQPS > 1000 {
		elasticweblog.Info("c. Invalid SinglePodQPS")

		err := field.Invalid(field.NewPath("spec").Child("singlePodQPS"),
			*r.Spec.SinglePodsQPS,
			"d. must be less than 1000")

		allErrs = append(allErrs, err)

		return apierrors.NewInvalid(
			schema.GroupKind{
				Group: "elasticweb.example.com",
				Kind:  "ElasticWeb",
			},
			r.Name,
			allErrs)
	} else {
		elasticweblog.Info("e. SinglePodQPS is valid")
		return nil
	}
}

通过上面的代码可见,最终是调用apierrors.NewInvalid生成错误实例,而此方法接收的是多个错误,因此需要为期准备切片作为入参,如果是多个参数校验失败,可以直接放入切片。

最终我们需要在ValidateCreate()ValidateUpdate()方法中调用我们上面封装的validateElasticWeb

// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (r *ElasticWeb) ValidateCreate() error {
	elasticweblog.Info("validate create", "name", r.Name)

	// TODO(user): fill in your validation logic upon object creation.

	return r.validateElasticWeb()
}

// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
func (r *ElasticWeb) ValidateUpdate(old runtime.Object) error {
	elasticweblog.Info("validate update", "name", r.Name)

	// TODO(user): fill in your validation logic upon object update.
	return r.validateElasticWeb()
}

测试

部署

由于我们选择使用cert-manager来管理证书,所以我们测试的时候需要将operator部署到集群。

1、部署CRD:

make install

2、构建镜像并推送到镜像仓库

make docker-build docker-push IMG=huiyichanmian/elasitcweb:v0.0.2

3、部署集成了webhook功能的controller

make deploy IMG=huiyichanmian/elasitcweb:v0.0.2

4、查看,确保启动成功

$ kubectl get pod -n elasticweb-system
NAME                                             READY   STATUS    RESTARTS   AGE
elasticweb-controller-manager-5dc874656b-dsqt5   2/2     Running   0          4h41m

验证Defaulter

修改config/samples/elasticweb_v1_elasticweb.yaml,将totalQPS字段进行注释,完整文件如下所示:

apiVersion: elasticweb.example.com/v1
kind: ElasticWeb
metadata:
  name: elasticweb-sample
  namespace: dev
spec:
  image: nginx:1.17.1
  port: 30003
  singlePodsQPS: 800
  # totalQPS: 2400

此时我们设置的单个Pod的QPS是800,如果webhook生效,总QPS就是1500,那么对应的Pod应该是两个,我们直接创建上面的资源对象后,查看是否符合我们的预期。

$ kubectl apply -f config/samples/elasticweb_v1_elasticweb.yaml
elasticweb.elasticweb.example.com/elasticweb-sample created

$ kubectl get pod -n dev
NAME                                 READY   STATUS    RESTARTS   AGE
elasticweb-sample-566d5dd6d9-6dzdj   1/1     Running   0          7s
elasticweb-sample-566d5dd6d9-6hqjd   1/1     Running   0          7s

可见此时的Pod的数量是2。符合我们的预期。

接着使用kubectl describe命令来查看elasticweb资源对象的详情。

$ kubectl describe elasticweb elasticweb-sample -n dev
Name:         elasticweb-sample
Namespace:    dev
Labels:       <none>
Annotations:  <none>
API Version:  elasticweb.example.com/v1
Kind:         ElasticWeb
Metadata:
  Creation Timestamp:  2022-08-17T07:18:25Z
  Generation:          1
  ......
Spec:
  Image:            nginx:1.17.1
  Port:             30003
  Single Pods QPS:  800
  Total QPS:        1300
Events:             <none>

可以看到Total QPS字段被webhook设置为1300,RealQPS也计算正确。

再来看Controller的日志。

image-20220817152923389.png

其中的webhook部分是否符合预期,如上图红框所示,发现TotalQPS字段为空,就将设置为默认值,并且在检测的时候SinglePodQPS的值也没有超过1000。

验证Validator

接下来就是验证webhook的参数校验功能了,其实创建时的逻辑已经在上面验证过了,我们现在的singlePodsQPS为800,小于1000,符合要求,从controller日志中可以看到"e. SinglePodQPS is valid"。

那么我们直接修改config/samples/elasticweb_v1_elasticweb.yaml中singlePodsQPS的值,将800改为1200。

$ kubectl apply -f config/samples/elasticweb_v1_elasticweb.yaml  
The ElasticWeb "elasticweb-sample" is invalid: spec.singlePodQPS: Invalid value: 1200: d. must be less than 1000

发现直接在终端抛出了错误。

使用kubectl describe命令来查看elasticweb资源对象的详情,可以发现Single Pods QPS的值依然是800。

查看日志:

image-20220817154323673.png

如何在本地测试

1、获取证书:

$ kubectl get secrets webhook-server-cert -n  elasticweb-system -o jsonpath='{..tls\.crt}' |base64 -d > certs/tls.crt\n

$ kubectl get secrets webhook-server-cert -n elasticweb-system -o jsonpath='{..tls\.key}' |base64 -d > certs/tls.key

2、修改main.go,让webhook server使用指定证书:

if os.Getenv("ENVIRONMENT") == "DEV" {
		path, err := os.Getwd()
		if err != nil {
			setupLog.Error(err, "unable to get work dir")
			os.Exit(1)
		}
		options.CertDir = path + "/certs"
	}
	mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), options)
	if err != nil {
		setupLog.Error(err, "unable to start manager")
		os.Exit(1)
	}

3、运行

$ make run ENVIRONMENT=DEV

补充

controller有两种运行方式,一种是在kubernetes环境内,一种是在kubernetes环境外独立运行,在编码阶段我们通常会在开发环境上运行controller,但是如果使用了webhook,由于其特殊的鉴权方式,需要将kubernetes签发的证书放置在本地的tmp/k8s-webhook-server/serving-crets/目录。

面对这种问题,官方给出的建议是:如果在开发阶段暂时用不到webhook,那么

在本地运行controller时屏蔽webhook的功能。

具体操作是首先修改main.go文件,其实就是在webhook控制器这块添加一个环境变量的判断:

if os.Getenv("ENABLE_WEBHOOK") != "false" {
        if err = (&elasticwebv1.ElasticWeb{}).SetupWebhookWithManager(mgr); err != nil {
            setupLog.Error(err, "unable to create webhook", "webhook", "ElasticWeb")
            os.Exit(1)
        }
    }

在本地启动controller得时候,使用make run ENABLE_WEBHOOK=false即可。

参考链接

xinchen.blog.csdn.net/article/det…