Skip to content

Resource Model, QoS & Node-Pressure Eviction

Vì sao đây là chương quan trọng nhất với độ ổn định production

Nếu chỉ được đọc một file trong chương này, hãy đọc file này. Mọi quyết định của scheduler — Filter NodeResourcesFit, Score LeastAllocated/MostAllocated, QoS, eviction, preemption — đều bắt nguồn từ hai con số bạn khai báo: requestslimits. Khai báo sai hai con số này tạo ra một chuỗi hệ quả: scheduler đặt Pod sai chỗ, container bị throttle âm thầm phá latency, Pod bị OOMKill lặp lại, hoặc bị evict hàng loạt khi node căng — tất cả nhìn như "sự cố hạ tầng" trong khi nguyên nhân gốc là cấu hình tài nguyên.

Điều khiến chủ đề này nguy hiểm là các triệu chứng đều âm thầm và lệch pha với nguyên nhân. CPU throttling không gây lỗi rõ ràng — nó chỉ làm p99 latency tăng. OOMKill nhìn như app crash. Eviction nhìn như node hỏng. Phần lớn engineer mất nhiều giờ debug ở tầng app trước khi nhận ra gốc rễ nằm ở resources. Mục tiêu của file này là cho bạn mental model chính xác để nhìn triệu chứng và truy ngược về requests/limits.

Internal model: requests vs limits

Hai trường này phục vụ hai mục đích hoàn toàn khác nhau, và nhầm lẫn chúng là sai lầm nền tảng:

  • requests: lượng tài nguyên Pod được đảm bảo. Scheduler chỉ dùng requests để quyết định đặt Pod (Filter NodeResourcesFit). Một Pod với requests.cpu: 500m chỉ "đặt chỗ" 500m trên node, kể cả khi limits.cpu là 2000m (Resource Management).
  • limits: trần tài nguyên container được phép dùng. Scheduler không nhìn limits. limits được kubelet/kernel thực thi lúc runtime.

Mệnh đề cốt lõi phải khắc cốt: scheduler đặt Pod dựa trên requests, kernel thực thi dựa trên limits. Hai cơ chế độc lập. Một node có thể "đầy" theo scheduler (tổng requests chạm allocatable) trong khi CPU thực rảnh 90% — vì các Pod khai requests cao hơn mức dùng thật. Đó là nghịch lý "Pending nhưng nhìn rảnh" ở file 2, và lời giải luôn là chỉnh requests về sát thực tế, không phải thêm node.

CPU: tài nguyên nén được (compressible)

CPU là tài nguyên compressible — có thể bị bóp lại mà không giết process. Cơ chế thực thi limits.cpuCPU throttling qua CFS quota của cgroups Linux. Tài liệu nói rõ: "cpu limit được thực thi bằng CPU throttling. Khi container tiến gần limit, kernel hạn chế truy cập CPU... cpu limit là giới hạn cứng kernel thực thi. Container không thể dùng nhiều CPU hơn limit" (Resource Management).

CFS quota: cơ chế và cạm bẫy latency

CFS (Completely Fair Scheduler) chia thời gian thành các chu kỳ (period) mặc định 100ms. Một container với limits.cpu: 500m được cấp 50ms CPU-time mỗi chu kỳ 100ms (500m = 0.5 core = 50% của một chu kỳ). Nếu container dùng hết 50ms quota trước khi hết 100ms, nó bị throttleđứng im cho đến đầu chu kỳ tiếp theo.

Đây là nguồn gốc của một class lỗi latency cực kỳ phổ biến và khó chẩn đoán. Xét một service xử lý request theo burst: phần lớn thời gian dùng ít CPU, nhưng khi có request đến cần xử lý nhanh trong vài ms với nhiều core. Nếu limits.cpu: 1, ứng dụng đa luồng có thể "đốt" quota 100ms-period chỉ trong ~25ms thực (nếu chạy 4 luồng song song), rồi bị throttle 75ms còn lại — request đó đột ngột chậm hơn 75ms dù CPU node hoàn toàn rảnh. Đo container_cpu_cfs_throttled_periods_total sẽ thấy throttling cao trong khi CPU usage trung bình thấp — dấu hiệu kinh điển.

