Hình dung một tài xế bị luật giới hạn "mỗi 100 giây chỉ được chạy tối đa 100 giây-động-cơ". Nghe thừa thãi — cho tới khi anh ta cần tăng tốc gấp: đạp hết ga trong 8 giây liền là hết hạn ngạch của cả trăm giây, và chiếc xe đứng khựng lại giữa đường chờ đồng hồ reset, dù tính trung bình cả phút động cơ mới chạy có 60% công suất. Nhìn vào đồng hồ công suất trung bình, bạn thấy xe nhàn nhã. Nhìn vào người ngồi trong xe, họ vừa bị giật cục ba lần. Đây chính xác là chuyện xảy ra với một container có limits CPU, và "ứng dụng chậm mà CPU chỉ 60%" là báo cáo sự cố khó chịu nhất sinh ra từ nó. Bài này tôi dựng lại đúng cảnh đó và chỉ thẳng vào thủ phạm.
(Các con số là độ trễ và đếm throttle đo trong container, không phải perf counter phần cứng.)
Tải theo đợt: làm 15 ms rồi nghỉ 100 ms
Quota 1 nhân. Mỗi vòng, tất cả luồng cùng làm một mẩu việc ~15 ms rồi cả nhóm nghỉ 100 ms — mô phỏng một dịch vụ nhận yêu cầu theo nhịp.
| Số luồng | CPU trung bình | p50 | p99 | Bị ghim |
|---|---|---|---|---|
| 1 | 37,5% | 60,81 ms | 66,95 ms | 0 |
| 2 | 62,6% | 97,59 ms | 113,43 ms | 60 |
| 4 | 83,6% | 219,36 ms | 308,98 ms | 160 |
| 8 | 94,2% | 703,55 ms | 987,06 ms | 461 |
Dòng thứ hai là dòng tôi muốn bạn nhìn kỹ. CPU trung bình 62,6% — mọi bảng điều khiển sẽ hiện màu xanh và ghi "còn 37% dư địa" — trong khi tiến trình đã bị ghim 60 lần và p99 là 113 ms. Con số trung bình và con số người dùng cảm nhận đang kể hai câu chuyện trái ngược nhau.
Vì sao CPU trung bình không thấy được
Chìa khoá nằm ở một chi tiết: quota được tính theo từng chu kỳ 100 ms, không tính theo phút. Một luồng thì mỗi chu kỳ làm 15 ms, còn dư 85 ms quota, không bao giờ chạm trần, ghim 0 lần. Nhưng tám luồng cùng lúc thì ngay chu kỳ đầu đã cần 8 × 15 = 120 ms thời gian CPU, mà quota chỉ cho 100 ms — nên đến mili giây thứ 100, cả tám luồng bị treo cho tới chu kỳ sau. Trung bình một phút vẫn chỉ 94% vì phần nghỉ 100 ms kéo con số xuống, trong khi độ trễ người dùng cảm nhận đã tăng gấp mười lăm lần. Nói cách khác: mức sử dụng CPU trung bình không phát hiện được việc bị ghim — hai đại lượng này đo hai thứ khác nhau, và chỉ một trong hai liên quan tới độ trễ.
Với tải liên tục cũng vậy, chỉ khác cách nhìn
Cùng quota 1 nhân, 2.000 "yêu cầu" mỗi cái ~2 ms CPU, chạy liên tục:
| Số luồng | CPU dùng | p50 | p95 | p99 | Bị ghim |
|---|---|---|---|---|---|
| 1 | 99,7% | 7,27 | 8,24 | 9,05 ms | 26 |
| 2 | 100,1% | 7,96 | 58,92 | 61,20 ms | 157 |
| 4 | 100,5% | 8,91 | 86,45 | 88,79 ms | 172 |
| 8 | 100,3% | 94,44 | 98,50 | 100,48 ms | 163 |
| 16 | 100,4% | 193,26 | 298,70 | 395,36 ms | 205 |
CPU y hệt nhau ở cả năm dòng — quanh 100% quota. p99 chênh 44 lần. Và hãy chú ý các giá trị p95/p99: 58,92 / 86,45 / 98,50 / 100,48 ms — chúng bám quanh bội số của 100 ms, đúng bằng chu kỳ quota. Đó là chữ ký không thể nhầm của việc bị ghim: những yêu cầu chậm không chậm ngẫu nhiên, chúng chậm đúng một chu kỳ. Nếu biểu đồ độ trễ của bạn có một cụm dồn ở 100 ms, 200 ms hoặc 300 ms trong khi phần lớn yêu cầu xong trong vài mili giây, hãy đi đọc cpu.stat trước khi làm bất cứ điều gì khác.
Chỉ số cần cảnh báo nằm ngay trong container:
grep -E 'nr_periods|nr_throttled|throttled_usec' /sys/fs/cgroup/cpu.stat
| Trường | Nghĩa |
|---|---|
nr_periods |
Số chu kỳ 100 ms đã trôi qua |
nr_throttled |
Số chu kỳ trong đó có ai đó bị treo |
throttled_usec |
Tổng thời gian bị treo |
Tỷ lệ đáng theo dõi là nr_throttled / nr_periods. Trên 5% là đã ảnh hưởng tới đuôi độ trễ; trên 20% là dịch vụ đang bị bóp nghẹt. Với Prometheus và cAdvisor thì cùng tỷ lệ đó là:
rate(container_cpu_cfs_throttled_periods_total[5m])
/ rate(container_cpu_cfs_periods_total[5m])
Chỉ số này có sẵn ở mọi cụm Kubernetes và gần như không ai đặt cảnh báo lên nó.
Ba cách chữa
Cách đúng và rẻ nhất là giảm số luồng cho khớp quota — bảng đầu tiên cho thấy vì sao: một luồng với quota 1 nhân không bị ghim lần nào (xem phần 34 cho danh sách biến môi trường của từng runtime). Nếu công việc thật sự cần nhiều nhân thì tăng quota chứ đừng chỉ tăng số luồng: cấp 1 nhân rồi chạy 8 luồng là cấu hình xấu nhất trong mọi bảng ở trên. Cách thứ ba, gây tranh cãi hơn, là bỏ hẳn giới hạn CPU và chỉ giữ requests — số liệu ở đây ủng hộ nó: requests đảm bảo phần tối thiểu qua trọng số CFS (phần 6), còn limits chỉ thêm cơ chế ghim, nên bỏ limits thì pod dùng được nhân rảnh và không bao giờ bị treo. Đổi lại là mất khả năng dự đoán: một pod bùng phát có thể ăn hết nhân rảnh của node, và với cụm chạy tải hỗn hợp thì đó là rủi ro thật. Nếu vẫn giữ limits, hãy đặt nó rộng rãi — gấp hai tới bốn lần requests — chứ đừng đặt sát mức trung bình, vì quota sát mức trung bình đảm bảo mọi đợt bùng phát đều bị ghim.
Mẫu số chung
Cái bẫy "một trần cứng tính theo cửa sổ ngắn khiến tải bùng phát bị chặn dù trung bình còn rảnh" không riêng gì CFS của Linux — nó là hình dạng chung của mọi hạn ngạch theo cửa sổ.
Trong thế giới Java, đây đúng là lý do một JVM chạy trong container hay bị giật GC bí ẩn: JVM đọc số nhân để chỉnh cỡ pool GC, cỡ ForkJoinPool.commonPool(), cỡ thread pool mặc định — nếu nó thấy 8 nhân của host nhưng quota chỉ 1, nó dựng 8 luồng GC rồi tất cả cùng bị throttle đúng chu kỳ, tạo ra pause dài. UseContainerSupport (bật mặc định từ JDK 10+) đọc quota cgroup chính là để tránh cảnh này — cùng bài học "khớp số luồng với quota" ở tầng runtime.
Trong Go, GOMAXPROCS mặc định bằng số nhân host chứ không đọc quota cgroup, nên một service Go cấp 1 nhân nhưng chạy 8 P sẽ dẫm đúng vào dòng tệ nhất của bảng; đó là lý do thư viện automaxprocs của Uber tồn tại — nó đặt GOMAXPROCS khớp với cpu.max, biến "8 luồng, quota 1" thành "1 luồng, quota 1".
Còn ở cơ sở dữ liệu, cùng chữ ký "đuôi dồn ở bội số của một chu kỳ" hiện ra khác chỗ: một connection pool đặt quá lớn so với số nhân DB thực có khiến các truy vấn xếp hàng chờ khe CPU, và độ trễ đuôi nhảy bậc theo lượt lập lịch — đo trung bình thì CPU "còn rảnh", đo p99 thì thấy nghẹt. Câu trả lời giống hệt: khớp số worker đồng thời với tài nguyên thật, đừng để nhiều hơn.
Mẫu số chung: một hạn ngạch cứng tính trên cửa sổ ngắn biến "trung bình còn rảnh" thành "đuôi bị chặn từng đợt", nên trung bình mức dùng là chỉ số sai để phát hiện nó — phải đo trực tiếp số lần bị chặn (throttle, hàng đợi, pause) mới thấy. Vì thế câu hỏi đúng khi một dịch vụ "chậm mà CPU thấp" không phải "còn bao nhiêu dư địa trung bình" mà là "có ai đang đâm vào trần trong từng cửa sổ ngắn không" — và câu trả lời nằm ở nr_throttled, không nằm ở biểu đồ phần trăm CPU.
Muốn tự kiểm trong ba mươi giây, chạy ngay trong container đang phục vụ thật:
c=/sys/fs/cgroup
p1=$(awk '/nr_periods/{print $2}' $c/cpu.stat); t1=$(awk '/nr_throttled/{print $2}' $c/cpu.stat)
sleep 30
p2=$(awk '/nr_periods/{print $2}' $c/cpu.stat); t2=$(awk '/nr_throttled/{print $2}' $c/cpu.stat)
awk -v p=$((p2-p1)) -v t=$((t2-t1)) 'BEGIN{
if (p>0) printf "trong 30 giay: %d chu ky, bi ghim %d (%.1f%%)\n", p, t, t*100/p
else print "khong doc duoc cpu.stat"
}'
echo "cpu.max: $(cat $c/cpu.max 2>/dev/null) nproc: $(nproc)"
Nếu tỷ lệ trên 5% mà biểu đồ CPU của bạn vẫn dưới 70%, bạn vừa tìm ra nguyên nhân của những yêu cầu chậm mà không ai giải thích được.
Phần sau: không gian tên — đo chi phí cách ly.