kubebuilder 进阶使用教程

2021-03-01 11:00:53 浏览数 (1)

本篇将继续深入学习kubebuilder开发,并介绍一些深入使用时遇到的问题。包括:finalizer、控制器对CRD的update status、kubebuilder注释等。并且会分享一些在开发过程中使用的小技巧。

status

我们先看一个新建的crd的结构体:

代码语言:javascript复制
// BucketStatus defines the observed state of Bucket
type BucketStatus struct {
    // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
    // Important: Run "make" to regenerate code after modifying this file
    Progress int32 `json:"progress"`
}

//  kubebuilder:object:root=true

// Bucket is the Schema for the buckets API
type Bucket struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   BucketSpec   `json:"spec,omitempty"`
    Status BucketStatus `json:"status,omitempty"`
}

这里,Spec和Status均是Bucket的成员变量,Status并不像Pod.Status一样,是PodsubResource.因此,如果我们在controller的代码中调用到Status().Update(),会触发panic,并报错:the server could not find the requested resource

如果我们想像k8s中的设计那样,那么就要遵循k8s中status subresource的使用规范:

  • 用户只能指定一个CRD实例的spec部分;
  • CRD实例的status部分由控制器进行变更。

设计subresource风格的status

需要在Bucket的注释中添加一行// kubebuilder:subresource:status,变成如下:

代码语言:javascript复制
//  kubebuilder:subresource:status
//  kubebuilder:object:root=true

// Bucket is the Schema for the buckets API
type Bucket struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   BucketSpec   `json:"spec,omitempty"`
    Status BucketStatus `json:"status,omitempty"`
}

创建Bucket资源时,即便我们填入了非空的status结构,也不会更新到apiserver中。Status只能通过对应的client进行更新。比如在controller中:

代码语言:javascript复制
if bucket.Status.Progress == 0 {
      bucket.Status.Progress = 1
      err := r.Status().Update(ctx, &bucket)
      if err != nil {
          return ctrl.Result{}, err
      }
  }

这样,只要bucket实例的status.Progress为0时(比如我们创建一个bucket实例时,由于status.Progress无法配置,故初始化为默认值,即0),controller就会帮我们将它变更为1.

注意:kubebuilder 2.0开发生成的crd模板,无法通过apiserver的crd校验。社区有相关的记录和修复https://github.com/kubernetes...,但是这个修复没有针对1.11.*版本。所以1.11.*版本的k8s,要使用kubebuilder 2.0 必须给apiserver配置一个featuregate:- --feature-gates=CustomResourceValidation=false,关闭对crd的校验。

finalizer

finalizer即终结器,存在于每一个k8s内的资源实例中,即**.metadata.finalizers,它是一个字符串数组,每一个成员表示一个finalizer。控制器在删除某个资源时,会根据该资源的finalizers配置,进行异步预删除处理,所有的finalizer都执行完毕后,该资源会被真正删除。

这里的预删除处理,一般指对该资源的关联资源进行增删改操作。比如:一个A资源被删除时,其finalizer规定必须将A资源的Selector指向的所有service都删除。

当我们需要设计这类finalizer时,就可以自定义一个controller来实现。

因为finalizer的存在,资源的Delete操作,演变成了一个Update操作:给资源加入一个deletiontimestamp。我们设计controller时,需要对这个字段做好检查。

范例

我们设计一个Bucket类和一个Playbook类,Playbook.Spec.Selector是一个选择器,可以通过该选择器找到对应的Bucket。Playbook控制器需要做以下事情:

  • 如果一个Playbook对象没有删除时间戳(被创建或更新),我们检查并配置一个finalizer:testdelete给它
  • 如果一个Playbook有删除时间戳(被删除),我们检查是否该对象的finalizer包含testdelete.
  • 如果包含,我们检查该Playbook对象的spec.Selector是否不为空
  • 如果不为空,我们根据spec.Selector List相同namespace下所有的bucket,并将它们一一删除

Reconcile函数中增加如下代码:

代码语言:javascript复制
    myplaybookFinalizerName := "testdelete"
    if book.ObjectMeta.DeletionTimestamp.IsZero() {
        if !containsString(book.ObjectMeta.Finalizers, myplaybookFinalizerName) {
            book.ObjectMeta.Finalizers = append(book.ObjectMeta.Finalizers, myplaybookFinalizerName)
            err := r.Update(ctx, &book)
            if err != nil {
                return ctrl.Result{}, err
            }
        }
    } else {
        if containsString(book.ObjectMeta.Finalizers, myplaybookFinalizerName) && book.Spec.Selector != nil {
            bList := &opsv1.BucketList{}
            err := r.List(ctx, bList, client.InNamespace(book.Namespace), client.MatchingLabels(book.Spec.Selector))
            if err != nil {
                return ctrl.Result{}, fmt.Errorf("can't find buckets match playbook, %s", err.Error())
            }
            for _, b := range bList.Items {
                err = r.Delete(ctx, &b)
                if err != nil {
                    return ctrl.Result{}, fmt.Errorf("can't delete buckets %s/%s, %s",b.Namespace, b.Name, err.Error())
                }
            }
            book.ObjectMeta.Finalizers = removeString(book.ObjectMeta.Finalizers, myplaybookFinalizerName)
            err = r.Update(ctx, &book)
            return ctrl.Result{}, err
        }
    }

cluster-scope

k8s中node、pv等资源是集群级别的,它们没有namespace字段,因此查询node资源时也无需规定要从哪个namespace查。

我们在进行k8s operator时经常也需要设计这样的字段,但是默认情况下,kubebuilder会给我们创建namespace scope的crd资源,可以通过如下方式修改:

在执行kubebuilder create api ****后,我们在生成的资源的*_types.go文件中,找到资源的主结构体,增加一条注释kubebuilder:resource:scope=Cluster,比如:

代码语言:javascript复制
//  kubebuilder:object:root=true
//  kubebuilder:resource:scope=Cluster

// Bookbox is the Schema for the bookboxes API
type Bookbox struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   BookboxSpec   `json:"spec,omitempty"`
    Status BookboxStatus `json:"status,omitempty"`
}

这样执行make install,会在config/crd/bases/目录下生成对应的crd的yaml文件,里面就申明了该crd的scope:

代码语言:javascript复制
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  creationTimestamp: null
  name: bookboxes.ops.netease.com
spec:
  group: ops.netease.com
  names:
    kind: Bookbox
    plural: bookboxes
  scope: Cluster
  **

需要注意的是,// kubebuilder:resource:scope=Cluster这句注释,必须放在结构体的上方、且必须是最靠近该结构体的一条kubebuilder注释,否则会失效。

kubebuilder 注释标记

我们注意到,在设计subresource风格的status和cluster-scope中我们都是用kubebuilder的注释标记,实现我们想要的资源形态,这里有更多关于注释标记的说明,比如:令crd支持kubectl scale,对crd实例进行基础的值校验,允许在kubectl get命令中显示crd的更多字段,等等.此处举两例:

kubectl get 时显示crd的status.replicas:

代码语言:javascript复制
//  kubebuilder:printcolumn:JSONPath=".status.replicas",name=Replicas,type=string

限定字段的值为固定的几个:

代码语言:javascript复制
type Host struct {
    ..
    Spec HostSpec
}
type HostSpec struct {
//  kubebuilder:validation:Enum=Wallace;Gromit;Chicken
    HostName string
}

kubebuilder 的log

kubebuilder的log使用了第三方包"github.com/go-logr/logr"。当我们在开发reconciler时,如果需要在某处打日志,我们需要在Reconcile方法中将

代码语言:javascript复制
_ = r.Log.WithValues("playbook", req.NamespacedName)

改为

代码语言:javascript复制
log := r.Log.WithValues("playbook", req.NamespacedName)

从而获得一个logger实例。之后的逻辑中,我们可以执行:

代码语言:javascript复制
log.Info("this is the message", $KEY, $VALUE)

注意,这里KEY和VALUE都是interface{}结构,可以是字符串或整型等,他们表示在上下文中记录的键值对,反映到程序日志中,会是这个样子:

代码语言:javascript复制
// code:
  log.Info("will try get bucket from changed","bucket-name", req.NamespacedName)
  
// output:
  2019-09-11T11:53:58.017 0800    INFO    controllers.Playbook    will try get bucket from changed    {"playbook": "default/playbook-sample", "bucket-name": {"namespace": "default", "name": "playbook-sample"}}

logr包提供的logger只有Info和Error两种类型,但可以通过V(int)配置日志级别。不管是Info还是Error,都采用上面例子的格式,即:

代码语言:javascript复制
log.Info(string, {key, value} * n )
log.Error(string, {key, value} * n )
n>=0

如果不遵循这种格式,运行期间会抛出panic。

给Reconciler做扩展

增加eventer

我们需要在某些时候创建k8s event进行事件记录,但Reconciler默认是只有一个Client接口和一个Logger的:

代码语言:javascript复制
type PlaybookReconciler struct {
    client.Client
    Log logr.Logger
}

