Lần đầu deploy app lên Kubernetes, nhiều người copy pod spec từ tutorial rồi đặt requests: 100m CPU, limits: 200m CPU, memory: 128Mi request và 256Mi limit. Chạy ổn vài ngày, rồi traffic tăng chút thì app chậm dần, P99 latency từ 200ms nhảy lên 2 giây. CPU util trung bình chỉ 15%, node còn rảnh, vậy mà app cứ chậm. Mất nửa ngày mới tìm ra: CPU throttling, container bị kernel pause vì vượt quota trong cửa sổ 100ms, dù node còn thừa CPU.

Tuần sau, một pod khác bị OOMKilled lúc 3 giờ sáng, memory limit set quá sát, Java heap + off-heap vượt nhẹ là bị SIGKILL ngay, không có log warning nào cả. Restart xong thì mất context đang xử lý.

Hai trường requestslimits trong pod spec nhìn giống nhau nhưng làm những việc rất khác nhau, và nhầm lẫn giữa chúng tạo ra phần lớn các vấn đề khó chịu trên Kubernetes. Bài này giải thích cách requests/limits hoạt động và cách áp dụng chúng đúng cách cho production, từ góc nhìn của dev deploy app, không phải cluster admin.


Requests và limits: hai con số, hai câu chuyện

Requests, “tôi cần ít nhất này”

requests nói với Kubernetes scheduler rằng container cần ít nhất bao nhiêu CPU và memory để chạy. Scheduler dùng con số này để quyết định pod có nhét vừa vào node nào không, nó cộng tổng requests của tất cả pod trên node, so với capacity, rồi quyết định.

Điểm quan trọng mà nhiều người ban đầu hiểu sai: requests không phải giới hạn runtime. Pod có thể dùng nhiều hơn requests nếu node còn rảnh. Nó chỉ là “lời hứa” với scheduler, “tôi sẽ cần ít nhất chừng này, hãy đảm bảo node có đủ”.

Nếu mọi pod trên node đều dùng đúng bằng requests, mọi thứ ổn. Nhưng thực tế thì pod burst lên rồi xuống liên tục, requests chỉ đảm bảo baseline.

Limits, “tuyệt đối không được vượt”

limits là giới hạn cứng do kernel cgroup thực thi, per-container. Đây là nơi kernel can thiệp trực tiếp:

Memory vượt limit thì process bị OOMKilled, SIGKILL, không có tín hiệu cảnh báo, không có graceful shutdown. Đang xử lý request giữa chừng cũng bị kill ngay.

CPU vượt limit thì bị throttle, kernel pause container cho đến period tiếp theo. Không kill, nhưng chậm đi, đôi khi chậm rất nhiều.

Sự khác biệt này cực kỳ quan trọng: CPU là compressible resource (thiếu thì chậm, không chết), memory là incompressible (thiếu thì chết ngay). Hiểu được điều này sẽ ảnh hưởng đến cách bạn set limits cho từng loại.

Đơn vị đo

CPU tính bằng “core”, 1 là 1 core full, 500m (millicore) là nửa core, 100m là 1/10 core. Nhưng “core” ở đây không phải tốc độ xung nhịp, nó là thời gian CPU trong một cửa sổ, thường 100ms. 500m nghĩa là container được dùng 50ms CPU time trong mỗi 100ms.

Memory tính bằng bytes, 512Mi (mebibyte = 2^20), 2Gi (gibibyte). Cẩn thận phân biệt MiM, Mi là binary (1,048,576 bytes), M là decimal (1,000,000 bytes), chênh khoảng 4.8%. Nên luôn dùng Mi/Gi để tránh nhầm.


CPU throttling: vì sao app chậm dù node rảnh

Đây là vấn đề tốn nhiều thời gian nhất để thực sự hiểu, và cũng là vấn đề phổ biến nhất khi team bắt đầu dùng Kubernetes nghiêm túc.

Cơ chế CFS

Linux Completely Fair Scheduler (CFS) quản lý CPU cho container qua hai tham số trên cgroup: cpu.cfs_period_us (cửa sổ thời gian, mặc định 100,000 microsecond = 100ms) và cpu.cfs_quota_us (hạn mức thời gian CPU trong cửa sổ đó).

Khi bạn set CPU limit = 500m, Kubernetes set quota = 50,000us trong period 100,000us. Container được dùng 50ms CPU time trong mỗi 100ms. Dùng hết quota trước khi hết period? Kernel đình chỉ container cho đến period tiếp theo, đây chính là throttling.

Vì sao multi-threaded app dễ bị throttle

Đây là phần mà nhiều dev không lường trước. Ứng dụng Go, Java, hay Node.js (worker threads) có thể chạy nhiều thread song song. Với limit 500m, quota là 50ms per 100ms period. Nhưng nếu 10 thread cùng chạy full CPU trong 5ms, tổng CPU time đã là 50ms, hết quota trong 5ms, bị pause 95ms còn lại.

