Private Docs

ทำไม 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 — เอกสารนั้นบอกว่า ควรทำอะไร, เอกสารนี้บอกว่า ทำแล้วเกิดอะไรขึ้นจริง และสมมติฐานข้อไหนผิด

แหล่งข้อมูล: kubectl live query + az aks show บน aks-SupperApp-dev วันที่ 22/08/2026


สรุปสั้น

Right-size (2 PR)✅ merged + sync ลง cluster แล้ว
CPU request ของ user pool89% → 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 CPUdev+uat: service ทั่วไป 100m → 50m · user/thirdparty 250m → 100m (46 ไฟล์) · พ่วง sonarqube memory 2Gi → 3Gi−2.95 coresmerged
PR #2439 right-size memorydependency-track 4Gi → 1536Mi + frontend 256Mi → 64Mi · user/thirdparty (dev+uat) 512Mi → 256Mi−4.2 GiB, −0.45 coremerged
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 เสร็จ:

Nodecpu% (นับ DS)cpu% (ไม่นับ DS)mem%ผ่านเกณฑ์ 0.5?
...vmss00000076%54%44%
...vmss00000278%56%20%
...vmss00000h77%55%40%
...vmss00000q82%60%53%
...vmss00003383%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

Nodekube-system pod ที่ไม่ใช่ DaemonSetยุบได้ไหม
...vmss000000coredns❌ ถูกกัน
...vmss000002ama-metrics-ksm, konnectivity-agent❌ ถูกกัน
...vmss00000hama-metrics, coredns-autoscaler❌ ถูกกัน
...vmss00000qazure-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):

ControllerCPU reqLoadBalancerIngress ที่ใช้ class นี้ข้อสรุป
nginx (default)1.0public IP 20.198.139.250 ใบ🔴 ไม่มีใครใช้เลย
nginx-internal-01.0internal 172.29.88.526 (dev ทั้งหมด + ArgoCD UI)ต้องเก็บ
sit-nginx-internal-01.0internal 172.29.88.422 (SIT ที่เหลือ 1 pod)ลบได้
argocd-nginx-internal-01.0internal 172.29.88.61 ใบ🔴 ทางเข้าซ้ำ

argocd-nginx-internal — ingress controller ทั้งชุดเพื่อ URL ซ้ำ

มันรับ Ingress ใบเดียวคือ argocd-server-ingress-publicargocd-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คืนอย่างอื่นความเสี่ยง
1scaling.minReplicas: 1 + threshold: steady ทั้ง 4 CR−2.0 coresต่ำ · reversible
2ลบ CR argocd-nginx-internal−0.5internal LB ~$12/เดือน⚠️ เช็ค DNS argocd-nonprod.exim.go.th ก่อน
3ลบ/ปิด CR default (nginx public)−0.5public LB + public IP⚠️ ยืนยันว่าไม่มี DNS ภายนอกชี้ 20.198.139.25
4ลบ CR sit-nginx-internal−0.5internal 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
sonarqube2,067Mi3Gi48%
dependency-track-apiserver855Mi1,536Mi80%
dependency-track-frontend4Mi64Mi16 เท่า
user-service (dev/uat)144–151Mi256Mi~70%
thirdparty-service (dev/uat)75–84Mi256Mi~3 เท่า
service ทั่วไป67–120Mi128Mi (คงเดิม)บางตัวเหลือ 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 ทุกตัวรวมกัน