Mọi container đều chạy trong một cgroup. Bài này đ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 và bộ nhớ của cgroup v2

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ố đó.

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.

Đây là gốc rễ của một lớp lỗi cấu hình rất 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.

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×

Hai cái giá, và cái thứ hai đắt hơn:

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.

Đ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 ở 7,539 s. Trong một máy chủ web, "luồng" là "yêu cầu", và con số đó là p99 của bạn.

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_throttledthrottled_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

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ả:

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.

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ợ.

Thử ba mươi giây

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.