Contents

K8s Operator 开发 Part3:最佳实践

https://img.lixueduan.com/kubernetes/cover/k8s-operator-3-best-practices.png

本文主要分享如何 K8s Operator 开发最佳实践: 如使用 OwnerReference 做级联删除,使用 Finalizers 做资源清理等等。

前面两篇文章分别分享了如何开发 K8s Operator 以及如何在本地进行调试。

这篇主要分享一些 K8s Operator 开发的最佳实践,如使用 OwnerReference 做级联删除,使用 Finalizers 做资源清理等等。

1. 使用 OwnerReference 做级联删除与调谐触发

什么是 OwnerReference

K8s GC 在删除一个对象时,任何 ownerReference 是该对象的对象都会被清除,与此同时,Kubebuidler 支持所有对象的变更都会触发 Owner 对象 controller 的 Reconcile 方法。

例如:Pod 的 ownerReferences 是 ReplicasSet,ReplicasSet 的 ownerReferences 是 Deployment

$ kubectl get po keystone-api-548b4c7b4-psj2c -oyaml|grep ownerReferences -A 6
  ownerReferences:
  - apiVersion: apps/v1
    blockOwnerDeletion: true
    controller: true
    kind: ReplicaSet
    name: keystone-api-548b4c7b4
    uid: bacbadbe-32f8-4510-b34e-720d9aae5b8a
$ kubectl get po keystone-api-548b4c7b4-psj2c -oyaml|grep ownerReferences -A 6
  ownerReferences:
  - apiVersion: apps/v1
    blockOwnerDeletion: true
    controller: true
    kind: ReplicaSet
    name: keystone-api-548b4c7b4
    uid: bacbadbe-32f8-4510-b34e-720d9aae5b8a
$ kubectl get rs keystone-api-548b4c7b4 -oyaml|grep ownerReferences -A 6
  ownerReferences:
  - apiVersion: apps/v1
    blockOwnerDeletion: true
    controller: true
    kind: Deployment
    name: keystone-api
    uid: 6069be14-1823-442f-9536-489978945944

使用 OwnerRefrence 来做资源关联,有两个特性:

  • Owner 资源被删除,被关联的子资源会被级联删除,利用 K8s 的 GC 来做资源清理;

  • 子资源对象的变更事件变更可以触发 Owner 对象的 Reconcile 方法;

基于上述特性,我们可以优化两个地方:

  • 1)Application 对象的清理逻辑

  • 2)Application 对象调谐的触发

通过设置OwnerReference,如果Application 资源被删除,Kubernetes 也会自动删除Deployment资源。同时这也允许控制器监视Deployment中的变化,并确保保持所需的状态(例如 Deployment 镜像)。

比如:Deployment 中镜像被手动修改后,Controller watch 到变化,会自动将其恢复为 Application 中指定的镜像。

因此,我们可以借助 OwnerReference 来简化代码。

优化 App 对象的清理逻辑

之前 Controller 在清理 Application 时,需要先删除对应的 Deployment,然后才能删除 Application 上的 Finalizer

现在只需要子资源(Deployment) OwnerReferences 设置为 Application,这样删除 Application 时,K8s 的 GC 会自动删除关联的子资源(Deployment)

创建 Deployment 对象修改如下:

func (r *ApplicationReconciler) generateDeployment(app v1.Application) appsv1.Deployment {
    deploy := appsv1.Deployment{
       ObjectMeta: metav1.ObjectMeta{
          Name:      deploymentName(app.Name),
          Namespace: app.Namespace,
          Labels: map[string]string{
             "app": app.Name,
          },
       },
       Spec: appsv1.DeploymentSpec{
          Replicas: ptr.To(int32(1)), // 副本数
          Selector: &metav1.LabelSelector{
             MatchLabels: map[string]string{
                "app": app.Name,
             },
          },
          Template: corev1.PodTemplateSpec{
             ObjectMeta: metav1.ObjectMeta{
                Labels: map[string]string{
                   "app": app.Name,
                },
             },
             Spec: corev1.PodSpec{
                Containers: []corev1.Container{
                   {
                      Name:  app.Name,
                      Image: app.Spec.Image,
                      // Ports: []corev1.ContainerPort{
                      // {
                      //    ContainerPort: 80,
                      // },
                      // },
                   },
                },
             },
          },
       },
    }
    // Set the ownerRef for the Deployment, ensuring that the Deployment
    // will be deleted when the Application CR is deleted.
    _ = controllerutil.SetControllerReference(&app, &deploy, r.Scheme)
    return deploy
}

增加下面这一句,为 Deploy 对象设置 Owner

    _ = controllerutil.SetControllerReference(&app, &deploy, r.Scheme)

