1. 程式人生 > 實用技巧 >如何自定義Kubernetes資源

如何自定義Kubernetes資源

目前最流行的微服務架構非Springboot+Kubernetes+Istio莫屬, 然而隨著越來越多的微服務被拆分出來, 不但Deploy過程boilerplate的配置越來越多, 且繁瑣易錯, 維護成本也逐漸增高, 那麼是時候採用k8s提供的擴充套件自定義資源的方法, 將重複的template抽到後面, 從而簡化Deploy配置的數量與複雜度.

Tips:
一個基礎的k8s微服務應該由幾部分組成, 首先Deployment負責App的部署, Service負責埠的暴露, ServiceAccount負責賦予Pod相應的Identity, ServiceRole+RoleBinding負責api的訪問許可權控制, VirtualService負責路由, 如果我們將許可權控制從RBAC

如果我們通過自定義資源的方式, 將每個微服務App的共有配置封裝起來, 暴露出可變部分供各個應用配置, 如image, public/private api, mesh內訪問許可權等, 並設定mandatory/required的欄位與validation pattern, 這樣每一個App只需要一個配置檔案, 就可以完成統一部署.

下面我們可以首先看一下k8s提供的擴充套件的兩種方法.

  • 通過Aggregated Apiserver
  • 通過Custom Resource Defination

這兩種最大的區別就是, 前者需要自己實現一個使用者自定義的Apiserver, 而後者是被kube-apiserver內的extension apiserver module所處理.

二者都需要通過自定義Controller來處理資源的配置.

建立CRD

雖然看似AA的開放程度更高, 但實際上通過CRD來定義自定義資源更成熟且方便. 通過官方文件, 我們可以看到CRD的定義規則, 如下面我們定義的一個資源Beer:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: beer.weyoung.io
spec:
  group: weyoung.io
  versions: 
    - name: v1alpha1
      storage: true
      served: true
      schema: 
          openAPIV3Schema: 
            type: object
            properties:
              spec:
                type: object
                properties:
                  BeerName:
                    description: This is a custom field of beer      
                    pattern: ^[a-z]+$
                    type: string
                required: 
                - beerName
            required: 
            - spec
            type: object
  names:
    kind: Beer 
    listKind: BeerList
    singular: beer
    plural: beers
  scope: Namespaced

這裡我們添加了一個自定義欄位beerName, 指定了pattern, 並且設定了spec與其下的beerName死required欄位. 其餘關鍵字可以查閱官方文件.

在apply該配置後, 通過kubectl get crd即可獲得已經存在的資源.

 > kubectl get crd
NAME                   CREATED AT
beers.weyoung.io   2020-12-12T17:28:34Z

有了CRD之後, 我們就可以apply自定義的資源了:

apiVersion: weyoung.io/v1alpha1
kind: Beer
metadata:
  name: test-beer
spec:
  beerName: abc

這裡大家可以試驗一下required與pattern的作用是否生效.

這裡有個比較關鍵的點, CRD中未宣告的資源依舊可以被成功新增到Beer資源裡, 併成功apply&configured, 但是不具備validation的效果, 這也可能是早期版本的crd把schema關鍵字換做validation的原因吧.

建立Controller

當我們實現了自己的CRD, 並且apply了根據CRD定義的自定義資源Beer後, 這僅僅是存在etcd的靜態資源, 還需要controller來根據Beer的變化建立相應資源, 讓Beer動起來.

首先可以先了解一下Controller的工作原理.

從圖中可以看出, Informer模組內的Reflactor會監聽k8sapi, 根據自己註冊的資源型別, 輪詢的將所有資源的最新狀態存入佇列, Indexer會對資源進行index, 生成key與namespace/name的對映關係.

通過Informer的監聽方法, 可以從佇列依次讀取, 放進WorkQueue中供controller消費, 而controller可以通過Lister API由namespace/name獲取到對應的自定義資源, 並做增刪改查操作.

看似有很多模組在其中, 不過k8s已經幫我們實現了大部分(Informer, Lister等), 即它的client-go library. 我們只需要用對應的api來實現自己的controller部分就可以了.

具體的邏輯, k8s官方提供了一個sample-controller來供大家學習, 本人通過對這個sample的踩坑, 總結了一些需要注意的地方會在之後highlight出來.

總體來講, 自定義自己的controller需要三個步驟:

  • 定義資源Type與註冊
  • 生成Informer相應程式碼
  • 編寫與呼叫Controller

當然在這之前, 需要先新增依賴.

  • apimachinery 負責client與k8sapi之間通訊編解碼等
  • client-go 呼叫k8s cluster相關api
  • code-generator 根據定義的type生成相關程式碼, 包括informer, client

這裡需要注意的是依賴版本應該最新, 並且一致, 因為在嘗試多次之後, 發現新老版本之間的變化非常大, 添加了很多新的module與功能, 而且如果不一致, 也會導致程式碼生成, 與k8sapi之間通訊等各種的異常.

當我們通過go init custom-k8s-controller初始化新的module後, 可以新增最新依賴:

go get apimachinery@master
go get client-go@master
go get code-generator@master

定義Type與註冊

首先需要在工程目錄建立一個存放自己api module的地方, 如api, 並在之下建立beercontroller, 放置我們自定義資源相關的程式碼, 版本號可以自己定義, v1/v1alphav1.

  • types.go
package v1alpha1

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

//Beer is a custom kubenetes resourec
type Beer struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`
	Spec              BeerSpec `json:"spec"`
}

//BeerSpec is the spec of Beer
type BeerSpec struct {
	BeerName string `json:"BeerName"`
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

//BeerList is a list of Beer
type BeerList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata"`

	Items []Beer `json:"items"`
}

這裡定義了Beer的成員BeerName, 且有兩行給程式碼生成器的註釋也很關鍵, 不能缺失.

  • register.go
...
var (
	// SchemeBuilder initializes a scheme builder
	SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
	// AddToScheme is a global function that registers this API group & version to a scheme
	AddToScheme = SchemeBuilder.AddToScheme
)

func addKnownTypes(scheme *runtime.Scheme) error {
	scheme.AddKnownTypes(
		SchemeGroupVersion,
		&Beer{},
		&BeerList{},
	)

	// register the type in the scheme
	metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
	return nil
}
...

這裡省略了一些, 完整程式碼可參考官方sample. 這裡主要的邏輯是將我們定義的資源註冊上去, 當然由於還沒有執行generator, AddKnownTypes會因為我們的Beer沒有Deepcopy而報錯.

生成程式碼

之前講過需要實現一個完整的自定義k8s資源需要很多東西, 但實際上除了controller之外的都可以生成出來, 這裡就使用到了code-generator裡面的generate-groups.sh.

由於要使用該指令碼, 這裡我們可以新增vendor來管理我們的之前新增的依賴. 在工程的根目錄輸出git mod vendor來建立vendor目錄, 這時所有之前新增過的依賴 (go.mod)都會出現在這個資料夾下供我們專案使用.

關於如何呼叫generator的邏輯可以參考官方sample, 這裡我們需要給vendor整體(-R)賦予可執行許可權, 否則在generate的過程會提示許可權問題.

關於generate-groups.sh的使用需要注意的是它其中的2,3,4引數, 分別為輸出目錄, 輸入目錄(我們自定義資源的module所在的目錄, 即之前建立的api資料夾), 自定義資源名:版本.

具體引數設定也可以參考:

../vendor/k8s.io/code-generator/generate-groups.sh \
  "deepcopy,client,informer,lister" \
  custom-k8s-controller/generated \
  custom-k8s-controller/api \
  beercontroller:v1alpha1 \
  --go-header-file $(pwd)/boilerplate.go.txt \
  --output-base $(pwd)/../../

在執行指令碼過後, 它會在types.go旁生成deepcopy程式碼, 並會在我們指定的generated資料夾下生成informer, lister等程式碼.

編寫與呼叫Controller

現在萬事俱備只欠實現最後的自定義資源處理邏輯, 就是之前提到的controller.

首先我們需要定義controller的成員:

type Controller struct {
	// kubeclientset is a standard kubernetes clientset
	kubeclientset kubernetes.Interface
	// beerclientset is a clientset for our own API group
	beerclientset clientset.Interface

	deploymentsLister appslisters.DeploymentLister
	deploymentsSynced cache.InformerSynced
	beersLister   listers.beerLister
	beersSynced   cache.InformerSynced

	// workqueue is a rate limited work queue. This is used to queue work to be
	// processed instead of performing it as soon as a change happens. This
	// means we can ensure we only process a fixed amount of resources at a
	// time, and makes it easy to ensure we are never processing the same item
	// simultaneously in two different workers.
	workqueue workqueue.RateLimitingInterface
	// recorder is an event recorder for recording Event resources to the
	// Kubernetes API.
	recorder record.EventRecorder
}

其中kubeclientset可以呼叫k8s的CRUD api, 例如建立更新deployment; beerclientset可以呼叫自定義資源的CRUD; deploymentsLister與beersLister可以通過namespace/name獲得對應資源; workqueue用來同步與限流多個自定義資源處理worker; recorder用來publish在資源處理過程中的事件, 該事件可以在kubectl describe裡面看到.

接著我們可以建立建構函式將clientset, informer傳入構建自己的controller物件, 並通過informer的AddEventHandler新增監聽, 將回調的beer資源通過indexer(cache)的MetaNamespaceKeyFunc方法轉換為key, 加入workqueue佇列.

當然有佇列必然有死迴圈去讀取這個佇列, 這也是我們啟動整個controller的入口, 我們需要從workqueue中pop交由下游處理, 在處理成功後呼叫forget方法清除queue, 否則會重新進行處理.

處理資源是我們controller的核心邏輯, 這裡我們可以再次通過indexer(cache)通過key反轉回namespace/name, 然後通過listers查詢對應的資源, 比如beer或者deployment, 通過對比自定義資源與背後k8s資源的區別, 對k8s資源進行CRUD.

func (c *Controller) syncHandler(key string) error {
	// Convert the namespace/name string into a distinct namespace and name
	namespace, name, err := cache.SplitMetaNamespaceKey(key)
	if err != nil {
		utilruntime.HandleError(fmt.Errorf("invalid resource key: %s", key))
		return nil
	}

	// Get the beer resource with this namespace/name
	beer, err := c.beersLister.beers(namespace).Get(name)
	if err != nil {
		// The beer resource may no longer exist, in which case we stop
		// processing.
		klog.Infof("beer %s is deleted ", name)
		if errors.IsNotFound(err) {
			utilruntime.HandleError(fmt.Errorf("beer '%s' in work queue no longer exists", key))
			return nil
		}

		return err
	}

	deploymentName := beer.Spec.beerName
	if deploymentName == "" {
		// We choose to absorb the error here as the worker would requeue the
		// resource otherwise. Instead, the next time the resource is updated
		// the resource will be queued again.
		utilruntime.HandleError(fmt.Errorf("%s: deployment name must be specified", key))
		return nil
	}

	deployment, err := c.deploymentsLister.Deployments(beer.Namespace).Get(beer.Name)
	// If the resource doesn't exist, we'll create it
	if errors.IsNotFound(err) {
		deployment, err = c.kubeclientset.AppsV1().Deployments(beer.Namespace).Create(context.TODO(), newDeployment(beer), metav1.CreateOptions{})
	}

	// If an error occurs during Get/Create, we'll requeue the item so we can
	// attempt processing again later. This could have been caused by a
	// temporary network failure, or any other transient reason.
	if err != nil {
		return err
	}

  // ***********************
  // Handle custom CRUD here
  // ***********************

	c.recorder.Event(beer, corev1.EventTypeNormal, SuccessSynced, MessageResourceSynced)
	return nil
}

整個syncHanlder函式分為四個部分:

  • 通過index做key->namespace/name
  • 通過lister查詢beer資源
    • 如果不存在, 說明這是一次刪除操作, 直接返回
  • 通過lister獲取deployment
    • 如果不存在, 通過beer中相關欄位建立對應的deployment, 或者其他k8s資源
  • 否則, 說明資源已經存在, 則對比beer與實際資源的區別, 判斷是否需要對相應的資源, 如deployment進行更新. 這裡舉例子

當然在整個過程中也可以通過recorder來廣播event, 這樣在apply自定義資源後, 可以獲取一定資訊, 方便檢視或者debug問題原因.

controller完整的邏輯存在大量boilerplate的程式碼, 具體也可以參考官方的sample進行學習.

最後還有一點需要注意的是, 在我們建立並啟動自己的controller時, 即本次custom-k8s-controller的入口main package內, 除了呼叫構造以及啟動函式, 一定要記得傳入的Informer需要呼叫Start進行啟動, 否則無法獲得資源變化的回撥. 當然這裡也涉及到需要傳入一個stopSignal可以讓監聽中斷, 這一個可以直接copy官方程式碼.

總結

k8s已經提供了一個非常詳盡的demo來實現自定義資源並通過controller進行配置, 但是對於沒有go開發經驗, 有不少由於版本, 依賴, 許可權造成的小問題, 希望文章中highlight的點能提供一定幫助~

最後通過不懈努力, 我們可以通過一個自定義的Beer資源來deploy pod了. 由於這條路的打通, 我們可以繼續新增CRD的schema, 並在controller內進行解析, 簡化deploy過程, 統一基於k8s的微服務基礎架構. 比如對於任意一個通過Beer資源deploy的app, 我們都通過istio sidecar來做ingress/outgress, 對於image的完整倉庫地址, 我們也在後面通過不同部署(apply)環境來組合不同的地址, 還有大量預設值的公共配置, 都不需要再重複的寫在多個yaml中了.

Reference