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 = ยืดไม่ได้ต้องฆ่าทิ้ง
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)
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) — เหมือนยึดกุญแจทุกดอกจากพนักงาน ใหม่ก่อน แล้วแจกคืนทีละดอกเฉพาะห้องที่งานเขาต้องเข้าจริงๆ ไม่ใช่แจกกุญแจแม่บ้านทั้งพวงตั้งแต่ วันแรกแล้วค่อยมาคิดทีหลังว่าควรยึดคืนห้องไหนบ้าง
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 ออกจากกันชัดเจนแม้จะเป็นแอปเดียวกัน)
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 หายไปเกินที่รับได้
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.