基于 kubebuilder 的 operators 的 webhook 设计&二次开发

前情提要

之前博客已经完成了一个Operator的设计、开发、部署、验证过程,实战中刻意跳过了一个重要的知识点:webhook,如今是时候学习它了,这是个很重要的功能;
什么是AdmissionWebhook,就要先了解K8S中的admission controller, 按照官方的解释是: admission controller是拦截(经过身份验证)API Server请求的网关,并且可以修改请求对象或拒绝请求。
启动引导令牌是一种简单的 bearer token ,这种令牌是在新建集群或者在现有集群中添加新加新节点时使用的。 它被设计成能支持 kubeadm,但是也可以被用在其他上下文中以便用户在 不使用 kubeadm 的情况下启动cluster。它也被设计成可以通过 RBAC 策略,结合Kubelet TLS Bootstrapping 系统进行工作。

简而言之,它可以认为是拦截器,类似web框架中的middleware。

webhook 简介

熟悉java开发的读者大多知道过滤器(Servlet Filter),如下图,外部请求会先到达过滤器,做一些统一的操作,例如转码、校验,然后才由真正的业务逻辑处理请求:


image

Operator中的webhook,其作用与上述过滤器类似,外部对CRD资源的变更,在Controller处理之前都会交给webhook提前处理,流程如下图,该图来自《Getting Started with Kubernetes | Operator and Operator Framework》:

image

再来看看webhook具体做了哪些事情,如下图,kubernetes官方博客明确指出webhook可以做两件事:修改(mutating)和验证(validating)


image

kubebuilder为我们提供了生成webhook的基础文件和代码的工具,与制作API的工具类似,极大地简化了工作量,咱们只需聚焦业务实现即可;
基于kubebuilder制作的webhook和controller,如果是同一个资源,那么它们在同一个进程中;

场景设计

为之前的elasticweb项目上增加需求,让webhook发挥实际作用;

1 如果用户忘记输入总QPS,系统webhook负责设置默认值1300,操作如下图:


image

2 为了保护系统,给单个pod的QPS设置上限1000,如果外部输入的singlePodQPS值超过1000,就创建资源对象失败,如下图所示:


image

准备工作

和controller类似,webhook既能在kubernetes环境中运行,也能在kubernetes环境之外运行;
如果webhook在kubernetes环境之外运行,是有些麻烦的,需要将证书放在所在环境,默认地址是:

# 上一篇博文,已经说明,需要提前的将配置文件和证书文件拷贝到指定目录下,方便进行开发测试。
/tmp/k8s-webhook-server/serving-certs/tls.{crt,key}

为了让webhook在kubernetes环境中运行,咱们要做一点准备工作安装cert manager,执行以下操作:

# 文件如果失效的话,记得寻找类似的文件进行替代。
kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.2.0/cert-manager.yaml

上述操作完成后会新建很多资源,如namespace、rbac、pod等,以pod为例如下:


image

生成webhook

进入elasticweb工程下,执行以下命令创建webhook:

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

上述命令执行完毕后,先去看看main.go文件,如下图红框1所示,自动增加了一段代码,作用是让webhook生效:


image

上图红框2中的elasticweb_webhook.go就是新增文件,内容如下:

/*


Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v1

import (
    apierrors "k8s.io/apimachinery/pkg/api/errors"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/runtime/schema"
    "k8s.io/apimachinery/pkg/util/validation/field"
    ctrl "sigs.k8s.io/controller-runtime"
    logf "sigs.k8s.io/controller-runtime/pkg/log"
    "sigs.k8s.io/controller-runtime/pkg/webhook"
)

// log is for logging in this package.
var elasticweblog = logf.Log.WithName("elasticweb-resource")

func (r *ElasticWeb) SetupWebhookWithManager(mgr ctrl.Manager) error {
    return ctrl.NewWebhookManagedBy(mgr).
        For(r).
        Complete()
}

// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!

// +kubebuilder:webhook:path=/mutate-elasticweb-com-bolingcavalry-v1-elasticweb,mutating=true,failurePolicy=fail,groups=elasticweb.com.bolingcavalry,resources=elasticwebs,verbs=create;update,versions=v1,name=melasticweb.kb.io

var _ webhook.Defaulter = &ElasticWeb{}

// 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)
    }
}

// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation.
// +kubebuilder:webhook:verbs=create;update,path=/validate-elasticweb-com-bolingcavalry-v1-elasticweb,mutating=false,failurePolicy=fail,groups=elasticweb.com.bolingcavalry,resources=elasticwebs,versions=v1,name=velasticweb.kb.io

var _ webhook.Validator = &ElasticWeb{}

// 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()
}

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

    // TODO(user): fill in your validation logic upon object deletion.
    return nil
}

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

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

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

        allErrs = append(allErrs, err)

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

上述代码有两处需要注意,第一处和填写默认值有关,如下图:


image

第二处和校验有关,如下图:


image

要实现的业务需求就是通过修改上述elasticweb_webhook.go的内容来实现,不过代码稍后再写,先把配置都改好;

开发(配置)

打开文件config/default/kustomization.yaml,下图四个红框中的内容原本都被注释了,现在请将注释符号都删掉,使其生效:

image

还是文件config/default/kustomization.yaml,节点vars下面的内容,原本全部被注释了,现在请全部放开,放开后的效果如下图:

image

配置已经完成,可以编码了;

开发(编码)

打开文件elasticweb_webhook.go
新增依赖:

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

找到Default方法,改成如下内容,可见代码很简单,判断TotalQPS是否存在,若不存在就写入默认值,另外还加了两行日志:

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)
    }
}

接下来开发校验功能,咱们把校验功能封装成一个validateElasticWeb方法,然后在新增和修改的时候各调用一次,如下,可见最终是调用apierrors.NewInvalid生成错误实例的,而此方法接受的是多个错误,因此要为其准备切片做入参,当然了,如果是多个参数校验失败,可以都放入切片中:

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

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

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

        allErrs = append(allErrs, err)

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

再找到新增和修改资源对象时被调用的方法,在里面调用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()
}

编码完成,可见非常简单,接下来,咱们把以前实战遗留的东西清理一下,再开始新的部署和验证;

清理工作

# 1 删除elasticweb资源对象:
kubectl delete -f config/samples/elasticweb_v1_elasticweb.yaml

# 2 删除controller
kustomize build config/default | kubectl delete -f -

# 3 删除CRD
make uninstall

接下来就可以部署webhook了;

部署

# 1 部署CRD
make install
# 2 构建镜像并推送到仓库
make docker-build docker-push IMG=12589/elasticweb:001
# 3 部署集成了webhook功能的controller:
make deploy IMG=12589/elasticweb:001
# 4 查看pod,确认启动成功:
kubectl get pods --all-namespaces

验证Defaulter(添加默认值)

修改文件config/samples/elasticweb_v1_elasticweb.yaml,修改后的内容如下,可见totalQPS字段已经被注释掉了:

apiVersion: v1
kind: Namespace
metadata:
  name: dev
  labels:
    name: dev
---
apiVersion: elasticweb.com.bolingcavalry/v1
kind: ElasticWeb
metadata:
  namespace: dev
  name: elasticweb-sample
spec:
  # Add fields here
  image: tomcat:8.0.18-jre8
  port: 30003
  singlePodQPS: 500
  # totalQPS: 600

创建一个elasticweb资源对象:

kubectl apply -f config/samples/elasticweb_v1_elasticweb.yaml

此时单个pod的QPS是500,如果webhook的代码生效的话,总QPS就是1300,而对应的pod数应该是3个,接下来咱们看看是否符合预期;
先看elasticweb、deployment、pod等资源对象是否正常,如下所示,全部符合预期:

zhaoqin@zhaoqindeMBP-2 ~ % kubectl get elasticweb -n dev                                                                 
NAME                AGE
elasticweb-sample   89s
zhaoqin@zhaoqindeMBP-2 ~ % kubectl get deployments -n dev
NAME                READY   UP-TO-DATE   AVAILABLE   AGE
elasticweb-sample   3/3     3            3           98s
zhaoqin@zhaoqindeMBP-2 ~ % kubectl get service -n dev    
NAME                TYPE       CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
elasticweb-sample   NodePort   10.105.125.125   <none>        8080:30003/TCP   106s
zhaoqin@zhaoqindeMBP-2 ~ % kubectl get pod -n dev    
NAME                                 READY   STATUS    RESTARTS   AGE
elasticweb-sample-56fc5848b7-5tkxw   1/1     Running   0          113s
elasticweb-sample-56fc5848b7-blkzg   1/1     Running   0          113s
elasticweb-sample-56fc5848b7-pd7jg   1/1     Running   0          113s

用kubectl describe命令查看elasticweb资源对象的详情,如下所示,TotalQPS字段被webhook设置为1300,RealQPS也计算正确:

zhaoqin@zhaoqindeMBP-2 ~ % kubectl describe elasticweb elasticweb-sample -n dev
Name:         elasticweb-sample
Namespace:    dev
Labels:       <none>
Annotations:  <none>
API Version:  elasticweb.com.bolingcavalry/v1
Kind:         ElasticWeb
Metadata:
  Creation Timestamp:  2021-02-27T16:07:34Z
  Generation:          2
  Managed Fields:
    API Version:  elasticweb.com.bolingcavalry/v1
    Fields Type:  FieldsV1
    fieldsV1:
      f:metadata:
        f:annotations:
          .:
          f:kubectl.kubernetes.io/last-applied-configuration:
      f:spec:
        .:
        f:image:
        f:port:
        f:singlePodQPS:
    Manager:      kubectl-client-side-apply
    Operation:    Update
    Time:         2021-02-27T16:07:34Z
    API Version:  elasticweb.com.bolingcavalry/v1
    Fields Type:  FieldsV1
    fieldsV1:
      f:status:
        f:realQPS:
    Manager:         manager
    Operation:       Update
    Time:            2021-02-27T16:07:34Z
  Resource Version:  687628
  UID:               703de111-d859-4cd2-b3c4-1d201fb7bd7d
Spec:
  Image:           tomcat:8.0.18-jre8
  Port:            30003
  Single Pod QPS:  500
  Total QPS:       1300
Status:
  Real QPS:  1500
Events:      <none>

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


image

最后别忘了用浏览器验证web服务是否正常,我这里的完整地址是:http://192.168.xxx.xx:30003/
至此,完成了webhook的Defaulter验证,接下来验证Validator

验证Validator

接下来该验证webhook的参数校验功能了,先验证修改时的逻辑;
编辑文件config/samples/update_single_pod_qps.yaml,值如下:

spec:
  singlePodQPS: 1100

用patch命令使之生效:

kubectl patch elasticweb elasticweb-sample \
-n dev \
--type merge \
--patch "$(cat config/samples/update_single_pod_qps.yaml)"

此时,控制台会输出错误信息:

Error from server (ElasticWeb.elasticweb.com.bolingcavalry "elasticweb-sample" is invalid: spec.singlePodQPS: Invalid value: 1100: d. must be less than 1000): admission webhook "velasticweb.kb.io" denied the request: ElasticWeb.elasticweb.com.bolingcavalry "elasticweb-sample" is invalid: spec.singlePodQPS: Invalid value: 1100: d. must be less than 1000

再用kubectl describe命令查看elasticweb资源对象的详情,如下图红框,依然是500,可见webhook已经生效,阻止了错误的发生:


image

再去看controller日志,如下图红框所示,和代码对应上了


image

接下来再试试webhook在新增时候的校验功能;

清理前面创建的elastic资源对象,执行命令:

kubectl delete -f config/samples/elasticweb_v1_elasticweb.yaml

修改文件,如下图红框所示,咱们将singlePodQPS的值改为超过1000,看看webhook是否能检查到这个错误,并阻止资源对象的创建:


image

执行以下命令开始创建elasticweb资源对象:

kubectl apply -f config/samples/elasticweb_v1_elasticweb.yaml

控制台提示以下信息,包含了咱们代码中写入的错误描述,证明elasticweb资源对象创建失败,证明webhook的Validator功能已经生效:

namespace/dev created
Error from server (ElasticWeb.elasticweb.com.bolingcavalry "elasticweb-sample" is invalid: spec.singlePodQPS: Invalid value: 1500: d. must be less than 1000): error when creating "config/samples/elasticweb_v1_elasticweb.yaml": admission webhook "velasticweb.kb.io" denied the request: ElasticWeb.elasticweb.com.bolingcavalry "elasticweb-sample" is invalid: spec.singlePodQPS: Invalid value: 1500: d. must be less than 1000

不放心的话执行kubectl get命令检查一下,发现空空如也:

zhaoqin@zhaoqindeMBP-2 elasticweb % kubectl get elasticweb -n dev       
No resources found in dev namespace.
zhaoqin@zhaoqindeMBP-2 elasticweb % kubectl get deployments -n dev
No resources found in dev namespace.
zhaoqin@zhaoqindeMBP-2 elasticweb % kubectl get service -n dev
No resources found in dev namespace.
zhaoqin@zhaoqindeMBP-2 elasticweb % kubectl get pod -n dev
No resources found in dev namespace.

还要看下controller日志,如下图红框所示,符合预期:


image

operator的webhook的开发、部署、验证咱们就完成了,整个elasticweb也算是基本功能齐全,希望能为您的operator开发提供参考;

阅读拓展

参考文章: https://xinchen.blog.csdn.net/article/details/113922328

Watches 如何监控:
elasticweb_controller.go 文件原内容:

func (r *ElasticWebReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&elasticwebv1.ElasticWeb{}).
        Complete(r)
}

elasticweb_controller.go 文件更新后内容:

func (r *ElasticWebReconciler) SetupWithManager(mgr ctrl.Manager) error {
    //return ctrl.NewControllerManagedBy(mgr).
    //  For(&elasticwebv1.ElasticWeb{}).
    //  Complete(r)

    // 追加 pod 个数的 Watches 监控
    return ctrl.NewControllerManagedBy(mgr).
        For(&appsv1.Deployment{}).Watches(&source.Kind{Type: &corev1.Pod{}}, &handler.EnqueueRequestForObject{}).
        Complete(r)
}

作用说明,这样我们通过 deployment 部署的资源,我们就可以很好的监控起来了 pod 个数的变化,并且把他矫正为正常的,资源值。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354

推荐阅读更多精彩内容