本文發表於一年多前。舊文章可能包含過時內容。請檢查頁面中的資訊自發布以來是否已變得不正確。

為 Pod 標籤編寫控制器

Operator 被證明是在 Kubernetes 中執行有狀態分散式應用的絕佳解決方案。像 Operator SDK 這樣的開源工具提供了構建可靠且可維護的 Operator 的方法,使得擴充套件 Kubernetes 和實現自定義排程變得更加容易。

Kubernetes Operator 在您的叢集中運行復雜的軟體。開源社群已經為 Prometheus、Elasticsearch 或 Argo CD 等分散式應用程式構建了許多 Operator。即使在開源之外,Operator 也可以幫助為您的 Kubernetes 叢集帶來新功能。

一個 Operator 是一組自定義資源和一組控制器。控制器監視 Kubernetes API 中特定資源的變化,並透過建立、更新或刪除資源來做出反應。

Operator SDK 最適合構建功能完備的 Operator。儘管如此,您仍然可以使用它來編寫單個控制器。本文將引導您完成用 Go 語言編寫一個 Kubernetes 控制器,該控制器將為具有特定註解的 Pod 新增一個 `pod-name` 標籤。

為什麼我們需要一個控制器來做這件事?

我最近在一個專案中工作,我們需要建立一個 Service,將流量路由到 ReplicaSet 中的特定 Pod。問題是 Service 只能透過標籤選擇 Pod,而 ReplicaSet 中的所有 Pod 都具有相同的標籤。有兩種方法可以解決這個問題:

  1. 建立一個沒有選擇器的 Service,並直接管理該 Service 的 Endpoint 或 EndpointSlice。我們需要編寫一個自定義控制器來將 Pod 的 IP 地址插入到這些資源中。
  2. 為 Pod 新增一個具有唯一值的標籤。然後我們可以在 Service 的選擇器中使用這個標籤。同樣,我們需要編寫一個自定義控制器來新增這個標籤。

控制器是一個控制迴圈,它跟蹤一個或多個 Kubernetes 資源型別。上面選項 2 中的控制器只需要跟蹤 Pod,這使得實現起來更簡單。這就是我們將透過編寫一個 Kubernetes 控制器來實現的選項,該控制器將為我們的 Pod 新增一個 `pod-name` 標籤。

StatefulSet 原生支援透過為集合中的每個 Pod 新增 `pod-name` 標籤來實現此功能。但是,如果我們不想或不能使用 StatefulSet 呢?

我們很少直接建立 Pod;大多數情況下,我們使用 Deployment、ReplicaSet 或其他高階資源。我們可以在 PodSpec 中指定要新增到每個 Pod 的標籤,但不能使用動態值,因此無法複製 StatefulSet 的 `pod-name` 標籤。

我們嘗試過使用可變准入 Webhook。當任何人建立 Pod 時,Webhook 會用包含 Pod 名稱的標籤來修補 Pod。令人失望的是,這不起作用:並非所有 Pod 在建立前都有名稱。例如,當 ReplicaSet 控制器建立 Pod 時,它會向 Kubernetes API 伺服器傳送 `namePrefix` 而不是 `name`。API 伺服器在將新 Pod 持久化到 etcd 之前,但僅在呼叫我們的准入 Webhook 之後,才會生成一個唯一的名稱。因此,在大多數情況下,我們無法透過可變 Webhook 知道 Pod 的名稱。

一旦 Pod 存在於 Kubernetes API 中,它就幾乎是不可變的,但我們仍然可以新增一個標籤。我們甚至可以從命令列執行此操作。

kubectl label my-pod my-label-key=my-label-value

我們需要監視 Kubernetes API 中任何 Pod 的變化並新增我們想要的標籤。我們不會手動執行此操作,而是編寫一個控制器來為我們完成。

使用 Operator SDK 引導控制器

控制器是一個協調迴圈,它從 Kubernetes API 讀取資源的期望狀態,並採取行動使叢集的實際狀態接近期望狀態。

為了儘快編寫這個控制器,我們將使用 Operator SDK。如果尚未安裝,請按照官方文件進行安裝。

$ operator-sdk version
operator-sdk version: "v1.4.2", commit: "4b083393be65589358b3e0416573df04f4ae8d9b", kubernetes version: "v1.19.4", go version: "go1.15.8", GOOS: "darwin", GOARCH: "amd64"

讓我們建立一個新目錄來編寫控制器

mkdir label-operator && cd label-operator

接下來,讓我們初始化一個新的 Operator,然後我們將向其中新增一個控制器。為此,您需要指定一個域和一個倉庫。該域用作自定義 Kubernetes 資源所屬組的字首。由於我們不定義自定義資源,因此該域無關緊要。該倉庫將是我們即將編寫的 Go 模組的名稱。按照慣例,這是您將儲存程式碼的倉庫。

舉個例子,這是我執行的命令:

# Feel free to change the domain and repo values.
operator-sdk init --domain=padok.fr --repo=github.com/busser/label-operator