Hệ quả thiết kế:

  • Đặt limits.cpu quá thấp gây throttling phá latency, kể cả khi node rảnh CPU.
  • Với workload nhạy latency, cân nhắc không đặt limits.cpu (chỉ đặt requests) — để container dùng CPU rảnh của node khi cần, chỉ bị "ép" về requests khi có tranh chấp. Đây là khuyến nghị gây tranh cãi nhưng có cơ sở: CPU là compressible, nên bỏ limit không gây nguy hiểm như bỏ limit memory; tranh chấp được giải quyết theo tỷ lệ requests.
  • Trên GKE, đa số trường hợp tốt nhất là đặt requests.cpu đúng theo đo đạc và để limits.cpu rộng rãi hoặc bỏ trống cho service latency-sensitive.

Memory: tài nguyên không nén được (incompressible)

Memory là incompressible — không thể "bóp lại". Một process đã chiếm RAM thì không thể bị "throttle bộ nhớ"; cách duy nhất để thu hồi là giết nó. Vì vậy limits.memory được thực thi bằng OOM kill của kernel: "memory limit được thực thi bởi kernel qua OOM kill. Khi container dùng quá memory limit, kernel có thể terminate nó" (Resource Management).

OOM kill: cơ chế và hành vi restart

Khi container vượt limits.memory, kernel OOM killer giết process trong cgroup đó. Pod được đánh dấu OOMKilled và restart theo restartPolicy (mặc định Always cho Deployment) — thường kèm backoff tăng dần (CrashLoopBackOff nếu lặp lại).

Khác biệt then chốt với CPU:

  • CPU vượt limit → throttle (chậm, không chết).
  • Memory vượt limit → OOMKill (chết, restart).

Vì vậy limits.memory luôn phải đặt cẩn thận và sát thực tế cộng headroom. Đặt quá thấp → OOMKill lặp lại; quá cao → kém hiệu quả đóng gói và mất khả năng phát hiện rò rỉ bộ nhớ sớm. Khác CPU, không nên bỏ trống limits.memory trừ khi có lý do rõ ràng, vì memory không có cơ chế thu hồi mềm — một Pod rò rỉ bộ nhớ không limit có thể nuốt toàn bộ RAM node, gây node-pressure eviction lan rộng (phần dưới).

QoS classes: phân loại tự động quyết định số phận khi node căng

Kubernetes tự gán mỗi Pod một trong ba QoS class dựa trên cách khai báo requests/limits (Resource Management). Bạn không khai báo QoS trực tiếp — nó là hệ quả của requests/limits:

QoS classĐiều kiệnSố phận khi node căng
GuaranteedMọi container có requests == limits cho cả CPU memoryĐuổi cuối cùng
BurstableCó ít nhất một request/limit nhưng không thỏa GuaranteedĐuổi giữa
BestEffortKhông request và không limit nàoĐuổi đầu tiên

QoS class quyết định hai thứ: thứ tự bị OOM khi node hết RAM (kernel oom_score_adj) và thứ tự bị node-pressure eviction (phần dưới). Một Pod BestEffort (không khai báo gì) là ứng viên bị giết đầu tiên ở cả hai cơ chế — đó là lý do không bao giờ chạy workload quan trọng dưới dạng BestEffort.

Guaranteed (requests == limits) cho độ ổn định cao nhất nhưng kém linh hoạt nhất (không tận dụng được tài nguyên rảnh) và có thể lãng phí nếu requests đặt cao. Phần lớn workload production tốt nhất là Burstable với requests đặt đúng — đảm bảo lượng tối thiểu, cho phép burst, và không nằm đầu danh sách evict.

Node-Pressure Eviction: khi kubelet tự dọn node

Đây là cơ chế khác với preemption (file 7) và khác với taint-based eviction (file 5). Node-pressure eviction do kubelet thực hiện khi node sắp cạn tài nguyên cục bộ, để cứu node khỏi sụp đổ (Node-pressure Eviction).

Eviction signals

Kubelet theo dõi các tín hiệu (Node-pressure Eviction):

SignalÝ nghĩa
memory.availableRAM còn lại của node
nodefs.availableDung lượng filesystem chính (chứa /var/lib/kubelet, log, ephemeral storage)
nodefs.inodesFreeinode còn lại của nodefs
imagefs.availableDung lượng filesystem chứa image
imagefs.inodesFreeinode của imagefs
pid.availablePID còn lại

