กลับไปยังบทความ

DevOps

Kubernetes Operator และ CRD: เข้าใจ Reconcile Loop และสร้าง Operator ด้วย Go

เข้าใจการทำงานของ Kubernetes operator ตั้งแต่นิยาม CRD, เขียน controller ด้วย reconcile loop ไปจนถึงสร้าง database operator ด้วย Go และ kubebuilder พร้อมตัวอย่าง kopf ฝั่ง Python

24 กันยายน 256910 นาทีในการอ่าน

kubernetes operators and crds

Kubernetes operator คือ custom controller ที่ดูแลแอปพลิเคชันผ่าน resource type ของตัวเอง เราแค่บอกสิ่งที่ต้องการไว้ใน custom resource เช่น "PostgreSQL cluster เวอร์ชัน 16 จำนวน 3 replica" แล้ว operator จะคอยสร้าง อัปเดต และซ่อม StatefulSet, Service และ backup ที่อยู่เบื้องหลัง จนสภาพจริงตรงกับที่เขียนไว้ พูดง่าย ๆ คือเอาความรู้ที่คนดูแลระบบเคยต้องทำด้วยมือ มาเขียนเป็นโค้ด

operator สร้างจาก extension point สองตัวของ Kubernetes ตัวแรกคือ Custom Resource Definition (CRD) ที่ใช้เพิ่มชนิด resource ใหม่เข้าไปใน API ตัวที่สองคือ controller ที่คอย watch resource ชนิดนั้นแล้วลงมือทำงาน บทความนี้อธิบายทั้งสองส่วน อธิบาย reconcile loop และลองสร้าง database operator เล็ก ๆ ด้วย Go, kubebuilder และ controller-runtime

CRD: เพิ่ม resource type ของเราเอง

Kubernetes มี kind ติดมาให้อยู่แล้ว เช่น Deployment และ Service ส่วน CRD คือการลงทะเบียน kind ใหม่กับ API server พอ apply แล้ว type ใหม่นี้จะทำตัวเหมือน resource ทั่วไปทุกอย่าง ใช้ kubectl get ได้ กำหนด RBAC ได้ และ watch การเปลี่ยนแปลงได้

yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: postgresclusters.db.example.com
spec:
  group: db.example.com
  scope: Namespaced
  names:
    kind: PostgresCluster
    plural: postgresclusters
    singular: postgrescluster
    shortNames: [pgc]
  versions:
    - name: v1alpha1
      served: true
      storage: true
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: Replicas
          type: integer
          jsonPath: .spec.replicas
        - name: Ready
          type: integer
          jsonPath: .status.readyReplicas
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: [replicas, version, storage]
              properties:
                replicas:
                  type: integer
                  minimum: 1
                  maximum: 5
                version:
                  type: string
                  enum: ["15", "16"]
                storage:
                  type: string
                  pattern: '^[0-9]+Gi$'
            status:
              type: object
              properties:
                readyReplicas:
                  type: integer

จุดที่ควรสังเกต:

  • schema คือการ validate API server จะปฏิเสธ resource ที่ใส่ replicas: 9 ตั้งแต่ก่อนที่ controller จะเห็นด้วยซ้ำ อะไรที่ validate ใน schema ได้ ควรทำที่นี่
  • subresources.status แยก object ออกเป็น spec (สิ่งที่ผู้ใช้ต้องการ) กับ status (สิ่งที่ controller สังเกตเห็น) ผู้ใช้เขียน spec, controller เขียน status และไม่เขียนทับกัน
  • additionalPrinterColumns กำหนดว่า kubectl get จะแสดงคอลัมน์อะไร

ทีนี้ผู้ใช้ก็สร้าง database ได้ด้วย manifest สั้น ๆ:

yaml
apiVersion: db.example.com/v1alpha1
kind: PostgresCluster
metadata:
  name: orders-db
  namespace: shop
spec:
  replicas: 3
  version: "16"
  storage: 20Gi
bash
$ kubectl get pgc -n shop
NAME        REPLICAS   READY
orders-db   3          2

