如何自定義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
- //github.com/kubernetes/sample-controller
- //kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/
- //itnext.io/comparing-kubernetes-api-extension-mechanisms-of-custom-resource-definition-and-aggregated-api-64f4ca6d0966