Soft vs hard threshold

Khác biệt then chốt giữa hai loại ngưỡng (Node-pressure Eviction):

  • Hard threshold: vượt là đuổi ngay, grace period 0s. Các giá trị mặc định điển hình: memory.available<100Mi, nodefs.available<10%, imagefs.available<15%. Không thương lượng — kubelet giết Pod tức thì để cứu node.
  • Soft threshold: cho một grace period (eviction-soft-grace-period) trước khi đuổi, và Pod được dùng tới eviction-max-pod-grace-period để tắt nhẹ nhàng. Soft threshold cảnh báo sớm, cho cơ hội phản ứng.

Ngoài ra:

  • eviction-minimum-reclaim: lượng tối thiểu kubelet phải thu hồi mỗi lần đuổi, tránh đuổi nhỏ giọt liên tục.
  • eviction-pressure-transition-period: thời gian chờ trước khi node chuyển trạng thái pressure, chống "dao động" (oscillation) khi tài nguyên quanh ngưỡng.

Trước khi đuổi Pod, kubelet thử thu hồi tài nguyên cấp node (ví dụ xóa image không dùng) — self-healing trước khi hy sinh Pod.

Thứ tự chọn Pod để đuổi

Kubelet chọn Pod đuổi theo (Node-pressure Eviction):

  1. QoS class: BestEffort trước, rồi Burstable, Guaranteed cuối.
  2. Mức dùng vượt requests: Pod dùng vượt xa requests nhất bị đuổi trước. Đây là lý do nữa để đặt requests đúng — Pod khai requests thấp nhưng dùng nhiều sẽ nằm đầu danh sách evict.
  3. Pod priority: priority cao bị đuổi sau (file 7).

Điểm sống còn: node-pressure eviction KHÔNG tôn trọng PodDisruptionBudget

Đây là khác biệt quan trọng nhất so với API-initiated eviction, và là cái bẫy của nhiều đội. Node-pressure eviction là cơ chế tự động và không tôn trọng PodDisruptionBudget, cũng không dùng terminationGracePeriodSeconds của Pod (dùng eviction-max-pod-grace-period cho soft, 0s cho hard) (Node-pressure Eviction). Trong khi đó, API-initiated eviction (ví dụ kubectl drain lúc upgrade) tôn trọng PDB.

Hệ quả: PDB không bảo vệ bạn khỏi node-pressure eviction. Một node bị memory pressure có thể đuổi đủ Pod để vi phạm PDB, gây mất availability đột ngột. Cách phòng vệ thực sự không phải PDB mà là đặt requests/limits memory đúng để node không bao giờ rơi vào pressure — phòng bệnh, không chữa bệnh.

Production architecture patterns

Pattern: requests theo đo đạc, không theo đoán

Quy trình trưởng thành: chạy workload với requests rộng rãi ban đầu, đo P50/P95/P99 mức dùng CPU và memory thực qua nhiều ngày (gồm cả đỉnh tải), rồi đặt:

  • requests.cpu ≈ P50–P75 mức dùng (đảm bảo đủ cho phần lớn thời gian, để burst lên khi cần).
  • requests.memory ≈ P95–P99 (vì memory incompressible, thiếu là OOM).
  • limits.memory ≈ requests.memory × hệ số headroom (1.2–1.5).
  • limits.cpu: rộng hoặc bỏ trống cho service latency-sensitive.

Đây là nơi VPA (Vertical Pod Autoscaler, Chương 9) hỗ trợ: chế độ Off/recommendation giúp đề xuất requests dựa trên lịch sử thực mà không tự áp.

Pattern: phân tầng QoS theo tầm quan trọng

  • Workload critical, stateful: Guaranteed (requests == limits) hoặc Burstable với requests sát P99 — không bao giờ BestEffort.
  • Batch/best-effort thật sự: BestEffort hoặc Burstable requests thấp + PriorityClass thấp, chấp nhận bị đuổi đầu tiên khi node căng.

Real-world scenarios

Tình huống: p99 latency tăng bí ẩn

Một service Go thấy p99 nhảy lên dù CPU node usage chỉ ~30%. Debug app không ra. Cuối cùng phát hiện limits.cpu: 500m trên app đa luồng; container_cpu_cfs_throttled_periods_total cho thấy throttling 40% các period. Gỡ limit CPU (giữ requests), p99 trở lại bình thường. Bài học: throttling phá latency độc lập với usage trung bình của node.

