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 การเปลี่ยนแปลงได้
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 สั้น ๆ:
apiVersion: db.example.com/v1alpha1
kind: PostgresCluster
metadata:
name: orders-db
namespace: shop
spec:
replicas: 3
version: "16"
storage: 20Gi$ 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:
- Observe อ่าน desired state (คือ
specของ custom resource) และ actual state (object ที่มีอยู่จริงใน cluster) - Diff หาว่าอะไรขาด อะไรเกิน อะไรไม่ตรง
- Act สร้าง อัปเดต หรือลบ object เพื่อปิดช่องว่างนั้น
- Report เขียนสิ่งที่เห็นลงใน
status - 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-runtime | Go | library ที่อยู่ใต้ Go operator เกือบทุกตัว มี manager, client, cache, watch และ leader election |
| kubebuilder | Go | scaffold โปรเจกต์ และ generate CRD กับ RBAC จาก Go type ทำงานบน controller-runtime |
| Operator SDK | Go, Ansible, Helm | ใช้ kubebuilder เป็นฐานสำหรับ Go และเพิ่ม integration กับ Operator Lifecycle Manager (OLM) รวมถึงทางเลือกที่ไม่ใช่ Go |
| kopf | Python | เขียน handler ด้วย decorator สำหรับ create, update, delete และ timer เขียนได้เร็ว เหมาะกับงานเชื่อมระบบ |
ถ้าทีมเขียน Go อยู่แล้ว เริ่มที่ kubebuilder ได้เลย ส่วนที่เหลือของบทความนี้ใช้ตัวนี้
สร้าง database operator ด้วย kubebuilder
scaffold โปรเจกต์และ API:
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 แบบที่เห็นด้านบนให้
// 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
// 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 ปัจจุบันได้เลย:
make manifests # regenerate CRD and RBAC from markers
make install # apply the CRD to the cluster
make run # run the controller locallydatabase 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 ออก:
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 อะไร:
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 เป็นจุดเริ่มต้นที่ดี
