Operator และ Custom Resource
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”CustomResourceDefinition ต่อขยาย Kubernetes API ด้วย object Kind ใหม่ ส่วน Operator คือ CRD นั้นจับคู่กับ controller ที่คอย reconcile สถานะจริงให้ตรงกับที่ประกาศไว้ตลอดเวลา
CustomResourceDefinition: สอน API server ให้รู้จัก Kind ใหม่
หัวข้อที่มีชื่อว่า “CustomResourceDefinition: สอน API server ให้รู้จัก Kind ใหม่”CustomResourceDefinition (CRD) ลงทะเบียน object Kind ใหม่เข้ากับ Kubernetes API server พอติดตั้งแล้ว instance ของ Kind นั้นจะทำงานเหมือน built-in object ทุกอย่าง kubectl get/kubectl apply ใช้ได้ตามปกติ object เหล่านี้ถูกเก็บใน etcd เหมือนของอื่น ๆ และ watch ผ่าน API ได้เหมือน Pod หรือ Deployment ตัว CRD เองกำหนด schema ของ Kind นั้นด้วย OpenAPI v3 schema ทำให้ API server validate instance ตอนเขียนได้
apiVersion: apiextensions.k8s.io/v1kind: CustomResourceDefinitionmetadata: name: databases.example.comspec: group: example.com versions: - name: v1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: engine: type: string enum: ['postgres', 'mysql'] storageGB: type: integer minimum: 1 scope: Namespaced names: plural: databases singular: database kind: Database shortNames: ['db']# An instance of the new Kind, just like any other manifestapiVersion: example.com/v1kind: Databasemetadata: name: orders-dbspec: engine: postgres storageGB: 20kubectl apply -f orders-db.yamlkubectl get databaseskubectl get db orders-db -o yamlCRD เดี่ยว ๆ คือแค่ schema controller ต่างหากที่ทำให้กลายเป็น Operator
หัวข้อที่มีชื่อว่า “CRD เดี่ยว ๆ คือแค่ schema controller ต่างหากที่ทำให้กลายเป็น Operator”การ apply object Database นั้นไม่ทำอะไรด้วยตัวเอง API server แค่เก็บลง etcd แล้ว validate โครงสร้าง แต่ไม่มีอะไรไป provision database จริงให้ CRD เพิ่มแค่ schema กับที่เก็บสำหรับ Kind ใหม่ ไม่มี behavior ใด ๆ Operator pattern คือ CRD นั้นจับคู่กับ controller ที่คอย watch instance ของ custom resource แล้วขับเคลื่อน infrastructure จริงให้ตรงกับสิ่งที่อธิบายไว้ ใช้ reconciliation loop แบบเดียวกัน (watch, diff, act) ที่ built-in controller ของ Kubernetes เองใช้กับ Deployment กับ ReplicaSet เพียงแต่เล็งไปที่ความรู้เฉพาะแอปหรือเฉพาะงาน operation แทนที่จะเป็นการ schedule Pod ทั่วไป database Operator อาจ provision database instance จริงตอนที่ resource Database ถูกสร้าง ทำ backup เป็นระยะเพราะ resource บอกไว้แบบนั้น และจัดการ failover อัตโนมัติเมื่อ replica ตัวไหนไม่ healthy ถ้าไม่มี controller ตัวนี้ CRD ก็เป็นแค่รูปแบบที่ kubectl ยอมรับเท่านั้นเอง
ทำไมตรงนี้ programmatic client ถึงคุ้มค่าจริง ๆ
หัวข้อที่มีชื่อว่า “ทำไมตรงนี้ programmatic client ถึงคุ้มค่าจริง ๆ”ที่อื่นในคอร์สนี้ kubectl apply เพียงพออยู่แล้วเพราะมนุษย์รันครั้งเดียวแล้ว built-in controller ก็ทำต่อเอง ส่วน controller ของ Operator ต่างออกไป คือต้องรันต่อเนื่อง ตอบสนองต่อทุก add, update, delete ของ custom resource ตราบเท่าที่ cluster ยังอยู่ นี่คือสถานการณ์ที่คำสั่ง CLI ครั้งเดียวรองรับไม่ได้ แต่ client แบบ programmatic ที่ watch อยู่ตลอดรองรับได้ การใช้ class Watch ของ @kubernetes/client-node เล็งไปที่ API path ของ custom resource เป็นวิธีที่สมจริงในการสร้าง reconciliation loop ที่เป็นหัวใจของ Operator
import * as k8s from '@kubernetes/client-node';
const kc = new k8s.KubeConfig();kc.loadFromDefault();
const watch = new k8s.Watch(kc);
// Path for a namespace-scoped custom resource: /apis/{group}/{version}/namespaces/{namespace}/{plural}const path = '/apis/example.com/v1/namespaces/default/databases';
async function reconcile(database: any) { const name = database.metadata?.name; const engine = database.spec?.engine; const storageGB = database.spec?.storageGB; console.log(`reconciling Database ${name}: engine=${engine} storageGB=${storageGB}`); // Real controller: provision or update the actual database instance here}
const req = await watch.watch( path, {}, (type, apiObj) => { if (type === 'ADDED' || type === 'MODIFIED') { reconcile(apiObj); } else if (type === 'DELETED') { console.log(`Database ${apiObj.metadata?.name} deleted, tearing down real instance`); } }, (err) => { console.error('watch ended', err); },);
// Later, on shutdown:// req.abort();ทุก event ADDED หรือ MODIFIED จะรัน reconcile step ใหม่อีกครั้ง ที่เป็นแก่นของ Operator pattern เลย คือคอยเทียบ desired state (custom resource) กับ real state (infrastructure จริง) แล้วแก้ส่วนต่างไปเรื่อย ๆ โดยไม่ต้องให้มนุษย์มารันคำสั่งซ้ำ
รันจริงกับ Widget CRD ที่ใช้งานอยู่ (พิสูจน์ด้านล่าง) informer ให้ reconcile log จริง — ใช้ makeInformer จาก @kubernetes/client-node 2.x ไม่ใช่ Watch loop ที่เขียนเองทั้งหมด
$ npx tsx src/widget-controller.ts[ADDED] gizmo[reconcile] Widget/gizmo size=medium resourceVersion=1579informer started, watching Widgets...patched gizmo.spec.size -> small[MODIFIED] gizmo[reconcile] Widget/gizmo size=small resourceVersion=1661demo complete, exitingimport * as k8s from '@kubernetes/client-node';
const kc = new k8s.KubeConfig();kc.loadFromDefault();
const customApi = kc.makeApiClient(k8s.CustomObjectsApi);
const GROUP = 'example.com';const VERSION = 'v2';const PLURAL = 'widgets';const NAMESPACE = 'default';
function reconcile(widget: any): void { const name = widget.metadata?.name; const size = widget.spec?.size; const resourceVersion = widget.metadata?.resourceVersion; console.log(`[reconcile] Widget/${name} size=${size} resourceVersion=${resourceVersion}`);}
async function main(): Promise<void> { const listFn = () => customApi.listNamespacedCustomObject({ group: GROUP, version: VERSION, namespace: NAMESPACE, plural: PLURAL });
const informer = k8s.makeInformer( kc, `/apis/${GROUP}/${VERSION}/namespaces/${NAMESPACE}/${PLURAL}`, listFn as any, );
informer.on('add', (obj: any) => reconcile(obj)); informer.on('update', (obj: any) => reconcile(obj)); informer.on('delete', (obj: any) => console.log(`[DELETED] ${obj.metadata?.name}`)); informer.on('error', (err: any) => { console.error('[informer error]', err?.body ?? err); setTimeout(() => informer.start(), 1000); });
await informer.start();}
main();makeInformer ห่อ class Watch ดิบไว้พร้อม local cache และ relist-on-error อัตโนมัติ — error handler ด้านบน restart informer แทนที่จะปล่อยให้ process ตายตอน connection หลุด ซึ่งเป็น resiliency แบบเดียวกับที่ controller ของ Operator จริงต้องมีตอนรันโดยไม่มีคนดูแลตลอดอายุ cluster
CRD หลาย version: served, storage, และ version ที่ kubectl เลือกจริง
หัวข้อที่มีชื่อว่า “CRD หลาย version: served, storage, และ version ที่ kubectl เลือกจริง”CRD ระดับ production ไม่ค้างอยู่ที่ version เดียวตลอดไปหรอก spec.versions[] เป็น list และแต่ละตัวควบคุมแยกกันว่ามัน served ไหม (API request ใช้ version นี้ได้ไหม) กับเป็น storage ไหม (etcd เก็บ object ในรูปแบบของ version นี้ — ต้องมี version เดียวใน list ที่ storage: true)
apiVersion: apiextensions.k8s.io/v1kind: CustomResourceDefinitionmetadata: name: widgets.example.comspec: group: example.com scope: Namespaced names: plural: widgets singular: widget kind: Widget shortNames: ['wg'] versions: - name: v1 served: true storage: false additionalPrinterColumns: - name: Size type: string jsonPath: .spec.size - name: Ready type: string jsonPath: .status.conditions[?(@.type=="Ready")].status - name: Age type: date jsonPath: .metadata.creationTimestamp schema: openAPIV3Schema: type: object properties: spec: type: object properties: size: type: string enum: ['small', 'medium', 'large'] replicas: type: integer required: ['size'] x-kubernetes-validations: - rule: "self.size != 'large' || has(self.replicas)" message: "replicas is required when size is 'large'" status: type: object properties: conditions: type: array items: type: object properties: type: { type: string } status: { type: string } subresources: status: {} - name: v2 served: true storage: true additionalPrinterColumns: - name: Size type: string jsonPath: .spec.size schema: openAPIV3Schema: type: object properties: spec: type: object properties: size: type: string enum: ['small', 'medium', 'large'] required: ['size'] status: type: object properties: conditions: type: array items: type: object properties: type: { type: string } status: { type: string } subresources: status: {} conversion: strategy: Noneconversion.strategy: None พิสูจน์แล้ว: object เดียวกัน อ่านเป็นสอง version
หัวข้อที่มีชื่อว่า “conversion.strategy: None พิสูจน์แล้ว: object เดียวกัน อ่านเป็นสอง version”None ใช้ได้แค่ตอนที่ทุก version ที่ served share schema เดียวกันเป๊ะ (ตรงนี้ทั้ง v1 กับ v2 share รูปแบบ spec.size เดียวกัน) — API server “assumes the same schema for all versions and only sets the apiVersion field of custom resources to the proper value” ตาม เอกสาร CRD versioning ของ Kubernetes พิสูจน์ด้วยการสร้าง object เป็น v1 แล้วอ่าน object เดียวกันกลับมาเป็น v2 โดยไม่มี webhook รันอยู่เลย
$ kubectl apply -f widget-instance.yaml # apiVersion: example.com/v1widget.example.com/gizmo created
$ kubectl get widget.v2.example.com gizmo -o yamlapiVersion: example.com/v2kind: Widgetmetadata: name: gizmo namespace: default resourceVersion: "1421" uid: 8365eba8-52b9-4a35-b491-48dc446e0ab7spec: size: mediumถ้า schema ของ version ต่าง ๆ ต่างกันจริง ๆ (field ที่เปลี่ยนชื่อ, spec ที่ปรับโครงสร้างใหม่) None round-trip ให้ถูกต้องไม่ได้ ต้องใช้ conversion.strategy: Webhook แทน คือ webhook server จริง อ้างจาก spec.conversion.webhook.clientConfig รับ request ConversionReview แล้วคืน object ที่แปลงเป็น version ที่ขอมาแล้ว คอร์สนี้ไม่ได้รัน webhook server ดังนั้นส่วนนี้เป็น docs-only — ดู เอกสาร versioning เดียวกัน สำหรับรูปแบบ request/response ของ ConversionReview
CEL rejection จริงจาก x-kubernetes-validations
หัวข้อที่มีชื่อว่า “CEL rejection จริงจาก x-kubernetes-validations”CEL rule ด้านบนถูกบังคับใช้โดย API server เอง ไม่ต้องมี webhook หรือ admission controller เลย — พิสูจน์ด้วย rejection จริง
$ kubectl apply -f widget-bad.yaml # spec: { size: large } (no replicas)The Widget "bad-gizmo" is invalid: spec: Invalid value: replicas is required when size is 'large'กว่าจะถึงตรงนั้นเจอ CEL error ตอน compile จริงก่อนหนึ่งครั้ง คือ has(self.replicas) compile ไม่ผ่านถ้า replicas ไม่ถูกประกาศไว้ใน schema จริง ๆ ที่ไหนสักที่
$ kubectl apply -f widget-crd-broken.yamlThe CustomResourceDefinition "widgets.example.com" is invalid: spec.versions[0].schema.openAPIV3Schema.properties[spec].x-kubernetes-validations[0].rule:Invalid value: ...: compilation failed: ERROR: <input>:1:28: undefined field 'replicas' | self.size != 'large' || has(self.replicas) | ...........................^has() เช็คการมีอยู่ของ field ได้เฉพาะที่ OpenAPI schema รู้จักจริง ๆ เท่านั้น — CEL rule ที่อ้างถึง field ที่ไม่ได้ประกาศไว้จะถูก reject ตอน apply CRD เลย ก่อนที่ instance ไหนจะถูกสร้างด้วยซ้ำ ไม่ใช่แค่ถูกเมินเฉย ๆ
Printer column: gotcha จริงที่น่าแปลกใจ
หัวข้อที่มีชื่อว่า “Printer column: gotcha จริงที่น่าแปลกใจ”ทั้งสอง version ประกาศ additionalPrinterColumns แต่ v1 มีสามคอลัมน์ (Size, Ready, Age) ส่วน v2 — version ที่เป็น storage — มีแค่หนึ่งคอลัมน์ (Size) kubectl get widgets เฉย ๆ ไม่ได้ใช้คอลัมน์ของ storage version เลย
$ kubectl explain widgetGROUP: example.comKIND: WidgetVERSION: v2
$ kubectl api-resources --api-group=example.com -o wideNAME SHORTNAMES APIVERSION NAMESPACED KIND VERBSwidgets wg example.com/v2 true Widget ...
$ kubectl get widgetsNAME SIZEgizmo medium
$ kubectl get widget.v1.example.comNAME SIZE READY AGEgizmo medium True 14skubectl resolve widgets/widget เปล่า ๆ ไปที่ example.com/v2 — ไม่ใช่ storage version — เพราะ CRD version priority สำหรับชื่อที่ตาม convention vN/vNbetaM/vNalphaM จัดอันดับด้วยตัวเลขล้วน ๆ (v2 แซง v1) ไม่สนว่าตัวไหน storage: true storage version กับ version ที่ kubectl เลือกเป็น default คือคนละแนวคิดกันเลย พิสูจน์ตรงนี้ด้วย printer column ที่หายไปเฉย ๆ เมื่อ version ที่ served ตัวเลขสูงกว่าดันประกาศคอลัมน์น้อยกว่า — กับดักเงียบ ๆ ที่คนอ่านที่คิดว่า “storage version” คือ “version ที่ kubectl get โชว์” จะเจอง่ายมาก
storedVersions: สะสมเมื่อ flip storage แต่ไม่เคยเคลียร์เองเลย
หัวข้อที่มีชื่อว่า “storedVersions: สะสมเมื่อ flip storage แต่ไม่เคยเคลียร์เองเลย”การ flip ว่า version ไหน storage: true ไม่ได้ rewrite object ที่มีอยู่แล้วใน etcd ย้อนหลัง — status.storedVersions track ทุก version ที่ยังมี object encode อยู่จริง และ API server ไม่เคยลบ entry ออกจากมันเองอัตโนมัติ
$ kubectl apply -f widget-crd.yaml # v1 storage:false, v2 storage:true (flipped)customresourcedefinition.apiextensions.k8s.io/widgets.example.com configured
$ kubectl get crd widgets.example.com -o jsonpath='{.status.storedVersions}'["v1","v2"]
$ kubectl label widget gizmo touch=1 --overwrite # any write re-stores the object in the new storage versionwidget.example.com/gizmo labeled
$ kubectl get crd widgets.example.com -o jsonpath='{.status.storedVersions}'["v1","v2"]แม้หลังจาก object เดียวที่มีอยู่จะถูก rewrite เป็น encoding ของ v2 จริง ๆ แล้ว storedVersions ก็ยังคง list v1 อยู่ดี — เอกสารของ Kubernetes บอกชัดเจนว่านี่คือ manual cleanup step คือ “verify that the old version is no longer listed” หลังจากยืนยันแล้วเท่านั้น (โดย migrate object ทุกตัว เช่นด้วย kubectl get <kind> -o json | kubectl replace -f - กวาดทุก namespace) ว่าไม่มีอะไรเก็บอยู่ใน version นั้นอีกแล้ว แล้วค่อย patch field เองด้วยมือ
$ kubectl patch crd widgets.example.com --subresource=status --type=merge -p '{"status":{"storedVersions":["v2"]}}'customresourcedefinition.apiextensions.k8s.io/widgets.example.com patched
$ kubectl get crd widgets.example.com -o jsonpath='{.status.storedVersions}'["v2"]ต้องยืนยันและเคลียร์ storedVersions ด้วยมือแบบนี้ก่อนเท่านั้นถึงจะปลอดภัยที่จะลบ version เก่าออกจาก spec.versions จริง ๆ — ลบทั้งที่มันยัง list อยู่จะ fail เพราะ object อาจยังมีอยู่ใน encoding ของ version นั้น
status subresource: พิสูจน์แล้วว่าแยกจาก spec จริง
หัวข้อที่มีชื่อว่า “status subresource: พิสูจน์แล้วว่าแยกจาก spec จริง”subresources: { status: {} } แยก /status ออกเป็น endpoint ของตัวเอง มี RBAC surface ของตัวเอง เหมือน built-in Deployment เป๊ะ — การ update มันต้องใช้ --subresource=status และ patch แค่ spec เฉย ๆ แตะมันไม่ได้เลย
$ kubectl patch widget gizmo --subresource=status --type=merge -p '{"status":{"conditions":[{"type":"Ready","status":"True"}]}}'widget.example.com/gizmo patchedOperator SDK กับ OLM: สร้าง controller โดยไม่ต้องเขียนทุกอย่างเอง
หัวข้อที่มีชื่อว่า “Operator SDK กับ OLM: สร้าง controller โดยไม่ต้องเขียนทุกอย่างเอง”เอกสาร Operator pattern ของ Kubernetes list framework หลายตัวที่ generate CRD scaffolding, reconcile-loop boilerplate, และ testing harness ที่ตัวอย่าง makeInformer เล็ก ๆ ของบทนี้ทำด้วยมือ คือ kubebuilder (Go, scaffold โปรเจกต์ controller-runtime เต็มรูปแบบ), Operator SDK ของ Operator Framework (operator แบบ Go/Ansible/Helm เป็นบ้านของ Operator Lifecycle Manager (OLM) — ส่วนที่จัดการติดตั้ง, upgrade, และ resolve dependency ของ operator เองข้าม cluster ไม่ใช่แค่สิ่งที่ controller ของ operator ตัวเดียวทำ), Kopf (framework แบบ Pythonic), kube-rs (Rust), KubeOps (.NET), และ Metacontroller (ประกอบ operator จาก webhook ที่คุณ implement เอง โดยไม่ต้องเขียน controller binary เลย) จะเลือกตัวไหนขึ้นอยู่กับภาษาของทีมและว่า OLM ที่จัดการ install/upgrade/dependency ของตัว operator เองคุ้มค่าที่จะเอามาใช้เพิ่มบน reconcile logic ของ operator เองไหม
flowchart LR
cr["Database custom resource (desired state)"] -->|watch| ctrl["Operator controller"]
ctrl -->|diff| decide{"Does real state match spec?"}
decide -->|no| act["Provision, resize, or repair"]
decide -->|yes| noop["Do nothing this cycle"]
act --> infra["Real PostgreSQL instance"]
infra -->|status| ctrl Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”CRD คือแค่การลงทะเบียน schema ไม่มีอะไรมากไปกว่านั้น — พิสูจน์แล้วด้านบน conversion.strategy: None round-trip object ระหว่าง version ได้จริงโดยไม่มี code รันเลย เพราะทั้งสอง version อธิบายรูปแบบเดียวกัน x-kubernetes-validations reject instance ที่ผิดได้จริงโดยไม่มี code รันเลย เพราะ CEL รันอยู่ข้างใน API server เอง ทุกอย่างที่ดูเหมือน “behavior” — provision database, restart replica ที่พัง — มาจาก controller แยกต่างหากที่ watch schema นั้นทั้งหมด นี่คือเหตุผลที่ Operator pattern ทั้งหมดย่อลงมาเหลือ: CRD คือ contract, controller คือ implementation และ storedVersions/การ resolve printer column (พิสูจน์แล้วด้านบนว่าน่าแปลกใจจริง ๆ) จริง ๆ แล้วก็แค่ “ตอนนี้ API server คืนรูปแบบไหนกลับมา” ไม่ใช่ “รูปแบบไหนถูกต้อง”
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
conversion.strategy: None | พิสูจน์แล้วด้านบน: ไม่ต้องมี infrastructure เพิ่ม round-trip ระหว่าง version ได้ทันที | ใช้ได้แค่ตอนที่ schema ของทุก version ที่ served เหมือนกันเป๊ะ — field rename จริง ๆ ต้องใช้ Webhook แทน |
conversion.strategy: Webhook | รองรับ schema ที่เปลี่ยนจริงระหว่าง version | ต้องรันและดูแล webhook server จริง (docs-only ในบทนี้ ไม่มี server deploy) |
x-kubernetes-validations (CEL) | พิสูจน์แล้วด้านบน: reject จริงพร้อม message ที่กำหนดเอง ไม่ต้อง deploy component เพิ่ม | compile-time error อ้างได้แค่ field ที่มีอยู่ใน schema แล้ว (พิสูจน์แล้วด้านบน) — validate กับ external state ไม่ได้ |
version ตัวเลขสูงกว่าที่ served แต่ไม่ใช่ storage | ให้ client อ่าน/เขียนรูปแบบใหม่ได้ทันที | พิสูจน์แล้วด้านบน: kubectl เลือกมันเป็น default discovery แบบเงียบ ๆ แม้จะไม่ใช่ storage version ซึ่งซ่อน printer column ที่ storage version ประกาศไว้ได้ |
เคลียร์ storedVersions ด้วยมือ | ควบคุมได้เต็มที่ว่าจะประกาศว่า encoding เก่าหายไปเมื่อไหร่แน่ | พิสูจน์แล้วด้านบน: ไม่มีอะไรทำแบบนี้ให้อัตโนมัติ — ข้ามขั้นตอนนี้ไป (หรือทำเร็วเกินไป) จะปล่อยให้ state เก่าค้างตลอดไปหรือเสี่ยงพัง read ของ object ที่ยัง migrate ไม่เสร็จ |
makeInformer (client-node) แทน Watch loop ที่เขียนเอง | พิสูจน์แล้วด้านบน: relist-on-error อัตโนมัติ, local cache, boilerplate น้อยกว่า | ยังเป็น controller ที่เขียนตั้งแต่ศูนย์ — ไม่มี leader election, ไม่มี work queue, ไม่มี rate limiting ต่างจาก scaffolding ของ kubebuilder/Operator SDK |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “storage version คือ version ที่ kubectl ใช้เป็น default” พิสูจน์แล้วด้านบน:
kubectl explain widget/kubectl get widgetsresolve ไปที่v2ด้วยลำดับความสำคัญตามตัวเลขล้วน ๆ ไม่เกี่ยวกับว่า version ไหนstorage: true - “flip storage: true ไป version ใหม่จะ migrate object ที่มีอยู่ทันที” พิสูจน์แล้วด้านบน:
status.storedVersionsยัง list version เก่าอยู่แม้หลังจาก object ที่มีอยู่ทุกตัวจะถูก rewrite จริง ๆ แล้ว — field นี้ไม่ auto-update ต้องเช็คและ patch เองด้วยมือ - “has() ใน CEL rule เช็ค field ชื่ออะไรก็ได้ ประกาศไว้หรือไม่ก็ตาม” พิสูจน์แล้วด้านบนด้วย compile error จริง:
has(self.replicas)fail การ validate CRD ไปเลยถ้าreplicasไม่ถูกประกาศไว้ใน OpenAPI schema - “webhook conversion server จำเป็นแค่ตอน rename version แบบผิวเผิน” strategy
Noneจัดการ rename ที่รูปแบบเหมือนกันได้อยู่แล้วแค่ผ่านapiVersion(พิสูจน์แล้วด้านบน) —Webhookจริง ๆ จำเป็นแค่ตอนที่ รูปแบบ field ต่างกันจริงระหว่าง version ซึ่งเป็นภาระที่หนักกว่าที่ฟังดู - “CRD instance ที่ apply ไม่ผ่านเพราะ Kind ยังไม่ติดตั้งเป็น error ประเภทเดียวกับ admission rejection” CRD instance ที่ apply กับ cluster ที่ไม่เคยติดตั้ง CRD เลยจะ fail ด้วย
no matches for kind(error ระดับ discovery ก่อนที่ schema หรือ CEL validation ไหนจะรันด้วยซ้ำ) — คนละแบบกับx-kubernetes-validationsrejection จริงที่พิสูจน์แล้วด้านบน ซึ่งต้องมี CRD ติดตั้งและ Established อยู่ก่อนแล้ว
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ตอน review CRD, conversion configuration, หรือ controller code ที่ AI สร้าง ให้เช็ค
- CRD หลาย version ที่
conversion.strategy: Noneทั้งที่ schema ของ version ต่างกันจริง (field ที่ rename หรือปรับโครงสร้างใหม่) — พิสูจน์แล้วด้านบนNoneเปลี่ยนแค่ labelapiVersionไม่ได้ transform ข้อมูลเลย ดังนั้น shape ที่ต่างจริงจะทำให้ read ผิดแบบเงียบ ๆ x-kubernetes-validationsrule ที่อ้าง field ที่ไม่เคยประกาศไว้ใน schema เดียวกัน — พิสูจน์แล้วด้านบนว่าถูก reject ตอน apply CRD ด้วย compile error จริง ไม่ใช่ถูกเมินเฉย ๆ ดังนั้น fail เร็ว แต่วิธีแก้ (ประกาศ field) พลาดง่ายตอน review ผ่าน ๆ- สมมติว่า
kubectl get <plural>สะท้อน storage version ของ CRD — พิสูจน์แล้วด้านบน มันสะท้อน version ที่ served ที่ priority สูงสุดตาม naming convention ซึ่งอาจเป็นคนละ version เลย - storage-version migration ที่ไม่มี step เช็ค/เคลียร์
storedVersionsชัดเจน — พิสูจน์แล้วด้านบน field นี้ไม่ update ตัวเอง ดังนั้น “เรา bump storage เป็น v2 แล้ว” โดยไม่มี step นี้จะทิ้ง version reference เก่าค้างไว้ตลอดไป - controller ที่ไม่มี error handler บน watch/informer ของมัน (หรือปล่อยให้ process exit ตอน connection หลุด) — พิสูจน์แล้วด้านบน
errorevent ของmakeInformerที่ restart informer คือสิ่งที่ทำให้ controller จริงมีชีวิตอยู่ตลอดอายุ cluster ไม่ใช่ script รันครั้งเดียว - CRD instance manifest ที่ apply โดยไม่เช็คก่อนว่า CRD เองติดตั้งบน cluster เป้าหมายแล้วหรือยัง — error
no matches for kindแปลว่า schema ไม่เคยถูกลงทะเบียนเลย คนละปัญหากับ validation rule ใด ๆ ข้างใน schema นั้น
🐛 หา bug ในโค้ดนี้
# @expect-error real CEL compile error, proven above: undefined field 'replicas'apiVersion: apiextensions.k8s.io/v1kind: CustomResourceDefinitionmetadata:name: widgets.example.comspec:group: example.comscope: Namespacednames: plural: widgets singular: widget kind: Widgetversions: - name: v1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: size: type: string x-kubernetes-validations: - rule: "self.size != 'large' || has(self.replicas)" message: "replicas is required when size is 'large'"เฉลย
replicas ถูกอ้างโดย has(self.replicas) แต่ไม่เคยถูกประกาศไว้ที่ไหนใน properties ของ spec เลย พิสูจน์แล้วด้านบนด้วย response จริงจาก API server: The CustomResourceDefinition "widgets.example.com" is invalid: ... compilation failed: ERROR: <input>:1:28: undefined field 'replicas' has() ของ CEL เช็คการมีอยู่ของ field ได้แค่ที่ OpenAPI schema ประกาศไว้จริง ๆ เท่านั้น วิธีแก้คือเติม replicas: { type: integer } เข้าไปคู่กับ size ใน properties block เดียวกัน