这样就可以借助 K8s 的 GC 逻辑,当 Application CR 对象被删除时,关联的 Deployment 对象会自动删除。

优化 App 对象调谐的触发

之前为了能够在 Deployment 变化时也触发调谐逻辑,我们通过 EnqueueRequestsFromMapFunc 监听 Deployment 对象的变化,并从 Deployment Label 中获取 App 名称,以此来触发 Application 对象的调谐,就像这样:

// SetupWithManager sets up the controller with the Manager.
func (r *ApplicationReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
       For(&v1.Application{}).
       Watches(&appsv1.Deployment{},
          handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request {
             app, ok := obj.GetLabels()["app"]
             if !ok { // if no app label,means not owned by app,do nothing
                return nil
             }
             return []ctrl.Request{{NamespacedName: types.NamespacedName{
                Namespace: obj.GetNamespace(),
                Name:      app, // return name is app name,not deployment name
             },
             }}
          })).
       Named("application").
       Complete(r)
}

现在只需要给 Deployment 添加 OwnerReferences,指定为对应的 Application 对象,然后在 SetupWithManager 中通过 Owns 指定即可, 这样当 Deployment 变化时就会触发关联 Application 对象的调谐,这样就可以移除这部分逻辑了。

完整代码如下:

// SetupWithManager sets up the controller with the Manager.
func (r *ApplicationReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
       For(&v1.Application{}).
       Owns(&appsv1.Deployment{}).
       Named("application").
       Complete(r)
}

配置子资源权限

为了 Watch 子资源,以触发调谐,需要给 Controller 赋予足够的权限。

在对于位置增加 Marker 赋予 Deployment CRUD 权限。

// +kubebuilder:rbac:groups=core.crd.lixueduan.com,resources=applications,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core.crd.lixueduan.com,resources=applications/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core.crd.lixueduan.com,resources=applications/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete


// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the Application object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/reconcile
func (r *ApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
}

核心部分就是下面这一句:

// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete

2. 使用 Finalizers 做资源清理

Finalizer 作用

K8s 官方文档:Finalizers

Finalizers 是 K8s 所有 API 对象中 metadata 中的一个字段,主要用于做资源清理工作。

当我们删除某个 API 对象时,如果存在 Finalizers 那么 api-server 不会立即将其删除,而是修改该对象的 metadata.deletionTimestamp 为当前时间,用于标记该对象已经被删除。

但是由于 Finalizers 存在,api-server 不会立即将其删除,因为删除后,Controller 就无法再查下到该对象,也就无法做清理工作了。

对于我们的 Operator 来说,建议为所有 CRD 都添加上 Finalizers,便于资源清理工作可以正常执行。

举个🌰:

假设我们定义了一个 VM 对象,在集群中创建一个 VM 对象之后,Controller 都会真正启动一台虚拟机,那么我们删除 VM 对象时自然也需要删除对应的虚拟机。

此时就可以使用 Finalizers,工作流程如下:

  • 1)用户创建 VM 对象

  • 2)Controller 检测到没有 Finalizers 则添加

  • 3)Controller 检测到 VM 对象创建,创建虚拟机

  • 4)用户删除 VM 对象

  • 5)api-server 检测到 VM 对象有 Finalizers,并未立即删除该对象,而是修改 metadata.deletionTimestamp 为当前时间,表示该对接处于删除中

  • 6)Controller 检测到 VM 对象 metadata.deletionTimestamp 不为 0,知道该对象已经在删除中,于是执行清理操作,删除虚拟机,清理完成后删除 VM 对象上的 Finalizers

  • 7) 检测到 VM 对象 metadata.deletionTimestamp 不为 0,且 Finalizers 为空,该对象被真正删除

代码逻辑大概是这样的:

  • 对于 DeletionTimestamp 为 0 的对象,说明是新建的,如果没有 Finalizers 就添加

  • 对于 DeletionTimestamp 不为 0 的对象,说明已经在删除中了,执行资源清理工作,清理完成后移除 Finalizers,让该对象能够成功被删除