接下來,我們需要建立一個新的控制器。此控制器將處理 Pod 而不是自定義資源,因此無需生成資原始碼。讓我們執行此命令來搭建所需的程式碼:

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

我們現在有了一個新檔案:`controllers/pod_controller.go`。此檔案包含一個 `PodReconciler` 型別,其中包含我們需要實現的兩個方法。第一個是 `Reconcile`,它目前看起來像這樣:

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

    // your logic here

    return ctrl.Result{}, nil
}

`Reconcile` 方法在 Pod 建立、更新或刪除時呼叫。Pod 的名稱和名稱空間在 `ctrl.Request` 中,該方法作為引數接收。

第二個方法是 `SetupWithManager`,目前它看起來像這樣:

func (r *PodReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        // Uncomment the following line adding a pointer to an instance of the controlled resource as an argument
        // For().
        Complete(r)
}

`SetupWithManager` 方法在 Operator 啟動時呼叫。它用於告訴 Operator 框架 `PodReconciler` 需要監視哪些型別。要使用 Kubernetes 內部使用的相同 `Pod` 型別,我們需要匯入它的一些程式碼。所有 Kubernetes 原始碼都是開源的,因此您可以在自己的 Go 程式碼中匯入任何您喜歡的部分。您可以在 Kubernetes 原始碼中或pkg.go.dev上找到可用包的完整列表。要使用 Pod,我們需要 `k8s.io/api/core/v1` 包。

package controllers

import (
    // other imports...
    corev1 "k8s.io/api/core/v1"
    // other imports...
)

讓我們在 `SetupWithManager` 中使用 `Pod` 型別,告訴 Operator 框架我們想要監視 Pod:

func (r *PodReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&corev1.Pod{}).
        Complete(r)
}

在繼續之前,我們應該設定控制器所需的 RBAC 許可權。在 `Reconcile` 方法上方,我們有一些預設許可權:

// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=pods/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core,resources=pods/finalizers,verbs=update

我們不需要所有這些。我們的控制器永遠不會與 Pod 的狀態或其終結器互動。它只需要讀取和更新 Pod。讓我們刪除不必要的許可權,只保留我們需要的:

// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;update;patch

我們現在準備編寫控制器的協調邏輯。

實現協調

這是我們希望 `Reconcile` 方法執行的操作:

  1. 使用 `ctrl.Request` 中的 Pod 名稱和名稱空間從 Kubernetes API 獲取 Pod。
  2. 如果 Pod 具有 `add-pod-name-label` 註解,則向 Pod 新增 `pod-name` 標籤;如果缺少該註解,則不新增標籤。
  3. 在 Kubernetes API 中更新 Pod 以持久化所做的更改。

讓我們為註解和標籤定義一些常量:

const (
    addPodNameLabelAnnotation = "padok.fr/add-pod-name-label"
    podNameLabel              = "padok.fr/pod-name"
)

協調函式的第一步是從 Kubernetes API 獲取我們正在處理的 Pod:

// Reconcile handles a reconciliation request for a Pod.
// If the Pod has the addPodNameLabelAnnotation annotation, then Reconcile
// will make sure the podNameLabel label is present with the correct value.
// If the annotation is absent, then Reconcile will make sure the label is too.
func (r *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := r.Log.WithValues("pod", req.NamespacedName)

    /*
        Step 0: Fetch the Pod from the Kubernetes API.
    */

    var pod corev1.Pod
    if err := r.Get(ctx, req.NamespacedName, &pod); err != nil {
        log.Error(err, "unable to fetch Pod")
        return ctrl.Result{}, err
    }

    return ctrl.Result{}, nil
}

當 Pod 被建立、更新或刪除時,將呼叫 `Reconcile` 方法。在刪除的情況下,我們對 `r.Get` 的呼叫將返回一個特定的錯誤。讓我們匯入定義此錯誤的包:

package controllers

import (
    // other imports...
    apierrors "k8s.io/apimachinery/pkg/api/errors"
    // other imports...
)

我們現在可以處理這個特定的錯誤,並且——因為我們的控制器不關心被刪除的 Pod——明確地忽略它:

    /*
        Step 0: Fetch the Pod from the Kubernetes API.
    */

    var pod corev1.Pod
    if err := r.Get(ctx, req.NamespacedName, &pod); err != nil {
        if apierrors.IsNotFound(err) {
            // we'll ignore not-found errors, since we can get them on deleted requests.
            return ctrl.Result{}, nil
        }
        log.Error(err, "unable to fetch Pod")
        return ctrl.Result{}, err
    }

接下來,讓我們編輯我們的 Pod,以便只有在我們的註解存在時才存在我們的動態標籤:

    /*
        Step 1: Add or remove the label.
    */

    labelShouldBePresent := pod.Annotations[addPodNameLabelAnnotation] == "true"
    labelIsPresent := pod.Labels[podNameLabel] == pod.Name

    if labelShouldBePresent == labelIsPresent {
        // The desired state and actual state of the Pod are the same.
        // No further action is required by the operator at this moment.
        log.Info("no update required")
        return ctrl.Result{}, nil
    }

    if labelShouldBePresent {
        // If the label should be set but is not, set it.
        if pod.Labels == nil {
            pod.Labels = make(map[string]string)
        }
        pod.Labels[podNameLabel] = pod.Name
        log.Info("adding label")
    } else {
        // If the label should not be set but is, remove it.
        delete(pod.Labels, podNameLabel)
        log.Info("removing label")
    }

最後,讓我們將更新後的 Pod 推送到 Kubernetes API:

    /*
        Step 2: Update the Pod in the Kubernetes API.
    */

    if err := r.Update(ctx, &pod); err != nil {
        log.Error(err, "unable to update Pod")
        return ctrl.Result{}, err
    }

將更新後的 Pod 寫入 Kubernetes API 時,存在 Pod 自我們首次讀取以來已被更新或刪除的風險。在編寫 Kubernetes 控制器時,我們應該記住,我們不是叢集中唯一的參與者。發生這種情況時,最好的做法是透過重新排隊事件從頭開始協調。我們正是這樣做:

    /*
        Step 2: Update the Pod in the Kubernetes API.
    */

    if err := r.Update(ctx, &pod); err != nil {
        if apierrors.IsConflict(err) {
            // The Pod has been updated since we read it.
            // Requeue the Pod to try to reconciliate again.
            return ctrl.Result{Requeue: true}, nil
        }
        if apierrors.IsNotFound(err) {
            // The Pod has been deleted since we read it.
            // Requeue the Pod to try to reconciliate again.
            return ctrl.Result{Requeue: true}, nil
        }
        log.Error(err, "unable to update Pod")
        return ctrl.Result{}, err
    }

別忘了在方法結束時成功返回:

    return ctrl.Result{}, nil
}

就這樣!我們現在可以在叢集上執行控制器了。

在叢集上執行控制器

要在您的叢集上執行我們的控制器,我們需要執行 Operator。為此,您只需要 `kubectl`。如果您沒有 Kubernetes 叢集,我建議您使用 KinD (Kubernetes in Docker) 在本地啟動一個。

從您的機器執行 Operator 只需此命令:

make run

幾秒鐘後,您應該會看到 Operator 的日誌。請注意,我們控制器的 `Reconcile` 方法已為叢集中所有已執行的 Pod 呼叫。

讓我們保持 Operator 執行,並在另一個終端中建立一個新的 Pod:

kubectl run --image=nginx my-nginx

Operator 應該迅速列印一些日誌,表明它對 Pod 的建立和隨後的狀態變化做出了反應。

INFO    controllers.Pod no update required  {"pod": "default/my-nginx"}
INFO    controllers.Pod no update required  {"pod": "default/my-nginx"}
INFO    controllers.Pod no update required  {"pod": "default/my-nginx"}
INFO    controllers.Pod no update required  {"pod": "default/my-nginx"}

讓我們檢查 Pod 的標籤:

$ kubectl get pod my-nginx --show-labels
NAME       READY   STATUS    RESTARTS   AGE   LABELS
my-nginx   1/1     Running   0          11m   run=my-nginx

讓我們向 Pod 新增一個註解,以便我們的控制器知道為其新增動態標籤:

kubectl annotate pod my-nginx padok.fr/add-pod-name-label=true

請注意,控制器立即做出反應並在其日誌中生成了一個新行:

INFO    controllers.Pod adding label    {"pod": "default/my-nginx"}
$ kubectl get pod my-nginx --show-labels
NAME       READY   STATUS    RESTARTS   AGE   LABELS
my-nginx   1/1     Running   0          13m   padok.fr/pod-name=my-nginx,run=my-nginx

太棒了!您剛剛成功編寫了一個 Kubernetes 控制器,能夠為叢集中的資源新增具有動態值的標籤。

控制器和 Operator,無論大小,都可能是您 Kubernetes 之旅的重要組成部分。現在編寫 Operator 比以往任何時候都容易。可能性是無限的。

下一步是什麼?

如果您想進一步,我建議從在叢集內部署控制器或 Operator 開始。Operator SDK 生成的 `Makefile` 將完成大部分工作。

在將 Operator 部署到生產環境時,實施健壯的測試始終是一個好主意。朝這個方向邁出的第一步是編寫單元測試。此文件將指導您為 Operator 編寫測試。我為我們剛剛編寫的 Operator 編寫了測試;您可以在此 GitHub 倉庫中找到我所有的程式碼。

如何瞭解更多?

Operator SDK 文件詳細介紹瞭如何進一步實現更復雜的 Operator。

在建模更復雜的用例時,作用於內建 Kubernetes 型別的單個控制器可能不夠。您可能需要使用自定義資源定義 (CRD) 和多個控制器來構建更復雜的 Operator。Operator SDK 是一個很好的工具來幫助您實現這一點。

如果您想討論構建 Operator,請加入 Kubernetes Slack 工作區中的 #kubernetes-operator 頻道!