Skip to main content

Kubernetes Best Practices & Code Review Checklist

Resource Requests & Limits​

resources:
requests:
cpu: "250m" # 0.25 core — ใช้ตัดสินใจ scheduling (Node ไหนมีที่พอ)
memory: "256Mi"
limits:
cpu: "500m" # เพดานสูงสุดที่ container ใช้ได้
memory: "512Mi"
  • ไม่ตั้ง requests → scheduler ไม่รู้ว่า Pod ต้องการ resource เท่าไหร่ อาจไปลงที่ Node ที่ resource ไม่พอจริง และ HPA ทำงานไม่ได้
  • ไม่ตั้ง limits.memory → container ใช้ memory ได้ไม่จำกัด เสี่ยง Node ทั้งตัว OOM และ ลาก Pod อื่นบน Node เดียวกันตายไปด้วย ("noisy neighbor")
  • limits.cpu ตั้งต่ำเกินไป → container ถูก CPU throttle แม้ Node จะมี CPU ว่างเหลือ (ต่างจาก memory ที่เกิน limit แล้ว container ถูก OOMKill ไปเลย)
🧠 จำง่ายๆ

"CPU โดน throttle (ชะลอ), Memory โดนประหาร (OOMKilled)" — เกิน CPU limit แค่ "โดนหน่วงให้ช้าลง" (compressible resource ใช้ซ้ำได้) แต่เกิน memory limit "ตายทันที" (incompressible resource คืน ไม่ได้) จำง่ายๆ ว่า CPU = ยืดเวลาได้, Memory = ยืดไม่ได้ต้องฆ่าทิ้ง

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

limits.cpu ถูก enforce ผ่าน CFS (Completely Fair Scheduler) quota/period ของ Linux kernel ไม่ใช่แค่ "จำกัดความเร็ว" เฉยๆ — kernel แบ่งเวลาเป็นช่วง period (default 100ms) แล้วคำนวณว่า container ใช้ CPU time ไปเท่าไหร่ในแต่ละ period ถ้าใช้ครบ quota ก่อนจบ period, container จะถูก "แช่แข็ง" (throttle) จนกว่า period ถัดไปจะเริ่ม แม้ CPU ของ Node จะว่างอยู่ก็ตาม — ปรากฏการณ์ นี้เรียกว่า CPU throttling แม้ utilization โดยรวมต่ำ เป็นปัญหาที่ debug ยากมากเพราะ metric "CPU usage" ดูต่ำ แต่ latency สูงผิดปกติ ต้องดู metric เฉพาะ container_cpu_cfs_throttled_ periods_total ถึงจะเจอสาเหตุจริง หลายทีม production จึงเลือกไม่ตั้ง limits.cpu เลย (ตั้ง แค่ requests.cpu) เพื่อหลีกเลี่ยงปัญหานี้ และยอมรับความเสี่ยงเรื่อง noisy-neighbor แทน

Probes — Liveness, Readiness, Startup​

Probeตอบคำถามว่าถ้า fail
readinessProbe"พร้อมรับ traffic หรือยัง?"Pod ถูกถอดออกจาก Service endpoint ชั่วคราว (ไม่ restart)
livenessProbe"ยัง alive อยู่ไหม หรือ deadlock ไปแล้ว?"kubelet restart container
startupProbe"boot เสร็จหรือยัง?"ปิดกั้น liveness/readiness ไม่ให้ทำงานจนกว่าจะผ่าน — ป้องกันแอป boot ช้าโดนฆ่าทิ้งก่อนพร้อม
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
ข้อผิดพลาดที่พบบ่อย

ตั้ง livenessProbe ชี้ไป endpoint เดียวกับ readinessProbe ที่เช็ค dependency ภายนอก (เช่น DB connection) — ถ้า DB ล่มชั่วคราว liveness probe จะ fail ไปด้วย ทำให้ kubelet restart container ทั้งที่ container เองไม่มีปัญหาอะไร ยิ่งซ้ำเติมปัญหาแทนที่จะช่วย ควรแยก liveness ให้เช็คแค่ "process นี้ยัง respond ไหม" ไม่เช็ค dependency ภายนอก