แต่ CRD เพียงอย่างเดียวไม่ได้ทำอะไรเลย API server แค่เก็บ object ลง etcd แล้วจบ ต้องมีอะไรสักอย่างมาลงมือทำ

Controller และ reconcile loop

resource ที่ติดมากับ Kubernetes ทำงานแบบนี้อยู่แล้ว Deployment controller ใน kube-controller-manager คอย watch Deployment แล้วปรับ ReplicaSet ให้ตรง custom controller ก็ทำแบบเดียวกันกับ type ของเรา (ดูว่า controller ในตัวรันอยู่ตรงไหนได้ในบทความ สถาปัตยกรรม Kubernetes)

หัวใจของ controller ทุกตัวคือ reconcile loop:

  1. Observe อ่าน desired state (คือ spec ของ custom resource) และ actual state (object ที่มีอยู่จริงใน cluster)
  2. Diff หาว่าอะไรขาด อะไรเกิน อะไรไม่ตรง
  3. Act สร้าง อัปเดต หรือลบ object เพื่อปิดช่องว่างนั้น
  4. Report เขียนสิ่งที่เห็นลงใน status
  5. Repeat ทำซ้ำทุกครั้งที่มีอะไรเกี่ยวข้องเปลี่ยน และทำเป็นระยะเพื่อกันพลาด

มีคุณสมบัติสองข้อที่ทำให้วิธีนี้เชื่อถือได้:

  • Level-triggered ไม่ใช่ edge-triggered reconcile ไม่ได้ถามว่า "เพิ่งเกิด event อะไรขึ้น" แต่ถามว่า "อะไรควรมี และตอนนี้มีอะไรอยู่" ถ้า controller พลาด event ไปหรือ restart กลางทาง รอบถัดไปก็ยังพาระบบไปถึงสถานะที่ถูกต้องได้
  • Idempotent รัน reconcile ติดกันสิบรอบโดยไม่มีอะไรเปลี่ยน ต้องไม่ก่อความเสียหาย เช็กก่อนสร้างเสมอ และอัปเดตเฉพาะเมื่อมีอะไรต่างไปจริง

อะไรทำให้ controller กลายเป็น operator

Operator = CRD + controller + ความรู้ด้านการดูแลระบบ controller ทั่วไปอาจแค่สร้าง StatefulSet แต่ database operator ต้องรู้วิธี:

  • bootstrap primary แล้วให้ replica มาต่อ
  • ทำ backup ตามรอบ และ restore จาก backup ได้
  • ตรวจพบว่า primary ล่ม แล้ว promote replica ขึ้นมาแทน
  • อัปเกรด minor version ทีละ pod
  • เปิดข้อมูลสำหรับเชื่อมต่อเป็น Secret ให้แอปใช้

งานเหล่านี้คืองานที่คน on-call ต้องเปิด runbook ทำเอง Operator Framework มี maturity model ที่ไล่ตั้งแต่ basic install, seamless upgrade, full lifecycle (backup, restore, failover) ไปจนถึง deep insights และ auto-pilot แต่ operator ที่ทีมเขียนใช้กันเองส่วนใหญ่ ไม่จำเป็นต้องไปไกลเกินสองสามระดับแรก

เลือก framework

เครื่องมือภาษาได้อะไรมาบ้าง
controller-runtimeGolibrary ที่อยู่ใต้ Go operator เกือบทุกตัว มี manager, client, cache, watch และ leader election
kubebuilderGoscaffold โปรเจกต์ และ generate CRD กับ RBAC จาก Go type ทำงานบน controller-runtime
Operator SDKGo, Ansible, Helmใช้ kubebuilder เป็นฐานสำหรับ Go และเพิ่ม integration กับ Operator Lifecycle Manager (OLM) รวมถึงทางเลือกที่ไม่ใช่ Go
kopfPythonเขียน handler ด้วย decorator สำหรับ create, update, delete และ timer เขียนได้เร็ว เหมาะกับงานเชื่อมระบบ

ถ้าทีมเขียน Go อยู่แล้ว เริ่มที่ kubebuilder ได้เลย ส่วนที่เหลือของบทความนี้ใช้ตัวนี้

สร้าง database operator ด้วย kubebuilder

scaffold โปรเจกต์และ API:

bash
kubebuilder init --domain example.com --repo github.com/acme/pg-operator
kubebuilder create api --group db --version v1alpha1 --kind PostgresCluster

นิยาม API เป็น Go type

เราไม่ต้องเขียน YAML ของ CRD เอง แค่เขียน Go struct พร้อม marker แล้ว make manifests จะ generate CRD แบบที่เห็นด้านบนให้

go
// api/v1alpha1/postgrescluster_types.go
package v1alpha1

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

type PostgresClusterSpec struct {
	// +kubebuilder:validation:Minimum=1
	// +kubebuilder:validation:Maximum=5
	Replicas int32 `json:"replicas"`

	// +kubebuilder:validation:Enum="15";"16"
	Version string `json:"version"`

	// +kubebuilder:validation:Pattern=`^[0-9]+Gi$`
	Storage string `json:"storage"`
}

type PostgresClusterStatus struct {
	ReadyReplicas int32              `json:"readyReplicas,omitempty"`
	Conditions    []metav1.Condition `json:"conditions,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName=pgc
// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=`.spec.replicas`
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`
type PostgresCluster struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   PostgresClusterSpec   `json:"spec,omitempty"`
	Status PostgresClusterStatus `json:"status,omitempty"`
}

เขียน reconciler

go
// internal/controller/postgrescluster_controller.go
package controller

import (
	"context"
	"fmt"

	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/meta"
	"k8s.io/apimachinery/pkg/api/resource"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"

	dbv1alpha1 "github.com/acme/pg-operator/api/v1alpha1"
)

type PostgresClusterReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

// +kubebuilder:rbac:groups=db.example.com,resources=postgresclusters,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=db.example.com,resources=postgresclusters/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete

func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var pg dbv1alpha1.PostgresCluster
	if err := r.Get(ctx, req.NamespacedName, &pg); err != nil {
		// Deleted: owned objects are garbage-collected via owner references.
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	labels := map[string]string{"app.kubernetes.io/instance": pg.Name}
	sts := &appsv1.StatefulSet{
		ObjectMeta: metav1.ObjectMeta{Name: pg.Name, Namespace: pg.Namespace},
	}

	_, err := controllerutil.CreateOrUpdate(ctx, r.Client, sts, func() error {
		if sts.CreationTimestamp.IsZero() {
			// Immutable fields: set only on create.
			sts.Spec.ServiceName = pg.Name
			sts.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels}
			sts.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{{
				ObjectMeta: metav1.ObjectMeta{Name: "data"},
				Spec: corev1.PersistentVolumeClaimSpec{
					AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
					Resources: corev1.VolumeResourceRequirements{
						Requests: corev1.ResourceList{
							corev1.ResourceStorage: resource.MustParse(pg.Spec.Storage),
						},
					},
				},
			}}
		}
		sts.Spec.Replicas = &pg.Spec.Replicas
		sts.Spec.Template.Labels = labels
		sts.Spec.Template.Spec.Containers = []corev1.Container{{
			Name:  "postgres",
			Image: fmt.Sprintf("postgres:%s", pg.Spec.Version),
			Ports: []corev1.ContainerPort{{Name: "pg", ContainerPort: 5432}},
			VolumeMounts: []corev1.VolumeMount{{
				Name: "data", MountPath: "/var/lib/postgresql/data",
			}},
		}}
		return controllerutil.SetControllerReference(&pg, sts, r.Scheme)
	})
	if err != nil {
		return ctrl.Result{}, err // requeued with back-off
	}

	pg.Status.ReadyReplicas = sts.Status.ReadyReplicas
	cond := metav1.Condition{
		Type: "Ready", Status: metav1.ConditionFalse,
		Reason: "Provisioning", ObservedGeneration: pg.Generation,
		Message: fmt.Sprintf("%d/%d replicas ready", sts.Status.ReadyReplicas, pg.Spec.Replicas),
	}
	if sts.Status.ReadyReplicas == pg.Spec.Replicas {
		cond.Status, cond.Reason = metav1.ConditionTrue, "AllReplicasReady"
	}
	meta.SetStatusCondition(&pg.Status.Conditions, cond)

	return ctrl.Result{}, r.Status().Update(ctx, &pg)
}

func (r *PostgresClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&dbv1alpha1.PostgresCluster{}).
		Owns(&appsv1.StatefulSet{}).
		Complete(r)
}

สิ่งที่โค้ดนี้ทำถูก:

  • CreateOrUpdate อ่าน StatefulSet ปัจจุบัน รัน mutate function แล้วค่อยส่ง create หรือ update เฉพาะเมื่อผลลัพธ์ต่างจากที่อ่านมา reconcile จึงยัง idempotent
  • field ที่ immutable อย่าง selector และ volume claim template ตั้งค่าเฉพาะตอนสร้าง ถ้าไปแก้บน StatefulSet ที่มีอยู่แล้วจะ validate ไม่ผ่าน เหตุผลอธิบายไว้ในส่วน StatefulSet ของ คู่มือ Kubernetes workloads
  • owner reference จาก SetControllerReference ทำให้เมื่อลบ PostgresCluster ตัว StatefulSet จะถูก garbage-collect ตามไปด้วย
  • Owns(&appsv1.StatefulSet{}) ทำให้เกิด reconcile ทุกครั้งที่ StatefulSet เปลี่ยน เช่นตอนที่ pod เพิ่ง ready นี่คือเหตุผลที่ status อัปเดตตามทัน
  • การ return error จะ requeue request พร้อม exponential back-off ถ้าเจอ conflict ตอนอัปเดต รอบถัดไปก็แก้ตัวเองได้

ระหว่างพัฒนา รันกับ kubeconfig ปัจจุบันได้เลย:

bash
make manifests   # regenerate CRD and RBAC from markers
make install     # apply the CRD to the cluster
make run         # run the controller locally

database operator ของจริงต้องทำมากกว่านี้ เช่น สร้าง headless Service และ Secret ที่เก็บ credential ตั้งค่า replication และทำ backup แต่ loop ยังเป็นแบบเดิม แค่มี object ให้ reconcile เพิ่มขึ้นทีละตัว

ใช้ finalizer เก็บกวาด resource ภายนอก

owner reference เก็บกวาดได้เฉพาะ object ใน cluster ถ้า operator ไปสร้างอะไรไว้ข้างนอก เช่น bucket สำหรับ backup หรือ DNS record ต้องใส่ finalizer Kubernetes จะยังไม่ลบ object จนกว่า controller จะเก็บกวาดเสร็จและถอด finalizer ออก:

go
const finalizer = "db.example.com/cleanup"

if !pg.DeletionTimestamp.IsZero() {
	if controllerutil.ContainsFinalizer(&pg, finalizer) {
		if err := r.deleteBackups(ctx, &pg); err != nil {
			return ctrl.Result{}, err
		}
		controllerutil.RemoveFinalizer(&pg, finalizer)
		return ctrl.Result{}, r.Update(ctx, &pg)
	}
	return ctrl.Result{}, nil
}
if controllerutil.AddFinalizer(&pg, finalizer) {
	return ctrl.Result{}, r.Update(ctx, &pg)
}

แนวคิดเดียวกันใน Python ด้วย kopf

สำหรับงานเล็ก ๆ kopf ให้เขียน handler ได้ทันทีโดยไม่ต้อง scaffold อะไร:

python
import kopf

@kopf.on.create("db.example.com", "v1alpha1", "postgresclusters")
@kopf.on.update("db.example.com", "v1alpha1", "postgresclusters")
def reconcile(spec, name, namespace, logger, **_):
    replicas = spec["replicas"]
    logger.info(f"ensuring {name} has {replicas} replicas")
    # build and apply the StatefulSet with the kubernetes client here
    return {"desiredReplicas": replicas}

รันด้วย kopf run operator.py kopf ทำงานแบบแยก handler ตาม event ไม่ได้มี reconcile function เดียวแบบ controller-runtime จึงต้องระวังให้แต่ละ handler idempotent เองด้วย

ก่อนจะเขียน operator เอง

  • ดูก่อนว่ามีคนทำไว้แล้วหรือยัง software แบบ stateful ที่ใช้กันแพร่หลายมี operator ที่โตแล้วรองรับ เช่น CloudNativePG และ postgres-operator ของ Zalando สำหรับ PostgreSQL, Strimzi สำหรับ Kafka (ดู การรัน Kafka แบบ KRaft บน Kubernetes), cert-manager สำหรับ TLS certificate และ Prometheus Operator สำหรับ monitoring
  • ถ้าไม่มี logic ที่ต้องทำต่อเนื่อง ใช้ Helm พอ ถ้างานคือ "ติดตั้ง manifest ชุดนี้พร้อมค่าบางอย่าง" chart ง่ายกว่ามาก operator คุ้มค่าเมื่อมีสิ่งที่ต้องตอบสนองตลอดเวลา เช่น failover, backup, scaling หรือ rotation
  • วางแผนรับมือตอนมันพังด้วย operator กลายเป็นส่วนหนึ่งของ control path ของระบบแล้ว ควรรันแบบมี leader election ตั้ง resource limit และตั้ง alert เมื่อ reconcile fail ต่อเนื่อง

คำถามที่พบบ่อย

CRD กับ operator ต่างกันอย่างไร

CRD แค่นิยามชนิด resource ใหม่และ schema ของมัน ส่วน operator คือ controller ที่ watch type นั้นและลงมือทำให้ cluster ตรงกับที่ประกาศไว้ มี CRD โดยไม่มี operator ได้ แต่มันก็แค่เก็บข้อมูลไว้เฉย ๆ

Kubernetes operator กับ controller คือสิ่งเดียวกันไหม

operator ทุกตัวเป็น controller แต่ controller ทุกตัวไม่ได้เป็น operator คำว่า operator ใช้กับ controller ที่ดูแล lifecycle ทั้งหมดของแอปใดแอปหนึ่งผ่าน custom resource เช่น backup, upgrade และ failover

ควรใช้ kubebuilder หรือ Operator SDK

ถ้าเขียน Go ทั้งสองตัวให้โปรเจกต์ที่แทบเหมือนกัน เพราะ Operator SDK ใช้ kubebuilder อยู่ข้างใต้ เลือก Operator SDK ถ้าต้องการ package สำหรับ OLM หรืออยากเขียน operator ด้วย Ansible หรือ Helm นอกนั้นใช้ kubebuilder ก็พอ

ถ้า operator crash จะเกิดอะไรขึ้น

workload ที่มันสร้างไว้ยังรันต่อได้ เพราะเป็น object ปกติของ Kubernetes แต่จะไม่มีใคร reconcile จนกว่า operator จะกลับมา ระหว่างนั้น failover หรือ scaling ที่ operator รับผิดชอบจะไม่เกิดขึ้น พอ restart กลับมา reconcile แบบ level-triggered ก็จะพาทุกอย่างกลับเข้าที่

รัน database บน Kubernetes โดยไม่มี operator ได้ไหม

ได้ ใช้ StatefulSet กับ persistent volume แต่เราต้องจัดการ replication, backup และ failover เองทั้งหมด ตามที่อธิบายไว้ใน การรัน database บน production สำหรับ database บน production การใช้ operator ที่โตแล้วมักช่วยลดงานมือได้มาก

สรุปสิ่งที่ควรจำ

  • CRD เพิ่ม type, controller คอย reconcile ส่วน operator เติมความรู้ด้านการดูแลระบบเข้าไปอีกชั้น
  • ทำ reconcile ให้ level-triggered และ idempotent, validate ใน schema และรายงานผลผ่าน condition ใน status
  • ใช้ owner reference เก็บกวาด object ใน cluster และใช้ finalizer กับของที่อยู่ข้างนอก
  • ลองใช้ operator ที่มีอยู่แล้วหรือ Helm chart ก่อนตัดสินใจเขียนเอง

ถ้าอยากลงมือฝึกเรื่องภายในของ Kubernetes แบบนี้ คอร์ส DevOps ฟรี ของ Vectorkub เป็นจุดเริ่มต้นที่ดี

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