Bỏ qua để đến nội dung
Knowledge Hub

Requests và limits trong Kubernetes: vì sao Pod mới dùng 40% CPU đã bị throttle Trung cấp

Thời gian đọc ước tính: 8 phút

checkout-api là service NestJS chạy Node 24, 6 replica. Mỗi container đặt cpu: 1 và memory: 768Mi cho cả request lẫn limit. Trong đợt khuyến mãi, p99 của POST /checkout tăng từ 180 ms lên 1,2 s.

Dashboard cho thấy mỗi Pod chỉ dùng 40% CPU limit. Không có OOMKilled, HPA không scale, database vẫn nhàn. (Mọi con số trong kịch bản này là minh hoạ.)

Câu hỏi của team: Pod dùng chưa tới nửa CPU sao vẫn thiếu CPU, và nên chỉnh thông số nào?

Tình huống Đặt Điều kiện
Mọi workload production Request CPU và memory theo usage đo được Scheduler và HPA chỉ nhìn request
Memory limit = request Memory không throttle được; overcommit kết thúc bằng OOM kill hoặc eviction
API nhạy latency, CPU theo đợt (hash, gzip, JSON) CPU limit cao hơn hẳn request, hoặc bỏ CPU limit Chỉ khi cluster cho phép và có theo dõi throttling
Cần cô lập chặt, benchmark, core riêng CPU limit = request (Guaranteed) Chấp nhận throttling; đặt limit theo burst đỉnh
Node.js trong container --max-old-space-size ≈ 75% memory limit Chừa chỗ cho Buffer, native memory và thread
HPA theo CPU Target utilization tính trên request Mọi container trong Pod đều có CPU request
Request Limit
Ai dùng kube-scheduler, HPA, kubelet khi xếp hạng eviction kubelet và container runtime, kernel thực thi qua cgroup
CPU Trọng số tương đối khi node tranh chấp Quota cứng mỗi period: throttling
Memory Scheduling; gợi ý memory.min/memory.low trên cgroup v2 Vượt thì kernel OOM-kill một process trong container
Nếu bỏ trống Lấy bằng limit khi chỉ đặt limit Không có trần: container dùng được phần trống của node

Scheduler chỉ đặt Pod lên node mà allocatable trừ tổng request hiện có còn đủ cho request của Pod. Limit do kernel thực thi về sau, bất kể scheduler đã tính gì.

Tổ hợp request và limit quyết định QoS class của Pod, thứ quan trọng khi hết memory:

Đang tải diagram…

checkout-api với cpu: 1 và memory: 768Mi cho cả request lẫn limit là Guaranteed. Guaranteed không có nghĩa “không bao giờ bị throttle”; nó chỉ có nghĩa CPU limit bằng request.

Kubelet và container runtime đổi CPU limit thành CFS quota: quota = limit × period, period mặc định 100 ms. cpu: 1 thành cpu.max = 100000 100000 trên cgroup v2: 100 ms thời gian CPU cho mỗi 100 ms, cộng dồn mọi thread của container.

Process Node không chỉ có một thread. Các lệnh async của crypto, zlib, fs chạy trên libuv threadpool, mặc định 4 thread. Bốn lệnh hash song song có thể tiêu hết quota trong một phần tư period:

Bốn thread của threadpool tiêu hết quota 100 ms trong 25 ms; cả container chờ tới period sau
Bốn thread của threadpool tiêu hết quota 100 ms trong 25 ms; cả container chờ tới period sauBước 1/5: t=0: bốn request checkout, mỗi request chạy một hash 40 ms trên bốn thread của threadpool, dùng bốn core cùng lúc.thời gian trong periodt=0: 4 hash bắt đầuchạy song song 4 corequota 100 mscòn đầyThreadpool4 thread, 4 lệnh hashEvent loopGET /cartCFS quotalimit 1 = 100 ms/100 ms
Bước 1/5

t=0: bốn request checkout, mỗi request chạy một hash 40 ms trên bốn thread của threadpool, dùng bốn core cùng lúc.

