Hãy tưởng tượng bạn được thuê nguyên một xưởng có 16 thợ, nhưng hợp đồng chỉ trả lương cho đúng một giờ-người trong mỗi giờ đồng hồ. Bạn thấy 16 người, nhưng tổng công bỏ ra mỗi giờ vẫn chỉ bằng một người làm liên tục. Bây giờ nếu bạn giao cùng một việc cho cả 16 người cùng xúm vào — họ giẫm chân nhau, mất công bàn giao, chia phần — thì việc xong chậm hơn giao cho một người. cgroup CPU quota đúng là cái hợp đồng đó: nó không giới hạn bạn thấy bao nhiêu nhân, nó giới hạn tổng thời gian bạn được chạy. Và bài này đo cái giá của việc tin nhầm rằng "thấy 16 nhân nghĩa là có 16 nhân".

Mọi container đều chạy trong một cgroup. Ta đo xem giới hạn CPU và bộ nhớ thật sự làm gì — và hai chỗ chúng khác hẳn với điều bạn thấy từ bên trong.

Giới hạn CPU thực thi rất chính xác

docker --cpus cpu.max nproc báo Thực tế dùng được Bị ghim / 6 giây
1 100000 100000 16 1,02 nhân 61
2 200000 100000 16 2,01 nhân 60
4 400000 100000 16 4,03 nhân 60

Cột "thực tế" khớp gần như hoàn hảo. cpu.max đọc là "được dùng x micro giây trong mỗi chu kỳ y micro giây", và nhân thực thi đúng con số đó — đúng như hợp đồng một giờ-người.

Cột "bị ghim" cũng đáng chú ý: 60 lần trong 6 giây — nghĩa là chu kỳ 100 ms nào cũng chạm trần. Với tải tính toán liên tục thì đó là bình thường, không phải triệu chứng.

Rồi nproc báo 16

Ba dòng đều báo 16, bất kể quota. Container thấy toàn bộ nhân của máy chủ; giới hạn nằm ở lượng thời gian chứ không ở số nhân. Bạn thấy đủ 16 thợ, nhưng ví lương chỉ đủ một người.

Đây là gốc rễ của một lớp lỗi cấu hình cực kỳ phổ biến, vì rất nhiều thứ tự chọn kích thước theo số nhân:

Thứ Đọc gì
nginx worker_processes auto số nhân
Go GOMAXPROCS mặc định số nhân
Node os.cpus().length số nhân
Java trước JDK 10 số nhân
Bể luồng của phần lớn thư viện số nhân

Với quota 1 nhân, tất cả những thứ trên tạo 16 worker. Mười sáu người xúm vào phần việc của một người.

Và 32 luồng cho quota 1 nhân làm chậm 44%

Cùng một khối lượng công việc, chia cho N luồng, quota 1 CPU:

Số luồng Tổng thời gian Luồng nhanh nhất / chậm nhất Lệch
1 5,243 s 5,242 / 5,242 s 1,0×
2 5,220 s 5,208 / 5,220 s 1,0×
4 5,386 s 5,377 / 5,386 s 1,0×
8 5,581 s 5,470 / 5,581 s 1,0×
16 6,038 s 5,417 / 6,038 s 1,1×
32 7,542 s 3,718 / 7,539 s 2,0×

Có hai cái giá, và cái thứ hai đắt hơn nhiều.

Thứ nhất, thông lượng giảm 44% — cùng công việc, thêm 2,3 giây, chỉ vì chia nhỏ ra nhiều luồng hơn. Thứ hai, và tệ hơn: đuôi phân bố giãn gấp đôi — luồng nhanh nhất xong ở 3,718 s, luồng chậm nhất tận 7,539 s. Trong một máy chủ web, "luồng" chính là "yêu cầu", và con số đó là p99 của bạn. Nửa số khách chờ gấp đôi thời gian, chỉ vì một dòng cấu hình tin lời nproc.