Tình huống: OOMKill hàng loạt lúc tải đỉnh

Một service Java đặt limits.memory: 1Gi dựa trên quan sát lúc tải thấp. Lúc đỉnh tải, heap + off-heap vượt 1Gi, OOMKill lặp lại, CrashLoopBackOff lan ra do request dồn vào replica còn sống. Nguyên nhân: limit memory đặt theo tải thấp, không tính đỉnh. Sửa: đo memory lúc đỉnh, đặt requests/limits theo P99 đỉnh + headroom, kèm topology spread để đỉnh không dồn một chỗ.

Tình huống: node-pressure evict bất chấp PDB

Một StatefulSet có PDB minAvailable: 2/3. Một Pod rò rỉ memory đẩy node vào memory pressure; kubelet đuổi cả Pod StatefulSet trên node đó, vi phạm PDB, mất quorum tạm thời. Đội tưởng PDB bảo vệ — nhưng node-pressure eviction không tôn trọng PDB. Sửa: đặt limits.memory cho mọi Pod (chặn rò rỉ lan), giám sát memory.available, và dùng topology spread để StatefulSet không tập trung trên node dễ pressure.

Cơ chế sâu hơn: oom_score_adj và thứ tự OOM trong kernel

Khi node thật sự cạn RAM (không kịp node-pressure eviction), kernel OOM killer ra tay — và nó chọn nạn nhân theo oom_score_adj, một giá trị mà kubelet đặt theo QoS class. Đây là tầng bảo vệ thấp hơn cả eviction, chạy ở kernel:

  • Guaranteed: oom_score_adj thấp nhất (−997) → kernel tránh giết.
  • Burstable: giá trị trung gian, tỷ lệ nghịch với requests (request càng cao, càng ít bị giết).
  • BestEffort: oom_score_adj = 1000 → ứng viên đầu tiên kernel giết.

Hệ quả: ngay cả khi eviction "lỡ tay", kernel vẫn ưu tiên giết Pod BestEffort/Burstable-request-thấp trước Guaranteed. Nhưng đừng nhầm: OOM kill ở kernel giết container vượt limit của chính nó trước tiên (cgroup OOM), còn oom_score_adj chỉ quyết định khi node-level OOM (toàn node hết RAM). Phân biệt hai loại OOM này quan trọng: cgroup OOM là lỗi limit của riêng Pod đó (sửa limit Pod); node-level OOM là node bị overcommit (sửa tổng requests/limits cluster-wide).

Cơ chế sâu hơn: overcommit và rủi ro node-level OOM

Vì scheduler chỉ nhìn requests còn kernel thực thi limits, một node có thể bị overcommit memory: tổng limits.memory của các Pod vượt RAM node, miễn là tổng requests.memory còn trong allocatable. Bình thường ổn (Pod hiếm khi cùng lúc chạm limit), nhưng khi nhiều Pod cùng tăng dùng memory về phía limit, node có thể hết RAM trước khi bất kỳ Pod nào vượt limit riêng — gây node-level OOM giết Pod ngẫu nhiên (theo oom_score_adj), kể cả Pod "vô tội" chưa vượt limit của mình.

Đây là rủi ro ẩn của việc đặt limits.memory rộng rãi và để requests thấp. Phòng vệ: với memory, giữ khoảng cách requests↔limits hẹp (memory incompressible, overcommit nguy hiểm hơn CPU). Với CPU thì ngược lại — overcommit CPU an toàn vì chỉ gây throttle, nên requests↔limits có thể rộng. Đây là một bất đối xứng cốt lõi: overcommit CPU thoải mái, overcommit memory dè dặt.

Cơ chế sâu hơn: ephemeral storage cũng schedule và evict được

ephemeral-storage (đĩa cục bộ cho log container, emptyDir, lớp ghi của container) là tài nguyên thứ ba mà scheduler xét (qua NodeResourcesFit) và kubelet evict theo (signal nodefs.available/nodefs.inodesFree). Nhiều đội quên khai requests.ephemeral-storage, dẫn tới hai sự cố: Pod ghi log/temp đầy đĩa node gây DiskPressure → evict hàng loạt; hoặc scheduler đặt quá nhiều Pod lên node vì không tính ephemeral storage. Với workload ghi đĩa nhiều (log verbose, xử lý file tạm), khai requests/limits ephemeral-storage là bắt buộc — và lưu ý GKE đếm cả image vào imagefs, nên image lớn cũng ăn vào ngưỡng eviction.

