likes
comments
collection
share

Operator开发

作者站长头像
站长
· 阅读数 7

概述

基于k8s做扩展的时候,一种典型方式则是 operator 开发,通过 CRD(CustomResourceDefinition) 来定义自定义资源,开发对应的 controller 来做业务控制,以期实现特定的业务需求。那么其本质是什么?是事件驱动。与我们常见的 C/S 架构不同,operator 开发核心是围绕 CR 的资源变化事件来进行处理。

本文则针对 operator 开发,做一些基础梳理

CRD Operator开发

定义

资源,作为 k8s 平台开发的一等公民,有其特定的格式,其详细设计在 github.com/kubernetes/… 查阅,我们一般的更多关注四个方面:annotation、labels、spec、status。

annotation:注解

一般多用于本资源的 owner 控制器之外的控制器使用,用来表明自身资源的一些非状态熟悉,或者对其他控制器的诉求。

例如,我们的环境中,典型的会在创建 vpcgw 的时候,给其设置如下标签:

k8s.v1.cni.cncf.io/networks: macvlan-conf-1

用来表明我们的 pod 期望加入一个额外的网络,pod本身的owner控制器(scheduler之类)其实并不关注这个字段如何处理,核心是 multus 相关控制器关注到这个注解后,会完成期望的操作。

labels:标签

如名,标签,更多的是提供了查找匹配的能力。

使用非常的广泛,典型的如我们启动pod的时候,一般会设置 app-name 标签,用来标识一簇应用。这样无论是 kubectl 运维,还是 controller 批量查找 都会很方便

spec: 规范(specification)

表明的是资源的预期是什么,类似驴子眼前的萝卜,是整个资源的预期。

例如:deployment 中设置副本数为3,只是代表期望是3,到底是不是3,只能从status中获取。

status:状态

表明的是资源当前的状态是什么,即驴子到底吃到萝卜没。即到底有没有3个副本。

在做 controller 的时候,我们则应当努力使资源处在 spec 所期望的 status 。而这个过程,则称为 调谐(reconcile)。后文说明调谐怎么做。

设计

严格来讲,一个资源对应一个CRD,这个也是我们所建议的高内聚,低耦合中的_高内聚_。那这里就有一个资源设计的问题,即怎么对资源做拆分,设计不同的 CRD?

宽泛来讲,建议与数据模型(数据库表)对齐,如果把不同的资源集中在同一个CRD中,可能会导致CRD过于臃肿,Reconciler 的业务逻辑复杂。但是如果太碎太小,又会导致资源数量太多,浪费资源。

不一而论,此处与数据模型设计异曲同工,全凭业务诉求与团队经验。几个有效的设计经验可以提出来以供参考:

经验一:存在包含关系(父子关系)的资源,拆开为不同的CRD

例如:k8s中,deployment->replicaSet->pod;slb->listener,target 之类

因为这类型的资源其本身除了从属关系外,自身也是具有复杂的业务逻辑,如果设计到一个CRD,那么reconcile 会十分麻烦

经验二:一个资源只有一个owner,只有他可以修改这个资源的所有信息,其他控制器只能修改 annotation

如果存在多个owner,就会出现竞争,在分布式系统中是个很麻烦的事情。但是经常我们又需要多个控制器协同作业,那么,暴露的越少越好。这里k8s提供了annotation字段,留与标记资源的一些信息,与其他控制器协同作业。

Reconciler

基本诉求

如前文,我们期望通过 reconcile 来处理 spec,以期达到某种 status,那么从面向对象的设计出发,则存在一个 调谐器(reconciler)来处理管理整个调谐的声明周期。

我们当前使用 kube-builder 一键生成,框架源码我们暂不赘述,详细的可以单开章节。

核心关注两个基本诉求:

1、怎么监听一个(多个)资源?

2、监听后怎么处理?

诉求一:怎么监听一个(多个)资源

首先,监听一个资源好理解,一个 reconciler 必定是调谐一个特定的资源。那何时需要监听多个资源?

一个典型场景:父子资源(仅以deployment、pod举例,不代表是其具体实现)

假定:deployment 中定义了期望pod有三副本,其状态中记录了副本数。那怎么实现呢?

两种方式:

方式一:pod reconciler 监听到创建出来一个副本后,给deployment的status副本数加一

方式二:deployment reconciler 监听到创建一个副本后,给deployment的status副本数加一

显然,方式一会有一个问题:deployment和pod的reconciler都修改deployment的status,即使是不同的字段也是不被推荐的。因为一个资源的status应当避免由多个控制器写,status代表的是本控制器对资源的处理结果,而并非是其他控制器的处理结果。其他控制器的处理结果则可以写到 annotations 上。所以我们则需要在 deployment reconciler 中,同时监听 deployment 和 pod 两种资源。写法如下:

/*
SLBReconciler 是作为 CRD:slb 的owner控制器存在,用来处理slb资源的变更
其中一个核心业务,是根据slb的配置,创建出 envoy-pod 出来。
同时,在slb中的status,需要记录这些pod的一些信息,以供更好的运维查看
那么也就意味着在slb的业务逻辑中,需要关注两类资源 CRD:slb 以及 k8s.core.Pod
a. For(&networkv1.SLB{}).
For 函数一般的则就是这个控制为谁而工作,此处自然就是 slb
b. Watches(
    &source.Kind{Type: &k8sCoreV1.Pod{}},
    handler.EnqueueRequestsFromMapFunc(r.podTransSlb),
    builder.WithPredicates(pred),
)
Watch 则是用来关注其他资源的变更,此处需要注意,无论怎样,watch后的处理,都尽量转换为 Reconcile 的处理
从其底层代码来看,Watch 是相当于把自己加入到了另一个资源的监听队列上,而另一个资源的变更和本资源的变更
一般是并行的,如果在 Watch.Handler直接处理 slb 的相关字段,那么就会与 For.Reconcile 产生竞争,锁就必不可免了
所以这里的框架设计也是比较优秀的:
func (r *SLBReconciler) podTransSlb(podObject client.Object) []reconcile.Request
这个回调会在Watch的资源发生变更的时候调用,可以看到入参是变化的资源,出参是一个请求切片。
这些请求会直接塞入到当前 reconciler 的事件队列中,此处也就是 slb
这样,就从pod的资源变更转换为了slb的资源变更,队列的处理则是有序的,也就避免了竞争
再者,后文会提到的一个原则:所有的处理逻辑(接口)都应当幂等,也确保了处理逻辑的正确执行。
通过这两者的确保,就完成了 slb-reconciler 对slb、pod两个资源的监听处理

*/

// SetupWithManager sets up the controller with the Manager.
func (r *SLBReconciler) SetupWithManager(mgr ctrl.Manager) error {
    pred := predicate.NewPredicateFuncs(r.podPredicate)
    return ctrl.NewControllerManagedBy(mgr).
        For(&networkv1.SLB{}).
        Watches(
            &source.Kind{Type: &k8sCoreV1.Pod{}},
            handler.EnqueueRequestsFromMapFunc(r.podTransSlb),
            builder.WithPredicates(pred),
        ).
        Complete(r)
}

func (r *SLBReconciler) podTransSlb(podObject client.Object) []reconcile.Request {
    labels := podObject.GetLabels()
    slbName, ok := labels[apis.Labels_Keys_SLB]
    if !ok {
        return nil
    }
    ctx := context.Background()
    log := log.FromContext(context.Background(), "controller", "SLB")
    log.Info("pod update, slb reconcile", "slb", slbName)

    slb := &networkv1.SLB{}
    err := r.Get(ctx, types.NamespacedName{Name: slbName}, slb)
    if err != nil {
        log.Info("pod update, slb reconcile skip, get slb error", "slb", slbName)
        return nil
    }

    return []reconcile.Request{
        {
            NamespacedName: types.NamespacedName{
                Name: slbName,
            },
        },
    }
}

func (r *SLBReconciler) podPredicate(podObject client.Object) bool {
    labels := podObject.GetLabels()
    _, ok := labels[apis.Labels_Keys_SLB]
    if !ok {
        return false
    }

    annos := podObject.GetAnnotations()
    keys := []string{
        apis.Annos_Keys_SLB_InternetVIP,
        apis.Annos_Keys_SLB_IntranetVIP,
        apis.Annos_Keys_SLB_BasicNetworkVIP,
        apis.Annos_Keys_SLB_BasicNetworkVipGw,
        apis.Annos_Keys_SLB_BasicNetworkIP,
        apis.Annos_Keys_SLB_BasicNetworkIpGw,
        apis.Annos_Keys_SLB_OverlaySubnetCIRD,
    }
    for _, key := range keys {
        if _, ok := annos[key]; !ok {
            return false
        }
    }

    return true
}

诉求二:监听后怎么处理

这里则是经验之谈了。

在 bube-builer 创建的代码框架中,我们可以做两种处理方式:

方式一:类 restful 风格

即不使用默认的 reconcile 函数,而是自定义 handler,实现 create、update、delete 函数,处理对应的事件。

方式二:reconcile

即默认方式,在reconcile函数中,通过不同字段的变化来处理。

例如:通过 DeletionTimestamp 是否为0,来处理删除逻辑;通过 Finalizer 的字段来实现对资源的删除保护。通过对spec字段的处理,来直接处理资源。如下,则是一个典型处理逻辑:

func (r *SLBReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := log.FromContext(ctx, "controller", "SLB")
    log.Info("SLB Reconcile start", "resource", req.String())

    // .1 get current object
    slb := &networkv1.SLB{}
    err := r.Get(ctx, req.NamespacedName, slb)
    if err != nil {
        if errors.IsNotFound(err) {
            log.Info("SLB deleted", "slb", req.String())
            return ctrl.Result{}, nil
        }
        return ctrl.Result{}, err
    }

    // .2 Finalizer
    if r.needFinalizer(ctx, slb) {
        _, err := r.addFinalizer(ctx, slb)
        if err != nil {
            log.Error(err, "Add finalizer fail")
            return ctrl.Result{}, err
        }
        return ctrl.Result{}, nil
    }

    // .3 Delete
    if !slb.DeletionTimestamp.IsZero() {
        log.Info("On remove slb", "slb", slb.Name)

        // a. remove
        err := r.onRemove(ctx, log, slb)
        if err != nil {
            return ctrl.Result{}, err
        }
        return ctrl.Result{}, nil
    }

    // .3 Apply: add and update
    log.Info("On apply slb", "slb", fmt.Sprintf("%+v\n", slb))
    err = r.onApply(ctx, log, slb)
    if err != nil {
        log.Error(err, "apply fail")
        return ctrl.Result{}, err
    }

    return ctrl.Result{}, nil
}

但无论是哪一种,都一个点需要额外的注意!!!

**

reconciler 处理的资源是"缓存",而非实时的request/response拿到的数据。

**

基本原则

原则一:一次 reconcile 只更新一次资源 Operator开发

如上,展示的是 kube-builder 的内部原理,包含了 kube-builder 以及 infomer 机制的部分代码,可以明显的看到,这里存在一个事件队列,一旦存在事件队列就意味着缓存已经产生,我们每次处理的都是当前能看到的资源状态,并非真实的状态,因为有可能还有其他的事件在后面排队等待处理。那基于此,我们看下图,分析下一次reconcile中,更新一次和更新多次有什么区别

Operator开发

如上图,更新多次与更新一次的主要区别在于

  1. 更新一次的时候,代码逻辑相对复杂,但是比较安全,不会出现(或者说降低了)处理中间态资源的情况

  2. 更新多次的时候,代码逻辑相对简单,但是相对危险,因为相比于更新一次,多次更新会产生无意义版本的资源变化通知,如果带入到了更加复杂的场景:这个 cr 的spec还存在手动修改,或者更上层应用层修改的情况,那么这里的资源变化相对会比一次的复杂很多

所以,建议在一次reconcile的时候,只更新一次status,如果非要多次,那么在更新后请立即返回,避免产生无意义版本资源,下面是一种参考写法:

/**** !!!! 仅做code展示,不要以此理解 slb 的设计 !!! ****/

/*
假定,slb中存在 instance、targetGroup、listener 三种子对象以及一些其他额外的属性
每次更新的时候,这写都可能更新,我们在处理每个逻辑段后,都需要明确,是否更新了 spec
如果更新了,那么立即返回,不然下一次一定是更新冲突。更新冲突:在前文已经描述,不再赘述
*/
func (r *SLBReconciler) onApply(
    ctx context.Context, log logr.Logger, slb *networkv1.SLB,
) error {
    // .1
    objectUpdated, err := r.instanceApply(ctx, log, slb)
    if err != nil {
        log.Error(err, "slb instance apply error")
        return err
    }
    if objectUpdated {
        return nil
    }

    // .2
    objectUpdated, err = r.targetGroupApply(ctx, log, slb)
    if err != nil {
        log.Error(err, "slb target group apply error")
        return err
    }
    if objectUpdated {
        return nil
    }

    // .3
    objectUpdated, err = r.listenerApply(ctx, log, slb)
    if err != nil {
        log.Error(err, "slb listener error")
        return err
    }
    if objectUpdated {
        return nil
    }

    // .x stop/start
    // .x EnvoyPodReplicas

    return nil
}

原则二:不依托 status 做任何事情

道理很简单,当我们收到一个资源变更后,里面的status就已经"过期",是 old-status。基于一个过期的状态,再怎么处理自身都是错的。应当做的是,根据本次的spec处理后,把收集到的 new-status,与 old-status对比,不一致的时候再更新 status。

原则三:先比较再更新

如原则一所展示,资源更新后会生成一个新的版本。而k8s在存储资源的时候,不会比较资源是否发生变化。

那么一旦某次 reconcile 无脑更新资源了,就会生成一个新的资源版本,这个新版本又会触发一次reconcile,如此往复,陷入死循环中。

原则四:所有的处理逻辑(接口)都应当幂等

同样,还是k8s的资源变更机制导致,可能会导致重复的推送某一个资源的变更到reconciler,如果处理逻辑或者接口不幂等,那么处理一定是有问题的。


参考资料

1、k8s api 设计

github.com/kubernetes/…

2、kube-builder

book.kubebuilder.io

转载自:https://juejin.cn/post/7343137522052562971
评论
请登录