if app.ObjectMeta.DeletionTimestamp.IsZero() {
    // The object is not being deleted, so if it does not have our finalizer,
    // then lets add the finalizer and update the object.
    if !sets.NewString(app.ObjectMeta.Finalizers...).Has(AppFinalizer) {
       log.Log.Info("new app,add finalizer", "app", req.NamespacedName)
       app.ObjectMeta.Finalizers = append(app.ObjectMeta.Finalizers, AppFinalizer)
       if err = r.Update(ctx, &app); err != nil {
          log.Log.Error(err, "unable to add finalizer to application", "app", req.NamespacedName)
          return ctrl.Result{}, err
       }
    }
} else {
    // The object is being deleted
    // if our finalizer is present, handle deletion
    // if not present, maybe clean up was already done,do nothing
    if sets.NewString(app.ObjectMeta.Finalizers...).Has(AppFinalizer) {
       log.Log.Info("app deleted, clean up", "app", req.NamespacedName)

       // do clean up

       // remove our finalizer from the list and update it.
       app.ObjectMeta.Finalizers = sets.NewString(app.ObjectMeta.Finalizers...).Delete(AppFinalizer).UnsortedList()
       if err = r.Update(ctx, &app); err != nil {
          log.Log.Error(err, "unable to delete finalizer", "app", req.NamespacedName)
          return ctrl.Result{}, err
       }
    }
    // if no finalizer,do nothing
    return ctrl.Result{}, nil
}

Kubebuilder 中使用

对于有外部资源依赖的 CRD,推荐使用 Finalizers 来保证外部资源正常清理。

Kubebuilder 默认生成的 RBAC Rule 就包含了更新 Finalizers 的权限,因此权限部分我们不需要额外处理。

// +kubebuilder:rbac:groups=core.crd.lixueduan.com,resources=applications,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core.crd.lixueduan.com,resources=applications/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core.crd.lixueduan.com,resources=applications/finalizers,verbs=update

然后就是代码实现,也是固定格式,相关代码如下:

func (r *ApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {

    logger := log.FromContext(ctx)
    log := logger.WithValues("application", req.NamespacedName)
    log.Info("start reconcile")
    
    // query app
    var app v1.Application
    err := r.Get(ctx, req.NamespacedName, &app)
    if err != nil {
       log.Error(err, "unable to fetch application")
       // we'll ignore not-found errors, since they can't be fixed by an immediate
       // requeue (we'll need to wait for a new notification), and we can get them
       // on deleted requests.
       return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // examine DeletionTimestamp to determine if object is under deletion
    if app.ObjectMeta.DeletionTimestamp.IsZero() {
       // The object is not being deleted, so if it does not have our finalizer,
       // then lets add the finalizer and update the object. This is equivalent
       // to registering our finalizer.
       if !controllerutil.ContainsFinalizer(&app, AppFinalizer) {
          controllerutil.AddFinalizer(&app, AppFinalizer)
          if err = r.Update(ctx, &app); err != nil {
             log.Error(err, "unable to add finalizer to application")
             return ctrl.Result{}, err
          }
       }
    } else {
       // The object is being deleted
       if controllerutil.ContainsFinalizer(&app, AppFinalizer) {
          // our finalizer is present, so lets handle any external dependency
          if err = r.deleteExternalResources(&app); err != nil {
             log.Error(err, "unable to cleanup application")
             // if fail to delete the external dependency here, return with error
             // so that it can be retried.
             return ctrl.Result{}, err
          }

          // remove our finalizer from the list and update it.
          controllerutil.RemoveFinalizer(&app, AppFinalizer)
          if err = r.Update(ctx, &app); err != nil {
             return ctrl.Result{}, err
          }
       }

       // Stop reconciliation as the item is being deleted
       return ctrl.Result{}, nil
    }

    // Your reconcile logic



    return ctrl.Result{}, nil
}

func (r *ApplicationReconciler) deleteExternalResources(app *v1.Application) error {
    //
    // delete any external resources associated with the cronJob
    //
    // Ensure that delete implementation is idempotent and safe to invoke
    // multiple times for same object.
    return nil
}

第一步是准备好 log,通过 WithValues 把 App 名称带上,这样后续打印日志时都会有 App 信息了。

    logger := log.FromContext(ctx)
    log := logger.WithValues("application", req.NamespacedName)
    log.Info("start reconcile")

第二步则是查询该对象详情,这里需要注意的是要使用client.IgnoreNotFound(err) 来忽略 NotFound Error,因为查询不到说明该对象已经被删除了,那就不需要在走 Reconcile 逻辑了,直接返回即可。

    var app v1.Application
    err := r.Get(ctx, req.NamespacedName, &app)
    if err != nil {
       log.Error(err, "unable to fetch application")
       // we'll ignore not-found errors, since they can't be fixed by an immediate
       // requeue (we'll need to wait for a new notification), and we can get them
       // on deleted requests.
       return ctrl.Result{}, client.IgnoreNotFound(err)
    }

第三步则是管理 Finalizers, 根据 DeletionTimestamp 来判断该对象是在删除中:

  • 不在删除中:判断是否有携带 Finalizers,没有就添加,有则不做任何处理

  • 在删除中:判断是否有携带 Finalizers,有则做资源清理,清理完移除 Finalizers,若没有则说明已经执行过资源清理了,直接返回,终止 Reconcile。

// examine DeletionTimestamp to determine if object is under deletion
if app.ObjectMeta.DeletionTimestamp.IsZero() {
    // The object is not being deleted, so if it does not have our finalizer,
    // then lets add the finalizer and update the object. This is equivalent
    // to registering our finalizer.
    if !controllerutil.ContainsFinalizer(&app, AppFinalizer) {
       controllerutil.AddFinalizer(&app, AppFinalizer)
       if err = r.Update(ctx, &app); err != nil {
          log.Error(err, "unable to add finalizer to application")
          return ctrl.Result{}, err
       }
    }
} else {
    // The object is being deleted
    if controllerutil.ContainsFinalizer(&app, AppFinalizer) {
       // our finalizer is present, so lets handle any external dependency
       if err = r.deleteExternalResources(&app); err != nil {
          log.Error(err, "unable to cleanup application")
          // if fail to delete the external dependency here, return with error
          // so that it can be retried.
          return ctrl.Result{}, err
       }

       // remove our finalizer from the list and update it.
       controllerutil.RemoveFinalizer(&app, AppFinalizer)
       if err = r.Update(ctx, &app); err != nil {
          return ctrl.Result{}, err
       }
    }

    // Stop reconciliation as the item is being deleted
    return ctrl.Result{}, nil
}

注意点

  • 不使用 Finalizer 时,资源被删除无法获取任何信息;

  • 对象的 Status 字段变化也会触发 Reconcile 方法;

  • Reconcile 逻辑需要幂等;

对象的 Status 字段变化也会触发 Reconcile 方法

为了防止触发无意义的 Reconcile,我们需要在更新 status 前判断是否需要更新,就像这样:

copyApp := app.DeepCopy()
// now,if ready replicas is gt 1,set status to true
copyApp.Status.Ready = deploy.Status.ReadyReplicas >= 1
if !reflect.DeepEqual(app, copyApp) { // update when changed
    log.Log.Info("sync app status", "app", req.NamespacedName)
    if err = r.Client.Status().Update(ctx, copyApp); err != nil {
       log.Log.Error(err, "unable to update application status", "app", req.NamespacedName)
       return ctrl.Result{}, err
    }
}

通过DeepCopy 复制一份,然后修改状态,修改后使用 reflect.DeepEqual 进行判断,只有不一致时才做更新,以避免触发无意义的 Reconcile。

Reconcile 逻辑需要幂等

为了确保 Kubernetes 中资源状态的一致性和可靠性。无论 Reconcile 操作被触发多少次,最终的结果应该是相同的,不会引入副作用或者错误的状态变化。以下是为什么需要幂等的几个重要原因:

1)确保资源一致性:

例如,假设你的 Controller 需要创建一个虚拟机(VM):

  • 如果 Reconcile 逻辑不是幂等的,它可能会尝试创建相同的虚拟机多次,导致重复的创建请求或错误。

  • 如果 Reconcile 逻辑是幂等的,即使它被多次触发,虚拟机也只会被创建一次,确保资源的一致性。

2)自动恢复与重复触发