Real-world scenario bổ sung: CrashLoop lan rộng do thiếu headroom memory

Một service 5 replica, mỗi replica limits.memory: 512Mi sát đỉnh dùng thực. Khi tải tăng 20%, một replica OOMKill, request dồn sang 4 replica còn lại, đẩy chúng vượt limit → OOMKill dây chuyền → toàn service CrashLoop dù tải chỉ tăng nhẹ. Đây là "metastable failure": hệ thống ổn định cho tới điểm gãy, rồi sụp dây chuyền. Nguyên nhân gốc: không có headroom memory để hấp thụ việc dồn tải khi mất replica. Sửa: tăng limits.memory thêm headroom (1.3–1.5× đỉnh), tăng số replica để mất một replica không dồn quá nặng, và topology spread để mất node không mất nhiều replica cùng lúc. Bài học: capacity phải tính cho cả trạng thái suy giảm (mất một phần replica), không chỉ trạng thái bình thường.

Khung quyết định: đặt requests/limits theo loại workload

Loại workloadrequests.cpulimits.cpurequests.memorylimits.memoryQoS mục tiêu
Service latency-sensitiveP50–P75rộng/bỏ trốngP95–P99~1.3× requestsBurstable
Service ổn định, dễ dự đoán= limitsP95= limitsP99Guaranteed
Batch/jobP50P95P90~1.2× requestsBurstable
Best-effort thật sự (cache có thể mất)thấp/0thấp/0BestEffort

Nguyên tắc: CPU rộng tay (compressible), memory chặt tay (incompressible). Đo trước, đặt sau — không đoán.

Common mistakes / anti-patterns

  • Bỏ trống requests → Pod thành BestEffort, nằm đầu danh sách OOM/evict; scheduler đặt mù.
  • limits.cpu thấp cho workload latency-sensitive → throttling phá p99 dù node rảnh.
  • Bỏ trống limits.memory → một Pod rò rỉ nuốt RAM node, gây pressure eviction lan rộng.
  • Đặt requests == limits cho mọi thứ (Guaranteed) để "an toàn" → lãng phí lớn, mất khả năng burst; chỉ dùng khi thực sự cần độ ổn định tuyệt đối.
  • Tin PDB chặn được node-pressure eviction → không; phòng bằng requests/limits đúng để tránh pressure.
  • Đặt limits theo tải thấp → OOMKill khi tải đỉnh; luôn đo ở đỉnh.

GCP-native implementation guidance

Phát hiện CPU throttling:

bash
# Trong Cloud Monitoring / PromQL:
rate(container_cpu_cfs_throttled_periods_total[5m])
  / rate(container_cpu_cfs_periods_total[5m])
# > 0.2 (20% period bị throttle) là dấu hiệu limit CPU quá thấp

Phát hiện OOMKill và đọc QoS class:

bash
kubectl get pod <pod> -o jsonpath='{.status.qosClass}{"\n"}'
kubectl get pod <pod> -o jsonpath='{range .status.containerStatuses[*]}{.lastState.terminated.reason}{"\n"}{end}'
# "OOMKilled" → vượt limits.memory

Xem allocatable vs node pressure:

bash
kubectl describe node <node> | grep -A6 "Allocated resources"
kubectl describe node <node> | grep -iE "MemoryPressure|DiskPressure|PIDPressure"

Tổng kết mental model

  • Scheduler dùng requests; kernel thực thi limits. Hai cơ chế độc lập.
  • CPU compressible → vượt limit thì throttle (chậm). Limit CPU thấp phá latency dù node rảnh.
  • Memory incompressible → vượt limit thì OOMKill (chết). Không nên bỏ trống limits.memory.
  • QoS (Guaranteed/Burstable/BestEffort) là hệ quả của requests/limits, quyết định thứ tự bị OOM/evict. Đừng để workload quan trọng là BestEffort.
  • Node-pressure eviction do kubelet, theo QoS + mức vượt requests + priority, và không tôn trọng PDB. Phòng bằng requests/limits đúng.

References