🧠 จำง่ายๆ

"readiness ถาม 'รับแขกได้ไหม', liveness ถาม 'ยังหายใจอยู่ไหม'" — ร้านอาหารที่ของในครัวหมด (DB ล่มชั่วคราว) ยัง "หายใจอยู่" (process ไม่ตาย) แค่ "รับแขกเพิ่มไม่ได้ชั่วคราว" (ไม่ readiness) ไม่ต้องเผาร้านทิ้งแล้วสร้างใหม่ (restart) แค่แขวนป้าย "ปิดชั่วคราว" (ถอดจาก Service endpoint)

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

startupProbe ไม่ใช่แค่ "เพิ่ม initialDelaySeconds ให้นานขึ้น" อย่างที่คนมักเข้าใจผิดว่าทำ หน้าที่เดียวกัน — ความต่างสำคัญคือ initialDelaySeconds เป็น fixed delay คงที่ (รอเท่านี้ วินาทีเป๊ะไม่ว่าแอปจะ boot เสร็จเร็วหรือช้าแค่ไหน) ส่วน startupProbe เป็น polling แบบมี เพดานสูงสุด (เช็คถี่ๆ ทันทีที่พร้อมก็ปล่อยผ่านเลย ไม่ต้องรอจนครบเวลา) ทำให้แอปที่ boot เร็ว ในบางครั้งแต่ช้าในบางครั้ง (เช่น ขึ้นกับขนาด cache ที่ต้อง warm up) ได้ประโยชน์ทั้งสองทาง: เร็ว เมื่อพร้อมเร็ว และไม่โดนฆ่าทิ้งเมื่อช้ากว่าปกติ (ตราบใดที่ยังไม่เกิน failureThreshold × periodSeconds ของ startupProbe เอง)

Security Context​

spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]

หลัก least privilege: รันเป็น non-root เสมอ, ปิด privilege escalation, ทำ root filesystem เป็น read-only (เขียนได้เฉพาะ volume ที่ mount ไว้ชัดเจน), และ drop Linux capability ทั้งหมด แล้วค่อยเพิ่มเฉพาะตัวที่จำเป็นจริงๆ

🧠 จำง่ายๆ

"Drop ทุกอย่างก่อน แล้วค่อยขอคืนเฉพาะที่จำเป็น" (deny-by-default) — เหมือนยึดกุญแจทุกดอกจากพนักงาน ใหม่ก่อน แล้วแจกคืนทีละดอกเฉพาะห้องที่งานเขาต้องเข้าจริงๆ ไม่ใช่แจกกุญแจแม่บ้านทั้งพวงตั้งแต่ วันแรกแล้วค่อยมาคิดทีหลังว่าควรยึดคืนห้องไหนบ้าง

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

readOnlyRootFilesystem: true ทำให้หลาย base image (โดยเฉพาะที่มาจาก Node.js/Python ที่ชอบ เขียน cache/temp file ลง /tmp หรือ /app) พังทันทีโดยไม่มี error message ที่ชัดเจน เพราะ มันพยายามเขียนไฟล์แล้วโดนปฏิเสธเงียบๆ หรือ error แบบกำกวม วิธีแก้ที่ถูกต้องไม่ใช่การปิด readOnlyRootFilesystem กลับไป แต่คือการระบุ emptyDir volume แยกไว้สำหรับ path ที่ต้อง เขียนจริงๆ เท่านั้น (เช่น mount emptyDir ไปที่ /tmp โดยเฉพาะ) วิธีนี้ยังคง "read-only root filesystem" ไว้ได้ (ป้องกัน attacker เขียน malicious binary ลง container ตอน runtime) ในขณะที่แอปยังเขียน temp file ที่จำเป็นได้ตามปกติ — เป็นเทคนิคที่แยก security engineer ที่เข้าใจ จริงออกจากคนที่แค่ปิด flag ทิ้งเพราะ "มันพัง"

