Kubernetes Operator 设计和Controller编写
Kubernetes Operator设计与编写
1.需求场景
在K8s为基础的PaaS的开发中,原生的编排对象包括了Deployment,StatefulSet,DaemonSet,Job等,作为平台的开发者,可能会觉得管理有状态应用比较复杂,而且编写模板的时候遇到对象的嵌套和应用的差异化配置也会感觉到不舒服。
在 Kubernetes 生态中,还有一个相对更加灵活和编程友好的管理“有状态应用”的解决方案,Operator。本质上就是给原生的资源类型增加一些包装,或者以代码的形式实现资源编排的控制逻辑,让一些应用的部署和控制逻辑得到复用,更加适应平台支持的业务的使用习惯;例如,对于众多的中间件如redis、memcached的部署,可用利用Operator达到自定义编排的效果。Operator的具体形态是一些Custom Resource Definition(CRD)和控制器的组合(可以没有CRD)。
然而,K8s自身只定义了reconcile loop和CRD的格式规范,自己写一份可用的CRD和控制器还是有点繁琐的。为了方便编写,利用coreos开发的operator-sdk是一个可选的选项,可以快速生成框架代码,在里面填充逻辑。
业界已经有众多组件的operator实现,但是需要注意的是operator-sdk的框架代码的新旧变动比较频繁(至少在0.9.0到1.0之间),新旧operator的代码有一些差异。
2. 实践
Kubebuilder和Operator SDK的区别
Kubebuilder和Operator SDK都是用来快速创建和管理Operator项目的,Kubebuilder可以独立于Operator SDK,但Operator SDK底层使用了Kubebuilder来创建Go项目。它们都使用到了controller-runtime模块,所以生成的项目目录基本相同。
Operator SDK在Kubebuilder提供的基本项目脚手架之上提供了其他功能。
Operator简要工作原理如下图:
Operator原理图[1]
client-go
client-go 是一个能够与 Kubernetes 集群通信的客户端,通过它可以对 Kubernetes 集群中各资源类型进行 CRUD 操作,它有三大 client 类,分别为:Clientset、DynamicClient、RESTClient。通过它,我们可以很方便的对 Kubernetes 集群 API 进行自定义开发,来满足个性化需求。
2.1 前置依赖
go 1.16以上的版本(如果编译operator-sdk,需要用到依赖的库)
一个在本地有cluster-admin权限的kubernetes集群
2.2 安装operator-sdk --[2]
export ARCH=$(case $(uname -m) in x86_64) echo -n amd64 ;; aarch64) echo -n arm64 ;; *) echo -n $(uname -m) ;; esac) export OS=$(uname | awk '{print tolower($0)}') export OPERATOR_SDK_DL_URL=https://github.com/operator-framework/operator-sdk/releases/download/v1.12.0 curl -LO ${OPERATOR_SDK_DL_URL}/operator-sdk_${OS}_${ARCH}
将operator-sdk_linux_amd64拷贝到/usr/local/bin/
查看operator-sdk:operator-sdk version
operator-sdk创建新的memcached-operator项目:
mkdir -p $HOME/projects/memcached-operator cd $HOME/projects/memcached-operator # we'll use a domain of example.com # so all API groups will be <group>.example.com operator-sdk init --domain example.com --repo github.com/example/memcached-operator
事件处理的工作流程
第一步,controller从k8s api-server里获取它关心的对象。这个操作依靠informer实现。Informer的实现如下
Informer工作图
informer利用listAndWatch机制,周期性全量获取并监听关心对象的变化,变化的增量会进入DeltaQueue。Informer会调用Indexer库对对象进行本地缓存的增删。此处的“本地”表示CRD运行的容器。
第二步:Informer的第二个职责是注册eventHandler,在事件发生的时候执行回调;
第三步:控制循环里的controller会执行对应的调谐任务。
编写自定义控制器代码的过程包括:编写 main 函数、编写自定义控制器的定义,以及编写控制器里的业务逻辑三个部分。
创建的框架代码main.go … func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") opts := zap.Options{ Development: true, } opts.BindFlags(flag.CommandLine) flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, MetricsBindAddress: metricsAddr, Port: 9443, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "86f835c3.example.com", }) if err != nil { setupLog.Error(err, "unable to start manager") os.Exit(1) } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) } if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up ready check") os.Exit(1) } setupLog.Info("starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "problem running manager") os.Exit(1) } }
其中的注册代码:controllers/memcached_controller.go
import ( ... appsv1 "k8s.io/api/apps/v1" ... ) func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&cachev1alpha1.Memcached{}). Owns(&appsv1.Deployment{}). Complete(r) }
NewControllerManagedBy()提供了一个可以注册多个controller配置的controller builder。
For(&cachev1alpha1.Memcached{})将Memcached类型指定为要监视的第一类资源。对于每个Memcached类型的Add/Update/Delete事件,协调循环将为该Memcached对象发送一个协调请求(namespace/name key)。
Owns(&appsv1.Deployment{})将Deployments类型指定为要监视的第二类资源。对于每个Deployment的Add/Update/Delete事件,event处理器会将每个事件映射为关联了这个deployment的Memcached的request。
2.3 create operator api
创建一个group为cache,版本为v1alpha1,关联CRD为Memcached的api:
$ operator-sdk create api --group cache --version v1alpha1 --kind Memcached --resource --controller
Writing kustomize manifests for you to edit...
…
如果想了解k8s api,关于Kubernetes API和组版本类型model的深入解释,可以查阅 https://book.kubebuilder.io/cronjob-tutorial/controller-overview.html。在K8s里,一般group+版本是为了随着版本升级修改api接口的行为。
通常,建议由一个controller器负责管理为项目创建的一种API,遵循controller-runtime本来的设计目标。
定义 API
所谓定义API,是定义跟api-server交互的资源数据结构,在api/v1alpha1/memcached_types.go里有
// MemcachedSpec defines the desired state of Memcached type MemcachedSpec struct { //+kubebuilder:validation:Minimum=0 // Size is the size of the memcached deployment Size int32 `json:"size"` } // MemcachedStatus defines the observed state of Memcached type MemcachedStatus struct { // Nodes are the names of the memcached pods Nodes []string `json:"nodes"` }
它们是被type Memcached struct包含的。
可以在里面添加字段,修改完成执行make manifests,
config/crd/bases/的yaml文件会产生相应的变化
2.4 编写Controller
在controllers/{your_app_name}_controller.go中添加reconcile逻辑:
在reconcile逻辑里可以利用client-go跟api-server交互,包括资源的CRUD。
以redis-operator为例:
RedisCluster是一个部署redis集群的CRD,
替换模板生成的代码中的 // your logic here
为自定义逻辑
func (r *ReconcileRedisCluster) Reconcile(request reconcile.Request) (reconcile.Result, error) { reqLogger := log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name) reqLogger.Info("Reconciling RedisCluster") // Fetch the RedisCluster instance instance := &redisv1beta1.RedisCluster{} err := r.client.Get(context.TODO(), request.NamespacedName, instance) if err != nil { if errors.IsNotFound(err) { // Request object not found, could have been deleted after reconcile request. // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. // Return and don't requeue reqLogger.Info("RedisCluster delete") instance.Namespace = request.NamespacedName.Namespace instance.Name = request.NamespacedName.Name r.handler.metaCache.Del(instance) return reconcile.Result{}, nil } // Error reading the object - requeue the request. return reconcile.Result{}, err } reqLogger.V(5).Info(fmt.Sprintf("RedisCluster Spec:\n %+v", instance)) if err = r.handler.Do(instance); err != nil { if err.Error() == needRequeueMsg { return reconcile.Result{RequeueAfter: 20 * time.Second}, nil } reqLogger.Error(err, "Reconcile handler") return reconcile.Result{}, err } if err = r.handler.rcChecker.CheckSentinelReadyReplicas(instance); err != nil { reqLogger.Info(err.Error()) return reconcile.Result{RequeueAfter: 20 * time.Second}, nil } return reconcile.Result{RequeueAfter: time.Duration(reconcileTime) * time.Second}, nil }
每个controller最终都被GenericController调用reconcile;
为了减轻kubernetes的压力,可以提前过滤一些事件,kubernetes提供了一个提前判断的方式,
即predicate,根据返回值判断资源变更与否(除此之外没有其他信息);
Pred := predicate.Funcs{ UpdateFunc: func(e event.UpdateEvent) bool { // returns false if redisCluster is ignored (not managed) by this operator. if !shoudManage(e.MetaNew) { return false } log.WithValues("namespace", e.MetaNew.GetNamespace(), "name", e.MetaNew.GetName()).V(5).Info("Call UpdateFunc") // Ignore updates to CR status in which case metadata.Generation does not change if e.MetaOld.GetGeneration() != e.MetaNew.GetGeneration() { log.WithValues("namespace", e.MetaNew.GetNamespace(), "name", e.MetaNew.GetName()). Info("Generation change return true", "old", e.ObjectOld, "new", e.ObjectNew) return true } return false }, DeleteFunc: func(e event.DeleteEvent) bool { // returns false if redisCluster is ignored (not managed) by this operator. if !shoudManage(e.Meta) { return false } log.WithValues("namespace", e.Meta.GetNamespace(), "name", e.Meta.GetName()).Info("Call DeleteFunc") metrics.ClusterMetrics.DeleteCluster(e.Meta.GetNamespace(), e.Meta.GetName()) // Evaluates to false if the object has been confirmed deleted. return !e.DeleteStateUnknown }, CreateFunc: func(e event.CreateEvent) bool { // returns false if redisCluster is ignored (not managed) by this operator. if !shoudManage(e.Meta) { return false } log.WithValues("namespace", e.Meta.GetNamespace(), "name", e.Meta.GetName()).Info("Call CreateFunc") return true }, } err = c.Watch(&source.Kind{Type: &redisv1beta1.RedisCluster{}}, &handler.EnqueueRequestForObject{}, Pred)
其中,查看Update的object变化
e.MetaOld.GetGeneration() != e.MetaNew.GetGeneration()
如在*controller.go中的,SetupWithManager的WithEventFilter
func (r *UserIdentityV3Reconciler) SetupWithManager(mgr ctrl.Manager) error { // define userevent and run ch := make(chan event.GenericEvent) subscription := r.PubsubClient.Subscription("userevent") userEvent := CreateUserEvents(mgr.GetClient(), subscription, ch) go userEvent.Run() return ctrl.NewControllerManagedBy(mgr). For(&identityv2.UserIdentityV2{}). WithEventFilter(predicate.Or( predicate.GenerationChangedPredicate{}, predicate.NewPredicateFuncs(func(meta metav1.Object, object runtime.Object) bool { obj, ok := object.(Object) return ok && IsStatusConditionFalse(*obj.GetConditions(), metav1.ConditionFalse) }), )). Watches(&source.Channel{Source: ch, DestBufferSize: 1024}, &handler.EnqueueRequestForObject{}). Complete(r) }
Operator的常见逻辑包括了创建应用、监控应用状态、扩缩容、升级、故障恢复
3. Controller-Runtime库
controller-runtime
库提供了一些抽象,表示watch和那些利用CRUD操作(Create, Update, Delete, Get, List)来 调谐资源的操作。Operators 至少会使用一个控制器来实现集群内的多个任务,而且经常会有许多资源CRUD的操作。Operator SDK 使用controller-runtime提供的 Client interface,它们提供了这些操作的接口(主要是为了规范)。
controller-runtime 定义了几个接口用来作集群交互:
client.Client
:在一个Kubernetes 集群实现CRUD操作的实现.manager.Manager
: 管理共享的依赖,比如缓存和客户端(这个跟informer有关联)reconcile.Reconciler
: 比较集群内当前资源的状态和期望状态,并且利用Client更新集群内对应资源的状态。
4. 其他
4.1 使用Markers增加rbac和validation
1. Markers (annotations)是operator-sdk支持的解析注释格式,可用于配置验证API。
具有+kubebuilder:validation前缀。
2. controller需要RBAC权限,在代码里通过+kubebuilder注解实现,格式类似于
//+kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/finalizers,verbs=update
operator-sdk会处理这些注解并生成对应的rbac的yaml文件。
4.2 运行Operator
有三种方法可以运行Operator:
1.本地运行,用于调试
2.作为Kubernetes集群内的Deployment
3. OLM的bundle格式
make install run在本地调试
make deploy部署deployment
卸载operator
make undeploy
5. FAQ
reconcile里是否能自动根据update,delete,create分离逻辑处理?
A: 不能。应该实现为幂等的。
You should not. Reconcile functions should be idempotent, and should always reconcile state by reading all the state it needs, then writing updates. This allows your reconciler to correctly respond to generic events, adjust to skipped or coalesced events, and easily deal with application startup. The controller will enqueue reconcile requests for both old and new objects if a mapping changes, but it's your responsibility to make sure you have enough information to be able clean up state that's no longer referenced.
References
[1]管控代码减少80%,网易PaaS On Kubernetes实践, https://sq.sf.163.com/blog/article/375803912161443840
[2]基于Golang的Operator, https://sdk.operatorframework.io/docs/building-operators/golang/
[3] client-go 中的 informer 源码分析,https://jimmysong.io/kubernetes-handbook/develop/operator.html
[4] 使用 client-go 对 Kubernetes 进行自定义开发及源码分析 https://cloud.tencent.com/developer/article/1433227
[5] UCloud 基于 Kubernetes Operator 的服务化实践https://www.infoq.cn/article/pPP3LRqf8BApcg3azNL3
[6] https://developer.ibm.com/tutorials/kubernetes-operators-framework/
[7] K8S中编写自己的CRD及Controller简明指南, https://juejin.cn/post/6844904199029784589
[8] Operator SDK FAQ https://sdk.operatorframework.io/docs/faqs/