5- kubebuilder 實戰: CRUD

在前兩天的文章當中我們搭建好了本地的 K8s 開發環境,並且瞭解了 kubebuilder 的基本使用方法,今天就從我之前遇到的一個真實需求出發完整的寫一個 Operator

需求分析

背景

在 K8s 運行的過程當中我們發現總是存在一些業務由於安全,可用性等各種各樣的原因需要跑在一些獨立的節點池上,這些節點池裏面可能再劃分一些小的節點池。

雖然我們可以使用 TaintLabel對節點進行劃分,使用 nodeSelectortolerations讓 Pod 跑在指定的節點上,但是這樣主要會有兩個問題:

需求

方案設計

節點池資源如下

apiVersion: nodes.lailin.xyz/v1
kind: NodePool
metadata:
  name: test
spec:
  taints:
   - key: node-pool.lailin.xyz
     value: test
     effect: NoSchedule
  labels:
   node-pool.lailin.xyz/test: ""

節點和節點池之間的映射如何建立?

Pod 和節點池之間的映射如何建立?

注: 對於 MVP 版本來說其實我們不需要使用自定義資源,只需要通過標籤和 RuntimeClass 結合就能滿足需求,但是這裏爲了展示一個完整的流程,我們使用了自定義資源

開發

創建項目

# 初始化項目
kubebuilder init --repo github.com/mohuishou/blog-code/k8s-operator/03-node-pool-operator --domain lailin.xyz --skip-go-version-check

# 創建 api
kubebuilder create api --group nodes --version v1 --kind NodePool

定義對象

// NodePoolSpec 節點池
type NodePoolSpec struct {
 // Taints 污點
 Taints []v1.Taint `json:"taints,omitempty"`

 // Labels 標籤
 Labels map[string]string `json:"labels,omitempty"`
}

創建

我們實現 Reconcile 函數,req會返回當前變更的對象的 NamespaceName信息,有這兩個信息,我們就可以獲取到這個對象了,所以我們的操作就是

func (r *NodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
 _ = r.Log.WithValues("nodepool", req.NamespacedName)
 // 獲取對象
 pool := &nodesv1.NodePool{}
 if err := r.Get(ctx, req.NamespacedName, pool); err != nil {
  return ctrl.Result{}, err
 }

 var nodes corev1.NodeList

 // 查看是否存在對應的節點,如果存在那麼就給這些節點加上數據
 err := r.List(ctx, &nodes, &client.ListOptions{LabelSelector: pool.NodeLabelSelector()})
 if client.IgnoreNotFound(err) != nil {
  return ctrl.Result{}, err
 }

 if len(nodes.Items) > 0 {
  r.Log.Info("find nodes, will merge data""nodes", len(nodes.Items))
  for _, n := range nodes.Items {
   n := n
   err := r.Patch(ctx, pool.Spec.ApplyNode(n), client.Merge)
   if err != nil {
    return ctrl.Result{}, err
   }
  }
 }

 var runtimeClass v1beta1.RuntimeClass
 err = r.Get(ctx, client.ObjectKeyFromObject(pool.RuntimeClass())&runtimeClass)
 if client.IgnoreNotFound(err) != nil {
  return ctrl.Result{}, err
 }

 // 如果不存在創建一個新的
 if runtimeClass.Name == "" {
  err = r.Create(ctx, pool.RuntimeClass())
  if err != nil {
   return ctrl.Result{}, err
  }
 }

 return ctrl.Result{}, nil
}

更新

相信聰明的你已經發現上面的創建邏輯存在很多的問題

我們 MVP 版本實現可以簡單一些,我們約定,所有屬於 NodePool 的節點 TanitLabel信息都應該由 NodePool管理,key 包含 kubernetes 標籤污點除外

func (r *NodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
  // ....
  
 if len(nodes.Items) > 0 {
  r.Log.Info("find nodes, will merge data""nodes", len(nodes.Items))
  for _, n := range nodes.Items {
   n := n

   // 更新節點的標籤和污點信息
+   err := r.Update(ctx, pool.Spec.ApplyNode(n))
-   err := r.Patch(ctx, pool.Spec.ApplyNode(n), client.Merge)
   if err != nil {
    return ctrl.Result{}, err
   }
  }
 }

 //...

 // 如果存在則更新
+ err = r.Client.Patch(ctx, pool.RuntimeClass(), client.Merge)if err != nil {
+  return ctrl.Result{}, err
+ }

 return ctrl.Result{}, err
}