在 Kubernetes 中,Reconcile 操作可能会被多次触发。这些触发可以由多种原因引起:

  • 资源的状态发生变化(如 spec 更新)。

  • 资源的状态回退(如 Controller 失败或节点失效后重新调度)。

  • 手动触发的 kubectl apply 或其他工具更新资源。

3)提高系统容错

Kubernetes 系统是高度分布式的,任何组件的失败(如网络问题、API Server 重启、Controller Pod 崩溃等)都可能导致 Reconcile 操作被重复执行。为了确保系统在这种情况下的鲁棒性和容错性,Reconcile 逻辑必须具备幂等性。

….

有很多原因,但是最根本的一个就是同一个对象的 Reconcile 操作会被触发很多次,为了保证正确性,Reconcile 操作必须要幂等。

3. 使用 Event 记录重要节点

当经过某些重要节点时,可以记录以下 Event,就像这样:

Event(object runtime.Object, eventtype, reason, message string)

首先需要在 Reconciler 对象上增加 Recorder 字段

// ApplicationReconciler reconciles a Application object
type ApplicationReconciler struct {
    client.Client
    Scheme *runtime.Scheme
    // See that we added the following code to allow us to pass the record.EventRecorder
    Recorder record.EventRecorder
}

并在启动时初始化该字段

if err = (&controller.ApplicationReconciler{
    Client: mgr.GetClient(),
    Scheme: mgr.GetScheme(),
    // Note that we added the following line:
    Recorder: mgr.GetEventRecorderFor("application-controller"),
}).SetupWithManager(mgr); err != nil {
    setupLog.Error(err, "unable to create controller", "controller", "Application")
    os.Exit(1)
}

