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_throttled là số chu kỳ mà cgroup bị chặn giữa chừng, throttled_usec là 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.pressure và 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à %CPU khô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 nproc trong 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.