Cách chữa là bảo runtime đọc quota thay vì đọc nproc:

GOMAXPROCS=1 ./dich-vu                      # Go, hoac dung uber-go/automaxprocs
java -XX:ActiveProcessorCount=1 ...         # JVM, hoac de JDK 10+ tu doc cgroup
worker_processes 1;                          # nginx
UV_THREADPOOL_SIZE=2 node app.js             # Node

JDK từ phiên bản 10 tự đọc cgroup và availableProcessors() trả về đúng quota — một trong những cải tiến ít được nhắc mà đáng giá nhất cho Java trong container.

Đọc quota cho đúng

cat /sys/fs/cgroup/cpu.max          # "100000 100000" hoac "max 100000"
cat /sys/fs/cgroup/cpu.stat         # usage_usec, nr_throttled, throttled_usec

nr_throttled và throttled_usec là hai con số chẩn đoán tốt nhất mà phần lớn hệ thống giám sát không thu thập. Chúng trả lời câu hỏi "dịch vụ này chậm vì quota hay vì lý do khác" một cách dứt khoát.

Với cgroup v1 (còn gặp trên hệ thống cũ) thì đường dẫn khác hẳn:

cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us

Bộ nhớ: bộ đệm trang tính vào giới hạn

Đây là chỗ dễ báo động hụt nhất. Container giới hạn 512 MB. Ghi một tệp 400 MB rồi sync:

memory.current  413 MB   (81% giới hạn)
  file (bộ đệm)  400 MB
  anon (thật)      0 MB

Chương trình không giữ byte nào, và bảng giám sát báo 81%. Đây là nguồn của rất nhiều cảnh báo giả. Con số đáng nhìn không phải memory.current mà là dòng anon trong memory.stat:

awk '/^anon |^file |^slab /{print}' /sys/fs/cgroup/memory.stat

Cấp thêm 300 MB bộ nhớ thật vào đúng container đó:

memory.current  511 MB
  file           203 MB   <- bi thu hoi
  anon           300 MB
OOM: 0 lần. Chạm trần: 789 lần.

Nhân thu hồi bộ đệm trang để nhường chỗ. Không ai chết. memory.events ghi 789 lần chạm trần — và đó cũng là bình thường, không phải sự cố.

Nhưng bộ đệm bẩn thì không thu hồi kịp

Lần đo đầu tiên tôi quên sync sau khi ghi tệp. Kết quả khác hẳn:

memory.current  511 MB | file 465 MB | anon 0 MB
OOM: 1 lần

Tiến trình xin 300 MB bị giết, trong khi lần chạy có sync thì không. Vì sao? Trang bộ đệm sạch vứt đi được ngay — bản gốc còn trên đĩa. Trang bẩn thì phải ghi xuống trước, và việc đó mất thời gian mà bộ cấp phát không có.

Nối thẳng với phần 13: một container ghi nhiều và nhanh có thể tích tụ hàng trăm megabyte trang bẩn, và khi có ai đó xin bộ nhớ đúng lúc ấy thì OOM killer ra tay — dù anon gần bằng 0 và không ai rò rỉ gì cả. Đây là dạng OOM khó chẩn đoán nhất, vì mọi số liệu sau sự cố đều trông bình thường.

Ba mức áp lực bộ nhớ của cgroup v2

cgroup v2 tách rõ ba mức, và cgroup v1 không có mức giữa:

Nút Nghĩa
memory.low Dưới mức này thì nhân ưu tiên không thu hồi. Bảo vệ mềm.
memory.high Vượt thì tiến trình bị làm chậm lại để nhân kịp thu hồi. Không giết.
memory.max Vượt thì OOM.

memory.high là công cụ tốt nhất mà ít người dùng: nó biến một cú chết đột ngột thành một đợt chậm dần, đủ thời gian để hệ thống giám sát kịp báo. Docker không phơi nó ra (-m chỉ đặt memory.max), nhưng systemd có MemoryHigh= và Kubernetes đang dần hỗ trợ.

Mẫu số chung: đừng tự chia phần theo cái mình thấy, hãy chia theo cái mình được cấp

Cái lỗi cốt lõi ở đây — đọc tài nguyên phần cứng nhìn thấy được (nproc) rồi tự chia việc theo đó, trong khi giới hạn thật nằm ở một ngân sách vô hình (quota) — không riêng gì container Linux. Nó lặp lại ở mọi runtime chạy trong môi trường bị bó.

Trong thế giới Java, đây đúng là câu chuyện JVM trước JDK 10: Runtime.getRuntime().availableProcessors() trả về số nhân máy chủ, nên fork/join pool, parallelStream(), và cỡ heap mặc định đều phồng lên vượt quota — dịch vụ bị ghim liên tục mà nhìn code không thấy sai. JDK 10+ đọc cgroup mới sửa được, và đó là lý do nâng JDK trong container lại là một tinh chỉnh hiệu năng thực thụ.

Trong Go, runtime.NumCPU() cũng đọc số nhân máy chủ, nên GOMAXPROCS mặc định phồng theo — cộng đồng phải viết uber-go/automaxprocs để đọc quota cgroup thay vào. Cùng một cái bẫy, cùng một cách chữa: đọc cái được cấp, không đọc cái nhìn thấy.

Còn ở cơ sở dữ liệu, cùng nguyên tắc: shared_buffers, work_mem, số worker song song của Postgres nếu đặt theo tổng RAM/nhân của máy chủ thay vì theo phần thật cấp cho container thì sẽ vượt giới hạn và bị OOM — đúng như tự chọn số thợ theo cả xưởng thay vì theo ví lương. Và cái bẫy bộ đệm trang ở trên còn khiến người ta chỉnh nhầm: thấy memory.current cao rồi hạ shared_buffers, trong khi phần cao đó chỉ là page cache sạch, thu hồi được ngay.

Mẫu số chung: runtime nào cũng có thói quen tự chia phần theo tài nguyên nó nhìn thấy, nhưng trong môi trường bị bó (container, cgroup, tenant), cái nhìn thấy và cái được cấp là hai con số khác nhau — và bám theo cái sai vừa làm giảm thông lượng vừa giãn đuôi trễ mà chẳng để lại dấu vết nào trong code. Nên câu hỏi đúng khi triển khai bất cứ dịch vụ nào vào container không phải "máy này có bao nhiêu nhân, bao nhiêu RAM" mà là "runtime của tôi đang đọc quota hay đang đọc phần cứng, và con số bộ nhớ tôi cảnh báo là anon thật hay chỉ là bộ đệm trang".

Muốn tự soi, xem container hoặc dịch vụ của bạn đang bị ghim bao nhiêu:

c=/sys/fs/cgroup                      # ben trong container
echo "cpu.max : $(cat $c/cpu.max 2>/dev/null)"
echo "nproc   : $(nproc)"
grep -E 'nr_throttled|throttled_usec|usage_usec' $c/cpu.stat 2>/dev/null

echo "--- bo nho ---"
echo "max     : $(cat $c/memory.max 2>/dev/null)"
echo "current : $(cat $c/memory.current 2>/dev/null)"
awk '/^anon |^file /{printf "  %-6s %d MB\n", $1, $2/1048576}' $c/memory.stat 2>/dev/null
grep . $c/memory.events 2>/dev/null | tr '\n' ' '; echo

Đọc hai lần cách nhau một phút. nr_throttled tăng đều với một dịch vụ đang rảnh là dấu hiệu quota quá chặt. Và nếu current cao mà anon thấp, cảnh báo bộ nhớ của bạn đang đo bộ đệm trang chứ không đo ứng dụng.

Phần sau: theo dõi trong sản phẩm — chọn chỉ số nào và đọc chúng ra sao.