ทำไม AKS ไม่ยอมยุบ node — right-size แล้วแต่ไม่ได้เงินคืน
ลด CPU/memory ที่ pod จองเกินจริงจนสำเร็จ request ลงจาก 89% เหลือ 74% แต่จำนวน node ไม่ขยับ — ตัวบล็อกจริงอยู่ที่วิธีที่ cluster-autoscaler นับ utilization ไม่ใช่ที่ pod และก้อนที่ใหญ่ที่สุดที่ยังไม่แตะคือ ingress controller 4 ชุดที่จอง 4 cores ใช้จริง 51m
อัปเดต: 2026-08-22
ต่อจาก Azure Cost Review — เอกสารนั้นบอกว่า ควรทำอะไร, เอกสารนี้บอกว่า ทำแล้วเกิดอะไรขึ้นจริง และสมมติฐานข้อไหนผิด
แหล่งข้อมูล:
kubectllive query +az aks showบนaks-SupperApp-devวันที่ 22/08/2026
สรุปสั้น
| Right-size (2 PR) | ✅ merged + sync ลง cluster แล้ว |
| CPU request ของ user pool | 89% → 74% (คืนมา ~2.95 cores) |
| จำนวน node | 🔴 5 → 5 ไม่ขยับ |
| เงินที่ประหยัดได้จริง ณ ตอนนี้ | $0 |
| ก้อนถัดไปที่ยังไม่แตะ | 4.0 cores ใน app-routing-system |
ประเด็นเดียวของเอกสารนี้: เราแก้ถูกจุด แต่ตัวที่กั้นไม่ให้ได้เงินคืนไม่ได้อยู่ที่ pod — มันอยู่ที่ วิธีที่ cluster-autoscaler นับว่า node ตัวไหน “ว่างพอจะยุบ”
1. กลไก — autoscaler ตัดสินจากอะไร
cluster-autoscaler ไม่ได้ดูว่า CPU เดินจริงเท่าไหร่ มันดู
utilization = ผลรวม request ของ pod บน node ÷ allocatable ของ node
แล้วเทียบกับเส้น scaleDownUtilizationThreshold ซึ่ง cluster นี้ตั้งไว้ที่ 0.5
- ต่ำกว่าเส้นติดต่อกัน 10 นาที → เริ่มย้าย pod ออกแล้วคืน node
- สูงกว่าเส้น → ไม่แตะเลย ต่อให้ pod ย้ายไปที่อื่นได้สบายก็ตาม
จุดที่ทำให้ตัวเลขเพี้ยน
บน node ทุกตัวมี DaemonSet — pod ที่ Kubernetes บังคับให้มี 1 ชุดต่อ node เสมอ (log agent, metrics agent, CSI secret driver, kube-proxy) รวมกัน 859m ต่อ node = 22% ของ allocatable
DaemonSet ไม่ใช่ภาระที่ต้องหาที่ให้ใหม่ — ลบ node ทิ้ง มันหายไปพร้อมกัน ไม่ได้ไปเบียด node อื่น
แต่ค่าตั้งต้น ignoreDaemonsetsUtilization: false สั่งให้ นับมันเข้า utilization ด้วย → ทุก node จึงมี “พื้น” 22% ที่ยกออกไม่ได้ ต่อให้ไม่มี application pod เลยสักตัว
เปรียบเทียบให้เห็นภาพ — รถบัสกับพนักงานประจำรถ
คิดว่า node = รถบัส 1 คัน มี 10 ที่นั่ง เรามี 5 คัน คันละ ~$154/เดือน
บนรถมีคน 2 ประเภท:
- ผู้โดยสาร = service ของเรา — ย้ายไปนั่งคันอื่นได้
- พนักงานประจำรถ (คนขับ กระเป๋ารถ) = DaemonSet — ทุกคันต้องมี ย้ายไม่ได้ และถ้าเลิกใช้รถคันนี้ พนักงานก็เลิกไปพร้อมกัน กินไป 2 จาก 10 ที่นั่ง
กฎ: จะยุบรถคันไหนได้ ต้อง “นั่งไม่ถึงครึ่งคัน”
ปัญหา: ตอนนับว่าเต็มแค่ไหน ระบบนับพนักงานประจำรถเป็นผู้โดยสารด้วย → รถที่มีผู้โดยสารจริง 4 คน ระบบเห็นเป็น 6/10 = 60% = “เกินครึ่ง ยุบไม่ได้” ทั้งที่ผู้โดยสาร 4 คนย้ายไปคันอื่นได้สบาย
สิ่งที่เราไปแก้ = บอกระบบว่า “ตอนนับว่าเต็มแค่ไหน อย่านับพนักงานประจำรถ”
2. ทำอะไรไปแล้วบ้าง
| สิ่งที่ทำ | รายละเอียด | ผล | สถานะ |
|---|---|---|---|
| PR #2438 right-size CPU | dev+uat: service ทั่วไป 100m → 50m · user/thirdparty 250m → 100m (46 ไฟล์) · พ่วง sonarqube memory 2Gi → 3Gi | −2.95 cores | merged |
| PR #2439 right-size memory | dependency-track 4Gi → 1536Mi + frontend 256Mi → 64Mi · user/thirdparty (dev+uat) 512Mi → 256Mi | −4.2 GiB, −0.45 core | merged |
| autoscaler profile (cluster-level) | ignore-daemonsets-utilization=true | −22%/node ในการนับ | applied |
ทั้ง 2 PR merge เข้า development ของ Backend_Iac แล้ว ArgoCD (superapp-dev / superapp-uat / superapp-security ตั้ง automated + selfHeal ทั้งหมด) sync ลง cluster เอง — merge = sync ไม่ต้องกดอะไร
az aks update -g sua-azure-nonprd -n aks-SupperApp-dev \
--cluster-autoscaler-profile ignore-daemonsets-utilization=true \
skip-nodes-with-local-storage=false
⚠️ กับดักที่เจอตอนรัน — ต้องส่ง flag พ่วงเสมอ
cluster นี้ตั้ง skipNodesWithLocalStorage: false ไว้ ซึ่งสวนกับค่า default ของ AKS (true)
ถ้าส่ง --cluster-autoscaler-profile ด้วย flag เดียว ค่าที่ตั้งไว้สวน default จะถูก reset กลับเงียบๆ — ต้องไล่ az aks show --query autoScalerProfile เทียบกับ default ก่อนทุกครั้ง แล้วส่งค่าที่ไม่ใช่ default พ่วงไปด้วย
Rollback: คำสั่งเดิมเปลี่ยนเป็น ignore-daemonsets-utilization=false (พ่วง skip-nodes-with-local-storage=false เหมือนกัน)
3. ผลจริง — ยังยุบไม่ได้ และทำไม
kubectl get configmap -n kube-system cluster-autoscaler-status หลังเปิด flag:
scaleDown:
status: NoCandidates
ไม่มี node ไหนเข้าเกณฑ์แม้แต่ตัวเดียว — วัดใหม่หลัง PR ทั้งสองใบ sync เสร็จ:
| Node | cpu% (นับ DS) | cpu% (ไม่นับ DS) | mem% | ผ่านเกณฑ์ 0.5? |
|---|---|---|---|---|
...vmss000000 | 76% | 54% | 44% | ❌ |
...vmss000002 | 78% | 56% | 20% | ❌ |
...vmss00000h | 77% | 55% | 40% | ❌ |
...vmss00000q | 82% | 60% | 53% | ❌ |
...vmss000033 | 83% | 60% | 50% | ❌ |
🔴 สาเหตุ: scheduler เกลี่ยใหม่ระหว่างทาง
ตอนวัดครั้งแรก (ก่อน PR #2439 deploy) มี 2 node ที่ถ้าไม่นับ DaemonSet จะตกมาเหลือ 42% และ 45% — ต่ำกว่าเกณฑ์พอดี นั่นคือเหตุผลที่เลือกเปิด flag นี้
แต่ตอน #2439 deploy pod ทั้งหมด restart แล้ว scheduler เกลี่ยใหม่ → ตอนนี้ทุก node ขยับขึ้นมาอยู่ที่ 54–60% เท่ากันหมด ไม่มีตัวไหนต่ำกว่าเกณฑ์อีก
flag ที่เปิดไปยังถูกต้องและจำเป็น (มันตัด 22% ปลอมออกจากทุก node) แต่ ไม่พอด้วยตัวมันเอง — ต้องคืน CPU request อีกก้อนหนึ่ง
บทเรียน: ห้ามวัด utilization แล้ววางแผนยุบ node คร่อมจังหวะ rollout — pod restart ทีเดียวเกลี่ยตัวเลขใหม่หมด ต้องวัดหลัง rollout นิ่งแล้วเท่านั้น
4. ด่านที่สองที่รออยู่ข้างหน้า
ต่อให้ทำ node ให้ต่ำกว่าเกณฑ์ได้ ยังมี skipNodesWithSystemPods: true ที่สั่งว่า ห้ามแตะ node ที่มี pod ของ kube-system ที่ไม่ใช่ DaemonSet
system pool มี node เดียวและถูก taint CriticalAddonsOnly ไว้ → addon ของ Azure ไหลลงมาอยู่บน user node กระจาย 4 ใน 5 node
| Node | kube-system pod ที่ไม่ใช่ DaemonSet | ยุบได้ไหม |
|---|---|---|
...vmss000000 | coredns | ❌ ถูกกัน |
...vmss000002 | ama-metrics-ksm, konnectivity-agent | ❌ ถูกกัน |
...vmss00000h | ama-metrics, coredns-autoscaler | ❌ ถูกกัน |
...vmss00000q | azure-policy ×2, azure-wi-webhook, metrics-server ×2 | ❌ ถูกกัน |
...vmss000033 | ไม่มีเลย | ✅ ผ่านด่านนี้ |
🔴 ข้อสรุปที่ต้องรู้: ตราบใดที่
skipNodesWithSystemPodsยังเป็นtrueและ addon ยังกระจายแบบนี้ — ยุบได้มากที่สุด 1 node ไม่ว่าจะไล่ตัด request ต่อไปอีกเท่าไหร่
5. 🎯 ก้อนที่ใหญ่ที่สุดที่ยังไม่แตะ — app-routing-system
8 pods (4 controller × 2 replica × 500m) = จอง 4.0 cores ใช้จริงรวม 51m = พอง 78 เท่า
ก้อนเดียวนี้ใหญ่กว่าที่ right-size ทั้ง 2 PR คืนมารวมกัน
ไล่ดูว่าแต่ละตัวรับใช้ Ingress จริงกี่ใบ (kubectl get ingress -A):
| Controller | CPU req | LoadBalancer | Ingress ที่ใช้ class นี้ | ข้อสรุป |
|---|---|---|---|---|
nginx (default) | 1.0 | public IP 20.198.139.25 | 0 ใบ | 🔴 ไม่มีใครใช้เลย |
nginx-internal-0 | 1.0 | internal 172.29.88.5 | 26 (dev ทั้งหมด + ArgoCD UI) | ต้องเก็บ |
sit-nginx-internal-0 | 1.0 | internal 172.29.88.4 | 22 (SIT ที่เหลือ 1 pod) | ลบได้ |
argocd-nginx-internal-0 | 1.0 | internal 172.29.88.6 | 1 ใบ | 🔴 ทางเข้าซ้ำ |
argocd-nginx-internal — ingress controller ทั้งชุดเพื่อ URL ซ้ำ
มันรับ Ingress ใบเดียวคือ argocd-server-ingress-public → argocd-nonprod.exim.go.th
ทั้งที่ ArgoCD มีทางเข้าอีกทางผ่าน nginx-internal อยู่แล้ว (argocd.sua-az-dev.internal)
🔴 ลบ ArgoCD เอง ไม่ประหยัดอะไรเลย
pod ทั้ง 7 ตัวใน namespace argocd ไม่ได้ตั้ง resource request ไว้เลย (<none> ทุกตัว) → autoscaler นับเป็น 0 อยู่แล้ว · ใช้จริง 187m CPU / ~600Mi memory
เงินอยู่ที่ ingress controller ที่ตั้งไว้หน้า ArgoCD ไม่ใช่ที่ ArgoCD — และการลบ ArgoCD = เสียกลไก deploy ของ dev/uat/security ทั้งหมด
แผนที่แนะนำ
| ลำดับ | ทำอะไร | คืน CPU | คืนอย่างอื่น | ความเสี่ยง |
|---|---|---|---|---|
| 1 | scaling.minReplicas: 1 + threshold: steady ทั้ง 4 CR | −2.0 cores | — | ต่ำ · reversible |
| 2 | ลบ CR argocd-nginx-internal | −0.5 | internal LB ~$12/เดือน | ⚠️ เช็ค DNS argocd-nonprod.exim.go.th ก่อน |
| 3 | ลบ/ปิด CR default (nginx public) | −0.5 | public LB + public IP | ⚠️ ยืนยันว่าไม่มี DNS ภายนอกชี้ 20.198.139.25 |
| 4 | ลบ CR sit-nginx-internal | −0.5 | internal LB ~$12/เดือน | ต่ำ · SIT ว่างแล้ว |
ทำครบ 4 ข้อ: 4.0 → 0.5 core → user pool เหลือ ~12.3 cores = เฉลี่ย 42%/node เมื่อไม่นับ DaemonSet → ต่ำกว่าเกณฑ์ 0.5 → ปลดล็อกการยุบ node ได้จริง (ยังติดด่าน §4 ที่จำกัดไว้ 1 node)
ข้อ 1 ปลอดภัยพอที่จะทำได้ทันที ส่วนข้อ 2–4 ต้องเช็ค DNS ก่อน โดยเฉพาะข้อ 3 ที่มี public IP — ถ้ามีใครชี้ DNS มาที่ IP นั้นแล้วเราลบ = พังข้างนอกโดยไม่รู้ตัว
หมายเหตุเรื่อง GitOps — CR 2 ใน 4 ตัวไม่ได้อยู่ใน repo
nginx-internal และ sit-nginx-internal มีไฟล์อยู่ใน Backend_Iac ที่ src/yamls/{dev,sit}/nginx-internal-controller.yaml แต่ ไม่ได้อยู่ใต้ path ที่ ArgoCD sync (superapp-dev ชี้ src/yamls/dev/superapp)
ส่วน default กับ argocd-nginx-internal ไม่มีในrepo เลย = สร้างมือ
แปลว่าแก้ผ่าน kubectl ได้ตรงๆ ไม่ต้องสู้กับ ArgoCD แต่ต้องตัดสินใจว่าจะ commit กลับ repo ไหม ไม่งั้นเกิด drift ที่ไม่มีใครรู้
🔸 เจอระหว่างทาง — Ingress 2 ใบที่ตายอยู่เงียบๆ
uat-nginx-internal ถูกอ้างโดย 2 Ingress (superappuat/fx-service-ingress, superappuat/redisinsight-ingress) แต่ ไม่มี controller ชื่อนี้อยู่ใน cluster → 2 ใบนั้นไม่ทำงานมานานแล้ว
UAT ที่เหลือ 24 ใบใช้ kong-uat ปกติ
6. เลขที่วัดสดไว้ใช้อ้างอิง
ค่าที่ pod ใช้จริงเทียบกับที่จองไว้หลังแก้ — ใช้แทนตัวเลขประมาณในเอกสารเก่าได้
| Pod | ใช้จริง | request หลังแก้ | headroom |
|---|---|---|---|
sonarqube | 2,067Mi | 3Gi | 48% |
dependency-track-apiserver | 855Mi | 1,536Mi | 80% |
dependency-track-frontend | 4Mi | 64Mi | 16 เท่า |
user-service (dev/uat) | 144–151Mi | 256Mi | ~70% |
thirdparty-service (dev/uat) | 75–84Mi | 256Mi | ~3 เท่า |
| service ทั่วไป | 67–120Mi | 128Mi (คงเดิม) | บางตัวเหลือ 6% |
🔴 ห้ามลดต่อ:
centralized,fxorchestrator,fxcodex,fxcontractใช้ 115–120Mi จาก request 128Mi = 90–94% — ลดอีกคือ under-request ไม่ใช่ right-size · ถ้าเจอ evict หรือ OOM หลังจากนี้ ให้เพิ่มเป็น 192Mi
ทำไม dependency-track ไม่ลงถึง 1Gi ทั้งที่ใช้จริงแค่ 855Mi
855Mi เป็นค่าตอน idle — DT apiserver เป็น JVM ที่พุ่งตอน mirror NVD/OSV feed
ถ้า request 1Gi จะเหลือ headroom แค่ ~20% แล้วกลายเป็น pod แรกที่โดน evict เวลา node memory ตึง ซึ่งคือความพังแบบเดียวกับที่ต้องแก้ให้ sonarqube (ใช้ 2,067Mi แต่ request ไว้ 2Gi = เกิน 100%)
1,536Mi ยังคืน request ได้ 2.5Gi (เกือบเท่ากับลงไป 1Gi) แต่ความเสี่ยงต่างกันมาก
limit คงเดิม 8Gi ไม่แตะ — JVM default max-heap = 25% ของ limit การลด limit จะเปลี่ยนพฤติกรรม heap ต้องทดสอบก่อน ไม่ควรพ่วงมากับงาน right-size
7. ที่ยังไม่ได้ยืนยัน
- ตัวเลขทั้งหมดเป็น snapshot จาก
kubectl topยังไม่ได้ดู P95 ย้อนหลัง 7 วันจาก Grafana - ยังไม่ได้พิสูจน์ว่าลด
app-routingแล้ว node จะตกต่ำกว่าเกณฑ์จริง — scheduler อาจเกลี่ยใหม่อีกรอบเหมือนที่เกิดกับ PR #2439 - ยังไม่ได้เช็คว่ามี DNS ภายนอกชี้ที่ public IP
20.198.139.25หรือไม่ (ตัวกั้นของแผนข้อ 3)
มุมมองที่ต้องไม่ลืม
งานทั้งหมดในหน้านี้เล่นอยู่ในกรอบ ~$154–380/เดือน
ส่วนที่บิลเกิน budget อยู่จริงคือ $1,388/เดือน และเกือบ $1,000 ของมันอยู่ที่ ปม hub ที่ยังเปิด Portal ดูไม่ได้เพราะติดสิทธิ์
เรื่องนั้นให้ผลมากกว่าการไล่ยุบ node ทุกตัวรวมกัน