Trạng thái sau từng bước:

  1. t=0: quota 100 ms, bốn hash chạy trên bốn core.
  2. t=25 ms: 4 × 25 ms = 100 ms đã dùng. Mọi thread trong cgroup bị throttle, mỗi hash còn thiếu 15 ms.
  3. t=30 ms: GET /cart tới. Event loop thuộc cùng cgroup nên cũng không chạy được.
  4. t=100 ms: quota được nạp lại. Các thread bị throttle chạy tiếp.
  5. t=115 ms: hash xong. Latency là 115 ms thay vì 40 ms, trong khi CPU trung bình một giây chỉ bằng 16% limit.

Vì vậy “40% limit” không chứng minh được gì. Dashboard lấy trung bình CPU trong 30 s đến 5 phút; throttling xảy ra bên trong 100 ms. Metric nhìn thấy nó là container_cpu_cfs_throttled_periods_total chia cho container_cpu_cfs_periods_total.

Memory có hai đường hỏng độc lập:

Vượt limit của chính mình thì restart một container; node thiếu memory thì evict cả Pod dùng vượt request
Vượt limit của chính mình thì restart một container; node thiếu memory thì evict cả Pod dùng vượt requestBước 1/6: Đường 1: V8 heap và Buffer của checkout-api tăng; working set chạm 768Mi.Node (16 GiB)checkout-apinode, PID 1760Micgroup memory.maxlimit 768Mikubeletmemory.availablereport-workerBestEffortDeploymentcontrollercấp phát
checkout-api → cgroup memory.maxcấp phát
Bước 1/6

Đường 1: V8 heap và Buffer của checkout-api tăng; working set chạm 768Mi.

OOMKilled Node-pressure eviction
Kích hoạt Container vượt memory limit của chính nó Tín hiệu của node, ví dụ memory.available<100Mi (hard threshold mặc định trên Linux)
Ai ra tay OOM killer của kernel trong cgroup kubelet, trước khi node cạn hẳn
Kết quả Container restart trong cùng Pod, exit 137 Pod chuyển phase Failed; controller tạo Pod mới
Thứ tự nạn nhân Container chạm limit Pod dùng vượt request trước, rồi theo priority, rồi theo mức vượt request

Thực tế Pod BestEffort (request 0) đi trước, Guaranteed đi sau cùng vì Pod Guaranteed không thể dùng vượt request. Nếu node OOM thật trước khi kubelet kịp phản ứng, kernel chọn theo oom_score_adj: −997 cho Guaranteed, 1000 cho BestEffort.

Node.js trong container (đo với Node 24.22 trong Docker):

  • os.availableParallelism() trả 1 với --cpus=1, nhưng os.cpus().length vẫn liệt kê mọi core của host. Threadpool vẫn 4 thread, không phụ thuộc CPU limit.
  • UV_THREADPOOL_SIZE phải đặt trước khi process khởi động (biến môi trường), không đặt từ code.
  • Không truyền flag, heap limit của V8 đi theo container limit: 432 MB ở 768Mi, 560 MB ở 1Gi. Phần còn lại của RSS (Buffer, native code, stack của thread) nằm ngoài heap.
  • --max-old-space-size=576 ở 768Mi cho heap limit 624 MB (old space cộng young generation), nên Node dừng với JavaScript heap out of memory kèm GC trace trước khi kernel gửi SIGKILL im lặng.

Chọn số từ metric (ít nhất một tuần, gồm ngày bận nhất):

  • CPU request ≈ p90–p95 của rate(container_cpu_usage_seconds_total[5m]) mỗi Pod lúc cao điểm.
  • Memory request = limit ≈ max container_memory_working_set_bytes cộng 20–30% dư.
  • Theo dõi tỷ lệ throttling sau mỗi lần đổi; dưới 5% số period là điểm xuất phát hay dùng, không phải chuẩn.

HPA theo CPU:

  • desired = ceil(current × currentUtilization / target), trong đó utilization là usage chia cho request, lấy trung bình các Pod.
  • Đồng bộ mỗi 15 s, bỏ qua thay đổi trong tolerance 10%, chờ 5 phút trước khi scale down.
  • Container nào thiếu CPU request thì HPA không làm gì với metric đó.

