Operator开发

概述

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

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

CRD

定义

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

annotation:注解

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

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

bash 复制代码
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 两种资源。写法如下:

go 复制代码
/*
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字段的处理,来直接处理资源。如下,则是一个典型处理逻辑:

go 复制代码
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
}

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

**

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

**

基本原则

原则一:一次 reconcile 只更新一次资源

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

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

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

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

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

go 复制代码
/**** !!!! 仅做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

相关推荐
蒙娜丽宁2 天前
Go语言错误处理详解
ios·golang·go·xcode·go1.19
qq_172805592 天前
GO Govaluate
开发语言·后端·golang·go
littleschemer3 天前
Go缓存系统
缓存·go·cache·bigcache
程序者王大川4 天前
【GO开发】MacOS上搭建GO的基础环境-Hello World
开发语言·后端·macos·golang·go
Grassto4 天前
Gitlab 中几种不同的认证机制(Access Tokens,SSH Keys,Deploy Tokens,Deploy Keys)
go·ssh·gitlab·ci
高兴的才哥5 天前
kubevpn 教程
kubernetes·go·开发工具·telepresence·bridge to k8s
少林码僧5 天前
sqlx1.3.4版本的问题
go
蒙娜丽宁6 天前
Go语言结构体和元组全面解析
开发语言·后端·golang·go
蒙娜丽宁6 天前
深入解析Go语言的类型方法、接口与反射
java·开发语言·golang·go
三里清风_6 天前
Docker概述
运维·docker·容器·go