现在就可以记录 Event 了。

比如

if app.ObjectMeta.DeletionTimestamp.IsZero() {
    // The object is not being deleted, so if it does not have our finalizer,
    // then lets add the finalizer and update the object. This is equivalent
    // to registering our finalizer.
    if !controllerutil.ContainsFinalizer(&app, AppFinalizer) {
       controllerutil.AddFinalizer(&app, AppFinalizer)
       if err = r.Update(ctx, &app); err != nil {
          log.Error(err, "unable to add finalizer to application")
          return ctrl.Result{}, err
       }
       // .. 添加
       // finalizer 时记录一个事件
       r.Recorder.Eventf(&app, corev1.EventTypeNormal, "AddFinalizer", fmt.Sprintf("add finalizer %s", AppFinalizer))
    }
} else {
    // The object is being deleted
    if controllerutil.ContainsFinalizer(&app, AppFinalizer) {
       // .. 
       // 移除 finalizer 时记录一个事件
       r.Recorder.Eventf(&app, corev1.EventTypeNormal, "RemoveFinalizer", fmt.Sprintf("remove finalizer %s", AppFinalizer))
    }
    
    if !reflect.DeepEqual(app, copyApp) { // update when changed
    log.Info("app changed,update app status")
    if err = r.Client.Status().Update(ctx, copyApp); err != nil {
       log.Error(err, "unable to update application status")
       return ctrl.Result{}, err
    }
    
    // 状态变化时也记录一个
    r.Recorder.Eventf(&app, corev1.EventTypeNormal, "UpdateStatus", fmt.Sprintf("update status from %v to %v", app.Status, copyApp.Status))
}

不推荐为所有操作都记录 Event,仅推荐在重要节点记录,避免过多 Event 影响集群稳定性。

4. Watching Resource

除了会自动 Watch OwnerReference 设置 的子资源之外,也可以手动设置 Watch 指定资源。

Watching Secondary Resources that are NOT Owned

这部分就像之前 Watch Deployment 一样,通过 EnqueueRequestsFromMapFunc 从 Deploy 触发 Application。

// SetupWithManager sets up the controller with the Manager.
func (r *ApplicationReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
       For(&v1.Application{}).
       Watches(&appsv1.Deployment{},
          handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request {
             app, ok := obj.GetLabels()["app"]
             if !ok { // if no app label,means not owned by app,do nothing
                return nil
             }
             return []ctrl.Request{{NamespacedName: types.NamespacedName{
                Namespace: obj.GetNamespace(),
                Name:      app, // return name is app name,not deployment name
             },
             }}
          })).
       Named("application").
       Complete(r)
}

Using Predicates to refine watches

可以使用 Predicates 来做过滤,Predicates 允许您基于事件(如创建、更新或删除)和资源字段(如标签、批注或状态字段)定义条件,通过使用 Predicates 可以优化控制器的行为,使其仅响应它所监视的资源中的特定更改。

ps:通过使用 Predicates,可以避免不必要的协调,并可以确保控制器只对相关的更改做出反应。

Predicates 一般在以下情况下使用:

  • 您希望忽略某些更改,例如不影响控制器所关注的字段的更新。

    • 比如仅更新了 label 增加了部分标记信息,可能就不需要触发 Controller。
  • 您希望仅为具有特定 labels 或者 annotations 的资源触发协调。

  • 您希望监视外部资源并仅对特定更改做出反应。

定义 predicate

首先定义 predicate,限制完整代码如下:

package controller

import (
    v1 "github.com/lixd/i-operator/api/v1"
    "sigs.k8s.io/controller-runtime/pkg/event"
    "sigs.k8s.io/controller-runtime/pkg/predicate"
)

// Predicate to trigger reconciliation only on size changes in the Busybox spec
var updatePred = predicate.Funcs{
    // Only allow updates when the spec.image or spec.enabled of the Application resource changes
    UpdateFunc: func(e event.UpdateEvent) bool {
       oldObj := e.ObjectOld.(*v1.Application)
       newObj := e.ObjectNew.(*v1.Application)

       // Trigger reconciliation only if the spec.size field has changed
       return oldObj.Spec.Image != newObj.Spec.Image || oldObj.Spec.Enabled != newObj.Spec.Enabled
    },

    // Allow create events
    CreateFunc: func(e event.CreateEvent) bool {
       return true
    },

    // Allow delete events
    DeleteFunc: func(e event.DeleteEvent) bool {
       return true
    },

    // Allow generic events (e.g., external triggers)
    GenericFunc: func(e event.GenericEvent) bool {
       return true
    },
}

核心逻辑:

    UpdateFunc: func(e event.UpdateEvent) bool {
       oldObj := e.ObjectOld.(*v1.Application)
       newObj := e.ObjectNew.(*v1.Application)

       // Trigger reconciliation only if the spec.size field has changed
       return oldObj.Spec.Image != newObj.Spec.Image || oldObj.Spec.Enabled != newObj.Spec.Enabled
    },

Update 时增加限制,只有 spec.image 和 spec.enabled 两个字段有变化时才触发调谐逻辑,从而忽略 Application 对象的无关变化,减少无效的调谐触发。

只处理了 Update 事件,对于 Generic、Create、Delete 等事件,并未做限制。

使用 predicate

在 Watches 中增加 WithPredicates Option,指定上一步中定义的 updatePred。

// SetupWithManager sets up the controller with the Manager.
func (r *ApplicationReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
       For(&v1.Application{}).
       Watches(&appsv1.Deployment{},
          handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request {
             app, ok := obj.GetLabels()["app"]
             if !ok { // if no app label,means not owned by app,do nothing
                return nil
             }
             return []ctrl.Request{{NamespacedName: types.NamespacedName{
                Namespace: obj.GetNamespace(),
                Name:      app, // return name is app name,not deployment name
             },
             }}
          }), builder.WithPredicates(updatePred)).
       Named("application").
       Complete(r)
}

ps:由于 Demo 中 Deploy 已经作为子资源了,这里 Watches 并不需要的,只作为参考。

5. Kubebuilder 支持的标记元数据(Marker)

在 Kubebuilder 中很多功能都可以使用 标记元数据 Marker 来实现,比如之前配置权限时就是用的 Marker,具体格式如下:

// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete

Marker 格式

//+ kubebuilder xxx 格式的注释就是 Kubebuilder 支持的标记元数据,也叫做 Marker,这些标记的作用就是告诉controller-tools生成额外的信息。

例如,我们的 Application 对象上就新增了 marker 来设置 shortName 和 printcolumn 等信息。

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName="app"
// +kubebuilder:printcolumn:name="Image",type="string",JSONPath=`.spec.image`
// +kubebuilder:printcolumn:name="Enabled",type=boolean,JSONPath=`.spec.enabled`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.ready`
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"

// Application is the Schema for the applications API.
type Application struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   ApplicationSpec   `json:"spec,omitempty"`
    Status ApplicationStatus `json:"status,omitempty"`
}

大致包括三种类型:

  • CRD

  • Controller

  • Webhook

下面分别介绍一下,几个部分常用的一些 Marker,更详细的大家可以查阅 Kubebuilder Marker 官方文档

CRD 部分

CRD 部分

api/v1/application_types.go

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName="app"
// +kubebuilder:printcolumn:name="Image",type="string",JSONPath=`.spec.image`
// +kubebuilder:printcolumn:name="Enabled",type=boolean,JSONPath=`.spec.enabled`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.ready`
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"

// Application is the Schema for the applications API.
type Application struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   ApplicationSpec   `json:"spec,omitempty"`
    Status ApplicationStatus `json:"status,omitempty"`
}

这里有两个后加入的 marker,分别讲一下:

  • shortName:定义当前 CRD 的短命,完整名称为 Application,这里定义了 shortName 为 app,这样使用时会比较方便。

  • printcolumn:定义执行 kubectl get 命令是展示的字段

CRD Validation

CRDs 支持使用 OpenAPI v3 schema 格式的校验规范,就像这样:

type ToySpec struct {
    // +kubebuilder:validation:MaxLength=15
    // +kubebuilder:validation:MinLength=1
    Name string `json:"name,omitempty"`

    // +kubebuilder:validation:MaxItems=500
    // +kubebuilder:validation:MinItems=1
    // +kubebuilder:validation:UniqueItems=true
    Knights []string `json:"knights,omitempty"`

    Alias   Alias   `json:"alias,omitempty"`
    Rank    Rank    `json:"rank"`
}

// +kubebuilder:validation:Enum=Lion;Wolf;Dragon
type Alias string

// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=3
// +kubebuilder:validation:ExclusiveMaximum=false
type Rank int32

自定义 CRD 展示字段

对于 CRD,默认情况下,我们查询时只会展示 NAME 和 AGE 两列,那么如何增加自定义列呢?

[root@bench ~]# kubectl get applications
NAME   AGE
demo   3s

对于 Kubebuilder,可以通过在对应资源上增加注释的形式来增加展示的列,就像这样:

// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
  • name 就是 kubectl 命令中展示的 表头名字

  • type 就是数据的类型,取值:boolean,date,integer,number,string

  • JSONPath 则是配置这个字段该怎么取值