Tranh luận về CPU limit:

  • Không CPU limit: container dùng CPU rảnh của node và chỉ được chia theo request khi tranh chấp. Không throttling, latency kém đoán trước hơn.
  • Có CPU limit: hành vi dễ đoán, nhưng burst phải trả giá bằng throttling.
  • Memory thì khác: không thể thu hồi memory bằng cách cho process chạy chậm lại, nên giữ limit.

Demo chạy 20 đợt, mỗi đợt bốn lệnh pbkdf2 song song, cách nhau 900 ms, và đọc cpu.stat của cgroup:

Terminal window
docker run --rm --cpus=1 -v "$PWD/examples/src/kubernetes-resources-limits:/demo" \
node:24-alpine node /demo/throttle-demo.ts
CPU limit Docker p50 p99 CPU trung bình Period bị throttle
--cpus=1 105–189 ms 221–299 ms 19–22% 31–39 trên 72–81
--cpus=1, UV_THREADPOOL_SIZE=1 78 ms 180 ms 12% 3 trên 66
--cpus=2 53 ms 171 ms 23% 12 trên 57
--cpus=4 51 ms 83 ms 23% 0 trên 53
không limit 55 ms 82 ms 23% —

Đo trên Docker Desktop (cgroup v2, 10 core); số thay đổi giữa các lần chạy. CPU trung bình chưa tới một phần tư core, vậy mà với --cpus=1 khoảng một nửa số period có hoạt động bị throttle.

CPU limit chặt (limit = request)

  • Hiệu năng dễ đoán; test trên staging khớp production.
  • Pod chạy loạn không lấy được CPU của Pod hàng xóm.
  • Có QoS Guaranteed; bắt buộc nếu muốn core riêng với static CPU manager policy.

Cái giá

  • Burst ngắn bị throttle dù node còn core rảnh.
  • Latency spike không hiện trên dashboard CPU trung bình.
  • Cần thêm replica để hấp thụ burst, tốn tiền hơn.
  1. Mọi container đặt request CPU và memory từ ít nhất một tuần metric.
  2. Memory limit bằng memory request; --max-old-space-size khoảng 75% con số đó.
  3. Nếu giữ CPU limit, cảnh báo khi tỷ lệ throttling trên 5% kéo dài 10 phút.
  4. UV_THREADPOOL_SIZE chọn theo CPU limit và lượng việc hash, gzip, fs.
  5. Target HPA chỉnh theo request, và mọi container trong Pod đều có CPU request.
  6. Load test với burst sát thực tế chạy trước khi đổi limit, đo p99 và throttling cùng lúc.
  7. Kiểm tra allocatable của node và tổng request mỗi node trước khi tăng request hoặc limit.
  8. Có cảnh báo cho restart do OOMKilled và cho Pod bị evict.

Bỏ memory limit để “hết OOMKilled” chỉ đẩy rủi ro sang node:

Memory không có limit

Hay gặp

resources:
requests: { cpu: 500m, memory: 256Mi } # usage thật 700Mi, không limit

Nên viết

resources:
requests: { cpu: "1", memory: 768Mi }
limits: { cpu: "2", memory: 768Mi } # memory limit = request

Vì sao

Usage vượt xa request khiến Pod này là ứng viên bị evict đầu tiên khi node thiếu memory, và lúc phình ra nó có thể đẩy Pod khác ra ngoài.

Cách hiểu sai Hệ quả Cần làm
QoS Guaranteed thì không bị throttle Burst vẫn chạm CPU quota Theo dõi tỷ lệ throttling cho mọi class
Pod bị throttle thì HPA sẽ scale Usage trung bình vẫn dưới target, HPA không làm gì Đặt request theo usage; cảnh báo theo throttling
Thêm replica là hết throttle Mỗi Pod vẫn burst trong quota của chính nó Nâng limit hoặc giảm burst song song
  1. Vì sao container cpu: 1 có thể bị throttle khi trung bình chỉ dùng 0,2 core?
  2. Pod bị OOMKilled trong khi node còn memory trống. Limit nào bị chạm, và Pod ra sao?
  3. Khi node thiếu memory, kubelet evict Pod nào trước, và vì sao Pod Guaranteed đi sau cùng?
  4. Request 200m, usage thật 600m: HPA thấy gì?