我们可以往struct中添油加醋:

代码语言:javascript复制
type PlaybookReconciler struct {
    client.Client
    Eventer record.EventRecorder
    Log logr.Logger
} 

PlaybookReconciler的初始化在main.go中,kubebuilder设计的manager自带了事件广播的生成方法,直接使用即可:

代码语言:javascript复制
if err = (&controllers.PlaybookReconciler{
        Client: mgr.GetClient(),
        Eventer: mgr.GetEventRecorderFor("playbook-controller"),
        Log:    ctrl.Log.WithName("controllers").WithName("Playbook"),
    }).SetupWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create controller", "controller", "Playbook")
        os.Exit(1)
    }

注意这里要给我们的控制器配置好rbac,在kubebuilder 2.3.0中,可以在控制器代码文件的注释中添加:

代码语言:javascript复制
//  kubebuilder:rbac:groups="",resources=events,verbs=get;list;watch;create;update;patch

reconciler监控多个资源变化

我们在开发过程中,可能需要开发一个类似service-->selector-->pods的资源逻辑,那么,在service的reconciler里,我们关注service的seletor的配置,并且检查匹配的pods是否有所变更(增加或减少),并更新到同名的endpoints里;同时,我们还要关注pod的更新,如果pod的label发生变化,那么要找出所有'之前匹配了这些pod'的service,检查service的selector是否仍然匹配pod的label,如有变动,也要更新endpoints。

这就意味着,我们需要能让reconciler能观察到service和pod两种资源的变更。我们在serviceReconciler的SetupWithManager方法中,可以看到:

代码语言:javascript复制
func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).
        Complete(r)
}

只需要在For方法调用后再调用Watches方法即可:

代码语言:javascript复制
func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).Watches(&source.Kind{Type: &opsv1.Pod{}}, &handler.EnqueueRequestForObject{}).
        Complete(r)
}

此外,我们可以将service设计为pod的owner,然后在podController的For方法后在调用Owns方法:

代码语言:javascript复制
func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).Owns(&opsv1.Pod{}).
        Complete(r)
}

我们在Owns方法的定义注释中可以看到它与Watch方法其实是类似的:

代码语言:javascript复制
// Owns defines types of Objects being *generated* by the ControllerManagedBy, and configures the ControllerManagedBy to respond to
// create / delete / update events by *reconciling the owner object*.  This is the equivalent of calling
// Watches(&handler.EnqueueRequestForOwner{&source.Kind{Type: <ForType-apiType>}, &handler.EnqueueRequestForOwner{OwnerType: apiType, IsController: true})
func (blder *Builder) Owns(apiType runtime.Object) *Builder {
    blder.managedObjects = append(blder.managedObjects, apiType)
    return blder
}

不论是For,Own,Watch,都是kubebuilder中的Builder提供的,Builder是kubebuilder开放给用户构建控制器的唯一合法入口(你还可以用更hack的手段去构建,可能对源码造成入侵),它还提供了许多有用的方法,可以让我们更灵活自由地初始化一个controller。

注意:kubebuilder 2.0中,构建一个reconciler时,可以用Own,Watch方法来额外监听一些资源,但是For方法必须要有,如果没有For方法,编译出来的程序运行时会报错,类似于"kind.Type should not be empty"

监听指定字段的变更

有时候我们想让自己的代码更加清晰,让控制器的工作更有针对性。比如上文中举了一个service通过selector绑定bod的设想:我们在service的controller中list一遍service实例的selector指向的pod,并与status中的pods记录进行对比,这意味着,所有对service和pod的操作,都会触发这个操作。

我们想要在控制器watch pod资源变更时,检查pod是否变更了label,如果label没有变更,就不去执行reconcile,以此省去反复的list pod操作带来的开销。要如何实现呢?

方法1:添加自定义的入队predicate

Builder为我们提供了另一个方法:

代码语言:javascript复制
func (blder *Builder) WithEventFilter(p predicate.Predicate) *Builder

这个方法,是为Builder中每个Watch的对象设计一个变更过滤器:PredicatePredicate实现了几个方法:

代码语言:javascript复制
type Predicate interface {
    // Create returns true if the Create event should be processed
    Create(event.CreateEvent) bool

    // Delete returns true if the Delete event should be processed
    Delete(event.DeleteEvent) bool

    // Update returns true if the Update event should be processed
    Update(event.UpdateEvent) bool

    // Generic returns true if the Generic event should be processed
    Generic(event.GenericEvent) bool
}

我们以此设计一个自己的predicate:

代码语言:javascript复制
package controllers

import (
    "sigs.k8s.io/controller-runtime/pkg/predicate"
    "sigs.k8s.io/controller-runtime/pkg/event"
)

type ResourceLabelChangedPredicate struct {
    predicate.Funcs
}

func (rl *ResourceLabelChangedPredicate) Update (e event.UpdateEvent) bool{
    if !compareMaps(e.MetaOld.GetLabels(), e.MetaNew.GetLabels()) {
        return true
    }
    return false
}

然后修改注册控制器的方式:

代码语言:javascript复制
func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Service{}).Watches(&source.Kind{Type: &opsv1.Pod{}}, &handler.EnqueueRequestForObject{}).WithEventFilter(&ResourceLabelChangedPredicate{}).
        Complete(r)
}

这样,ServiceReconciler在监听其关注的对象时,只会关注对象的label是否发生变更,只有当label发生变更时,才会入队并进入reconcile逻辑。

这个方法目前看应该是kubebuilder团队推荐使用的方法,但是有个问题是,加入了predicate后,会在Reconciler关注的所有的对象上生效。也就是说即使Service实例的label发生变更,也会触发reconcile。这不是我们想看到的,我们想看到的是Service的selector变更时会进行reconcile。这时候我们可能就需要在predicate中增加对象类型的判断,比如:

代码语言:javascript复制
func (rl *ResourceLabelChangedPredicate) Update (e event.UpdateEvent) bool{
    oldobj, ok1 := e.ObjectOld.(*opsv1.Service)
    newobj, ok2 := e.ObjectNew.(*opsv1.Service)
    if ok1 && ok2 {
        if !compareMaps(oldobj.Spec.Selector, newobj.Spec.Selector) {
            return true
        } else {
            return false
        }
    }

    _, ok1 = e.ObjectOld.(*opsv1.Pod)
    _, ok2 = e.ObjectNew.(*opsv1.Pod)
    if ok1 && ok2 {
        if !compareMaps(e.MetaOld.GetLabels(), e.MetaNew.GetLabels()) {
            return true
        }
    }
    return false
}

记得通过注释添加rbac:

代码语言:javascript复制
//  kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;create;update;patch;delete
方法2:自定义一个入队器

我们先看上面提到的Watch方法,这个方法允许用户自己设计handler.EventHandler接口,这个接口实现了Create,Update,Delete,Generic方法,用来在资源实例的不同生命阶段,进行判断与入队。

sigs.k8s.io/controller-runtime/pkg/handler/enqueue.go中就有一个默认的实现:EnqueueRequestForObject。我们可以参考它设计一个自己的接口实现——名为EnqueueRequestForLabelChanged的入队器.

重写该入队器的Update方法,改为判断新旧两个实例的label是否一致,不一致则进行入队:

代码语言:javascript复制
func (e *EnqueueRequestForLabelChanged) Update(evt event.UpdateEvent, q workqueue.RateLimitingInterface) {
    if !compareMaps(evt.MetaOld.GetLabels(), evt.MetaNew.GetLabels()) {
        q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
            Name: evt.MetaNew.GetName(),
            Namespace: evt.MetaNew.GetNamespace(),
        }})
    }
}

注册reconciler时,watches的eventhandler参数使用自定义的enqueue:

代码语言:javascript复制
func (r *PlaybookReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&opsv1.Playbook{}).Watches(&source.Kind{Type: &opsv1.Bucket{}}, &EnqueueRequestForLabelChanged{}).
        Complete(r)
}

这样,ServiceReconciler将会监听service资源的所有变更,以及pod资源的label变更。

记得通过注释添加rbac:

代码语言:javascript复制
//  kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;create;update;patch;delete
best way to watch

通过前文我们了解到:

  1. 使用WithEventFilter配置变更过滤器,可以针对reconcilerwatch的所有资源,统一地设置事件监听规则;
  2. 使用自己实现的EventHandler,可以在reconcilerwatch特定资源时,设置该资源的事件监听规则。

阅读controller-runtime的代码我们会发现,官方允许用户调用WithEventFilter配置变更过滤器,但没有提供一个公开的方法让用户配置入队器,用户只能自己主动实现。其实在1.X的kubebuilder中,Watch方法允许用户配置predicate,用户可以给不同资源配置不同的变更过滤器。但在2.0中,这个函数被重新封装,不再直接开放给用户。取而代之的是用WithEventFilter方法配置应用到所有资源的变更过滤器。

可能设计者认为,一个reconciler要负责的应该是一个/多个资源对象的一种/同种变化。