需要注意的是:配置自定义列之后,默认的 Age 列就不会展示了,因此需要加上,一般固定这样就行:

// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"

完整内容如下:

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName="app"
// +kubebuilder:printcolumn:name="Image",type="string",JSONPath=`.spec.image`
// +kubebuilder:printcolumn:name="Enabled",type=boolean,JSONPath=`.spec.enabled`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.ready`
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"

// Application is the Schema for the applications API.
type Application struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   ApplicationSpec   `json:"spec,omitempty"`
    Status ApplicationStatus `json:"status,omitempty"`
}

然后重新生成 manifest 并安装

❯ make install
/Users/lixueduan/17x/projects/i-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases16:34:22  
/Users/lixueduan/17x/projects/i-operator/bin/kustomize build config/crd | kubectl apply -f -
customresourcedefinition.apiextensions.k8s.io/applications.core.crd.lixueduan.com configured

新生成的 CRD yaml 中增加了以下信息

- additionalPrinterColumns:
  - jsonPath: .spec.image
    name: Image
    type: string
  - jsonPath: .spec.enabled
    name: Enabled
    type: boolean
  - jsonPath: .status.ready
    name: Ready
    type: string
  - jsonPath: .metadata.creationTimestamp
    name: Age
    type: date

再次查看

[root@bench ~]# kubectl get applications
NAME   IMAGE        ENABLED   READY   AGE
demo   nginx:1.22   true      true    16m

自定义 CRD Shortname

默认情况下,我们需要使用 CRD 的完整名称来操作,比如现在我们要使用 applications,就像这样

[root@bench ~]# kubectl get applications
NAME   AGE
demo   3s

那能不能指定一个 shortname 呢,比如 app,在 Kubebuilder 中同样可以通过注释来指定,例如:

// +kubebuilder:resource:shortName="app"

然后重新生成 manifest 并安装

❯ make install
/Users/lixueduan/17x/projects/i-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases16:34:22  
/Users/lixueduan/17x/projects/i-operator/bin/kustomize build config/crd | kubectl apply -f -
customresourcedefinition.apiextensions.k8s.io/applications.core.crd.lixueduan.com configured

CRD 增加了 shortNames 配置:

spec:
  group: core.crd.lixueduan.com
  names:
    kind: Application
    listKind: ApplicationList
    plural: applications
    shortNames:
    - app

现在就可以使用 shortName app 来操作了

[root@bench ~]# kubectl get app
NAME   IMAGE        ENABLED   READY   AGE
demo   nginx:1.22   true      true    16m

自定义 CRD Subresources

由于用户会更新 Application 对象,我们的 Controller 也会更新 Application,那么就可能会产生冲突。

一般推荐做法则是,将 status 作为 subresource,这个也是 Kubebuilder 默认的,对应的 marker 如下:

// +kubebuilder:subresource:status

将状态作为子资源,更新时使用 app.Status().Update() 做局部更新,避免并发冲突。

  • scale:正确指定 replicas 字段的 specpath 以及 statuspath 后,可以使用kubectl scale命令的形式来方便地修改 CRD 的 replicas 值,就像 Deployment
//+kubebuilder:subresource:scale:specpath=.spec.replicas,statuspath=.status.replicas,selectorpath=.status.selector

Controller

因为在 Controller 中需要访问集群中的资源对象,因此 Controller 中也默认生成了 RBAC 相关的 Marker。

internal/controller/application_controller.go

// +kubebuilder:rbac:groups=core.crd.lixueduan.com,resources=applications,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core.crd.lixueduan.com,resources=applications/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core.crd.lixueduan.com,resources=applications/finalizers,verbs=update

func (r *ApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
//....
}

Webhook

Webhook 这里的 Marker 主要定义 Webhook 信息,包括:path、failurePolicy、group 等等

internal/webhook/v1/application_webhook.go

// +kubebuilder:webhook:path=/mutate-core-crd-lixueduan-com-v1-application,mutating=true,failurePolicy=fail,sideEffects=None,groups=core.crd.lixueduan.com,resources=applications,verbs=create;update,versions=v1,name=mapplication-v1.lixueduan.com,admissionReviewVersions=v1
type ApplicationCustomDefaulter struct {
    // TODO(user): Add more fields as needed for defaulting
}

// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation.
// NOTE: The 'path' attribute must follow a specific pattern and should not be modified directly here.
// Modifying the path for an invalid path can cause API server errors; failing to locate the webhook.
// +kubebuilder:webhook:path=/validate-core-crd-lixueduan-com-v1-application,mutating=false,failurePolicy=fail,sideEffects=None,groups=core.crd.lixueduan.com,resources=applications,verbs=create;update,versions=v1,name=vapplication-v1.lixueduan.com,admissionReviewVersions=v1

type ApplicationCustomValidator struct {
    // TODO(user): Add more fields as needed for validation
}

6. 其他

Controller Scope

设置 CRD Scope

在创建 API 时通过--namespaced 参数来指定时 namespace 还是 cluster 范围的 CRD,当不设置时,默认会当做 namespaced。

kubebuilder create api --group core --version v1 --kind Application --namespaced=true

如果已经创建,也可以直接更新 Marker 方式修改, 对应 Marker 如下:

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:resource:scope=Cluster,shortName=mc

Controller 作用域调整

同时,也可以指定 Controller 的 作用域,默认是 cluster 范围, 即:默认情况下 Controller 会 Watch 所有 namespace 下的 CRD,可以通过设置让 Controller 只 watch 某些特定 namespace 的对象。

默认是这样的,没有做相关配置:

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
...
})

指定单个 namespace

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
...
   Cache: cache.Options{
      DefaultNamespaces: map[string]cache.Config{"operator-namespace": cache.Config{}},
   },
})

指定多个 namespace

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
...
Cache: cache.Options{
    DefaultNamespaces: map[string]cache.Config{
        "operator-namespace1": cache.Config{},
        "operator-namespace2": cache.Config{},
        },
    },
})

Use External Resource

部分情况下,我们可能需要为外部 API 对象定义一个 Controller,比如:

  • K8s Core API:例如 Pod、Service、Deployment。

  • 其他项目定义的 CRD

外部 CRD

同样是使用 create api 命令,不过需要指定 --resource=false flag 表示不创建资源对象,并通过 --external-api-path-external-api-domain 等 flag 指定导入资源信息。

kubebuilder create api --group <theirgroup> --version <theirversion> --kind <theirKind> --controller --resource=false --external-api-path=<their Golang path import> --external-api-domain=<theirdomain>

要创建 Webhook 也是类似的操作

kubebuilder create webhook --group certmanager --version v1 --kind Issuer --defaulting --programmatic-validation --external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 --external-api-domain=cert-manager.io

K8s Core API

对于 K8s 的 API 对象,不需要从外部导入,直接使用即可,以 Pod 为例:

创建 Controller:

kubebuilder create api --group core --version v1 --kind Pod --controller=true --resource=false

创建 Webhook

kubebuilder create webhook --group core --version v1 --kind Pod --programmatic-validation

PROJECT 配置文件详解

项目根目录会生成一个名为 PROJECT 的文件,内容一般长这样:

domain: crd.lixueduan.com
layout:
- go.kubebuilder.io/v4
projectName: i-operator
repo: github.com/lixd/i-operator
resources:
- api:
    crdVersion: v1
    namespaced: true
  controller: true
  domain: crd.lixueduan.com
  group: core
  kind: Application
  path: github.com/lixd/i-operator/api/v1
  version: v1
  webhooks:
    defaulting: true
    validation: true
    webhookVersion: v1
version: "3"

这里面则是记录了 Kubebuilder 的项目配置信息,各个字段含义如下:

  • layout:定义了全局插件,比如当前只有一个 go.kubebuilder.io/v4 插件

  • domain:项目域名

  • projectName:项目名称

  • repo:项目 go module 定义的地址

  • version:项目版本,当前为 3

  • resources 项目中定义的资源列表

    • api.crdVersion:CRD 对象的 Kubernetes API version

    • api.namespaced:API 的 RBAC 权限,可以是 namespaced 或者 cluster 范围。

    • controller:是否为 CRD 对象创建 Controller

    • domain:资源的 domain

    • group:资源的 group,GVK 中的 G

    • kind:资源的 kind,GVK 中的 K

    • version:资源的 version,GVK 中的 V

    • path:资源路径,为 <repo>/api/<kind> 格式

    • webook.defaulting:是否创建 Mutating Webhook

    • webhook.validation:是否创建 Validating Webhook

    • webhook.webhookVersion:Webhook 对象的 Kubernetes API version

通过该文件,我们可以知道当前项目的大致情况,包括创建了哪些 CR 对象,对象的 domain、GVK、是否有 Webhook 等等。

7. 小结

本文主要分享如何 K8s Operator 开发最佳实践:

  • 1)使用 OwnerReference 做级联删除
  • 2)使用 Finalizers 做资源清理等等
  • 3)使用 Event 记录重要节点
  • 4)多种 Watch Resource 方式
  • 5)Kubebuilder 支持的标记元数据(Marker)
  • 6)Controller 作用域以及 PROJECT 配置文件作用

在分享下前面两篇文章: