cgroups — trần CPU, RAM và I/O của một container
Công cụ đặt giới hạn cứng mà nice không làm được: cpu.max, memory.max và io.max của cgroup v2, cùng lý do container hay thấy sai số core khi đọc /proc.
TL;DR: cgroup là cơ chế kernel gom một nhóm tiến trình lại rồi áp trần tài nguyên lên cả nhóm — và đây là thứ thật sự đứng sau chữ "giới hạn" của mọi container. Trần CPU nằm ở cpu.max dạng hạn ngạch trên mỗi chu kỳ; vượt hạn ngạch thì cả nhóm bị treo tới đầu chu kỳ sau, sinh ra những đợt độ trễ nhọn mà biểu đồ CPU trung bình không hề thấy. Trần bộ nhớ có hai mức tách bạch: memory.high bóp lại, memory.max giết. Đừng nhầm với nice — nice chỉ đổi thứ tự ưu tiên lúc có tranh chấp, không đặt được trần nào cả.
Một service chạy trong Kubernetes có p99 vượt 2 giây. Biểu đồ CPU của pod thì đẹp: trung bình dùng chưa tới một nửa hạn mức. Không ai tìm ra chỗ chậm trong code, vì chỗ chậm không nằm trong code.
Đội hạ tầng của Indeed đã kể lại đúng ca này: sau khi sửa cách đặt giới hạn CPU, độ trễ phản hồi tệ nhất của một ứng dụng của họ rơi từ hơn hai giây xuống 30 mili-giây. Không dòng code ứng dụng nào thay đổi.
1. Analogy — aptomat và lời khuyên tiết kiệm điện
Trong một toà nhà, có hai cách "giới hạn" điện của một căn hộ.
Cách thứ nhất là dán tờ giấy: "giờ cao điểm xin dùng ít thôi". Nếu hàng xóm không dùng gì, căn hộ đó vẫn bật hết máy lạnh mà chẳng ai ngăn. Tờ giấy chỉ có tác dụng lúc tranh chấp.
Cách thứ hai là lắp aptomat 5 ampe. Không quan tâm hàng xóm dùng bao nhiêu, không quan tâm nhà máy điện đang dư thừa: vượt ngưỡng là ngắt.
nice là tờ giấy. cpu.max của cgroup là cái aptomat. Nhầm hai thứ này là lý do rất nhiều người viết renice 19 cho một job nền rồi ngạc nhiên khi nó vẫn nghiến trọn một lõi lúc máy rảnh.
| Ở toà nhà | Trong hệ thống |
|---|---|
| Tờ giấy khuyên dùng ít điện | nice — chỉ đổi tỉ lệ chia khi tranh chấp |
| Aptomat theo ampe | cpu.max — hạn ngạch cứng mỗi chu kỳ |
| Cầu chì tổng của cả tầng | memory.max của cgroup cha |
| Cảnh báo trước khi cắt | memory.high — bóp lại chứ chưa giết |

2. cgroup nằm ở đâu và nó gom cái gì?
cgroup (control group) là cơ chế kernel gom một tập tiến trình thành một nhóm có tên, rồi gắn các bộ điều khiển (controller) đo đếm và áp trần lên cả nhóm đó. Phiên bản 2 hợp nhất mọi controller vào một cây duy nhất, thay vì mỗi loại tài nguyên một cây rời rạc như phiên bản 1.
Cây ấy hiện ra dưới dạng thư mục, mount tại /sys/fs/cgroup:
# Mot tien trinh dang thuoc cgroup nao
cat /proc/self/cgroup
# 0::/user.slice/user-1000.slice/session-3.scope
# Container chay qua systemd nam o day
ls /sys/fs/cgroup/system.slice/docker-<id>.scope/
# cpu.max cpu.stat memory.max memory.high memory.current io.max ...
Hai đặc tính khiến nó khác hẳn cách giới hạn theo từng tiến trình:
- Trần áp lên cả nhóm. Mười thread trong nhóm chia nhau đúng một hạn ngạch, không phải mỗi thread một suất.
- Con cái thừa hưởng. Tiến trình sinh ra bên trong nhóm nằm luôn trong nhóm, nên không thoát được bằng cách
fork.
Đó chính là lý do container dùng cgroup: một container là một nhóm tiến trình cần bị chặn cùng nhau, chứ không phải một tiến trình đơn lẻ.
3. Ba cái trần và cách đọc chúng
CPU — file cpu.max chứa hai số: hạn ngạch và độ dài chu kỳ, đơn vị micro-giây.
cat cpu.max
# max 100000 -> khong gioi han, chu ky 100 ms
# 200000 100000 -> moi chu ky 100 ms duoc dung 200 ms CPU, tuc 2 loi
Cơ chế thi hành mới là chỗ quan trọng: kernel đếm thời gian CPU nhóm đã tiêu trong chu kỳ hiện tại, và khi chạm hạn ngạch thì treo toàn bộ nhóm cho tới đầu chu kỳ sau. Một nhóm 8 thread với hạn ngạch 2 lõi có thể xài hết suất chỉ trong 25 mili-giây đầu, rồi ngồi im 75 mili-giây. Trung bình đúng 2 lõi, nhưng request rơi vào quãng ngồi im thì cộng thêm cả chục mili-giây độ trễ. Đó là chỗ đuôi trễ của case Indeed bị phá.
Bộ nhớ — hai file, hai hành vi khác hẳn nhau:
| File | Khi vượt | Dùng khi nào |
|---|---|---|
memory.high | Kernel bóp tiến độ nhóm lại và ép thu hồi trang; không giết | Ngưỡng cảnh báo, muốn nhóm chậm lại thay vì chết |
memory.max | Thu hồi không đủ thì OOM killer ra tay trong phạm vi cgroup | Trần cứng cuối cùng |
Bài đó chốt rằng tổng RSS không phải đại lượng cộng được. memory.current của cgroup đi đường khác hẳn: kernel tính theo trang thuộc về nhóm, gồm cả page cache mà nhóm sinh ra, nên nó không khớp với bất kỳ phép cộng RSS nào bạn làm tay.
Kèm theo là memory.current (đang dùng bao nhiêu, đã gồm cả page cache thuộc về nhóm) và memory.oom.group — đặt bằng 1 thì kernel giết cả nhóm một lượt thay vì bắn tỉa một tiến trình, tránh cảnh container còn sống nhưng đã mất tiến trình lõi.
I/O — file io.max giới hạn theo từng thiết bị, khai bằng cặp số major và minor:
echo "259:0 rbps=104857600 wbps=52428800" > io.max
# thiet bi 259:0 duoc doc toi 100 MB/s, ghi toi 50 MB/s
4. Tự điền — container này đang bị bóp bao nhiêu?
Một service báo p99 xấu theo từng đợt. Bạn đọc cpu.stat của cgroup nó và thấy:
nr_periods 850
nr_throttled 127
throttled_usec 1823400
Ty le chu ky bi treo = ______ %
Trung binh moi lan treo = ______ ms
Ket luan = ______
Ba câu hỏi định hướng, không phải đáp án. Thứ nhất: nr_periods là số chu kỳ đã trôi qua, nr_throttled là số chu kỳ nhóm bị treo — tỉ lệ giữa hai số đó nói gì? Thứ hai: throttled_usec là tổng thời gian bị treo tính bằng micro-giây; chia cho số lần treo thì ra đại lượng gì, và nó so được với ngân sách độ trễ của một request không? Thứ ba: nếu biểu đồ mức dùng CPU trung bình của pod này vẫn xanh, con số nào ở trên giải thích được mâu thuẫn đó? Tính ra ba con số của bạn trước khi đọc tiếp.
Tỉ lệ chu kỳ bị treo là 127 / 850 ≈ 15%. Mỗi lần treo trung bình 1.823.400 / 127 ≈ 14.357 micro-giây, tức khoảng 14 mili-giây.
Đọc thành lời: cứ khoảng bảy chu kỳ thì có một chu kỳ mà nhóm xài hết hạn ngạch rồi bị treo, và mỗi lần treo dài trung bình 14 mili-giây. Request nào rơi trúng quãng đó lãnh trọn khoản cộng thêm ấy, bất kể nó chỉ cần vài trăm micro-giây để xử lý. Với dịch vụ nhạy độ trễ, 15% là mức thấy rõ trên p99 — bản thân ngưỡng "bao nhiêu phần trăm là quá nhiều" thì tuỳ ngân sách độ trễ của từng dịch vụ, đó là kinh nghiệm vận hành chứ không phải hằng số.
Mâu thuẫn với biểu đồ trung bình cũng được giải thích luôn: mức dùng trung bình tính trên toàn khoảng, nên nó làm phẳng đúng những khoảng treo ngắn tạo ra đuôi trễ. Trung bình đẹp và p99 xấu hoàn toàn sống chung được, và nr_throttled là chỗ chúng gặp nhau.
Hai hướng sửa, tuỳ nguyên nhân: nới hạn ngạch cho đúng nhu cầu đỉnh, hoặc giảm số thread để nhóm không đốt hết suất trong một khoảnh khắc ngắn. Hướng thứ hai hay bị bỏ qua nhưng thường mới là hướng đúng — đó là nội dung của pitfall ngay dưới.
5. Pitfall của riêng concept này
❌ Nhầm 1 — dùng nice để giới hạn CPU của một dịch vụ.
✅ nice chỉ đổi tỉ lệ chia khi có tranh chấp. Máy rảnh thì tiến trình nice 19 vẫn ăn trọn một lõi. Muốn trần cứng thì đặt cpu.max, hoặc CPUQuota= nếu quản bằng systemd.
❌ Nhầm 2 — tin /proc/cpuinfo bên trong container.
✅ File đó không bị che theo cgroup: tiến trình bị giới hạn 2 lõi vẫn nhìn thấy đủ 64 lõi của máy chủ, rồi dựng thread pool theo 64 và đâm thẳng vào hạn ngạch. Runtime đời mới tự đọc cgroup, đời cũ thì phải đặt tay số thread.
❌ Nhầm 3 — đặt hạn ngạch CPU thấp cho service nhạy độ trễ rồi chỉ theo dõi mức dùng trung bình.
✅ Mức trung bình che mất các đợt treo. Theo dõi nr_throttled và throttled_usec, vì đó mới là chỗ độ trễ đuôi được sinh ra.
❌ Nhầm 4 — coi memory.high và memory.max là hai cách viết của cùng một thứ.
✅ Một cái bóp, một cái giết. Đặt memory.high thấp hơn memory.max cho bạn một vùng đệm: nhóm chậm lại và bị ép thu hồi trang trước, thay vì đang chạy ngon thì bị bắn.
6. 📚 Đào sâu (tuỳ chọn)
- Control Group v2 — docs.kernel.org — tài liệu chính chủ cho
cpu.max,cpu.stat,memory.high,memory.max,memory.oom.groupvàio.max. - cgroups(7) — man7.org — phân cấp hợp nhất của v2, danh sách controller, cách nhóm được mount.
- Unthrottled: fixing CPU limits in the cloud — Indeed Engineering — ca thật đi từ độ trễ tệ nhất hơn hai giây xuống 30 mili-giây, kèm cách lần ra thủ phạm bằng
nr_throttled.
7. Liên hệ các bài khác
- RSS, VSZ và bộ nhớ thật — vì sao
memory.currentcủa cgroup không khớp với tổng RSS mà bạn cộng tay. - OOM kill và ulimit — chuyện gì xảy ra ngay sau khi
memory.maxbị vượt, và cách đọc dòng log của lần giết đó. - Mini-challenge — máy chậm, nghẽn ở đâu? — một trong bốn ca ở đó là một container bị treo theo chu kỳ.
- Bao nhiêu thread là đủ — cùng câu hỏi chọn số thread, nhưng ở đó chưa có tầng trần này chen vào.
8. Tóm tắt
- cgroup v2 là một cây thư mục dưới
/sys/fs/cgroup; đọc/proc/self/cgrouplà biết ngay tiến trình đang nằm ở nhánh nào. cpu.maxkhông bóp tốc độ mà cắt hẳn theo chu kỳ, nên hậu quả của nó là độ trễ nhọn thay vì thông lượng giảm đều.nr_throttledchia chonr_periodslà chỉ số quan trọng nhất mà hầu hết dashboard container không bày sẵn.- Cặp
memory.highvàmemory.maxcho phép thiết kế một vùng đệm giữa "chậm lại" và "bị giết". - Trong container, mọi câu hỏi "máy này có bao nhiêu lõi" phải hỏi cgroup, không hỏi
/proc/cpuinfo.
9. Tự kiểm tra
Q1Một pod có cpu.max là 200000 100000, mức dùng CPU trung bình chỉ khoảng 45% hạn mức, nhưng p99 xấu theo từng đợt. Chuyện gì đang xảy ra và bạn đọc file nào để xác nhận?▸
Hạn ngạch được thi hành theo từng chu kỳ 100 mili-giây chứ không phải theo trung bình dài hạn. Một nhóm nhiều thread có thể đốt trọn suất 200 mili-giây CPU chỉ trong vài chục mili-giây đầu chu kỳ, rồi bị treo tới chu kỳ sau. Trung bình trên toàn khoảng vẫn thấp, nhưng request rơi vào quãng treo lãnh thêm cả chục mili-giây.
File cần đọc là cpu.stat: nr_throttled chia cho nr_periods cho tỉ lệ chu kỳ bị treo, còn throttled_usec chia cho nr_throttled cho độ dài trung bình mỗi lần treo. Hai con số đó nói được điều mà biểu đồ mức dùng trung bình không nói.
Q2Vì sao renice một job nền xuống 19 không ngăn được nó chiếm trọn một lõi, trong khi cpu.max thì ngăn được?▸
nice là tham số cho bộ lập lịch dùng khi có tranh chấp: nó quyết định ai được ưu tiên khi nhiều tiến trình cùng đòi CPU. Không ai tranh thì bộ lập lịch chẳng có lý do gì để bắt tiến trình đó nghỉ, nên nó chạy hết tốc lực.
cpu.max làm việc khác hẳn: kernel đếm thời gian CPU nhóm đã tiêu trong chu kỳ hiện tại và treo cả nhóm khi chạm hạn ngạch, bất kể máy có rảnh hay không. Một cái là thứ tự ưu tiên, một cái là trần tuyệt đối.
Q3Đặt memory.high thấp hơn memory.max mang lại điều gì mà chỉ đặt mỗi memory.max không có?▸
Một vùng đệm giữa hai hành vi khác nhau về bản chất. Vượt memory.high thì kernel bóp tiến độ nhóm lại và ép thu hồi trang, tiến trình vẫn sống — bạn có thời gian để cảnh báo, để scale, hoặc để nhóm tự thải bớt page cache.
Chỉ đặt memory.max thì ranh giới trở thành nhị phân: chạy bình thường cho tới đúng một byte vượt trần, rồi OOM killer ra tay trong phạm vi cgroup, không có pha trung gian nào để can thiệp.
Q4Một ứng dụng Java trong container 2 lõi tự dựng thread pool theo Runtime.availableProcessors và ra 64. Điều gì đã xảy ra, và hậu quả cụ thể là gì?▸
Số lõi mà tiến trình nhìn thấy đến từ thông tin CPU của máy chủ, thứ không bị cgroup che đi: giới hạn nằm ở hạn ngạch cpu.max, không nằm ở việc ẩn bớt lõi. Runtime đời cũ đọc con số của máy chủ nên tưởng mình có 64 lõi.
Hậu quả là 64 thread cùng chạy đốt hết hạn ngạch 2 lõi trong khoảng rất ngắn của mỗi chu kỳ, rồi cả nhóm bị treo phần còn lại. Thêm thread ở đây làm độ trễ tệ đi chứ không tốt lên. Cách sửa là để runtime đọc giới hạn cgroup, hoặc đặt tay số thread cho khớp hạn ngạch.
Q5Vì sao trần tài nguyên của container phải áp lên một nhóm tiến trình thay vì lên từng tiến trình riêng lẻ?▸
Vì một container hiếm khi là một tiến trình. Nó có tiến trình chính, các tiến trình con, đôi khi cả sidecar hay script phụ; giới hạn theo từng tiến trình sẽ bị né sạch chỉ bằng cách sinh thêm tiến trình con, mỗi cái nhận một suất mới.
cgroup chặn đúng lỗ hổng đó: trần tính trên tổng của cả nhóm, và mọi tiến trình sinh ra bên trong đều tự động thuộc nhóm. Nhờ vậy tổng tài nguyên của container là một con số có thể bảo đảm, thứ mà trình lập lịch cụm cần để xếp chỗ.
Bài tiếp theo: OOM kill và ulimit — khi kernel ra tay
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên