10分鐘開發Kubernetes Operator

語言: CN / TW / HK

Operator是擴充套件原生Kubernetes能力的主要模式,本文通過一個簡單示例,介紹瞭如何從0開始構建Kubernetes Operator實現使用者自定義功能。原文: Build a Kubernetes Operator in 10 Minutes

你也許能夠將應用熟練的部署到Kubernetes上,但你知道什麼是Operator嗎?Operator是如何工作的?如何構建Operator?這是一個複雜的課題,但幸運的是,自2016年發明以來,已經開發了許多相關工具,可以簡化工程師的生活。

這些工具允許我們將自定義邏輯加入Kubernetes,從而自動化大量任務,而這已經超出了軟體本身功能的範圍。

閒話少說,讓我們深入瞭解更多關於Operator的知識吧!

圖1. Maximilian Weisbecker@Unsplash

什麼是Operator?

等一下,你知道Kubernetes(或k8s)嗎?簡單介紹一下,這是由谷歌雲開發的"可以在任何地方部署、擴充套件和管理容器應用程式的開源系統"。

大多數人使用Kubernetes的方式是使用原生資源(如pod、deployment、service等)部署應用程式。但是,也可以擴充套件Kubernetes的功能,從而新增滿足特定需求的新業務邏輯,這就是Operator的作用。

Operator的主要目標是將工程師的邏輯轉換為程式碼,以便實現原生Kubernetes無法完成的某些任務的自動化。

負責開發應用程式或服務的工程師對系統應該如何執行、如何部署以及如何在出現問題時做出反應有很深的瞭解。將這些技術知識封裝在程式碼中並自動化操作的能力意味著在可以花費更少的時間處理重複任務,而在重要問題上可以投入更多時間。

例如,可以想象Operator在Kubernetes中部署和維護MySQLElasticsearchGitlab runner等工具,Operator可以配置這些工具,根據事件調整系統狀態,並對故障做出反應。

聽起來很有趣不是嗎?讓我們動手幹吧。

構建Operator

可以使用Kubernetes開發的controller-runtime專案從頭構建Operator,也可以使用最流行的框架之一加速開發週期並降低複雜性(KubebuilderOperatorSDK)。因為Kubebuilder框架非常容易使用,文件也很容易閱讀,而且久經考驗,因此我選擇基於Kubebuilder構建。

不管怎樣,這兩個專案目前正在合併為單獨的專案。

1. 設定開發環境

開發Operator需要以下必備工具:

  • go version v1.17.9+
  • docker version 17.03+
  • kubectl version v1.11.3+
  • 訪問Kubernetes v1.11.3+叢集(強烈建議使用kind設定自己的本地叢集,它非常容易使用!)

然後安裝kubebuilder:

bash $ curl -L -o kubebuilder http://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH) && chmod +x kubebuilder && mv kubebuilder /usr/local/bin/

如果一切正常,應該會看到類似輸出(版本可能會隨時間發生變化):

bash $ kubebuilder version Version: main.version{KubeBuilderVersion:"3.4.1", KubernetesVendor:"1.23.5", GitCommit:"d59d7882ce95ce5de10238e135ddff31d8ede026", BuildDate:"2022-05-06T13:58:56Z", GoOs:"darwin", GoArch:"amd64"}

太棒了,現在可以開始了!

2. 構建簡單的Operator

接下來做個小練習,構建一個簡單的foo operator,除了演示Operator的功能之外,沒有實際用處。

執行以下命令初始化新專案,該命令將下載controller-runtime二進位制檔案,併為我們準備好專案。

bash $ kubebuilder init --domain my.domain --repo my.domain/tutorial Writing kustomize manifests for you to edit... Writing scaffold for you to edit... Get controller runtime: $ go get sigs.k8s.io/[email protected] go: downloading sigs.k8s.io/controller-runtime v0.11.2 ... Update dependencies: $ go mod tidy go: downloading github.com/onsi/gomega v1.17.0 ...

下面是專案結構(注意這是一個Go專案):

bash $ ls -a -rw------- 1 leovct staff 129 Jun 30 16:08 .dockerignore -rw------- 1 leovct staff 367 Jun 30 16:08 .gitignore -rw------- 1 leovct staff 776 Jun 30 16:08 Dockerfile -rw------- 1 leovct staff 5029 Jun 30 16:08 Makefile -rw------- 1 leovct staff 104 Jun 30 16:08 PROJECT -rw------- 1 leovct staff 2718 Jun 30 16:08 README.md drwx------ 6 leovct staff 192 Jun 30 16:08 config -rw------- 1 leovct staff 3218 Jun 30 16:08 go.mod -rw-r--r-- 1 leovct staff 94801 Jun 30 16:08 go.sum drwx------ 3 leovct staff 96 Jun 30 16:08 hack -rw------- 1 leovct staff 2780 Jun 30 16:08 main.go

我們來看看這個Operator最重要的組成部分:

  • main.go是專案入口,負責設定並執行管理器。
  • config/包含在Kubernetes中部署Operator的manifest。
  • Dockerfile是用於構建管理器映象的容器檔案。

等等,這個管理器元件是什麼玩意兒?!

這涉及到部分理論知識,我們稍後再說!

Operator由兩個元件組成,自定義資源定義(CRD, Custom Resource Definition)和控制器(controller)。

CRD是"Kubernetes自定義型別"或資源藍圖,用於描述其規範和狀態。我們可以定義CRD的例項,稱為自定義資源(CR, Custom Resource)。

圖2. 自定義資源定義(CRD)和自定義資源(CR)。

控制器(也稱為控制迴圈)持續監視叢集狀態,並根據事件做出變更,目標是將資源的當前狀態變為使用者在自定義資源規範中定義的期望狀態。

圖3. 控制器操作概要圖示,作者Stefanie Lai。

一般來說,控制器是特定於某種型別的資源的,但也可以對一組不同的資源執行CRUD(建立、讀取、更新和刪除)操作。

在Kubernetes的文件中舉了一個控制器的例子: 恆溫器。當我們設定溫度時,告訴恆溫器所需的狀態,房間的實際溫度就是當前的實際狀態,恆溫器通過開啟或關閉空調,使實際狀態更接近預期狀態。

那管理器(manager)呢?該元件的目標是啟動所有控制器,並使控制迴圈共存。假設專案中有兩個CRD,同時有兩個控制器,每個CRD對應一個控制器,管理器將啟動這兩個控制器並使它們共存。

如果想了解Operator如何工作的更多細節,可以檢視文末參考資料列表。

現在我們知道了Operator是如何工作的,可以開始使用Kubebuilder框架建立一個Operator,我們從建立新的API(組/版本)和新的Kind(CRD)開始,當提示建立CRD和控制器時,按yes。

bash $ kubebuilder create api --group tutorial --version v1 --kind Foo Create Resource [y/n] y Create Controller [y/n] y Writing kustomize manifests for you to edit... Writing scaffold for you to edit... api/v1/foo_types.go controllers/foo_controller.go Update dependencies: $ go mod tidy Running make: $ make generate mkdir -p /Users/leovct/Documents/tutorial/bin GOBIN=/Users/leovct/Documents/tutorial/bin go install sigs.k8s.io/controller-tools/cmd/[email protected] /Users/leovct/Documents/tutorial/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."

接下來是最有意思的部分!我們將定製CRD和控制器來滿足需求,注意看已經建立了兩個新資料夾:

  • api/v1包含Foo CRD(參見foo_types.go)。
  • controllers包含Foo控制器(參見foo_controller.go)。

3. 自定義CRD和Controller

接下來定製我們可愛的Foo CRD(參見api/v1/foo_types.go)。正如前面所說,這個CRD沒有任何目的,只是簡單展示如何使用Operator在Kubernetes中執行簡單的任務。

Foo CRD在其定義中有name欄位,該欄位指的是Foo正在尋找的朋友的名稱。如果Foo找到了一個朋友(一個和朋友同名的pod),happy狀態將被設定為true。

```go package v1

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

// FooSpec defines the desired state of Foo type FooSpec struct { // Name of the friend Foo is looking for Name string json:"name" }

// FooStatus defines the observed state of Foo type FooStatus struct { // Happy will be set to true if Foo found a friend Happy bool json:"happy,omitempty" }

//+kubebuilder:object:root=true //+kubebuilder:subresource:status

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

Spec   FooSpec   `json:"spec,omitempty"`
Status FooStatus `json:"status,omitempty"`

}

//+kubebuilder:object:root=true

// FooList contains a list of Foo type FooList struct { metav1.TypeMeta json:",inline" metav1.ListMeta json:"metadata,omitempty" Items []Foo json:"items" }

func init() { SchemeBuilder.Register(&Foo{}, &FooList{}) } ```

接下來實現控制器邏輯。沒什麼複雜的,通過觸發reconciliation請求獲取Foo資源,從而得到Foo的朋友的名稱。然後,列出所有和Foo的朋友同名的pod。如果找到一個或多個,將Foo的happy狀態更新為true,否則設定為false

注意,控制器也會對Pod事件做出反應(參見mapPodsReqToFooReq)。實際上,如果建立了一個新的pod,我們希望Foo資源能夠相應更新其狀態。這個方法將在每次發生Pod事件時被觸發(建立、更新或刪除)。然後,只有當Pod名稱是叢集中部署的某個Foo自定義資源的"朋友"時,才觸發Foo控制器的reconciliation迴圈。

```go package controllers

import ( "context"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"

tutorialv1 "my.domain/tutorial/api/v1"

)

// FooReconciler reconciles a Foo object type FooReconciler struct { client.Client Scheme *runtime.Scheme }

// RBAC permissions to monitor foo custom resources //+kubebuilder:rbac:groups=tutorial.my.domain,resources=foos,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=tutorial.my.domain,resources=foos/status,verbs=get;update;patch //+kubebuilder:rbac:groups=tutorial.my.domain,resources=foos/finalizers,verbs=update

// RBAC permissions to monitor pods //+kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch

// Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. func (r *FooReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := log.FromContext(ctx) log.Info("reconciling foo custom resource")

// Get the Foo resource that triggered the reconciliation request
var foo tutorialv1.Foo
if err := r.Get(ctx, req.NamespacedName, &foo); err != nil {
    log.Error(err, "unable to fetch Foo")
    return ctrl.Result{}, client.IgnoreNotFound(err)
}

// Get pods with the same name as Foo's friend
var podList corev1.PodList
var friendFound bool
if err := r.List(ctx, &podList); err != nil {
    log.Error(err, "unable to list pods")
} else {
    for _, item := range podList.Items {
        if item.GetName() == foo.Spec.Name {
            log.Info("pod linked to a foo custom resource found", "name", item.GetName())
            friendFound = true
        }
    }
}

// Update Foo' happy status
foo.Status.Happy = friendFound
if err := r.Status().Update(ctx, &foo); err != nil {
    log.Error(err, "unable to update foo's happy status", "status", friendFound)
    return ctrl.Result{}, err
}
log.Info("foo's happy status updated", "status", friendFound)

log.Info("foo custom resource reconciled")
return ctrl.Result{}, nil

}

// SetupWithManager sets up the controller with the Manager. func (r *FooReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&tutorialv1.Foo{}). Watches( &source.Kind{Type: &corev1.Pod{}}, handler.EnqueueRequestsFromMapFunc(r.mapPodsReqToFooReq), ). Complete(r) }

func (r *FooReconciler) mapPodsReqToFooReq(obj client.Object) []reconcile.Request { ctx := context.Background() log := log.FromContext(ctx)

// List all the Foo custom resource
req := []reconcile.Request{}
var list tutorialv1.FooList
if err := r.Client.List(context.TODO(), &list); err != nil {
    log.Error(err, "unable to list foo custom resources")
} else {
    // Only keep Foo custom resources related to the Pod that triggered the reconciliation request
    for _, item := range list.Items {
        if item.Spec.Name == obj.GetName() {
            req = append(req, reconcile.Request{
                NamespacedName: types.NamespacedName{Name: item.Name, Namespace: item.Namespace},
            })
            log.Info("pod linked to a foo custom resource issued an event", "name", obj.GetName())
        }
    }
}
return req

} ```

我們已經完成了對API定義和控制器的編輯,可以執行以下命令來更新Operator manifest。

bash $ make manifests /Users/leovct/Documents/tutorial/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases

4. 執行Controller

我們使用Kind設定本地Kubernetes叢集,它很容易使用。

首先將CRD安裝到叢集中。

bash $ make install /Users/leovct/Documents/tutorial/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases kubectl apply -k config/crd customresourcedefinition.apiextensions.k8s.io/foos.tutorial.my.domain created

可以看到Foo CRD已經建立好了。

bash $ kubectl get crds NAME CREATED AT foos.tutorial.my.domain 2022-06-30T17:02:45Z

然後終端中執行控制器。請記住,也可以將其部署為Kubernetes叢集中的deployment。

bash $ make run /Users/leovct/Documents/tutorial/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases /Users/leovct/Documents/tutorial/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..." go fmt ./... go vet ./... go run ./main.go INFO controller-runtime.metrics Metrics server is starting to listen {"addr": ":8080"} INFO setup starting manager INFO Starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"} INFO Starting server {"kind": "health probe", "addr": "[::]:8081"} INFO controller.foo Starting EventSource {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "source": "kind source: *v1.Foo"} INFO controller.foo Starting EventSource {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "source": "kind source: *v1.Pod"} INFO controller.foo Starting Controller {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo"} INFO controller.foo Starting workers {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "worker count": 1}

如你所見,管理器啟動了,然後Foo控制器也啟動了,控制器現在正在執行並監聽事件!

5. 測試控制器

為了測試是否一切工作正常,我們建立兩個Foo自定義資源以及一些pod,觀察控制器的行為。

首先,在config/samples中建立Foo自定義資源清單,執行以下命令在本地Kubernetes叢集中建立資源。

```yaml apiVersion: tutorial.my.domain/v1 kind: Foo metadata: name: foo-01 spec: name: jack


apiVersion: tutorial.my.domain/v1 kind: Foo metadata: name: foo-02 spec: name: joe ```

bash $ kubectl apply -f config/samples foo.tutorial.my.domain/foo-1 created foo.tutorial.my.domain/foo-2 created

可以看到控制器為每個Foo自定義資源建立事件觸發了reconciliation迴圈。

bash INFO controller.foo reconciling foo custom resource {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-1", "namespace": "default"} INFO controller.foo foo's happy status updated {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-1", "namespace": "default", "status": "false"} INFO controller.foo foo custom resource reconciled {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-1", "namespace": "default"} INFO controller.foo reconciling foo custom resource {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-2", "namespace": "default"} INFO controller.foo foo's happy status updated {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-2", "namespace": "default", "status": "false"} INFO controller.foo foo custom resource reconciled {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-2", "namespace": "default"}

如果檢查Foo自定義資源狀態,可以看到狀態為空,這正是所期望的,目前為止一切正常!

bash $ kubectl describe foos Name: foo-1 Namespace: default API Version: tutorial.my.domain/v1 Kind: Foo Metadata: ... Spec: Name: jack Status: Name: foo-2 Namespace: default API Version: tutorial.my.domain/v1 Kind: Foo Metadata: ... Spec: Name: joe Status:

接下來我們部署一個叫jack的pod來觀察系統的反應。

yaml apiVersion: v1 kind: Pod metadata: name: jack spec: containers: - name: ubuntu image: ubuntu:latest # Just sleep forever command: [ "sleep" ] args: [ "infinity" ]

Pod部署完成後,應該可以看到控制器對pod建立事件作出響應,然後按照預期更新第一個Foo自定義資源狀態,可以通過describe Foo自定義資源來驗證。

bash INFO pod linked to a foo custom resource issued an event {"name": "jack"} INFO controller.foo reconciling foo custom resource {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-1", "namespace": "default"} INFO controller.foo pod linked to a foo custom resource found {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-1", "namespace": "default", "name": "jack"} INFO controller.foo foo's happy status updated {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-1", "namespace": "default", "status": true} INFO controller.foo foo custom resource reconciled {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-1", "namespace": "default"}

我們更新第二個Foo自定義資源規範,將其name欄位的值從joe更改為jack,控制器應該捕獲更新事件並觸發reconciliation迴圈。

bash INFO controller.foo pod linked to a foo custom resource found {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-2", "namespace": "default", "name": "jack"} INFO controller.foo foo's happy status updated {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-2", "namespace": "default", "status": true} INFO controller.foo foo custom resource reconciled {"reconciler group": "tutorial.my.domain", "reconciler kind": "Foo", "name": "foo-2", "namespace": "default"}

Yeah,成功了!我們已經做了足夠多的實驗,你應該明白這是怎麼回事了!如果刪除名為jack的pod,自定義資源的happy狀態將被設定為false。

我們可以確認Operator是正常工作的!最好再編寫一些單元測試和端到端測試,但本文不會覆蓋相關內容。

為自己感到驕傲吧,你已經設計、部署並測試了第一個Operator!恭喜!!

如果需要瀏覽完整程式碼,請訪問GitHub: http://github.com/leovct/kubernetes-operator-tutorial。

更多工作

我們已經看到如何建立非常基本的Kubernetes operator,但遠非完美,還有很多地方需要改善,下面是可以探索的主題列表:

  • 優化事件過濾(有時,事件會被提交兩次…)。
  • 完善RBAC許可權。
  • 改進日誌記錄系統。
  • 當operator更新資源時,觸發Kubernetes事件。
  • 獲取Foo自定義資源時新增自定義欄位(也許顯示happy狀態?)
  • 編寫單元測試和端到端測試。

通過這個列表,可以深入挖掘這一主題。

你好,我是俞凡,在Motorola做過研發,現在在Mavenir做技術工作,對通訊、網路、後端架構、雲原生、DevOps、CICD、區塊鏈、AI等技術始終保持著濃厚的興趣,平時喜歡閱讀、思考,相信持續學習、終身成長,歡迎一起交流學習。 \ 微信公眾號:DeepNoMind