一、Kubernetes Operator 简介
Kubernetes Operator 是通过连接主 API 并 watch 时间的一组进程,一般会 watch 有限的资源类型。当相关 watch 的 event 触发的时候,operator 做出响应并执行具体的动作。这可能仅限于与主 API 交互,但通常会涉及在其他一些系统上执行某些操作(可以是集群中或集群外资源)。 Kubernetes 操作员是连接到主 API 并监视事件的进程,通常是在有限数量的资源类型上。当相关事件发生时,操作员做出反应并执行特定的操作。
操作符被实现为一组控制器,其中每个控制器监视一个特定的资源类型。当被监视资源上发生相关事件时,将启动一个协调周期。Operators 是控制器的集合,并且每个控制器 watch 指定的资源类型。当被 watched 的资源时间触发的时候,协调周期也将随之启动。在协调周期期间,控制器有责任检查当前状态是否与被 watched 资源描述的期望状态相匹配。有趣的是,根据设计,时间并不会传递到协调周期中,这将会强制地让你去考虑实例的整个状态,这种方法被称为基于水平触发而不是基于边缘触发(level-based, as opposed to edge-based)。这源自于电子电路的设计,水平触发是接收 event(例如中断)并对状态做出反应的理念,而基于边缘的触发是接收 event 并对状态变化做出反应的理念。 水平触发虽说效率较低,因为它强制重新评估完整的状态,而不是仅仅关注改变了什么,但在信号可能丢失或多次重复传输的复杂不可靠环境中,这种方式被认为是更适用的。这种设计的选择会影响我们编写控制器代码的方式。如下提供了一个高层次的总结:
当向API服务器发送请求时,特别是对于创建和删除请求,它们将会经历上述的阶段。需要注意的是,也可以指定 webhook 来执行请求的更改和验证。如果 operator 引入 CRD(custom resource definition),可能还必须定义这些 webhook。一般来说,operator 进程会开放一个端口来来实现 webhook endpoint。 如果 operator 引入了一个新的 CRD,Operator SDK 将会协助你来搭建,为确保 CRD 符合 Kubernetes 扩展 API 的最佳实践,请遵循这些约定。这里所提到的所有的最佳实践都在 operator-utils 代码库中,并以可运行的例子体现。在 operator 项目中,也可以将 operator-utils 以 library 的方式导入,以此提供一些有用的工具。
二、创建 watches
正如我们所说,控制器监视资源上的事件,这是通过对手表的抽象来实现的。watch 是一种接收某种类型(核心类型或 CRD)的机制。一般通过指定以下内容来创建 watch 机制: handler 将被监视类型上的 events 映射到一个或多个调用协调周期的实例,监视类型和实例类型不必相同; predicate 是一组能够过滤 events 且可自定义的函数。 如下记录了以上提及的内容:
通常来说,同一类型(kind)开启多个 watch 是可行的,因为 watch 是多路复用的。也应该尽可能多地尝试过滤 event,这边有个 predicate 例子,用来过滤 secret 资源上的 event,这里只对类型为 TLS 的 secret 资源 event 感兴趣:
isAnnotatedSecret : = predicate. Funcs { UpdateFunc : func ( e event. UpdateEvent ) bool { oldSecret, ok : = e. ObjectOld . ( * corev1. Secret ) if ! ok { return false } newSecret, ok : = e. ObjectNew . ( * corev1. Secret ) if ! ok { return false } if newSecret. Type != util. TLSSecret { return false } oldValue, _ : = e. MetaOld . GetAnnotations ( ) [ certInfoAnnotation] newValue, _ : = e. MetaNew . GetAnnotations ( ) [ certInfoAnnotation] old : = oldValue == "true" new : = newValue == "true" if ! reflect. DeepEqual ( newSecret. Data [ util. Cert ] , oldSecret. Data [ util. Cert ] ) || ! reflect. DeepEqual ( newSecret. Data [ util. CA ] , oldSecret. Data [ util. CA ] ) { return new} return old != new} , CreateFunc : func ( e event. CreateEvent ) bool { secret, ok : = e. Object . ( * corev1. Secret ) if ! ok { return false } if secret. Type != util. TLSSecret { return false } value, _ : = e. Meta . GetAnnotations ( ) [ certInfoAnnotation] return value == "true" } ,
}
一个非常常见的模式是观察创建(和拥有)资源上的 events,并且定期在拥有这些资源的 CR 上执行协调周期 reconcile cycle。为此,可以使用 EnqueueRequestForOwner handler,按照如下方式完成:
err = c. Watch ( & source. Kind { Type : & examplev1alpha1. MyControlledType { } } , & handler. EnqueueRequestForOwner { } )
另一种不太常用的情况是将一个 events 传播到多个资源上。考虑一种情况,一个控制器注入了 TLS secret 的路由,同一个命名空间中的多个路由可以指向同一个 secret。如果 secret 发生了改变,需要更新所有路由。因此,需要在 secret 类型上创建一种 watch 机制,处理程序如下所示:
type enqueueRequestForReferecingRoutes struct { client. Client
}
func ( e * enqueueRequestForReferecingRoutes) Create ( evt event. CreateEvent , q workqueue. RateLimitingInterface ) { routes, _ : = matchSecret ( e. Client , types. NamespacedName { Name : evt. Meta . GetName ( ) , Namespace : evt. Meta . GetNamespace ( ) , } ) for _, route : = range routes { q. Add ( reconcile. Request { NamespacedName : types. NamespacedName { Namespace : route. GetNamespace ( ) , Name : route. GetName ( ) , } } ) }
}
func ( e * enqueueRequestForReferecingRoutes) Update ( evt event. UpdateEvent , q workqueue. RateLimitingInterface ) { routes, _ : = matchSecret ( e. Client , types. NamespacedName { Name : evt. MetaNew . GetName ( ) , Namespace : evt. MetaNew . GetNamespace ( ) , } ) for _, route : = range routes { q. Add ( reconcile. Request { NamespacedName : types. NamespacedName { Namespace : route. GetNamespace ( ) , Name : route. GetName ( ) , } } ) }
}
三、资源 Reconciliation Cycle
协调周期 reconcile cycle 是在被 watch 的 event 传递后框架将控制权转交给我们地方。正如之前所解释的,在该 reconcile cycle 中没有获得相关时间类型的信息,是因为是基于水平触发的方式来工作。 如下是一个管理 CRD 控制器的常见 reconcile cycle 的模型,和其他任何一个模型一样,它不会反映任何特定用例,但希望它将有助于解决编写 operator 时遇到的问题:
从上图中可以看到,主要步骤是: 初始化实例:如果实例的某些值没有被初始化,会在这一步进行处理; 判断实例的 deletion 状态,如果实例正在被删除,也需要做一些特殊的清理。 管理控制器的业务逻辑,如果以上步骤均通过,最终可以管理和执行该实例的 reconcile 逻辑,这个逻辑每个控制器都不尽相同。
四、资源验证
这里存在两种类型的校验: 语义校验:可以通过创建 ValidatingAdmissionConfiguration 来完成。 注意:在控制器中不能校验 CR 合法性,一旦 CR 被 API Server 接受了,它就会存在 Etcd 中,CR 存在 Etcd 之后,管理该 CR 资源的控制器就无法拒绝它,如果这个 CR 是不合法的,控制器在尝试使用或处理它的时候将会发生错误。 推荐:由于不能保证 ValidatingAdmissionConfiguration 被创建或正常工作,还是应该在控制器内部去验证 CR,如果 CR 不合法,应该避免创建无限错误循环。
① 语法校验
可以按照Generating CRD的描述添加 OpenAPI 验证规则。 推荐:尽可能多地为自定义资源模型进行语法校验,尽量使用语法校验,因为它相对简单,并且可以防止格式错误的 CR 存储在 etcd 中。
② 语义校验
语义校验是为了确保字段具有合理的值,从而使整个资源记录是有意义的。语义验证业务逻辑取决于 CR 所代表的概念,并且必须由 operator 的开发人员进行编码实现。 如果给定的 CR 需要语义校验,那么 operator 需要暴露一个 webhook,作为 operator deploymen 的一部分,ValidatingAdmissionConfiguration 也应该被创建。 目前存在的局限性: 在 OpenShift 3.11 中,ValidatingAdmissionConfigurations 还处于技术预览阶段(将从 4.1 开始支持); Operator SDK 不支持脚手架形式的 webhook,可以使用 kubebuilder 来进行实现:
kubebuilder webhook - - group crew - - version v1 - - kind FirstMate - - type = mutating - - operations= create, update
③ 验证控制器中的资源
最好的方式是直接拒绝一个无效的 CR,而不是接受并保存在 Etcd 中,然后对它进行错误条件处理。当然也有可能的情况是,ValidatingAdmissionConfiguration 并没有被部署或者根本不可用,因此在控制器代码中进行语义校验仍然是一个很好的做法,应该做到的是,可以在 ValidatingAdmissionConfiguration 和控制器之间共享这部分结构化的代码。 控制器中调用验证方法的代码如下所示:
if ok, err : = r. IsValid ( instance) ; ! ok { return r. ManageError ( instance, err)
}
请注意,如果验证失败,按照错误管理部分中的描述来管理这个错误。IsValid 函数如下:
func ( r * ReconcileMyCRD ) IsValid ( obj metav1. Object ) ( bool , error) { mycrd, ok : = obj. ( * examplev1alpha1. MyCRD )
}
五、资源初始化
Kubernetes 的一个很好的惯例是用户只初始化他所需要的资源字段,其他的可以省略。但从编码人员和调试者的角度来说,实际上最好将所有的字段都初始化,这允许在编码的时候不必总是去校验字段是否被定义了,并且可以轻松地排除错误情况。 为了初始化资源,这里有两个选项: 定义一个 MutatingAdmissionConfiguration(类似于 ValidatingAdmissionConfiguration 的程序); 在控制器中定义一个初始化方法,代码应类似于此示例:
if ok : = r. IsInitialized ( instance) ; ! ok { err : = r. GetClient ( ) . Update ( context. TODO ( ) , instance) if err != nil { log. Error ( err, "unable to update instance" , "instance" , instance) return r. ManageError ( instance, err) } return reconcile. Result { } , nil
}
如果 IsInitialized 方法的结果返回 true,更新 instance 并 return,这将会立即出发另一个 reconcile cycle,第二次调用 IsInitialized 方法将会返回 false,代码逻辑将会执行到下一部分。
① 资源 Finalization
如果资源不属于操作员控制的 CR,但在删除该 CR 时需要采取措施,必须使用 finalizer。终结器提供了一种机制来通知 Kubernetes 控制平面,在执行标准 Kubernetes 垃圾收集逻辑之前需要执行一个操作。资源可以有一个或多个 finalizers,每一个控制器应该管理自己的 finalizer 并且忽略其他的。 管理 finalizers 的伪代码算法: 如果需要,在初始化方法中添加 finalizer。 当资源被删除,检查此控制器拥有的 finalizer 是否存在。 清理成功,移除 finalizer 并更新 CR; 如果失败决定是重试还是放弃并可能留下垃圾(在某些情况下这是可以接受的); 如果存在,执行如下清理逻辑:如果清理逻辑需要添加额外的资源,需要记住的是,无法在正在删除的命名空间中创建其他资源,删除命名空间将会触发 finalizer 并删除其下所有资源。 代码如下所示:
if util. IsBeingDeleted ( instance) { if ! util. HasFinalizer ( instance, controllerName) { return reconcile. Result { } , nil} err : = r. manageCleanUpLogic ( instance) if err != nil { log. Error ( err, "unable to delete instance" , "instance" , instance) return r. ManageError ( instance, err) } util. RemoveFinalizer ( instance, controllerName) err = r. GetClient ( ) . Update ( context. TODO ( ) , instance) if err != nil { log. Error ( err, "unable to update instance" , "instance" , instance) return r. ManageError ( instance, err) } return reconcile. Result { } , nil
}
② 资源所有权
资源所有权是 Kubernetes 中的原生概念,它决定了资源如何被删除。默认情况下,当一个资源被删除的时候,它的子资源也也会被删除(可以设置 cascade=false 来关闭这种行为)。这种行为有助于确保资源的正确垃圾收集,尤其是当资源控制多级层次结构中的其他资源时(deployment-> repilcaset->pod)。 建议:如果控制器创建资源并且它的生命周期与其他资源(kubernetes 核心资源或其他 CR)有关联,那么应该将此资源设置为其他资源的所有者,如下所示:
controllerutil. SetControllerReference ( owner, obj, r. GetScheme ( ) )
有关所有权的其他规则如下: 命名空间资源可以拥有集群资源,一个对象可以有一个所有者列表,如果多个命名空间对象拥有相同的集群资源,则每个对象都应声明所有权,而不会覆盖其他对象的所有权;
六、状态管理
Status 是资源的一个标准部分,被用于报告资源的状态。在这里将使用 status 报告最后一次执行协调循环的结果,也可以在 Status 中添加更多的信息。 在正常情况下,如果每次执行 reconcile cycle 的时候都要更新资源,这将触发更新时间,进而导致无限触发 reconcile cycle。因此,正如上面描述的那样,应该把 Status 作为子资源。使用这种方法,能够不增加 ResourceGeneration 元数据域的情况下更新资源的状态。 使用如下命令更新状态:
err = r. Status ( ) . Update ( context. Background ( ) , instance)
现在需要为 watch 机制写一个 predicate,用来丢弃不增加 ResourceGeneration 的更新事件,可以使用 GenerationChangePredicate 来完成此功能。上文提到过,在使用 finalizer 的时候,应该在初始化的时候设置,如果 finalizer 是初始化的唯一项,由于它是元数据项的一部分,因此 ResourceGeneration 不会递增。 为了说明该用例,以下是 predicate 的修改版本:
type resourceGenerationOrFinalizerChangedPredicate struct { predicate. Funcs
}
func ( resourceGenerationOrFinalizerChangedPredicate) Update ( e event. UpdateEvent ) bool { if e. MetaNew . GetGeneration ( ) == e. MetaOld . GetGeneration ( ) && reflect. DeepEqual ( e. MetaNew . GetFinalizers ( ) , e. MetaOld . GetFinalizers ( ) ) { return false } return true
}
type MyCRStatus struct { Status string `json: "status,omitempty" `LastUpdate metav1. Time `json: "lastUpdate,omitempty" `Reason string `json: "reason,omitempty" `
}
可以写一个函数来管理并保证 reconcile cycle 成功执行:
func ( r * ReconcilerBase ) ManageSuccess ( obj metav1. Object ) ( reconcile. Result , error) { runtimeObj, ok : = ( obj) . ( runtime. Object ) if ! ok { log. Error ( errors. New ( "not a runtime.Object" ) , "passed object was not a runtime.Object" , "object" , obj) return reconcile. Result { } , nil} if reconcileStatusAware, updateStatus : = ( obj) . ( apis. ReconcileStatusAware ) ; updateStatus { status : = apis. ReconcileStatus { LastUpdate : metav1. Now ( ) , Reason : "" , Status : "Success" , } reconcileStatusAware. SetReconcileStatus ( status) err : = r. GetClient ( ) . Status ( ) . Update ( context. Background ( ) , runtimeObj) if err != nil { log. Error ( err, "unable to update status" ) return reconcile. Result { RequeueAfter : time. Second , Requeue : true , } , nil} } else { log. Info ( "object is not RecocileStatusAware, not setting status" ) } return reconcile. Result { } , nil
}
七、错误管理
如果控制器进入了一个错误条件,并且在 reconcile 方法中返回了一个错误,operator 将会打印错误日志到标准输出,reconlie event 将会立即再次调度(默认的调度器实际上应该检测是否一遍又一遍地出现相同的错误,并增加相应的调度时间,以经验看来,这并没有发生)。如果错误一直存在,那么也将永远存在错误循环,而且这个错误条件对用户来说是不可见的。 有两种方法可以通知用户发生了错误,它们可以同时使用: 此外,如果错误能够自解决,应该在一段周期时间后重新调度 reconcile cycle。通常来说,周期时间是呈指数增长的,因此在每次迭代中,reconcile event 周期会越来越长(例如每次增长时间量的两倍)。 现在构建状态管理来处理错误条件:
func ( r * ReconcilerBase ) ManageError ( obj metav1. Object , issue error) ( reconcile. Result , error) { runtimeObj, ok : = ( obj) . ( runtime. Object ) if ! ok { log. Error ( errors. New ( "not a runtime.Object" ) , "passed object was not a runtime.Object" , "object" , obj) return reconcile. Result { } , nil} var retryInterval time. Duration r. GetRecorder ( ) . Event ( runtimeObj, "Warning" , "ProcessingError" , issue. Error ( ) ) if reconcileStatusAware, updateStatus : = ( obj) . ( apis. ReconcileStatusAware ) ; updateStatus { lastUpdate : = reconcileStatusAware. GetReconcileStatus ( ) . LastUpdate . Time lastStatus : = reconcileStatusAware. GetReconcileStatus ( ) . Status status : = apis. ReconcileStatus { LastUpdate : metav1. Now ( ) , Reason : issue. Error ( ) , Status : "Failure" , } reconcileStatusAware. SetReconcileStatus ( status) err : = r. GetClient ( ) . Status ( ) . Update ( context. Background ( ) , runtimeObj) if err != nil { log. Error ( err, "unable to update status" ) return reconcile. Result { RequeueAfter : time. Second , Requeue : true , } , nil} if lastUpdate. IsZero ( ) || lastStatus == "Success" { retryInterval = time. Second } else { retryInterval = status. LastUpdate . Sub ( lastUpdate) . Round ( time. Second ) } } else { log. Info ( "object is not RecocileStatusAware, not setting status" ) retryInterval = time. Second } return reconcile. Result { RequeueAfter : time. Duration ( math. Min ( float64 ( retryInterval. Nanoseconds ( ) * 2 ) , float64 ( time. Hour . Nanoseconds ( ) * 6 ) ) ) , Requeue : true , } , nil
}
注意,此函数会立即发送一个 event,然后使用错误条件更新状态,最后计算何时重新安排下一次 reconcile,该算法尝试将每个循环的时间加倍,最多到六个小时为止。六个小时是一个很好的上限时间,因为 event 大约持续 6 个小时,所以这应该确保始终有一个活动 event 描述当前的错误情况。