The AI bug catalog
ทุก manifest ด้านล่างถูกเขียนให้หน้าตาเหมือน YAML ที่ AI ผู้ช่วยเขียนจริง ๆ ทุกประการ — ส่วนใหญ่ลื่นไหล เป็นไปได้ตามหลักไวยากรณ์ และมีรูปร่างตาม training data ที่ยังจำ extensions/v1beta1 ได้ และ “แค่ตั้ง privileged: true มันจะแก้ permission error ให้เอง” — แล้วรันจริง: kubectl apply --dry-run=server (หรือ apply จริง เมื่อบั๊กนั้นเงียบต่อ API server) กับคลัสเตอร์ kind ของ Kubernetes 1.37.0 จริง (reading-a, 1 control-plane + 2 worker สร้างขึ้นสำหรับโมดูลนี้และลบทิ้งหลังจับการพิสูจน์เหล่านี้เสร็จแล้ว) output ของ kubectl, event, และผล curl ที่ยกมาด้านล่างคัดลอกมาจากการรันจริงเป๊ะ ไม่ได้เรียบเรียงจากความจำ สังเกตว่าอันไหนที่ API server ปฏิเสธจริง: มีแค่ 8 จาก 24 แพทเทิร์นเท่านั้นที่ทำให้ kubectl apply --dry-run=server ล้มเหลวตรง ๆ — ข้อ 1–8 ในตารางด้านล่าง อีก 16 ข้อที่เหลือ apply ผ่านสะอาดแล้วพังทีหลังแบบเงียบ ๆ ในแบบที่มีแค่ describe จริง, get events จริง, curl จริง, หรือการ reschedule ไปอีก node เท่านั้นที่เผยออกมา 24 แพทเทิร์นนี้จัดกลุ่มตามเจ็ดพื้นที่เดียวกับ วิธีอ่าน YAML ของ Kubernetes ที่ไม่ได้เขียนเอง และ Review Checklist ที่ตามหลังบทนี้
apiVersion & รูปร่าง
หัวข้อที่มีชื่อว่า “apiVersion & รูปร่าง”1. extensions/v1beta1 สำหรับ Deployment
หัวข้อที่มีชื่อว่า “1. extensions/v1beta1 สำหรับ Deployment”training data ของ AI ครอบคลุมหลายปีของ tutorial Kubernetes และ Deployment เคยอยู่ใต้ extensions/v1beta1 และ apps/v1beta1/apps/v1beta2 มาก่อนที่จะลงตัวที่ apps/v1 ถูกลบไปตั้งแต่ Kubernetes 1.16 (ปี 2019) แต่ก็ยังเป็นหนึ่งในค่า apiVersion ที่ถูกหลอนบ่อยที่สุดใน manifest ที่ AI สร้าง เพราะ training corpus สาธารณะจำนวนมากมีอายุก่อนการลบนี้
# k8s/ai-bug-catalog/extensions-v1beta1-deployment.yaml — @expect-error extensions/v1beta1 was removed in Kubernetes 1.16apiVersion: extensions/v1beta1kind: Deploymentmetadata: name: webspec: replicas: 2 template: metadata: labels: app: web spec: containers: - name: web image: nginx:1.27-alpineอาการ — output จริง จาก kubectl apply --dry-run=server กับคลัสเตอร์ 1.37.0 จริง:
error: resource mapping not found for name: "web" namespace: "" from "extensions-v1beta1-deployment.yaml": no matches for kind "Deployment" in version "extensions/v1beta1"ensure CRDs are installed firstนี่คือความล้มเหลวของ RESTMapper ฝั่ง client ไม่ใช่แม้แต่ request ที่ไปถึง API server ให้ปฏิเสธ — kubectl หา extensions/v1beta1 ใน discovery document ของคลัสเตอร์ไม่เจอเลย บน Kubernetes เวอร์ชันไหนก็ตามที่ใหม่กว่า 1.16
วิธีแก้:
apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: replicas: 2 selector: matchLabels: app: web template: metadata: labels: app: web spec: containers: - name: web image: nginx:1.27-alpineจับได้ด้วย: kubectl explain deployment (resolve ได้แค่ใต้ apps/v1), kubectl apply --dry-run=server/--dry-run=client (ล้มเหลวเหมือนกันทั้งคู่ — นี่คือ discovery error ไม่ใช่ schema error), kubeconform, kubectl api-resources
2. batch/v1beta1 สำหรับ CronJob
หัวข้อที่มีชื่อว่า “2. batch/v1beta1 สำหรับ CronJob”training data เก่าแบบเดียวกัน แค่คนละ API group: CronJob เลื่อนขึ้นเป็น batch/v1 ใน Kubernetes 1.21 และเวอร์ชัน batch/v1beta1 ถูกลบไปตั้งแต่ 1.25
# k8s/ai-bug-catalog/batch-v1beta1-cronjob.yaml — @expect-error batch/v1beta1 CronJob was removed in Kubernetes 1.25apiVersion: batch/v1beta1kind: CronJobmetadata: name: nightlyspec: schedule: '0 2 * * *' jobTemplate: spec: template: spec: restartPolicy: OnFailure containers: - name: report image: busybox:1.38อาการ — output จริง:
error: resource mapping not found for name: "nightly" namespace: "" from "batch-v1beta1-cronjob.yaml": no matches for kind "CronJob" in version "batch/v1beta1"ensure CRDs are installed firstวิธีแก้:
apiVersion: batch/v1kind: CronJobmetadata: name: nightlyspec: schedule: '0 2 * * *' jobTemplate: spec: template: spec: restartPolicy: OnFailure containers: - name: report image: busybox:1.38จับได้ด้วย: kubectl explain cronjob (batch/v1 เท่านั้น), dry-run=server, kubeconform
3. networking.k8s.io/v1beta1 Ingress กับรูปร่างเก่า backend.serviceName/servicePort
หัวข้อที่มีชื่อว่า “3. networking.k8s.io/v1beta1 Ingress กับรูปร่างเก่า backend.serviceName/servicePort”ข้อเท็จจริงเก่าสองอย่างซ้อนกันตรงนี้: Ingress API v1beta1 ถูกลบไปตั้งแต่ Kubernetes 1.22 และ รูปร่างของ backend ก็เปลี่ยนพร้อมกัน — serviceName/servicePort (v1beta1) กลายเป็น service.name/service.port.number (v1) โมเดล AI ที่ train บนตัวอย่าง Ingress ก่อนปี 2021 ผลิตทั้งสองอย่างนี้ซ้ำพร้อมกันเสมอ
# k8s/ai-bug-catalog/ingress-v1beta1-backend-shape.yaml — @expect-error networking.k8s.io/v1beta1 was removed in Kubernetes 1.22; the old backend.serviceName/servicePort shape is gone tooapiVersion: networking.k8s.io/v1beta1kind: Ingressmetadata: name: webspec: rules: - http: paths: - path: / backend: serviceName: web servicePort: 80อาการ — output จริง:
error: resource mapping not found for name: "web" namespace: "" from "ingress-v1beta1-backend-shape.yaml": no matches for kind "Ingress" in version "networking.k8s.io/v1beta1"ensure CRDs are installed firstวิธีแก้:
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: webspec: rules: - http: paths: - path: / pathType: Prefix backend: service: name: web port: number: 80สังเกตว่ารูปร่างใหม่ยังต้องการ pathType ด้วย — field จำเป็นที่ไม่มีอยู่เลยใน v1beta1 ดังนั้นการแก้แค่ apiVersion แล้วปล่อยรูปร่างเก่าของ backend กับ pathType ที่ขาดไว้ยังคงล้มเหลวอยู่ดี
จับได้ด้วย: kubectl explain ingress.spec.rules.http.paths (แบบ recursive แสดงว่า pathType จำเป็น และ backend.service.name/port.number คือรูปร่างจริง), dry-run=server, kubeconform
4. kind: PodSecurityPolicy
หัวข้อที่มีชื่อว่า “4. kind: PodSecurityPolicy”PodSecurityPolicy ถูก deprecate ตั้งแต่ Kubernetes 1.21 และถูกลบไปเลยตั้งแต่ 1.25 แทนที่ด้วย Pod Security Admission (label บน namespace) และสำหรับสิ่งที่ PSP ทำได้แต่ PSA ทำไม่ได้ ก็มี ValidatingAdmissionPolicy หรือ webhook ของบุคคลที่สาม (Kyverno, Gatekeeper) AI ผู้ช่วยยังแนะนำมันอยู่เรื่อย ๆ เพราะ “เพิ่ม PodSecurityPolicy” คือคำตอบมาตรฐานของ “จะจำกัด Pod แบบ privileged ยังไง” มาหลายปี
# k8s/ai-bug-catalog/podsecuritypolicy-removed.yaml — @expect-error PodSecurityPolicy was removed in Kubernetes 1.25apiVersion: policy/v1beta1kind: PodSecurityPolicymetadata: name: restrictedspec: privileged: false seLinux: rule: RunAsAny runAsUser: rule: RunAsAny fsGroup: rule: RunAsAny supplementalGroups: rule: RunAsAnyอาการ — output จริง:
error: resource mapping not found for name: "restricted" namespace: "" from "podsecuritypolicy-removed.yaml": no matches for kind "PodSecurityPolicy" in version "policy/v1beta1"ensure CRDs are installed firstวิธีแก้: ไม่มีตัวแทนแบบ 1:1 ตรง ๆ — ข้อจำกัดที่เทียบเท่าย้ายไปเป็น label บน Namespace ที่ควบคุมโดย Pod Security Admission controller ที่ built-in แทน object ระดับคลัสเตอร์แบบเดิม:
apiVersion: v1kind: Namespacemetadata: name: my-app labels: pod-security.kubernetes.io/enforce: restricted pod-security.kubernetes.io/enforce-version: latestจับได้ด้วย: kubectl explain podsecuritypolicy (ไม่มี kind นี้เลย), kubectl api-resources | grep -i podsecuritypolicy (ว่างเปล่า), dry-run=server, kubeconform ดู Pod & Cluster Security สำหรับระดับ restricted/baseline/privileged เต็มรูปแบบ และ ValidatingAdmissionPolicy
5. field ของ autoscaling/v1 ปนกับ field ของ autoscaling/v2 ใน HPA เดียวกัน
หัวข้อที่มีชื่อว่า “5. field ของ autoscaling/v1 ปนกับ field ของ autoscaling/v2 ใน HPA เดียวกัน”HorizontalPodAutoscaler มีสองรูปร่างที่ใช้งานจริงพร้อมกัน — autoscaling/v1 (target เดียวเป็นเปอร์เซ็นต์ CPU, targetCPUUtilizationPercentage) และ autoscaling/v2 (array metrics, multi-metric, custom/external metric) ตัวอย่างที่ AI สร้างมักวางตัวอย่างสไตล์ v1 แล้ว “ช่วยเสริม” บล็อก metrics ที่ copy มาจากตัวอย่าง v2 ในลมหายใจเดียวกัน — รูปร่างที่ไม่เคย valid บนเวอร์ชันไหนเลย
# k8s/ai-bug-catalog/hpa-v1-v2-mixed-shape.yaml — @expect-error targetCPUUtilizationPercentage is autoscaling/v1; metrics is autoscaling/v2 only — this object is declared as v1apiVersion: autoscaling/v1kind: HorizontalPodAutoscalermetadata: name: webspec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: web minReplicas: 2 maxReplicas: 10 targetCPUUtilizationPercentage: 60 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60อาการ — output จริง จาก kubectl apply กับคลัสเตอร์ 1.37.0 จริง:
Error from server (BadRequest): error when creating "hpa-v1-v2-mixed-shape.yaml": HorizontalPodAutoscaler in version "v1" cannot be handled as a HorizontalPodAutoscaler: strict decoding error: unknown field "spec.metrics"นี่คือ strict field validation ฝั่ง server จริงและปัจจุบัน — metrics ไม่ใช่ typo ที่ API server เพิกเฉยเงียบ ๆ มันคือ BadRequest ทันทีที่ object ถูก decode เป็น v1
วิธีแก้: เลือกเวอร์ชันเดียว autoscaling/v2 คือเวอร์ชันที่ควรเขียนวันนี้ (รองรับ multi-metric, behavior, และเป็นรูปร่างเดียวที่แสดงอะไรได้มากกว่า CPU percentage เดียว):
apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: webspec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: web minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60จับได้ด้วย: kubectl apply --dry-run=server (strict decoding รันฝั่ง server ไม่ว่าจะ dry-run หรือไม่), kubectl explain hpa.spec --api-version=autoscaling/v1 เทียบกับ --api-version=autoscaling/v2 (แบบ recursive แสดงว่าสองรูปร่างไม่เคยทับซ้อนกันเลย) ดู Horizontal Pod Autoscaling สำหรับการเทียบ v1/v2 เต็มรูปแบบ พิสูจน์ในแบบเดียวกัน
Workload
หัวข้อที่มีชื่อว่า “Workload”6. selector.matchLabels ถูกเปลี่ยนหลังจาก Deployment มีอยู่แล้ว
หัวข้อที่มีชื่อว่า “6. selector.matchLabels ถูกเปลี่ยนหลังจาก Deployment มีอยู่แล้ว”spec.selector ของ Deployment ถูกตั้งครั้งเดียวตอนสร้าง แล้ว immutable ตลอดไปหลังจากนั้น — ไม่ใช่เพราะ Kubernetes เข้มงวดโดยไม่มีเหตุผล แต่เพราะการเปลี่ยนมันจะทำให้ Pod/ReplicaSet ทุกตัวที่ selector เก่าจับคู่ไว้ถูกทิ้งแบบเงียบ ๆ AI ผู้ช่วยที่ถูกขอให้ “เพิ่ม label” ให้ Deployment ที่มีอยู่แล้วมักจะแก้ selector ไปพร้อมกับ label ของ template โดยสมมติว่าทั้งคู่เป็นแค่ metadata ที่แก้ได้อย่างอิสระ
# k8s/ai-bug-catalog/deployment-selector-immutable.yaml — @expect-error changing spec.selector on an existing Deployment is rejectedapiVersion: apps/v1kind: Deploymentmetadata: name: web namespace: bugcat-demospec: replicas: 2 selector: matchLabels: app: web tier: frontend template: metadata: labels: app: web tier: frontend spec: containers: - name: web image: registry.k8s.io/pause:3.10อาการ — output จริง apply ใส่ Deployment ชื่อ web ที่มีอยู่แล้วซึ่ง selector คือ {app: web}:
$ kubectl apply -f deployment-selector-immutable.yamlThe Deployment "web" is invalid: spec.selector: Invalid value: {"matchLabels":{"app":"web","tier":"frontend"}}: field is immutableวิธีแก้: ตัดสินใจ selector ให้ถูกต้องก่อน apply ครั้งแรก แล้วอย่าไปแตะมันอีกเลย ถ้า selector จำเป็นต้องเปลี่ยนจริง ๆ ไม่มีวิธี migrate แบบ in-place — ลบแล้วสร้างใหม่ (kubectl delete --cascade=orphan ก่อนถ้าอยากให้ Pod ที่มีอยู่รอด แล้ว adopt มันภายใต้ selector ใหม่):
apiVersion: apps/v1kind: Deploymentmetadata: name: web namespace: bugcat-demospec: replicas: 2 selector: matchLabels: app: web template: metadata: labels: app: web tier: frontend # extra template labels are fine to add — only the selector itself is frozen spec: containers: - name: web image: registry.k8s.io/pause:3.10จับได้ด้วย: dry-run=server กับคลัสเตอร์จริง (การเช็ก immutability เทียบกับ object ที่เก็บไว้ ดังนั้นจะ trigger ก็ต่อเมื่อ Deployment มีอยู่แล้วเท่านั้น — --dry-run=server บนชื่อใหม่เอี่ยมจะไม่เจอมันเลย), kubectl diff -f, การรีวิว
7. restartPolicy: Always บน Pod template ของ Job
หัวข้อที่มีชื่อว่า “7. restartPolicy: Always บน Pod template ของ Job”ประเด็นทั้งหมดของ Job คือ “ทำงานจนเสร็จ แล้วหยุด” — Pod template ของมันจำกัดไว้ที่ restartPolicy: OnFailure หรือ Never เท่านั้น โมเดล AI ตั้ง Always เป็น default อยู่บ่อย ๆ เพราะมันคือค่า default ในทุกที่อื่นของ Kubernetes (Deployment, StatefulSet, DaemonSet) และ YAML ของ Job ที่ AI สร้างมักจะ copy Pod template ที่มีรูปร่างแบบ Deployment มาทั้งดุ้น
# k8s/ai-bug-catalog/job-restartpolicy-always.yaml — @expect-error restartPolicy: Always is not a valid Job Pod template valueapiVersion: batch/v1kind: Jobmetadata: name: bad-jobspec: template: spec: restartPolicy: Always containers: - name: work image: busybox:1.38 command: ['sh', '-c', 'echo hi']อาการ — output จริง:
$ kubectl apply --dry-run=server -f job-restartpolicy-always.yamlThe Job "bad-job" is invalid: spec.template.spec.restartPolicy: Required value: valid values: "OnFailure", "Never"วิธีแก้:
apiVersion: batch/v1kind: Jobmetadata: name: bad-jobspec: template: spec: restartPolicy: OnFailure containers: - name: work image: busybox:1.38 command: ['sh', '-c', 'echo hi']จับได้ด้วย: dry-run=server, kubeconform, kubectl explain job.spec.template.spec.restartPolicy ดู Jobs & CronJobs
8. maxUnavailable: 0 กับ maxSurge: 0 พร้อมกันบน RollingUpdate
หัวข้อที่มีชื่อว่า “8. maxUnavailable: 0 กับ maxSurge: 0 พร้อมกันบน RollingUpdate”สองปุ่มนี้ไม่เป็นอิสระต่อกัน — รวมกันแล้วต้องเหลือทาง บางอย่าง ให้ rollout เดินหน้าได้ “ห้าม Pod ไหน unavailable เลย แล้วก็ห้ามสร้าง Pod เกินด้วย” ไม่ใช่กลยุทธ์ที่ระมัดระวัง มันคือ rollout ที่ไม่มีวันขยับ replica แม้แต่ตัวเดียวจากเก่าไปใหม่ได้เลย และ API server ปฏิเสธมันตั้งแต่ตอน apply ไม่ใช่ค้นพบว่า rollout ค้างทีหลัง AI ผู้ช่วยที่ถูกขอ “rolling update ที่ปลอดภัยที่สุดเท่าที่จะทำได้” มักจะตั้งทั้งสองปุ่มเป็นศูนย์ ด้วยเหตุผล “0 unavailable = ปลอดภัยที่สุด” โดยไม่รู้ว่าคู่นี้ invalid ร่วมกัน
# k8s/ai-bug-catalog/rollout-maxunavailable-maxsurge-zero.yaml — @expect-error maxUnavailable: 0 is invalid when maxSurge is also 0apiVersion: apps/v1kind: Deploymentmetadata: name: web-zerozerospec: replicas: 3 selector: matchLabels: app: web-zerozero strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 0 template: metadata: labels: app: web-zerozero spec: containers: - name: web image: registry.k8s.io/pause:3.10อาการ — output จริง:
$ kubectl apply --dry-run=server -f rollout-maxunavailable-maxsurge-zero.yamlThe Deployment "web-zerozero" is invalid: spec.strategy.rollingUpdate.maxUnavailable: Invalid value: 0: may not be 0 when `maxSurge` is 0วิธีแก้: ยอมให้มี surge Pod หนึ่งตัว (maxSurge: 1 ยังคง unavailable เป็นศูนย์) หรือยอมรับ strategy แบบ Recreate ถ้า workload รันสองเวอร์ชันคู่กันจริง ๆ ไม่ได้:
apiVersion: apps/v1kind: Deploymentmetadata: name: web-zerozerospec: replicas: 3 selector: matchLabels: app: web-zerozero strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1 template: metadata: labels: app: web-zerozero spec: containers: - name: web image: registry.k8s.io/pause:3.10จับได้ด้วย: dry-run=server, kubeconform ดู Deployments and ReplicaSets
9. StatefulSet ที่ serviceName ชี้ไปที่ไม่มีอะไรเลย
หัวข้อที่มีชื่อว่า “9. StatefulSet ที่ serviceName ชี้ไปที่ไม่มีอะไรเลย”serviceName ไม่ถูกตรวจสอบกับ Service จริงตอนสร้าง — API server ยอมรับ StatefulSet ที่อ้างถึง headless Service ที่ไม่มีอยู่จริง หรือ Service แบบ ClusterIP ธรรมดาที่ไม่ headless อย่างเต็มใจ manifest หลายไฟล์ที่ AI สร้างมัก “ลืม” Service headless คู่กัน หรือสร้างมันโดยไม่มี clusterIP: None เพราะไม่มีอะไรใน YAML ของ StatefulSet เอง error เลย
apiVersion: apps/v1kind: StatefulSetmetadata: name: db namespace: sts-demospec: serviceName: db-headless-not-created replicas: 2 selector: matchLabels: { app: db } template: metadata: labels: { app: db } spec: containers: - name: db image: registry.k8s.io/pause:3.10อาการ — output จริง; StatefulSet ขึ้นมาสมบูรณ์แข็งแรง ร่องรอยเดียวของบั๊กคือ DNS lookup:
$ kubectl apply -f statefulset-missing-headless-service.yamlstatefulset.apps/db created$ kubectl get statefulset,pod -n sts-demoNAME READY AGEstatefulset.apps/db 2/2 8s
NAME READY STATUS RESTARTS AGEpod/db-0 1/1 Running 0 8spod/db-1 1/1 Running 0 6s
$ kubectl run resolver --image=busybox:1.38 --restart=Never --rm -it -- nslookup db-0.db-headless-not-created.sts-demo.svc.cluster.local** server can't find db-0.db-headless-not-created.sts-demo.svc.cluster.local: NXDOMAINวิธีแก้: สร้าง headless Service คู่กัน (clusterIP: None) พร้อมกับ StatefulSet:
apiVersion: v1kind: Servicemetadata: name: db-headless-not-created namespace: sts-demospec: clusterIP: None selector: app: db ports: - port: 5432---apiVersion: apps/v1kind: StatefulSetmetadata: name: db namespace: sts-demospec: serviceName: db-headless-not-created replicas: 2 selector: matchLabels: { app: db } template: metadata: labels: { app: db } spec: containers: - name: db image: registry.k8s.io/pause:3.10จับได้ด้วย: ไม่มีอะไรอัตโนมัติตอน apply เลย — พิสูจน์แล้วข้างต้นว่านี่คือช่องว่างจริงและปัจจุบันของ server-side validation มีแค่ nslookup/dig จริงจากในคลัสเตอร์ หรือการรีวิวเท่านั้นที่จับได้ ดู StatefulSets and DaemonSets ที่พิสูจน์ NXDOMAIN แบบเดียวกันเป๊ะ
10. CronJob ที่ไม่ตั้ง concurrencyPolicy เลย (default เป็น Allow)
หัวข้อที่มีชื่อว่า “10. CronJob ที่ไม่ตั้ง concurrencyPolicy เลย (default เป็น Allow)”concurrencyPolicy มีค่า default เป็น Allow — run ก่อนหน้ากับ run ถัดไปตามตารางเวลาแค่ถูกปล่อยให้ทับซ้อนกันได้เฉย ๆ เมื่อเวลาทำงานจริงของ Job นานกว่าช่วงห่างของตาราง (report รายคืนที่นานกว่าหนึ่งวันหายาก แต่ Job เช็ก health รายนาทีที่ใช้เวลา 90 วินาทีไม่หายากเลย) นี่จะสร้าง run ที่ทับซ้อนกันแบบเงียบ ๆ ไม่จบสิ้น YAML ของ CronJob ที่ AI สร้างมักละ concurrencyPolicy ไปเลย เพราะ field นี้รู้สึกเหมือนเป็น optional และตารางเวลาเพียงอย่างเดียวก็ “ดู” ถูกต้องแล้ว
apiVersion: batch/v1kind: CronJobmetadata: name: overlap-demo namespace: cronjob-demospec: schedule: '* * * * *' jobTemplate: spec: backoffLimit: 0 template: spec: restartPolicy: Never containers: - name: work image: busybox:1.38 command: ['sh', '-c', 'echo start $(date +%T); sleep 100; echo done $(date +%T)']อาการ — output จริง; Job ที่ตัว body sleep 100 วินาที บนตารางเวลาทุกนาที โดยปล่อย concurrencyPolicy ไว้ไม่ได้ตั้ง:
$ kubectl get cronjob overlap-demo -o jsonpath='{.spec.concurrencyPolicy}'Allow$ kubectl get jobs -n cronjob-demoNAME STATUS COMPLETIONS DURATION AGEoverlap-demo-29840448 Complete 1/1 106s 2m15soverlap-demo-29840449 Running 0/1 65s 65soverlap-demo-29840450 Running 0/1 15s 15sJob สองตัว (...449 กับ ...450) รันพร้อมกันจริง ๆ — API server ถึงกับใส่ concurrencyPolicy: Allow ให้เต็มตัวเมื่อ query เข้าไปเลยด้วยซ้ำ ยืนยันว่านี่ไม่เคยเป็น “ไม่มี policy” มันคือ default ที่ทำงานอยู่ตลอด
วิธีแก้: ตั้ง concurrencyPolicy อย่างตั้งใจ — Forbid (ข้าม run ใหม่ไปเลยขณะที่ยังมี run เดิมทำอยู่) มักถูกต้องสำหรับอะไรก็ตามที่ไม่ควรทับซ้อนกัน:
apiVersion: batch/v1kind: CronJobmetadata: name: overlap-demo namespace: cronjob-demospec: schedule: '* * * * *' concurrencyPolicy: Forbid jobTemplate: spec: backoffLimit: 0 template: spec: restartPolicy: Never containers: - name: work image: busybox:1.38 command: ['sh', '-c', 'echo start $(date +%T); sleep 100; echo done $(date +%T)']จับได้ด้วย: ไม่มีอะไรอัตโนมัติเลย — valid ทั้งสองแบบ มีแค่การเฝ้าดู kubectl get jobs ตามเวลาจริง หรือการรีวิวเท่านั้นที่จับได้ ดู Jobs & CronJobs สำหรับกลไก Allow/Forbid/Replace เดียวกันที่พิสูจน์ไว้โดยตรง
11. selector ของ Service ที่ไม่ตรงกับ label ของ Pod ไหนเลย
หัวข้อที่มีชื่อว่า “11. selector ของ Service ที่ไม่ตรงกับ label ของ Pod ไหนเลย”typo ใน selector ของ Service — app: web-typo แทนที่จะเป็น app: web — valid ตาม schema ด้วยตัวเอง API server ไม่มีทางรู้ว่า label ของ Pod ที่ “ถูกต้อง” ควรเป็นอะไร Service ถูกสร้างสำเร็จ โดยมี Endpoint เป็นศูนย์ และคงเป็นแบบนั้นแบบเงียบ ๆ ตลอดไป
apiVersion: v1kind: Servicemetadata: name: web namespace: bugcat-demospec: selector: app: web-typo ports: - port: 80 targetPort: 80อาการ — output จริง; มี Pod จริงที่ label เป็น app: web-a9 อยู่ใน namespace เดียวกัน Service เลือก app: web-a9-typo:
$ kubectl apply -f service-selector-typo.yamlservice/web-a9 createdWarning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice$ kubectl get endpoints web-a9NAME ENDPOINTS AGEweb-a9 <none> 0s$ kubectl get endpointslice -l kubernetes.io/service-name=web-a9NAME ADDRESSTYPE PORTS ENDPOINTS AGEweb-a9-5m4cc IPv4 <unset> <unset> 0sไม่มี error ที่ไหนเลย — service/web-a9 created หน้าตาเหมือนกับ Service ที่ select ถูกต้องทุกประการ ร่องรอยเดียวคือ EndpointSlice ที่มีอยู่แต่ไม่ได้ list อะไรเลย
วิธีแก้: selector ของ Service ต้องตรงกับ label ของ Pod template เป๊ะ:
apiVersion: v1kind: Servicemetadata: name: web namespace: bugcat-demospec: selector: app: web-a9 ports: - port: 80 targetPort: 80จับได้ด้วย: ไม่มีอะไรเลยตอน apply — kubectl get endpointslice -l kubernetes.io/service-name=<svc> (หรือ kubectl get endpoints <svc> ที่เลิกใช้แล้ว) คือสัญญาณจริงเพียงอย่างเดียว และเห็นได้หลังเกิดเหตุแล้วเท่านั้น การรีวิว หรือเช็กอัตโนมัติแบบ “ทุก Service ต้องมี Endpoint อย่างน้อยหนึ่งตัว” คือทางเดียวที่จับได้ก่อนเกิดเหตุ
🐛 หา bug ในโค้ดนี้
apiVersion: v1kind: Servicemetadata:name: checkoutspec:selector: app: checkout-svcports: - port: 8080เฉลย
Deployment ที่ Service นี้ควรจะนำหน้าให้ label Pod ของมันว่า app: checkout ไม่ใช่ app: checkout-svc — แค่ -svc เกินมาหนึ่งคำที่ไม่มี schema check, kubectl explain, หรือ dry-run ไหนจับได้เลย เพราะ selector ของ Service คือแค่ label matcher ธรรมดาที่ไม่มีการอ้างอิงกลับไปที่ Deployment เฉพาะเจาะจงเลย object นี้ apply ผ่านสะอาดแล้วก็ “ขึ้น” ตลอดไปโดยมี endpoint จริงเป็นศูนย์ วิธีเดียวที่จะจับสิ่งนี้ได้คือสิ่งที่ข้อ 11 พิสูจน์ไว้เป๊ะ: อ่าน template.metadata.labels จริงของ Deployment แล้วเทียบกับ spec.selector ของ Service ด้วยตาเปล่า หรือ query kubectl get endpointslice หลัง apply ทุกครั้ง
Scheduling
หัวข้อที่มีชื่อว่า “Scheduling”12. Deployment ที่ไม่มี resource requests เลย แล้วมี HPA แบบ CPU-percentage นำหน้า
หัวข้อที่มีชื่อว่า “12. Deployment ที่ไม่มี resource requests เลย แล้วมี HPA แบบ CPU-percentage นำหน้า”HorizontalPodAutoscaler คำนวณ CPU utilization เป็นเปอร์เซ็นต์ของ resources.requests.cpu ของแต่ละ Pod — ถ้าไม่มี request ตั้งไว้ก็ไม่มีตัวหาร และ HPA คำนวณเปอร์เซ็นต์ไม่ได้เลย ไม่ว่า metric CPU จริงจะพร้อมใช้แค่ไหนก็ตาม ตัวอย่างที่ AI สร้างมักมี HPA ครบสมบูรณ์แล้วละ resources.requests บน Deployment ที่มันเล็งไปเลย เพราะสอง object นี้ถูกแก้ไข (หรือสร้าง) แยกจากกัน
apiVersion: apps/v1kind: Deploymentmetadata: name: web-app namespace: hpa-demospec: replicas: 2 selector: matchLabels: { app: web-app } template: metadata: labels: { app: web-app } spec: containers: - name: web image: registry.k8s.io/pause:3.10 # no resources.requests set at all---apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: web-app namespace: hpa-demospec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: web-app minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60อาการ — output จริง โดยติดตั้ง metrics-server และทำงานอยู่:
$ kubectl get hpa web-app -n hpa-demoNAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGEweb-app Deployment/web-app cpu: <unknown>/60% 2 10 2 34s
$ kubectl describe hpa web-app -n hpa-demoConditions: Type Status Reason Message ---- ------ ------ ------- ScalingActive False FailedGetResourceMetric the HPA was unable to compute the replica count: failed to get cpu utilization: missing request for cpu in container web of Pod web-app-7f4646f86-4p49qEvents: Warning FailedGetResourceMetric failed to get cpu utilization: missing request for cpu in container web of Pod web-app-7f4646f86-4p49qcpu: <unknown>/60% กับเหตุผลจริงของ FailedGetResourceMetric ระบุสาเหตุตรง ๆ เลย — นี่ไม่ใช่ metrics-server ล่ม metrics-server กำลังรันและรายงานปกติดี HPA แค่ไม่มีอะไรให้หารเลย
วิธีแก้: ตั้ง resources.requests.cpu (และโดยทั่วไป resources.requests.memory) บนทุก container ที่ HPA เล็งไป — นี่คือคู่ที่บังคับ ไม่ใช่ขั้นตอนปรับแต่งที่เป็น optional:
apiVersion: apps/v1kind: Deploymentmetadata: name: web-app namespace: hpa-demospec: replicas: 2 selector: matchLabels: { app: web-app } template: metadata: labels: { app: web-app } spec: containers: - name: web image: registry.k8s.io/pause:3.10 resources: requests: cpu: 100m memory: 64Miจับได้ด้วย: ไม่มีอะไรตอน apply เลย — ทั้งสอง object valid แยกกัน มีแค่ kubectl describe hpa (หรือ condition/event ของ API server เอง) หลังเกิดเหตุ หรือกฎการรีวิวที่จับคู่ “HPA เล็งไปที่ workload นี้” กับ “workload มี requests” เป็นเช็กเดียวกันเท่านั้น ดู Horizontal Pod Autoscaling ที่พิสูจน์คู่ <unknown>/FailedGetResourceMetric เดียวกันเป๊ะ และเรียกคู่นี้ว่า “สองข้อบังคับ” ของ autoscaler อย่างชัดเจน
13. PodDisruptionBudget ที่ minAvailable เท่ากับจำนวน replica เต็ม
หัวข้อที่มีชื่อว่า “13. PodDisruptionBudget ที่ minAvailable เท่ากับจำนวน replica เต็ม”PodDisruptionBudget ที่ตั้ง minAvailable เท่ากับ (หรือมากกว่า) replicas รวมของ workload valid ตาม schema และดูเหมือนปกป้องดี — แต่มันหมายถึง ไม่มีการหยุดชะงักโดยสมัครใจข้อไหนเลยที่ถูกอนุญาต ตลอดไป การ drain node, การอัปเกรดคลัสเตอร์, หรือ cluster-autoscaler ที่จะลดขนาดโดยเล็งไป Pod ไหนของ workload นั้นจะค้างไม่มีกำหนด ไม่ใช่เพราะอะไรพัง แต่เพราะ budget ไม่มีที่ว่างจริง ๆ เลย AI ผู้ช่วยที่ถูกขอให้ “ทำให้ workload นี้ไม่ถูกรบกวนเด็ดขาด” มักเลือกค่า minAvailable สูงสุดที่ยังดูสมเหตุสมผล — จำนวน replica เต็ม — โดยไม่รู้ตัวว่ามันบล็อกการบำรุงรักษาที่ถูกต้องตามกฎหมายตลอดไปด้วย
apiVersion: apps/v1kind: Deploymentmetadata: name: web-app namespace: pdb-demospec: replicas: 3 selector: matchLabels: { app: web-app } template: metadata: labels: { app: web-app } spec: containers: - name: web image: registry.k8s.io/pause:3.10---apiVersion: policy/v1kind: PodDisruptionBudgetmetadata: name: web-app-pdb namespace: pdb-demospec: minAvailable: 3 selector: matchLabels: { app: web-app }อาการ — output จริง จากการ kubectl drain node ที่มี Pod web-app สองในสามตัวอยู่จริง:
$ kubectl get pdb web-app-pdb -n pdb-demoNAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGEweb-app-pdb 3 N/A 0 2s
$ kubectl drain reading-a-worker --ignore-daemonsets --delete-emptydir-dataevicting pod pdb-demo/web-app-7f4646f86-4fzpqerror when evicting pods/"web-app-7f4646f86-4fzpq" -n "pdb-demo" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.evicting pod pdb-demo/web-app-7f4646f86-c8tmserror when evicting pods/"web-app-7f4646f86-c8tms" -n "pdb-demo" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.ALLOWED DISRUPTIONS: 0 เห็นได้ก่อนที่จะแม้แต่ลอง drain เลยด้วยซ้ำ — budget ไม่มีที่ว่างเลยทันทีที่ replica ทั้งสามตัวยังแข็งแรงอยู่ การ drain retry ไม่หยุด (หรือจนกว่า timeout ของมันเอง) ไม่มีวันสำเร็จกับ Pod สองตัวนี้
วิธีแก้: เหลือที่ว่างจริง — minAvailable (หรือ maxUnavailable) ที่ต่ำกว่าจำนวน replica เต็มอย่างเคร่งครัด เพื่อให้อย่างน้อยหนึ่งการหยุดชะงักโดยสมัครใจดำเนินต่อได้เสมอ:
apiVersion: policy/v1kind: PodDisruptionBudgetmetadata: name: web-app-pdb namespace: pdb-demospec: minAvailable: 2 selector: matchLabels: { app: web-app }จับได้ด้วย: kubectl get pdb (คอลัมน์ ALLOWED DISRUPTIONS เห็นได้ก่อนลอง drain เลยด้วยซ้ำ — สัญญาณจริงที่เร็วที่สุด), การลอง kubectl drain --dry-run=server จริง, การรีวิว ดู Pod Disruption & Cluster Autoscaling ที่พิสูจน์ข้อความปฏิเสธ eviction เดียวกันเป๊ะ
Networking
หัวข้อที่มีชื่อว่า “Networking”14. NetworkPolicy ที่ตั้งใจจะล็อก Pod แต่ policyTypes ลืม Egress
หัวข้อที่มีชื่อว่า “14. NetworkPolicy ที่ตั้งใจจะล็อก Pod แต่ policyTypes ลืม Egress”policyTypes ไม่ได้ถูกอนุมานจากการมีบล็อก egress:/ingress: อยู่ — มันต้อง list ไว้อย่างชัดเจน และ Kubernetes จำกัดแค่ทิศทางที่ระบุไว้จริงเท่านั้น NetworkPolicy ที่ตั้งใจว่า “Pod นี้ไม่ควรเรียกออกไปที่ไหนได้เลย” ที่ list แค่ policyTypes: [Ingress] ไม่ได้จำกัด egress อะไรเลย — traffic ขาออกของ Pod ยังคงไม่ถูกจำกัดอย่างสมบูรณ์แบบเงียบ ๆ
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: client-lockdown namespace: netpol-demospec: podSelector: matchLabels: app: client policyTypes: - Ingressอาการ — output จริง บนคลัสเตอร์จริงที่ CNI (kindnetd ของ kind 0.33 ที่เป็น default) บังคับใช้ NetworkPolicy จริง:
$ kubectl apply -f networkpolicy-missing-egress.yamlnetworkpolicy.networking.k8s.io/client-lockdown created$ kubectl get networkpolicy client-lockdown -n netpol-demo -o jsonpath='{.spec.policyTypes}'["Ingress"]
$ kubectl exec client -n netpol-demo -- wget -qO- --timeout=2 http://10.244.1.9<p><em>Thank you for using nginx.</em></p>request ขาออกของ Pod client ไปยัง Pod อื่นสำเร็จสะอาด — ไม่มีอะไรใน NetworkPolicy นี้จำกัดมันเลย เพราะ Egress ไม่เคยอยู่ใน policyTypes ผู้อ่านที่ไล่ดู podSelector กับ rule ที่ว่างเปล่าเพียงอย่างเดียวอาจสรุปง่าย ๆ ว่า “default-deny” สำเร็จแล้ว ซึ่งไม่จริงเลย ทั้งสองทิศทางที่ policy นี้ไม่ได้ระบุไว้
วิธีแก้: list ทุกทิศทางที่ policy ตั้งใจจะจำกัดจริง ๆ:
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: client-lockdown namespace: netpol-demospec: podSelector: matchLabels: app: client policyTypes: - Ingress - Egressจับได้ด้วย: ไม่มีอะไรตอน apply เลย — valid ไม่ว่าจะตั้งใจแบบไหน มีแค่การทดสอบการเชื่อมต่อจริงจากในคลัสเตอร์ (พิสูจน์แล้วข้างต้น) หรือกฎการรีวิวที่จับคู่ “ชื่อ/comment ของ NetworkPolicy นี้บอกว่า default-deny” กับ “policyTypes list ครบทั้งสองทิศทาง” เป็นเช็กเดียวกัน ข้อแก้ไขที่มีประโยชน์และใหม่จริง ๆ จากคลัสเตอร์ของโมดูลนี้เอง: คำแนะนำเก่าบอกว่า CNI default ของ kind (kindnet) ไม่บังคับใช้ NetworkPolicy เลย — พิสูจน์โดยตรงข้างต้น kindnetd:v20260820-69b56db7 (default ปัจจุบันของ kind 0.33) บังคับใช้จริง — การทดสอบควบคุมโดยไม่มี policy เลยสำเร็จเหมือนกัน ยืนยันว่าการบล็อกข้างต้นคือ policy ไม่ใช่ปัญหาเครือข่ายที่ไม่เกี่ยวข้อง
15. NetworkPolicy แบบ “default-deny-all” ที่ไม่มี rule allow DNS egress
หัวข้อที่มีชื่อว่า “15. NetworkPolicy แบบ “default-deny-all” ที่ไม่มี rule allow DNS egress”อีกทิศทางหนึ่งของกลไกเดียวกัน และเป็นกับดักที่ตกง่ายกว่ามาก: NetworkPolicy ที่ list policyTypes: [Ingress, Egress] ถูกต้อง แต่ ไม่มี egress: rule เลย จะบล็อกทุกทิศทางจริง รวมถึง DNS resolution ของคลัสเตอร์เองด้วย — Pod ภายใต้ policy นี้ resolve hostname ไหนก็ไม่ได้เลย รวมถึงชื่อ Service ของ Kubernetes เพราะ query ไปยัง CoreDNS (kube-dns, UDP/TCP port 53) เองก็เป็น traffic ขาออกที่ policy นี้ปฏิเสธ
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: client-default-deny-all namespace: netpol-demospec: podSelector: matchLabels: app: client policyTypes: - Ingress - Egressอาการ — output จริง:
$ kubectl apply -f networkpolicy-default-deny-blocks-dns.yamlnetworkpolicy.networking.k8s.io/client-default-deny-all created
$ kubectl exec client -n netpol-demo -- nslookup kubernetes.default.svc.cluster.local;; connection timed out; no servers could be reachedno servers could be reached — query ไปยัง CoreDNS ที่ 10.96.0.10:53 ไม่ได้รับ response กลับมาเลยด้วยซ้ำ เพราะ Egress default-deny บล็อกมันเหมือนกับการเชื่อมต่อขาออกอื่นทุกอย่าง
วิธีแก้: rule egress ที่ allow DNS ไปยังตัวให้บริการ DNS ของคลัสเตอร์ (namespace kube-system, port 53, ทั้งสอง protocol) เพิ่มเข้าไปพร้อมกับอะไรก็ตามที่ workload ต้องการเข้าถึงจริง:
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: client-default-deny-allow-dns namespace: netpol-demospec: podSelector: matchLabels: app: client policyTypes: - Ingress - Egress egress: - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kube-system ports: - protocol: UDP port: 53 - protocol: TCP port: 53อาการของวิธีแก้ พิสูจน์ในแบบเดียวกัน:
$ kubectl exec client -n netpol-demo -- nslookup kubernetes.default.svc.cluster.localAddress: 10.96.0.10:53Name: kubernetes.default.svc.cluster.localAddress: 10.96.0.1DNS resolve ได้อีกครั้ง ปลายทาง egress อื่นทุกอันที่ policy นี้ไม่ได้ allow ไว้อย่างชัดเจนยังคงถูกปฏิเสธอยู่ — ท่าที default-deny ที่ตั้งใจไว้ยังคงอยู่ทุกที่ยกเว้นข้อยกเว้นเดียวที่ workload ต้องการจริง
จับได้ด้วย: ไม่มีอะไรตอน apply เลย — มีแค่ DNS query จริงจากในคลัสเตอร์ (พิสูจน์แล้วข้างต้น) หรือรายการ checklist ที่ถามตรง ๆ ว่า “policy default-deny นี้ยัง allow DNS อยู่ไหม” นี่คือหนึ่งใน incident รายงานจริงที่พบบ่อยที่สุดของ NetworkPolicy ในโลกจริง เพราะตัว object เองไม่เคยดูผิดเลย
Storage & configuration
หัวข้อที่มีชื่อว่า “Storage & configuration”16. ใช้ hostPath แทนที่ PersistentVolumeClaim
หัวข้อที่มีชื่อว่า “16. ใช้ hostPath แทนที่ PersistentVolumeClaim”hostPath mount path บน node ที่ Pod บังเอิญไปตกลง — ไม่ portable ไม่มี replication และไม่มีแนวคิดของ “volume เดียวกัน ไม่ว่า Pod จะถูก reschedule ไปที่ไหน” ตัวอย่างที่ AI สร้างเลือกใช้มันบ่อยเพราะไม่ต้องมี StorageClass, ไม่ต้องมี provisioner, และไม่ต้องมี object เพิ่ม — มัน “ใช้ได้เลย” ในครั้งแรกบน demo node เดียว ซึ่งเป็นกรณีเดียวพอดีที่ปัญหาเรื่อง portability ไม่มีวันโผล่มาให้เห็น
apiVersion: v1kind: Podmetadata: name: writer namespace: hostpath-demospec: containers: - name: c image: busybox:1.38 command: ['sh', '-c', 'echo "important-data" > /data/state.txt && sleep 3600'] volumeMounts: - name: data mountPath: /data volumes: - name: data hostPath: path: /var/lib/app-data type: DirectoryOrCreateอาการ — output จริง; Pod spec เดียวกันเป๊ะ pin ไปที่ worker node ตัวแรกก่อน แล้ว reschedule ไปตัวที่สอง บนคลัสเตอร์สอง worker จริง:
$ kubectl exec writer -- cat /data/state.txt # on reading-a-workerimportant-data
$ kubectl delete pod writer$ kubectl apply -f writer-on-worker2.yaml # identical spec, nodeName: reading-a-worker2$ kubectl exec writer -- cat /data/state.txtcat: can't open '/data/state.txt': No such file or directoryไม่มีอะไรผิดปกติกับ Pod ตัวที่สองเลย — hostPath mount /var/lib/app-data ตามที่ขอเป๊ะ มันแค่ mount โฟลเดอร์ คนละอัน ว่างเปล่า บน filesystem ของอีก node เท่านั้น ไม่มี event ไม่มี warning ไม่มี error ระบุสิ่งนี้เลย
วิธีแก้: PersistentVolumeClaim กับ StorageClass จริง (หรือสำหรับข้อมูลระดับ node ที่ไม่จำเป็นต้องย้ายจริง ๆ volume แบบ local พร้อม node affinity ที่ถูกต้อง — ไม่ใช่ hostPath เปล่า ๆ สำหรับอะไรก็ตามที่ต้องรอดจากการ reschedule):
apiVersion: v1kind: PersistentVolumeClaimmetadata: name: app-data namespace: hostpath-demospec: accessModes: ['ReadWriteOnce'] resources: requests: storage: 1Gi---apiVersion: v1kind: Podmetadata: name: writer namespace: hostpath-demospec: containers: - name: c image: busybox:1.38 command: ['sh', '-c', 'echo "important-data" > /data/state.txt && sleep 3600'] volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: app-dataจับได้ด้วย: ไม่มีอะไรตอน apply เลย — Pod ทั้งสองตัวข้างต้น valid สมบูรณ์แยกกัน มีแค่การ reschedule จริงไปอีก node (พิสูจน์แล้วข้างต้น) หรือการรีวิวเท่านั้นที่จับได้
17. สมมติว่าข้อมูลของ Secret “ถูก encrypt” เพราะมันเป็น Secret
หัวข้อที่มีชื่อว่า “17. สมมติว่าข้อมูลของ Secret “ถูก encrypt” เพราะมันเป็น Secret”object Secret เก็บ field data แบบ base64-encode ไม่ใช่ encrypt — base64 คือการเข้ารหัส (encoding) ไม่ใช่ cipher และการ decode มันไม่ต้องใช้ key เลย manifest ของ Secret ที่ AI สร้าง (และ comment ที่ AI ผู้ช่วยเขียนกำกับไว้) มักบรรยายค่าว่า “encrypted” ซึ่งผิดเปล่า ๆ และชักจูงให้ปฏิบัติกับเนื้อหาของ Secret ปลอดภัยกว่าที่มันเป็นจริง (commit มันลง git, log field data ของมัน, ให้สิทธิ์อ่านมันกว้าง ๆ)
apiVersion: v1kind: Secretmetadata: name: db-creds namespace: bugcat-demotype: Opaquedata: password: UzNjcmV0UGFzcyE= # "encrypted" password -- this comment is the bugอาการ — output จริง:
$ kubectl get secret db-creds -n bugcat-demo -o jsonpath='{.data.password}'UzNjcmV0UGFzcyE=
$ kubectl get secret db-creds -n bugcat-demo -o jsonpath='{.data.password}' | base64 -dS3cretPass!คำสั่งเดียว ไม่ต้องใช้ key ไม่ต้องเข้าถึงคลัสเตอร์เกินกว่าสิทธิ์ RBAC get secret ธรรมดา ก็ได้ plaintext กลับมา ใครก็ตามที่ทำ kubectl get secret -o yaml object นี้ได้ก็มีรหัสผ่านจริงอยู่ในมือแล้ว
วิธีแก้: ไม่มีการแก้ระดับ manifest สำหรับสิ่งที่ Secret เป็นโดยพื้นฐาน — การบรรเทาจริงอยู่ระดับคลัสเตอร์กับ RBAC: เปิด encryption at rest สำหรับ resource Secret ใน EncryptionConfiguration ของ API server (ปกป้องข้อมูลใน etcd ตอนพัก ไม่ใช่ป้องกัน kubectl get จากผู้เรียกที่มีสิทธิ์อยู่แล้ว), จำกัดขอบเขต RBAC ให้ get/list บน secrets หายากจริง ๆ, และปฏิบัติกับ YAML ของ Secret เหมือน credential อื่นทุกตัว — ไม่เคย commit เป็น plaintext สร้างจาก secret manager จริงแทน:
apiVersion: v1kind: Secretmetadata: name: db-creds namespace: bugcat-demotype: Opaquedata: password: UzNjcmV0UGFzcyE= # base64-encoded, NOT encrypted -- readable by anyone who can `get` this Secret; real protection is RBAC scope + etcd encryption-at-rest (cluster-level, not per-object) + never committing this file with a real valueจับได้ด้วย: ไม่มีอะไรเลย — นี่คือความเข้าใจผิดระดับ documentation/comment ไม่ใช่ข้อบกพร่องของ schema หรือ runtime มีแค่ base64 -d จริง (พิสูจน์แล้วข้างต้น) หรือการรีวิวความปลอดภัยเท่านั้นที่จับข้อกล่าวอ้างที่ผิดนี้ได้
Security
หัวข้อที่มีชื่อว่า “Security”18. privileged: true เพื่อ “แก้ permission error”
หัวข้อที่มีชื่อว่า “18. privileged: true เพื่อ “แก้ permission error””เมื่อเจอ error แบบ permission-denied ในคอนเทนเนอร์ คำตอบที่เร็วที่สุดที่ AI ผู้ช่วยหาได้แทบทุกครั้งคือ securityContext.privileged: true — มันทำให้ error หายไปจริง โดยให้ ทุก Linux capability กับคอนเทนเนอร์และปิดเกือบทุก security boundary ของ container แทนที่จะให้ capability เดียวเฉพาะเจาะจง (หรือค่า runAsUser/fsGroup เฉพาะเจาะจง) ที่ workload ต้องการจริง ๆ
# k8s/ai-bug-catalog/privileged-container.yaml — @expect-error rejected under a namespace enforcing Pod Security "restricted"apiVersion: v1kind: Podmetadata: name: privileged-realistic namespace: psa-demospec: containers: - name: c image: registry.k8s.io/pause:3.10 securityContext: privileged: trueอาการ — output จริง apply ใส่ namespace ที่ label pod-security.kubernetes.io/enforce: restricted:
$ kubectl apply -f privileged-container.yamlError from server (Forbidden): error when creating "privileged-container.yaml": pods "privileged-realistic" is forbidden: violates PodSecurity "restricted:latest": privileged (container "c" must not set securityContext.privileged=true), allowPrivilegeEscalation != false (container "c" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "c" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "c" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "c" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")นี่คือ API server เองที่ list ทุก field ที่ restricted ต้องการ — ใน namespace ที่ตั้งแค่ warn (ไม่ใช่ enforce) Pod เดียวกันนี้จะถูก admit โดยข้อความเดียวกันพิมพ์เป็น warning แบบไม่บล็อกแทนที่จะเป็นการปฏิเสธ
ยังมีการปฏิเสธจริงระดับ API แบบไม่มีเงื่อนไขอีกอย่างที่ควรรู้ แม้ไม่มี Pod Security Admission ตั้งไว้เลยก็ตาม — privileged: true รวมกับ allowPrivilegeEscalation: false ที่ตั้งชัดเจนบน container เดียวกันคือความขัดแย้งเชิงโครงสร้างที่ API server ปฏิเสธเอง: spec.containers[0].securityContext: Invalid value: ...: cannot set allowPrivilegeEscalationto false andprivileged to true
วิธีแก้: ให้ Linux capability เฉพาะเจาะจงตัวเดียวที่ workload ต้องการจริง (securityContext.capabilities.add) หรือ field เฉพาะเจาะจงตัวเดียว (runAsUser, fsGroup) — ไม่ใช่ privileged: true แบบเหมารวม:
apiVersion: v1kind: Podmetadata: name: hardened namespace: psa-demospec: automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 10001 seccompProfile: type: RuntimeDefault containers: - name: c image: registry.k8s.io/pause:3.10 securityContext: allowPrivilegeEscalation: false capabilities: drop: ['ALL']จับได้ด้วย: Pod Security Admission warn/enforce ที่ restricted (output จริงข้างต้น), ValidatingAdmissionPolicy สำหรับกฎเฉพาะองค์กร, การรีวิว ดู Pod & Cluster Security
19. capabilities ไม่เคยถูก drop เลย แม้ image ไม่ต้องการอะไรเลย
หัวข้อที่มีชื่อว่า “19. capabilities ไม่เคยถูก drop เลย แม้ image ไม่ต้องการอะไรเลย”ชุด Linux capability ที่ container ทุกตัวได้รับเป็น default (NET_RAW, CHOWN, SETUID, และอีกโหลกว่า) กว้างกว่าที่ container แอปพลิเคชันเกือบทุกตัวใช้จริงมาก — securityContext.capabilities.drop: ["ALL"] (เพิ่มกลับมาเฉพาะตัวเดียวหรือสองตัวที่จำเป็นจริงผ่าน capabilities.add) คือ baseline ที่คลัสเตอร์ที่ hardened คาดหวัง และ manifest ที่ AI สร้างแทบไม่มีมันเลยถ้าไม่ได้ถูกขอชัดเจน เพราะ Pod รัน “ได้” โดยไม่มีมัน
# k8s/ai-bug-catalog/capabilities-not-dropped.yaml — @expect-error rejected under Pod Security "restricted" — capabilities.drop is requiredapiVersion: v1kind: Podmetadata: name: caps-only namespace: psa-demospec: automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 10001 seccompProfile: { type: RuntimeDefault } containers: - name: c image: registry.k8s.io/pause:3.10 securityContext: privileged: false allowPrivilegeEscalation: falseอาการ — output จริง; field อื่นทุกตัวของ restricted ถูกต้องแล้วบน Pod นี้ แยกให้เห็น violation เดียวชัด ๆ:
$ kubectl apply -f capabilities-not-dropped.yamlError from server (Forbidden): error when creating "capabilities-not-dropped.yaml": pods "caps-only" is forbidden: violates PodSecurity "restricted:latest": unrestricted capabilities (container "c" must set securityContext.capabilities.drop=["ALL"])วิธีแก้:
apiVersion: v1kind: Podmetadata: name: caps-only namespace: psa-demospec: automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 10001 seccompProfile: { type: RuntimeDefault } containers: - name: c image: registry.k8s.io/pause:3.10 securityContext: privileged: false allowPrivilegeEscalation: false capabilities: drop: ['ALL']จับได้ด้วย: Pod Security Admission warn/enforce ที่ restricted (output จริงข้างต้น), kube-linter/polaris/kube-score (ทุกตัว flag การไม่ drop capability เป็น default อยู่แล้ว), การรีวิว
20. automountServiceAccountToken ปล่อยไว้ที่ default (true) สำหรับ workload ที่ไม่เคยเรียก API server
หัวข้อที่มีชื่อว่า “20. automountServiceAccountToken ปล่อยไว้ที่ default (true) สำหรับ workload ที่ไม่เคยเรียก API server”Pod ทุกตัวได้ token ของ ServiceAccount ถูก mount ให้อัตโนมัติเป็น default ไม่ว่า workload จะเรียก Kubernetes API จริงหรือไม่ — web server ธรรมดา, batch job, หรือ sidecar ที่คุยกับ network เท่านั้นไม่เคยต้องการมันเลย และ token ที่ mount อยู่ใน filesystem คือ credential เป๊ะ ๆ ที่บั๊ก container-escape หรือ path-traversal จะไปหาก่อนเลย manifest ของ Pod ที่ AI สร้างแทบไม่เคยตั้ง automountServiceAccountToken: false เลย เพราะค่า default ก็ “ใช้ได้” อยู่แล้ว
apiVersion: v1kind: Podmetadata: name: token-default namespace: bugcat-demospec: containers: - name: c image: busybox:1.38 command: ['sleep', '3600']อาการ — output จริง; Pod นี้ไม่เคยเรียก API server เลย แต่ token ก็อยู่ตรงนั้น:
$ kubectl exec token-default -- ls /var/run/secrets/kubernetes.io/serviceaccount/ca.crtnamespacetokenวิธีแก้:
apiVersion: v1kind: Podmetadata: name: token-noautomount namespace: bugcat-demospec: automountServiceAccountToken: false containers: - name: c image: busybox:1.38 command: ['sleep', '3600']อาการของวิธีแก้ พิสูจน์ในแบบเดียวกัน:
$ kubectl exec token-noautomount -- ls /var/run/secrets/kubernetes.io/serviceaccount/ls: /var/run/secrets/kubernetes.io/serviceaccount/: No such file or directoryจับได้ด้วย: ไม่มีอะไรอัตโนมัติเลย — Pod ที่ “ใช้งานได้” สมบูรณ์ทั้งสองแบบ มีแค่การเช็กที่ชัดเจน (kubectl exec ... ls .../serviceaccount/, หรือกฎของ policy engine) หรือการรีวิวเท่านั้นที่จับได้
21. ServiceAccount ของ workload ผูกกับ cluster-admin
หัวข้อที่มีชื่อว่า “21. ServiceAccount ของ workload ผูกกับ cluster-admin”cluster-admin คือ ClusterRole แบบ built-in ที่รวม aggregate ไว้ล่วงหน้า ให้ verb ทุกตัวบน resource ทุกตัว ทั่วทั้งคลัสเตอร์ การผูก ServiceAccount ของ workload เข้ากับมัน — ทางแก้ที่เร็วที่สุดสำหรับ 403 Forbidden เกือบทุกอันที่ AI ผู้ช่วยเจอตอน debug RBAC — ให้ workload นั้น (และอะไรก็ตามที่ compromise มันได้) มีขอบเขตเท่ากับ cluster administrator ที่เป็นมนุษย์เป๊ะ ไม่ใช่แค่สอง-สาม verb เฉพาะเจาะจงที่มันต้องการจริง ๆ
apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRoleBindingmetadata: name: overprivileged-sa-adminsubjects: - kind: ServiceAccount name: overprivileged-sa namespace: bugcat-demoroleRef: kind: ClusterRole name: cluster-admin apiGroup: rbac.authorization.k8s.ioอาการ — output จริง; ServiceAccount ตัวนี้ตั้งใจให้อ่าน ConfigMap ของ namespace ตัวเองเท่านั้น:
$ kubectl auth can-i delete secrets --as=system:serviceaccount:bugcat-demo:overprivileged-sa -Ayes$ kubectl auth can-i delete nodes --as=system:serviceaccount:bugcat-demo:overprivileged-sayescontainer ที่ถูก compromise เพียงตัวเดียวที่รันด้วย identity นี้ลบ Secret ไหนก็ได้ ใน namespace ไหนก็ได้ และลบ Node ระดับคลัสเตอร์ได้ตรง ๆ เลย — ขอบเขตที่ไม่เกี่ยวอะไรกับการอ่าน ConfigMap เลย
วิธีแก้: Role ระดับ namespace (หรือ ClusterRole ที่จำกัดขอบเขตแคบ ๆ จำกัดด้วย resourceNames ถ้าเป็นไปได้) บวกกับ binding ที่ให้แค่ verb ที่ workload เรียกจริง ๆ เท่านั้น:
apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: name: configmap-reader namespace: bugcat-demorules: - apiGroups: [''] resources: ['configmaps'] verbs: ['get', 'list', 'watch']---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: name: configmap-reader-binding namespace: bugcat-demosubjects: - kind: ServiceAccount name: overprivileged-sa namespace: bugcat-demoroleRef: kind: Role name: configmap-reader apiGroup: rbac.authorization.k8s.ioจับได้ด้วย: kubectl auth can-i --list --as=<sa> (output จริงข้างต้น), การรีวิว (ClusterRoleBinding ไหนก็ตามที่อ้าง cluster-admin โดยมี ServiceAccount ของ workload เป็น subject ควรถูกมองซ้ำอีกครั้งอย่างจริงจัง) ดู RBAC & Service Accounts ที่พิสูจน์ output ของ can-i เดียวกันเป๊ะ
Observability
หัวข้อที่มีชื่อว่า “Observability”22. ไม่มี readinessProbe บน Deployment ที่อยู่หลัง Service
หัวข้อที่มีชื่อว่า “22. ไม่มี readinessProbe บน Deployment ที่อยู่หลัง Service”ถ้าไม่มี readinessProbe ตั้งไว้ container จะถือว่า “Ready” ทันทีที่ kubelet รายงานว่า process กำลังรัน — ไม่ใช่ทันทีที่แอปพลิเคชันข้างในเริ่มทำงานเสร็จจริงและกำลัง listen บน port แล้ว Endpoints controller ของ Service เพิ่ม IP ของ Pod ทันทีที่ Ready เป็น true ซึ่งอาจเกิดก่อนที่แอปที่ start ช้ากว่าจะ bind listening socket เลยด้วยซ้ำ
apiVersion: apps/v1kind: Deploymentmetadata: name: slow-start namespace: bugcat-demospec: replicas: 1 selector: matchLabels: { app: slow-start } template: metadata: labels: { app: slow-start } spec: containers: - name: web image: python:3.13-alpine command: ['sh', '-c', 'sleep 8 && python3 -m http.server 8080'] ports: [{ containerPort: 8080 }] # no readinessProbe -- Ready flips true as soon as the process is Running, 8s before it's actually listening---apiVersion: v1kind: Servicemetadata: name: slow-start namespace: bugcat-demospec: selector: { app: slow-start } ports: [{ port: 80, targetPort: 8080 }]อาการ — output จริง; request ที่ส่งทันทีหลัง Pod รายงาน 1/1 Running:
$ kubectl get pod -l app=slow-start -n bugcat-demo -o wideNAME READY STATUS AGEslow-start-76487cb657-mjpxr 1/1 Running 8s
$ kubectl run curler --image=busybox:1.38 --restart=Never --rm -it -- wget -qO- --timeout=1 http://slow-start.bugcat-demo.svc.cluster.localwget: can't connect to remote host (10.96.4.64): Connection refusedService list Pod นี้เป็น Endpoint แล้ว — READY 1/1 บอกอย่างนั้น — แล้วการเชื่อมต่อก็ถูกปฏิเสธอยู่ดี เพราะแอปพลิเคชันข้างในยังอยู่กลาง sleep
วิธีแก้: readinessProbe ที่เช็ก port/endpoint ที่แอปพลิเคชันกำลัง listen จริง ไม่ใช่แค่ process ยังมีชีวิตอยู่:
apiVersion: apps/v1kind: Deploymentmetadata: name: slow-start namespace: bugcat-demospec: replicas: 1 selector: matchLabels: { app: slow-start } template: metadata: labels: { app: slow-start } spec: containers: - name: web image: python:3.13-alpine command: ['sh', '-c', 'sleep 8 && python3 -m http.server 8080'] ports: [{ containerPort: 8080 }] readinessProbe: httpGet: { path: /, port: 8080 } periodSeconds: 2 failureThreshold: 2จับได้ด้วย: ไม่มีอะไรตอน apply เลย — request จริงในช่วงเวลา startup พอดี (พิสูจน์แล้วข้างต้น) คือทางเดียวที่จะเห็นมัน เพราะ request ถัดไปหลังแอป start เสร็จก็สำเร็จปกติทันที ดู Probes: Liveness, Readiness, Startup สำหรับสิ่งที่ readiness probe กันจริง ๆ
23. livenessProbe กับ readinessProbe ชี้ไปที่ endpoint เดียวกัน โดยไม่มีช่วงผ่อนผันให้ startup
หัวข้อที่มีชื่อว่า “23. livenessProbe กับ readinessProbe ชี้ไปที่ endpoint เดียวกัน โดยไม่มีช่วงผ่อนผันให้ startup”การใช้ HTTP endpoint เดียวซ้ำสำหรับทั้งสอง probe ดูมีประสิทธิภาพ — จนกว่าแอปพลิเคชันจะมี warm-up ที่ช้าจริง ๆ liveness probe ที่เริ่มเช็กทันที (initialDelaySeconds: 0 ค่า default) และยอมแพ้หลังจากพยายามไม่กี่ครั้งจะฆ่า Pod ที่ยังกำลัง start ตามปกติซ้ำแล้วซ้ำเล่า เพราะทุกครั้งที่ restart นาฬิกา warm-up จะถูกรีเซ็ตกลับไปศูนย์
apiVersion: v1kind: Podmetadata: name: slow-warmer-bad namespace: probes-demospec: containers: - name: c image: python:3.13-alpine command: ['sh', '-c', 'sleep 20 && python3 -m http.server 8080'] ports: [{ containerPort: 8080 }] livenessProbe: httpGet: { path: /, port: 8080 } periodSeconds: 2 failureThreshold: 2 readinessProbe: httpGet: { path: /, port: 8080 } periodSeconds: 2 failureThreshold: 2อาการ — output จริง; warm-up 20 วินาที เจอ liveness probe ที่ยอมแพ้ใน 4 วินาที (periodSeconds: 2 × failureThreshold: 2):
$ kubectl get pod slow-warmer-bad -n probes-demoNAME READY STATUS RESTARTS AGEslow-warmer-bad 0/1 Running 1 (5s ago) 40s
$ kubectl get events -n probes-demo --field-selector involvedObject.name=slow-warmer-bad --sort-by=.lastTimestampLAST SEEN TYPE REASON MESSAGE1s Warning Unhealthy Readiness probe failed: Get "http://10.244.2.21:8080/": dial tcp 10.244.2.21:8080: connect: connection refused1s Warning Unhealthy Liveness probe failed: Get "http://10.244.2.21:8080/": dial tcp 10.244.2.21:8080: connect: connection refused1s Normal Killing Container c failed liveness probe, will be restartedRESTARTS ไต่ขึ้น — container ถูกฆ่าโดย liveness probe ของตัวเองก่อนที่ warm-up 20 วินาทีจะเสร็จเลยด้วยซ้ำ เป็น restart loop ที่เกิดจากตัวเองล้วน ๆ ไม่ใช่บั๊กของแอปพลิเคชัน
วิธีแก้: startupProbe ให้เวลา container ที่ start ช้าได้บูตเต็มที่ ด้วย budget ของตัวเอง ก่อนที่ livenessProbe จะเริ่มเช็กเลยด้วยซ้ำ — readinessProbe ก็ควรแยกต่างหาก เพื่อให้ “ยังไม่พร้อม” (เอาออกจาก Service) กับ “ตายจริง ฆ่าแล้วสร้างใหม่” ไม่เคยเป็นสัญญาณเดียวกัน:
apiVersion: v1kind: Podmetadata: name: slow-warmer-fixed namespace: probes-demospec: containers: - name: c image: python:3.13-alpine command: ['sh', '-c', 'sleep 20 && python3 -m http.server 8080'] ports: [{ containerPort: 8080 }] startupProbe: httpGet: { path: /, port: 8080 } periodSeconds: 2 failureThreshold: 15 # up to 30s to finish starting, before liveness ever runs livenessProbe: httpGet: { path: /, port: 8080 } periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: { path: /, port: 8080 } periodSeconds: 5 failureThreshold: 2จับได้ด้วย: ไม่มีอะไรตอน apply เลย — RESTARTS/event จริงและปัจจุบันตามเวลาจริง (พิสูจน์แล้วข้างต้น) คือสัญญาณเดียว ดู Probes: Liveness, Readiness, Startup ที่พิสูจน์กลไก restart loop เดียวกันเป๊ะและวิธีแก้ด้วย startupProbe
24. สมมติว่า kubectl exec -it <pod> -- /bin/sh ใช้ได้เสมอ
หัวข้อที่มีชื่อว่า “24. สมมติว่า kubectl exec -it <pod> -- /bin/sh ใช้ได้เสมอ”image แบบ minimal หรือ distroless (build ด้วย FROM scratch หรือ gcr.io/distroless/*) ไม่มี shell และไม่มี coreutils เลย — kubectl exec ต้องการให้ container runtime เริ่ม binary ที่ระบุชื่อ ใน filesystem ของ container ตัวเดียวกันนั้น และไม่มีอะไรอยู่ตรงนั้นให้เริ่มเลย คำแนะนำ debug ที่ AI สร้างมักใช้ kubectl exec -it <pod> -- sh หรือ -- /bin/bash เป็น default เพราะมันเป็นคำตอบที่ถูกต้องสำหรับ base image ส่วนใหญ่มาก ๆ แค่ไม่ใช่ตัวนี้
# scripts/kubectl-exec-distroless.sh — @expect-error the target image ships no shell at allkubectl exec -it distroless-app -- shอาการ — output จริง; Pod ที่รัน registry.k8s.io/pause:3.10 (ไม่มี shell ไม่มี binary ใดนอกจาก pause loop เอง):
$ kubectl exec distroless-app -- sherror: Internal error occurred: Internal error occurred: error executing command in container: failed to exec in container: failed to start exec "...": OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATHวิธีแก้: ephemeral container (GA ตั้งแต่ Kubernetes 1.25) แนบ container ที่สอง ใช้แล้วทิ้ง — ด้วย image ของตัวเอง ปกติ — เข้าไปใน process namespace ที่ Pod ที่กำลังรันแชร์กันอยู่ ทำให้มันเห็นและโต้ตอบกับ process ของ target container ได้โดยไม่ต้องติดตั้งอะไรข้างใน target เลย:
kubectl debug distroless-app --image=busybox:1.38 --target=app -- sh -c 'ps aux'อาการของวิธีแก้ พิสูจน์ในแบบเดียวกัน — ephemeral container แนบเข้าไปแล้วเห็น process tree ของ target เอง:
$ kubectl debug distroless-app --image=busybox:1.38 --target=app -- sh -c 'ps aux'Targeting container "app". If you don't see processes from this container it may be because the container runtime doesn't support this feature.Defaulting debug container name to debugger-zqmxs.
PID USER TIME COMMAND 1 65535 0:00 /pausePID 1 (/pause process จริงของ target container เอง) มองเห็นได้จากข้างในของ debug container ที่แนบเข้าไป — พิสูจน์ว่าสอง container นี้แชร์ process namespace เดียวกันจริง ไม่ใช่แค่ Pod ใหม่ที่ไม่เกี่ยวข้องเผอิญเริ่มขึ้นมาใกล้ ๆ
จับได้ด้วย: ไม่มีอะไรแบบ static เลย — นี่คือข้อเท็จจริงระดับ runtime ล้วน ๆ เกี่ยวกับ target image ดู Logging & Metrics ที่พิสูจน์ error OCI runtime exec failed เดียวกันเป๊ะและวิธีแก้ด้วย kubectl debug --target แบบเจาะลึก รวมถึงการ debug ทั้ง Node ด้วยวิธีเดียวกัน
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart TD
Y["YAML ที่ AI สร้าง"] --> D{"kubectl apply<br/>--dry-run=server"}
D -- "ปฏิเสธ (8 จาก 24)" --> R["apiVersion ที่ถูกลบ, field ที่ immutable,<br/>enum ที่ invalid, strict-decoding mismatch"]
D -- "รับผ่านสะอาด (16 จาก 24)" --> S["Object ถูกสร้างขึ้น<br/>และดูสมบูรณ์แข็งแรง"]
S --> B["พฤติกรรมจริงเท่านั้น:<br/>describe / events / curl / reschedule / drain"]
B --> F1["การตั้งค่าผิดแบบเงียบ ๆ ค่อยโผล่มา --<br/>NXDOMAIN, HPA target <unknown>,<br/>EndpointSlice ว่างเปล่า, DNS ถูกบล็อก,<br/>ข้อมูลตกอยู่บน node ผิด"]
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ถ้า
kubectl apply --dry-run=serverผ่าน manifest ก็ถูกต้อง” พิสูจน์แล้วข้างต้น: 16 จาก 24 แพทเทิร์นนี้ apply ผ่านสะอาด — StatefulSet ที่ไม่มี headless Service, Service ที่ selector พิมพ์ผิด, PodDisruptionBudget ที่บล็อกการ drain ในอนาคตทุกครั้ง ล้วนผ่าน dry-run=server โดยไม่มีการบ่นเลย ความถูกต้องตาม schema กับความถูกต้องเชิงปฏิบัติการเป็นคำถามคนละอันกัน - “apiVersion ที่ถูกลบไปแล้วจะถูกจับได้ในการรีวิวแน่นอน” มันคือแพทเทิร์นความผิดพลาดที่โค้ดที่ AI สร้างขายว่าลื่นไหลและทันสมัยพอดี — รูปร่างของ YAML มักไม่เปลี่ยนเลยด้วยซ้ำ (Deployment ใต้
extensions/v1beta1อ่านเหมือนกับตัวใต้apps/v1ทุกประการ) ดังนั้นการไล่ดูด้วยตามนุษย์ก็ไม่มีทางจับได้ง่ายกว่าการดูkubectl applyสำเร็จเฉย ๆ เลย - “
privileged: trueเป็นแค่เวอร์ชันที่แรงกว่าของ capability เฉพาะที่ฉันต้องการ” มันเป็นการให้สิทธิ์คนละประเภทเลย — มันปิด security boundary ของ container ทั้งหมดพร้อมกัน ไม่ใช่แค่เพิ่ม Linux capability เดียว (NET_ADMIN,SYS_PTRACE, อะไรก็ตามที่มันต้องการจริง) ที่ workload ต้องการ - “เนื้อหาของ Secret ถูก encrypt” พิสูจน์แล้วข้างต้น:
base64 -dบน fielddataไม่ต้องใช้ key ไม่ต้องมีสิทธิ์เข้าถึงพิเศษเกินกว่าสิทธิ์get secretธรรมดาเลย