概述
基于k8s做扩展的时候,一种典型方式则是 operator 开发,通过 CRD(CustomResourceDefinition) 来定义自定义资源,开发对应的 controller 来做业务控制,以期实现特定的业务需求。那么其本质是什么?是事件驱动。与我们常见的 C/S 架构不同,operator 开发核心是围绕 CR 的资源变化事件来进行处理。
本文则针对 operator 开发,做一些基础梳理
CRD
定义
资源,作为 k8s 平台开发的一等公民,有其特定的格式,其详细设计在 https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md 查阅,我们一般的更多关注四个方面: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 只更新一次资源
如上,展示的是 kube-builder 的内部原理,包含了 kube-builder 以及 infomer 机制的部分代码,可以明显的看到,这里存在一个事件队列,一旦存在事件队列就意味着缓存已经产生,我们每次处理的都是当前能看到的资源状态,并非真实的状态,因为有可能还有其他的事件在后面排队等待处理。那基于此,我们看下图,分析下一次reconcile中,更新一次和更新多次有什么区别
如上图,更新多次与更新一次的主要区别在于
-
更新一次的时候,代码逻辑相对复杂,但是比较安全,不会出现(或者说降低了)处理中间态资源的情况
-
更新多次的时候,代码逻辑相对简单,但是相对危险,因为相比于更新一次,多次更新会产生无意义版本的资源变化通知,如果带入到了更加复杂的场景:这个 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 设计
https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md
2、kube-builder
https://book.kubebuilder.io