Bốn người bệnh bước vào phòng khám, cả bốn nói đúng một câu: "tôi thấy mệt". Một người thiếu máu, một người mất ngủ, một người nhiễm trùng, một người chỉ vừa chạy bộ về. Nếu bác sĩ chỉ nghe lời than "mệt" thì bó tay — nhưng một xét nghiệm máu tách bốn ca ra tức khắc, mỗi ca một dấu vân tay riêng. "Dịch vụ chậm" là lời than "tôi thấy mệt" của hệ thống: nó gộp mọi nguyên nhân vào một triệu chứng. Bài này tôi dựng bốn "bệnh nhân" khác nhau nhưng cùng than một câu, rồi tìm cho ra cái xét nghiệm máu tách được chúng.
Bốn mươi tám phần trước đo từng thứ một: bộ nhớ, đĩa, tiến trình, lời gọi hệ thống. Bài này làm ngược lại — dựng sẵn bốn sự cố khác nhau, tất cả đều biểu hiện thành đúng một câu "dịch vụ chậm", rồi xem chỉ số nào tách được chúng ra. Tôi cố tình không nhìn vào nguyên nhân khi đọc số, để biết chỉ số nào thật sự chẩn đoán được. (Đo trong container debian:12-slim với cgroup v2 thật, không có phần cứng đặc biệt.)
Bảng số liệu
Công việc thử nghiệm là một vòng tính toán thuần CPU, mất 1,85 s khi không bị cản trở gì. Bốn hoàn cảnh, mỗi hoàn cảnh chạy 3 lần trong container debian:12-slim với cgroup v2:
| A. Bình thường | B. --cpus=0.3 |
C. 12 tiến trình tranh CPU | D. Ghi đĩa liên tục | |
|---|---|---|---|---|
| Thời gian | 1,851 s | 11,379 s | 4,623 s | 1,965 s |
| Chậm gấp | 1,00× | 6,15× | 2,50× | 1,06× |
nr_throttled |
0 | 114 | 0 | 0 |
throttled |
0 | 7 952 ms | 0 | 0 |
cpu.pressure some |
0,12 ms | 7 886 ms | 1 395 ms | 0,41 ms |
io.pressure some |
0 | 0 | 0 | 306 ms |
nproc |
10 | 10 | 10 | 10 |
loadavg |
0,40–2,82 | 0,31–2,20 | 1,33–3,14 | 1,38–3,13 |
Hai hàng cuối là hai hàng vô dụng, và tôi để chúng lại chính vì thế.
Cùng triệu chứng, khác vân tay
Ba sự cố khác nhau về bản chất cho ra cùng một lời phàn nàn. B chậm 6,15 lần, C chậm 2,50 lần — nhìn từ phía người dùng thì cả hai chỉ là "hôm nay hệ thống ì". Không có cách nào phân biệt từ bên ngoài.
Nhưng dấu vân tay bên trong thì tách bạch tuyệt đối. B có nr_throttled = 114 còn C có nr_throttled = 0; ngược lại C có cpu.pressure cao mà không hề bị bóp. D thì cả hai chỉ số CPU đều gần 0 trong khi io.pressure là 306 ms. Không có trường hợp nào mập mờ giữa hai cột — đúng cái xét nghiệm máu tách bốn ca "mệt".
Và hai công cụ mà ai cũng gõ đầu tiên lại là hai công cụ vô dụng nhất. nproc trả về 10 ở cả bốn cột, kể cả khi container chỉ được cấp 0,3 nhân. loadavg còn tệ hơn: nó không đo container mà đo cả máy chủ, nên ở cột A — hoàn cảnh không có tải gì thêm — vẫn có lần nó hiện 2,82, cao hơn cả cột B đang bị bóp nghẹt.
Vì sao hai công cụ quen thuộc lại mù
nproc đọc số CPU mà nhân nhìn thấy, còn hạn mức của container nằm ở chỗ khác hoàn toàn: cpu.max. Trong kịch bản B, cpu.max là 30000 100000 — ba mươi mili-giây CPU cho mỗi chu kỳ một trăm mili-giây. Nhân vẫn có mười nhân và vẫn nói với bạn là có mười nhân; nó chỉ không cho tiến trình của bạn dùng quá 0,3 nhân trên trung bình. Đây là lý do vì sao các thư viện tự chọn số luồng theo nproc hay tạo ra mười luồng trong một container chỉ được cấp một phần ba nhân, rồi tất cả cùng chờ nhau.
loadavg thì không thuộc về cgroup nào cả. Nó là số của toàn bộ nhân, và trong container nó vẫn là số của toàn bộ nhân. Bằng chứng nằm ngay trong bảng: các lần chạy của tôi nối tiếp nhau, và tải còn sót lại từ kịch bản trước vẫn hiện trong loadavg của kịch bản sau. Đó là lý do cột A có dải rộng từ 0,40 tới 2,82 dù nó không hề thay đổi gì.
Hai chỉ số có ý nghĩa nằm trong /sys/fs/cgroup:
cpu.statđếm chuyện bóp hạn mức.nr_throttledlà số chu kỳ mà cgroup bị chặn giữa chừng,throttled_useclà tổng thời gian bị chặn. Nó lớn hơn 0 chỉ khi có hạn mức và bạn đang chạm trần. Đây là một chẩn đoán dứt khoát, không phải một dấu hiệu gợi ý.cpu.pressurevàio.pressure(PSI) đếm thời gian có việc muốn chạy nhưng phải chờ. Chúng phân biệt được "chờ CPU" với "chờ đĩa", điều mà%CPUkhông làm được.
Ba con số này cộng lại thành một cây quyết định đọc theo đúng thứ tự: bị bóp trước, tranh giành sau, nghẽn I/O sau nữa, còn lại mới là lỗi của mã.
Bị bóp còn đắt hơn tỷ lệ bị bóp
Đây là phần tôi không chờ đợi. Ở kịch bản B, hạn mức là 0,3 nhân nên tôi tưởng công việc 1,85 s sẽ mất 1,85 / 0,3 ≈ 6,2 s. Thực tế nó mất 11,05 s.
Đo usage_usec thì thấy lý do: cùng một công việc tiêu thụ 3,34 s CPU khi bị bóp, so với 1,83 s khi tự do. Tổng thời gian khớp chính xác — 3,34 s chạy cộng 7,60 s bị chặn bằng đúng 10,94 s. Không phải công việc bị kéo dài, mà là nó tốn nhiều CPU hơn để làm cùng một việc.
Quét bốn mức hạn mức cho thấy đây là hiệu ứng có mức độ, không phải nhiễu:
--cpus |
Thời gian | CPU thực tiêu thụ | Hệ số CPU |
|---|---|---|---|
| 1,0 | 1,834 s | 1,833 s | 1,00× |
| 0,7 | 2,666 s | 1,891 s | 1,03× |
| 0,5 | 4,128 s | 2,087 s | 1,13× |
| 0,3 | 11,046 s | 3,342 s | 1,81× |
Bóp càng chặt thì mỗi giây CPU càng làm được ít việc. Ở mức 0,3, gần một nửa lượng CPU được cấp bị tiêu vào chi phí của chính việc bị ngắt quãng.
Nghĩa là gì trong công việc hằng ngày
- Khi có người báo "dịch vụ chậm", chỉ số đầu tiên cần xem là
nr_throttled. Nếu nó tăng, mọi giờ bỏ ra để tối ưu mã đều là giờ phí — hãy sửa hạn mức. Đọc bằng một lệnh:cat /sys/fs/cgroup/cpu.stat. - Đừng để thư viện tự chọn số luồng theo
nproctrong container. Nó sẽ đếm nhầm và tạo thừa luồng. Khai tường minh theo hạn mức thật. - Đừng dùng
loadavgđể chẩn đoán bên trong container. Nó nói về hàng xóm chứ không nói về bạn. - Đặt hạn mức quá chặt là tự bắn vào chân hai lần: vừa được ít CPU hơn, vừa dùng lượng CPU đó kém hiệu quả hơn. Nếu buộc phải giới hạn, ở mức 0,7 chi phí phụ chỉ 3%, còn ở 0,3 nó là 81%.
- Bốn dòng đáng dán vào sổ tay:
cat /sys/fs/cgroup/cpu.stat # nr_throttled, throttled_usec
cat /sys/fs/cgroup/cpu.max # han muc thuc su
cat /sys/fs/cgroup/cpu.pressure # cho CPU
cat /sys/fs/cgroup/io.pressure # cho dia
Cùng cái bẫy "nproc nói dối trong container", đổi ngăn xếp
Chuyện "runtime tự chọn số luồng/kích thước pool theo số CPU nhìn thấy thay vì hạn mức cgroup, rồi tạo thừa và giẫm chân nhau" không riêng gì thư viện C — nó cắn mọi runtime hiện đại chạy trong container.
Trong thế giới Java, đây là một trong những bẫy container nổi tiếng nhất. JVM cũ đọc Runtime.availableProcessors() từ số CPU host, nên một pod giới hạn 0,3 nhân vẫn dựng ForkJoinPool, GC threads, và connection pool theo cả chục nhân — đúng cái bài này đo. Java 10+ thêm UseContainerSupport để đọc cpu.max/cpu.quota thay vì nproc, và đó là lý do bạn phải chạy JVM đủ mới trong container. Vẫn nhiều thư viện tự chọn thread theo availableProcessors() cần kiểm lại.
Trong Go, runtime.GOMAXPROCS mặc định bằng số CPU nhìn thấy (nproc), không đọc cgroup quota — đúng cái mù bài này chỉ ra. Đó là lý do thư viện automaxprocs của Uber ra đời và gần như bắt buộc cho Go chạy trong Kubernetes: nó đọc cpu.max và đặt GOMAXPROCS cho khớp hạn mức, tránh scheduler Go tạo quá nhiều OS thread rồi bị throttle.
Còn ở cơ sở dữ liệu, cùng bẫy hiện ra khi tự động cấu hình: PostgreSQL/MySQL trong container mà đặt max_worker_processes, parallelism, hay buffer pool theo CPU/RAM host sẽ vượt hạn mức cgroup và bị throttle — đúng cái "được ít CPU hơn mà còn dùng kém hiệu quả hơn". Chuẩn là cấu hình theo limit thật của container, không theo phần cứng máy chủ.
Mẫu số chung: trong container, cái mà nproc/loadavg/availableProcessors() báo là tài nguyên của cả máy, còn cái thật sự chi phối bạn là hạn mức cgroup (cpu.max) — và mọi thứ tự cấu hình theo con số đầu tiên sẽ vừa đếm sai vừa tự làm mình bị throttle. Chẩn đoán đúng "vì sao chậm" phải đọc dấu vân tay bên trong cgroup (cpu.stat, PSI), không đọc con số toàn-máy. Nên câu hỏi đúng khi một dịch vụ container chậm không phải "máy chủ có tải cao không" mà là "cgroup của tôi có nr_throttled tăng không" — vì đó là con số duy nhất nói về chính bạn.
Muốn tự thấy nproc nói dối còn nr_throttled nói thật, chạy thử:
docker run --rm --cpus=0.3 debian:12-slim sh -c '
echo "cpu.max : $(cat /sys/fs/cgroup/cpu.max)"
echo "nproc noi: $(nproc) <-- so nay khong dung han muc"
T0=$(awk "/throttled_usec/{print \$2}" /sys/fs/cgroup/cpu.stat)
end=$(( $(date +%s) + 5 ))
while [ $(date +%s) -lt $end ]; do :; done
T1=$(awk "/throttled_usec/{print \$2}" /sys/fs/cgroup/cpu.stat)
echo "bi chan : $(( (T1-T0)/1000 )) ms trong 5 giay"
awk "/nr_throttled/{print \"nr_throttled: \" \$2}" /sys/fs/cgroup/cpu.stat'
Chạy đúng lệnh đó, rồi đổi --cpus=0.3 thành --cpus=4 và chạy lại. nproc sẽ không đổi một chữ số nào, còn nr_throttled thì rơi về 0. Đó là toàn bộ điều bài này muốn nói.