ApplyNode 方法如下所示,主要是修改節點的標籤和污點信息

// ApplyNode 生成 Node 結構,可以用於 Patch 數據
func (s *NodePoolSpec) ApplyNode(node corev1.Node) *corev1.Node {
 // 除了節點池的標籤之外,我們只保留 k8s 的相關標籤
 // 注意:這裏的邏輯如果一個節點只能屬於一個節點池
 nodeLabels := map[string]string{}
 for k, v := range node.Labels {
  if strings.Contains(k, "kubernetes") {
   nodeLabels[k] = v
  }
 }

 for k, v := range s.Labels {
  nodeLabels[k] = v
 }
 node.Labels = nodeLabels

 // 污點同理
 var taints []corev1.Taint
 for _, taint := range node.Spec.Taints {
  if strings.Contains(taint.Key, "kubernetes") {
   taints = append(taints, taint)
  }
 }

 node.Spec.Taints = append(taints, s.Taints...)
 return &node
}

我們使用 make run將服務跑起來測試一下

首先我們準備一份 NodePool 的 CRD,使用 kubectl apply -f config/samples/ 部署一下

apiVersion: nodes.lailin.xyz/v1
kind: NodePool
metadata:
  name: master
spec:
  taints:
    - key: node-pool.lailin.xyz
      value: master
      effect: NoSchedule
  labels:
    "node-pool.lailin.xyz/master""8"
    "node-pool.lailin.xyz/test""2"
  handler: runc

部署之後可以獲取到節點的標籤

   labels:
      beta.kubernetes.io/arch: amd64
      beta.kubernetes.io/os: linux
      kubernetes.io/arch: amd64
      kubernetes.io/hostname: kind-control-plane
      kubernetes.io/os: linux
      node-pool.lailin.xyz/master: "8"
      node-pool.lailin.xyz/test: "2"
      node-role.kubernetes.io/control-plane: ""
      node-role.kubernetes.io/master: ""

以及 RuntimeClass

apiVersion: node.k8s.io/v1
  handler: runc
  kind: RuntimeClass
  scheduling:
    nodeSelector:
      node-pool.lailin.xyz/master: "8"
      node-pool.lailin.xyz/test: "2"
    tolerations:
    - effect: NoSchedule
      key: node-pool.lailin.xyz
      operator: Equal
      value: master

我們更新一下 NodePool

apiVersion: nodes.lailin.xyz/v1
kind: NodePool
metadata:
  name: master
spec:
  taints:
    - key: node-pool.lailin.xyz
      value: master
      effect: NoSchedule
  labels:
+    "node-pool.lailin.xyz/master""10"
-    "node-pool.lailin.xyz/master""8"
-    "node-pool.lailin.xyz/test""2"
  handler: runc

可以看到 RuntimeClass

scheduling:
  nodeSelector:
    node-pool.lailin.xyz/master: "10"
  tolerations:
  - effect: NoSchedule
    key: node-pool.lailin.xyz
    operator: Equal
    value: master

和節點對應的標籤信息都有了相應的變化

   labels:
      beta.kubernetes.io/arch: amd64
      beta.kubernetes.io/os: linux
      kubernetes.io/arch: amd64
      kubernetes.io/hostname: kind-control-plane
      kubernetes.io/os: linux
      node-pool.lailin.xyz/master: "10"
      node-role.kubernetes.io/control-plane: ""
      node-role.kubernetes.io/master: ""

預刪除: Finalizers

我們可以直接使用 kubectl delete NodePool name刪除對應的對象,但是這樣可以發現一個問題,就是 NodePool 創建的 RuntimeClass 以及其維護的 Node Taint Labels 等信息都沒有被清理。

當我們想要再刪除一個對象的時候,清理一寫想要清理的信息時,我們就可以使用 Finalizers 特性,執行預刪除的操作。

k8s 的資源對象當中存在一個 Finalizers字段,這個字段是一個字符串列表,當執行刪除資源對象操作的時候,k8s 會先更新 DeletionTimestamp 時間戳,然後會去檢查 Finalizers 是否爲空,如果爲空纔會執行刪除邏輯。所以我們就可以利用這個特性執行一些預刪除的操作。注意:預刪除必須是冪等的

func (r *NodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
 _ = r.Log.WithValues("nodepool", req.NamespacedName)
 // ......

+ // 進入預刪除流程
+ if !pool.DeletionTimestamp.IsZero() {
+  return ctrl.Result{}, r.nodeFinalizer(ctx, pool, nodes.Items)}

+ // 如果刪除時間戳爲空說明現在不需要刪除該數據,我們將 nodeFinalizer 加入到資源中
+ if !containsString(pool.Finalizers, nodeFinalizer) {
+  pool.Finalizers = append(pool.Finalizers, nodeFinalizer)
+  if err := r.Client.Update(ctx, pool); err != nil {
+   return ctrl.Result{}, err
+  }}

 // ......
}

預刪除的邏輯如下

// 節點預刪除邏輯
func (r *NodePoolReconciler) nodeFinalizer(ctx context.Context, pool *nodesv1.NodePool, nodes []corev1.Node) error {
 // 不爲空就說明進入到預刪除流程
 for _, n := range nodes {
  n := n

  // 更新節點的標籤和污點信息
  err := r.Update(ctx, pool.Spec.CleanNode(n))
  if err != nil {
   return err
  }
 }

 // 預刪除執行完畢,移除 nodeFinalizer
 pool.Finalizers = removeString(pool.Finalizers, nodeFinalizer)
 return r.Client.Update(ctx, pool)
}

我們執行 kubectl delete NodePool master 然後再獲取節點信息可以發現,除了 kubernetes 的標籤其他 NodePool 附加的標籤都已經被刪除掉了

    labels:
      beta.kubernetes.io/arch: amd64
      beta.kubernetes.io/os: linux
      kubernetes.io/arch: amd64
      kubernetes.io/hostname: kind-control-plane
      kubernetes.io/os: linux
      node-role.kubernetes.io/control-plane: ""
      node-role.kubernetes.io/master: ""

OwnerReference

我們上面使用 Finalizer 的時候只處理了 Node 的相關數據,沒有處理 RuntimeClass,能不能用相同的方式進行處理呢?當然是可以的,但是不夠優雅。

對於這種一一映射或者是附帶創建出來的資源,更好的方式是在子資源的 OwnerReference 上加上對應的 id,這樣我們刪除對應的 NodePool 的時候所有 OwnerReference 是這個對象的對象都會被刪除掉,就不用我們自己對這些邏輯進行處理了。

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

 // 如果不存在創建一個新的
 if runtimeClass.Name == "" {
+  runtimeClass = pool.RuntimeClass()
+  err = ctrl.SetControllerReference(pool, runtimeClass, r.Scheme)
+  if err != nil {
+   return ctrl.Result{}, err
+  }
+  err = r.Create(ctx, runtimeClass)
-  err = r.Create(ctx, pool.RuntimeClass())
  return ctrl.Result{}, err
 }

 // ...
}

在創建的時候使用 controllerutil.SetOwnerReference 設置一下 OwnerReference 即可,然後我們再試試刪除就可以發現 RuntimeClass 也一併被刪除了。

注意,RuntimeClass 是一個集羣級別的資源,我們最開始創建的 NodePool 是 Namespace 級別的,直接運行會報錯,因爲 Cluster 級別的 OwnerReference 不允許是 Namespace 的資源。

這個需要在 api/v1/nodepool_types.go 添加一行註釋,指定爲 Cluster 級別

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

// NodePool is the Schema for the nodepools API
type NodePool struct {

修改之後我們需要先執行 make uninstall 然後再執行 make install

總結

回顧一下,這篇文章我們實現了一個 NodePool 的 Operator 用來控制節點以及對應的 RuntimeClass,除了基本的 CURD 之外我們還學習了預刪除和 OwnerReference 的使用方式。之前在 kubectl delete 某個資源的時候有時候會卡住,這個其實是因爲在執行預刪除的操作,可能本來也比較慢,也有可能是預刪除的時候返回了錯誤導致的。

下一篇我們一起來爲我們的 Operator 加上 Event 和 Status。

參考文獻

[^1]: 容器運行時類 (Runtime Class): https://kubernetes.io/zh/docs/concepts/containers/runtime-class/

[^2]: kubebuilder 進階使用: https://zhuanlan.zhihu.com/p/144978395

[^3]: kubebuilder2.0 學習筆記——搭建和使用 https://segmentfault.com/a/1190000020338350

[^4]: KiND - How I Wasted a Day Loading Local Docker Images: https://iximiuz.com/en/posts/kubernetes-kind-load-docker-image/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UDA55y5RCpzyDvfSfs9d2w