Labels & Organization​

ใช้ label มาตรฐาน (recommended labels) เพื่อให้ tooling (Helm, ArgoCD, monitoring) ทำงานร่วมกันได้สม่ำเสมอ:

labels:
app.kubernetes.io/name: my-app
app.kubernetes.io/instance: my-app-prod
app.kubernetes.io/version: "1.2.0"
app.kubernetes.io/managed-by: helm
🧠 จำง่ายๆ

"name = ชื่อแอป, instance = ชื่อแอป+environment เฉพาะตัว" — app.kubernetes.io/name: my-app เหมือนนามสกุล (ใช้ร่วมกันได้ทุก environment) ส่วน instance: my-app-prod เหมือนชื่อเต็มเฉพาะ ตัว (แยกแต่ละ deployment ออกจากกันชัดเจนแม้จะเป็นแอปเดียวกัน)

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

Label เหล่านี้ไม่ใช่แค่ "สวยงามไว้ดู" — มันคือกลไกที่ Helm ใช้ตัดสินใจว่า resource ไหน "เป็นของ" release ไหนตอนทำ helm uninstall/helm upgrade (ผ่าน label app.kubernetes.io/managed-by: Helm และ annotation คู่กัน) ถ้า label เหล่านี้ไม่ถูกต้องหรือถูกแก้ไขด้วยมือหลัง Helm deploy ไปแล้ว Helm อาจ "หลงทาง" ไม่รู้ว่า resource นั้นเป็นของตัวเองไหม ทำให้ helm uninstall ลบไม่ครบ หรือ helm upgrade พยายามสร้าง resource ซ้ำที่มีอยู่แล้วจนเกิด conflict error — การแก้ label ของ resource ที่ Helm จัดการอยู่ด้วยมือ (เช่นผ่าน kubectl label) จึงเป็นสิ่งที่ควรเลี่ยงอย่างยิ่ง

Code Review Checklist​

  • ทุก container มี resources.requests และ resources.limits ที่สมเหตุสมผล
  • มี readinessProbe เสมอ, livenessProbe เช็คเฉพาะ process health ไม่เช็ค dependency ภายนอก
  • รันเป็น non-root (runAsNonRoot: true) ยกเว้นมีเหตุผลชัดเจนที่ทำไม่ได้
  • Secret ไม่ hardcode ค่าใน manifest ที่ commit เข้า repo (ใช้ sealed-secrets/external secret manager แทน)
  • imagePullPolicy และ image tag ชัดเจน — หลีกเลี่ยง :latest ใน production (ทำให้ rollback คาดเดาไม่ได้ว่าจะได้ version ไหนกลับมา)
  • Deployment selector กับ Pod template label ตรงกัน (ไม่งั้น apply ไม่ผ่านหรือ Service หา Pod ไม่เจอ)
  • ตั้ง PodDisruptionBudget สำหรับ workload สำคัญ เพื่อป้องกัน voluntary disruption (เช่น node drain ตอน maintenance) ทำให้ availability หายไปเกินที่รับได้
English Summary

CPU limits are enforced via the Linux CFS quota/period mechanism and can cause throttling even when overall utilization looks low (watch container_cpu_cfs_throttled_periods_total) — some production teams skip limits.cpu entirely for this reason, keeping only requests.cpu. Use three probe types correctly: readiness controls traffic routing, liveness controls restarts (and should check only process health, not external dependencies), and startupProbe is a polling mechanism with a ceiling, not a fixed delay like initialDelaySeconds — faster boots get through immediately. Apply least-privilege securityContext; when readOnlyRootFilesystem: true breaks an image that writes to /tmp, mount a dedicated emptyDir there rather than disabling the read-only root. Standard app.kubernetes.io/* labels aren't cosmetic — Helm relies on them to track which resources belong to a release, and hand-editing them can break helm uninstall/ upgrade. Also: pin image tags instead of :latest, keep selectors matching Pod template labels, and set a PodDisruptionBudget for anything that can't tolerate voluntary disruption.