Kết quả: P99 latency nhảy vọt lên gần 100ms (thời gian bị pause), trong khi CPU utilization trung bình nhìn rất thấp (vì phần lớn thời gian container đang bị đình chỉ, không compute). Đây là lý do “CPU util thấp mà app chậm”, dấu hiệu đặc trưng của throttling.

Hay gặp tình huống debug một Go service có P99 latency spike 500ms không giải thích được. CPU util trung bình 20%, memory ổn. Mất nửa ngày mới nhìn vào metric container_cpu_cfs_throttled_seconds_total, số khá cao. Bỏ CPU limit ra, P99 giảm về 50ms ngay lập tức. Bài học sâu sắc về CPU limit.

Có nên đặt CPU limit?

Đây là quan điểm có thể gây tranh cãi, nhưng dựa trên thực tế và được nhiều người ở Google, Datadog chia sẻ: thường không nên đặt CPU limit, hoặc đặt rất rộng (gấp 4-10 lần request).

Lý do: CPU là compressible resource, thiếu CPU thì app chạy chậm, không chết. Scheduler + request đã đảm bảo fairness giữa các pod trên cùng node. Thêm limit vào chỉ tạo throttling không cần thiết khi node vẫn còn CPU rảnh, bạn đang ngăn app dùng resource mà không ai cần.

Nhưng có ngoại lệ. Multi-tenant cluster nơi bạn phải chặn noisy neighbor, một pod không được phép chiếm hết CPU node. Workload cần dự đoán được latency (ví dụ batch processing có SLA). Hoặc tổ chức yêu cầu limit bắt buộc qua policy (nhiều enterprise có LimitRange bắt buộc).

Nếu phải đặt limit, đặt rộng: 4x request hoặc hơn. Mục đích là chặn trường hợp extreme, không phải micromanage CPU usage.

Phát hiện throttling

Metric Prometheus quan trọng nhất cho CPU throttling:

sum by (pod) (
  rate(container_cpu_cfs_throttled_seconds_total{pod=~"my-app-.*"}[5m])
)

Giá trị > 0 đáng kể nghĩa là app đang bị throttle. Nên có panel này trên dashboard Grafana cho mọi service, ngay cạnh panel CPU usage. Nhiều team có CPU usage panel mà không có throttling panel, miss hoàn toàn vấn đề.


Memory: OOMKilled và không có cảnh báo

Memory không “throttle”

Đây là khác biệt cốt lõi giữa CPU và memory mà bạn phải ghi nhớ: process không thể “chạy chậm để dùng ít RAM”. Khi container chạm memory limit, kernel cố gắng reclaim page cache trước. Nếu vẫn không đủ, OOM killer trong cgroup chọn process trong cgroup đó để kill, thường là process chính của container.

Container exit với reason OOMKilled, exit code 137 (= 128 + 9, tức SIGKILL). Không có warning log, không có graceful shutdown hook, không có chance để flush data. Đang giữa chừng ghi database cũng bị cắt ngang.

kubectl describe pod <pod>
# Last State: Terminated
#   Reason: OOMKilled
#   Exit Code: 137

Trên node, dmesg | grep -i oom sẽ show process nào bị kill và bao nhiêu memory cgroup đang dùng lúc bị kill. Thông tin này giúp xác nhận đây là cgroup OOM (per-container) chứ không phải node-level OOM.

Node-level OOM

Khác với cgroup OOM: khi toàn bộ node hết RAM (vì nhiều pod không có limit, cùng burst), node OOM killer chạy. Nó chọn victim dựa trên oom_score_adj mà kubelet đặt theo QoS class, BestEffort bị kill trước, Guaranteed gần như miễn nhiễm. Node-level OOM nguy hiểm hơn nhiều vì có thể kill pod quan trọng.

Ước lượng memory cho từng ngôn ngữ

Đây là phần mà nhiều team sai nhất, set memory limit dựa trên “cảm tính” thay vì hiểu runtime memory model.

Go, GC với GOGC=100 mặc định, memory usage khoảng 2x live heap. Từ Go 1.19, bạn có thể set GOMEMLIMIT để GC biết giới hạn, set thấp hơn container limit khoảng 10% để GC có room nhường chỗ cho non-heap allocation (goroutine stack, cgo).

JVM, container-aware từ JDK 10+ (tự đọc cgroup limit để set ergonomics). Nhưng vẫn cần chú ý -XX:MaxRAMPercentage=75 hoặc explicit -Xmx. Quan trọng: off-heap memory (direct buffer, code cache, metaspace) thường bị quên, limit nên cao hơn heap max đáng kể. Hay gặp JVM service set -Xmx=1g và limit 1Gi, off-heap thêm 200MB → OOMKilled.

Node.js, --max-old-space-size=<MB> giới hạn V8 heap, nhưng native module và Buffer nằm ngoài heap. Set limit cao hơn max-old-space-size khoảng 30-50%.

Python, không có GC cho off-heap, nhưng process fork (gunicorn workers) cộng dồn memory. Tính per-worker × num-workers + shared library. Mỗi gunicorn worker 200MB × 4 workers = 800MB minimum, chưa kể overhead.

Memory nên set limit

Khác với CPU, memory nên có limit. Lý do rất thực dụng: memory leak của một pod không có limit sẽ tràn sang ảnh hưởng toàn node, các pod khác bị evict hoặc killed. Memory limit đảm bảo leak bị contain trong cgroup, chỉ pod đó bị OOMKilled, không ảnh hưởng neighbor.

Giá trị hay dùng: limit = request × 1.5 đến 2. Quá sát request thì dễ OOM khi burst (Java GC spike, large request payload). Quá xa thì pod có thể dùng nhiều hơn scheduler tính, gây overcommit.

Nếu muốn pod ở QoS class Guaranteed (xem phần sau), set limit = request.


QoS class và eviction

Kubernetes gán mỗi pod một QoS class dựa trên requests/limits. QoS class quyết định pod nào bị evict trước khi node thiếu tài nguyên, rất quan trọng cho reliability.

Ba class

Guaranteed, mọi container trong pod có requests == limits cho cả CPU và memory. Đây là class ưu tiên cao nhất. Pod Guaranteed gần như không bao giờ bị evict trừ khi node thực sự critical. oom_score_adj = -997, gần miễn nhiễm với OOM killer.

Burstable, có ít nhất một request, nhưng request ≠ limit hoặc chỉ có một loại (ví dụ có memory request/limit nhưng không có CPU limit). Phần lớn pod production rơi vào class này. oom_score_adj tỷ lệ thuận với % memory request so với node.

BestEffort, không có request lẫn limit. Pod này bị evict đầu tiên khi node thiếu tài nguyên. oom_score_adj ≈ 1000, rất dễ bị kill. Gần như không bao giờ nên dùng cho production workload.

Eviction thứ tự

Khi node bị memory pressure (dưới ngưỡng evictionHard), kubelet evict pod theo thứ tự: BestEffort trước, rồi Burstable vượt quá requests, cuối cùng mới đến Guaranteed.

Nên design QoS class theo tầm quan trọng: API chính, payment service → Guaranteed (set request = limit). Worker xử lý queue, batch job → Burstable (chấp nhận bị ép khi cần). Dev/test workload → Burstable thấp, không cần Guaranteed.

Nếu workload critical mà đặt BestEffort (quên khai báo requests), node bận chút là nó bay đầu tiên, bug rất dễ mắc khi copy template cũ không có requests.


Đặt giá trị: dựa trên số liệu, không đoán

Đây là quy trình nên follow cho mọi service mới deploy lên Kubernetes. Đoán requests/limits gần như luôn sai, app thật chạy khác benchmark.

Bước 1: đo baseline

Chạy app ở load thực tế (hoặc load test nếu chưa có production traffic) vài ngày. Thu thập ba metric quan trọng:

container_memory_working_set_bytes, memory thực tế đang dùng (không tính page cache có thể reclaim). Lấy P95 và P99 qua PromQL.

container_cpu_usage_seconds_total, CPU usage, tính rate để ra CPU util trung bình và P95.

container_cpu_cfs_throttled_seconds_total, phải gần 0 nếu không đặt limit (hoặc limit đủ rộng).

Bước 2: tính toán

Công thức khởi đầu: CPU request ≈ P95 CPU usage × 1.2 (buffer 20%). CPU limit: bỏ, hoặc 4x request nếu bắt buộc. Memory request ≈ P95 working set × 1.2. Memory limit ≈ request × 1.5 (hoặc bằng request nếu muốn Guaranteed).

Đây chỉ là điểm bắt đầu, review lại sau 1-2 tuần với data thực tế, điều chỉnh.

Bước 3: autoscaler

Vertical Pod Autoscaler (VPA), ở mode Off hoặc Initial, VPA cho recommendation requests/limits dựa trên usage lịch sử. Nên dùng nó như input để review, không để auto-apply trên production critical vì nó cần restart pod để áp dụng.

Horizontal Pod Autoscaler (HPA), scale số replica theo metric. Quan trọng: HPA dùng requests làm baseline. targetCPUUtilizationPercentage: 70 nghĩa là scale lên khi CPU usage vượt 70% của requests. Requests sai → HPA scale sai timing, quá sớm hoặc quá muộn.


Trường hợp đặc biệt

Init containers

Init containers chạy tuần tự trước main container, xong rồi thoát. Requests/limits của init container tính riêng, nhưng trong thời gian scheduling, Kubernetes lấy max giữa max init container request và tổng main container request. Nếu init container cần nhiều resource (ví dụ download model ML 2GB), set high request cho init nhưng main container vẫn nhẹ.

Sidecar containers (K8s 1.29+)

Sidecar chạy suốt vòng đời pod, cộng dồn vào tổng resource request/limit. Envoy sidecar 100m CPU + 128Mi memory × 200 pod = 20 core + 25Gi memory chỉ cho sidecar. Đây là chi phí “ẩn” của service mesh mà nhiều team quên tính khi capacity planning.

Burstable CPU trên node nhiều core

Node 16 core, pod request 500m, không có limit. Pod có thể burst lên dùng tới 16 core nếu node rảnh, tốt cho performance burst. Nhưng khi nhiều pod cùng burst, neighbor bị ép. Và metric CPU util của pod có thể show > 1000% (vì dùng hơn 10 core), gây nhầm lẫn khi đọc dashboard.

Quy tắc capacity: tổng requests trên node nên ≤ 80% capacity để còn buffer cho burst và system overhead (kubelet, kube-proxy, OS).

LimitRange và ResourceQuota

Ở namespace level, LimitRange đặt default request/limit cho pod không khai báo, và enforce min/max. ResourceQuota đặt trần tổng request/limit trong namespace. Rất hữu ích cho cluster shared, tránh team quên khai báo (pod thành BestEffort) hoặc vô tình chiếm toàn bộ cluster.


Debug thực tế

Pod bị OOMKilled

Quy trình debug: đầu tiên kubectl describe pod xác nhận reason OOMKilled, exit code 137. Tiếp theo check dmesg hoặc journalctl -k trên node để xem process nào bị killer chọn và memory usage lúc bị kill. Sau đó xem metric memory working set trong khoảng thời gian trước khi bị kill, memory tăng dần (leak?) hay spike đột ngột (large request?).

Nếu ngôn ngữ có heap dump (Go pprof, JVM -XX:+HeapDumpOnOutOfMemoryError), enable để catch lần tới. Và câu hỏi quan trọng nhất: tăng limit hay sửa leak? Tăng limit cho memory leak chỉ là trì hoãn, pod sẽ bị kill lại, chỉ chậm hơn.

App chậm nhưng CPU util thấp

Dấu hiệu kinh điển của throttling. Kiểm tra: container_cpu_cfs_throttled_seconds_total tăng? Nhiều thread trong state R+ chờ CPU nhưng cgroup quota hết? top trong pod thấy %CPU bị cap?

Giải pháp: bỏ CPU limit hoặc tăng gấp nhiều lần, đo lại latency. Nếu P99 cải thiện đáng kể → throttling là nguyên nhân.

Pod pending lâu

kubectl describe pod thường ghi “0/10 nodes available: Insufficient cpu” hoặc “Insufficient memory”. Ba khả năng: node thực sự đầy → scale node hoặc giảm requests cho pod khác. Request quá lớn so với bất kỳ node nào → không bao giờ fit, cần chia nhỏ workload hoặc dùng node lớn hơn. Node selector, taint, affinity chặn → kiểm tra constraints.


Tóm tắt

requests cho scheduler và HPA biết pod cần bao nhiêu. limits cho kernel cgroup biết không được vượt bao nhiêu. Hai con số phục vụ hai mục đích khác nhau, nhầm lẫn giữa chúng là nguồn gốc của hầu hết vấn đề resource trên Kubernetes.

CPU limit thường gây throttling không cần thiết, cân nhắc bỏ hoặc đặt rất rộng. App multi-threaded burst CPU trong vài millisecond là đủ bị throttle gần 100ms. Dấu hiệu: P99 cao mà CPU util thấp. Memory limit thì nên có, vượt limit là OOMKilled ngay, không warning. Ước lượng memory cần hiểu runtime model của ngôn ngữ bạn dùng (heap + off-heap + fork workers).

QoS class quyết định ai bị evict trước khi node thiếu tài nguyên, Guaranteed cho service quan trọng, Burstable cho phần còn lại, BestEffort gần như không bao giờ dùng cho production.

Đặt giá trị dựa trên metric thật (working set bytes, CPU rate, throttle seconds), không đoán. Review mỗi quý khi traffic pattern thay đổi. Requests sai ảnh hưởng scheduling, HPA, capacity planning, tất cả downstream đều sai theo.

Làm đúng requests/limits là đòn bẩy đơn giản mà tác động lớn: cluster tận dụng tốt hơn, pod ít bị killed bí ẩn, latency ổn định. Và khi sự cố xảy ra, biết nhìn vào đâu, throttle metric, memory working set, describe pod, giúp debug nhanh hơn nhiều so với đoán mò.


Tham khảo