事实上,在开发operator的过程中,最好也是将一个reconciler的工作内容细粒度化。特别是:不应该在一个reconciler逻辑中进行两次资源的update(update status除外),否则会引发版本不一致的报错。

使用非缓存的client

Reconciler中的client.Client是一个结构,提供了Get,List,Update,Delete等一系列k8s client的操作,但是其Get,List方法均是从cache中获取数据,如果Reconciler同步数据不及时(需要注意,实际上同步数据的是manager中的成员对象:cache,Reconciler直接引用了该对象),获取到的就是脏数据。

与EventRecorder类似地, manger中其实也初始化好了一个即时的client:apiReader,供我们使用,只需要调用mgr.GetAPIReader()即可获取。

注意到apiReader是一个只读client,,其使用方法与Reconciler的Client类似(Get方法,List方法):

代码语言:javascript复制
r.ApiReader.Get(ctx, req.NamespacedName, bucket)

官方建议我们直接使用带cache的client即可,该client是一个分离的client,其读方法(get,list)均从一个cache中获取数据。写方法则直接更新到apiserver。

多版本切换

在crd的开发和演进过程中,必然会存在一个crd的不同版本。kubebuilder支持以一个conversion webhook的方式,支持对一个crd资源以不同版本进行读取。简单地描述就是:

代码语言:javascript复制
kubectl apply -f config/samples/batch_v2_cronjob.yaml

创建一个v2的cronjob后,可以通过v1和v2两种版本进行读取:

代码语言:javascript复制
kubectl get cronjobs.v2.batch.tutorial.kubebuilder.io -o yaml
kubectl get cronjobs.v1.batch.tutorial.kubebuilder.io -o yaml

显然,get命令得到的v1和v2版本的cronjob会存在一些字段上的不同,conversion webhook会负责进行不同版本的cronjob之间的数据转换。

贴下学习资料:https://book.kubebuilder.io/multiversion-tutorial/tutorial.html

在webhook中使用client

有时候我们需要在某个对象的webhook中查询集群中的其他资源,比如某个operator规定了一个PodBox,规定每个PodBox中只能有一个Pod,那么在validatecreate的webhook中就要ListPod By PodBox并计数。

kubebuilder 2.X 将webhook封装得太过简介,所以我们需要搞个新法子:

我们在types和webhook的目录下新建一个文件, 在里面构建一个全局client:

代码语言:javascript复制
package v1

import (
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
)

var globalClient client.Client
var globalReader client.Reader

func InitClient(mgr ctrl.Manager) {
    globalClient = mgr.GetClient()
    globalReader = mgr.GetAPIReader()
}

在 main.go中, 各种SetupWithManager之前,先执行InitClient,初始化这些client, validateCreate方法中可以直接使用这些client。

添加自定义的webhook

我们开发的operator可能会需要对用户新建的pod进行注入,比如注入一些信息到annotations中, 也有可能要对原生对象的更新/删除操作进行判断,那么如何在我们的项目中添加这些对象的webhook?

社区提供了一个案例:https://github.com/kubernetes-sigs/controller-runtime/blob/master/examples/builtins/validatingwebhook.go

但是在该案例下,每次执行make generate时 会报错:

代码语言:javascript复制
invalid field type interface{sigs.k8s.io/controller-runtime/pkg/client.Reader; sigs.k8s.io/controller-runtime/pkg/client.StatusClient; sigs.k8s.io/controller-runtime/pkg/client.Writer}

不过测试了一下 只要不执行generate,其他步骤都可以正常执行, 比如make docker-build

使用索引

client-go支持构建带索引的缓存,并且允许用户自定义索引函数的名称和内容。当缓存的数据带有索引时,我们可以更快地通过索引List到想要的数据,既可以提高性能又可以减少代码量。

kubebuilder 2.0 提供了很简单的索引构建方式。比如我们要以pod中的spec.NodeName为索引, 方便我们快速List查询某个节点上的pod:

代码语言:javascript复制
// 要先构建好manager

mgr.GetFieldIndexer().IndexField(&v1.Pod{}, "indexNodeNameOfPod", func(o runtime.Object) []string {
        v := o.(*v1.Pod).Spec.NodeName
        return []string{v}
    })
    
    ...
    
// 使用manager的client进行List
podList := &v1.PodList{}
err := mgr.GetClient().List(context.TODO(), podList, &client.MatchingFields{"indexNodeNameOfPod": node.Name})

“原文链接:https://segmentfault.com/a/1190000020359577 ”

0 人点赞