Ở bài cgroup bóp CPU ta thấy cgroup giới hạn CPU bằng cách đóng băng. Với bộ nhớ, cách giới hạn khác hẳn và đáng sợ hơn: chạm hạn mức thì có thể bị giết. docker run --memory=256m hay memory limit trong Kubernetes đặt một trần cứng cho container, và khi vượt trần, cgroup OOM killer bắn một tiến trình trong container. Nhưng "chạm trần" nghĩa là gì, và có phải cứ đầy là chết? Bài này đo trực tiếp — và phát hiện con số "đang dùng bao nhiêu bộ nhớ", đứng một mình, gần như vô dụng để biết container có nguy hiểm hay không.
cgroup giới hạn bộ nhớ thế nào
Trần bộ nhớ của một cgroup nằm ở memory.max — một hạn cứng. Khi tổng bộ nhớ của cgroup chạm memory.max, nhân không giết ngay; trước hết nó cố thu hồi (reclaim): xóa các trang cache sạch, đẩy các trang ẩn danh (anonymous) ra swap. Chỉ khi thu hồi không hạ nổi mức dùng xuống dưới trần, nhân mới gọi OOM killer giết một tiến trình bên trong cgroup đó — quan trọng là chỉ trong container, không đụng tới máy chủ hay các container khác (khác với OOM toàn máy ở phạm vi).
Có một hạn thứ hai, memory.high — hạn mềm: khi vượt nó, nhân ghì tiến trình lại và thu hồi ráo riết, nhưng không giết. memory.high để làm chậm một cách êm ái; memory.max là lằn ranh tử. Và điểm mấu chốt tôi muốn đo: cả page cache lẫn bộ nhớ ẩn danh đều được tính vào memory.current (mức đang dùng). Hai thứ đó tính chung một con số, nhưng chúng khác nhau một trời một vực khi chạm trần.
Đo: cùng đầy 255 MB, kết cục trái ngược
Tôi chạy container với memory.max = 256 MB, tắt swap, rồi lấp đầy bộ nhớ theo hai cách khác nhau và xem điều gì xảy ra. Cách thứ nhất: chỉ đọc một file 512 MB có sẵn ba lần, để lấp cgroup bằng page cache (bộ nhớ đệm file, là các trang sạch). Cách thứ hai: malloc rồi ghi 512 MB, lấp bằng bộ nhớ ẩn danh (anon).
| Lấp đầy bằng | memory.current | memory.stat | Kết cục |
|---|---|---|---|
| Page cache (đọc file) | 255 MB | file=253, anon=0 | exit 0, oom_kill=0 |
| Anon (malloc + ghi) | chạm 256 | toàn anon | exit 137, oom_kill=1 |
Hai dòng này là hai thế giới. Với page cache, memory.current lên tới 255 MB — sát trần 256, trông như "đầy" — nhưng tiến trình đọc file chạy trơn tru, thoát bình thường (exit 0), và memory.events báo oom_kill = 0: không ai bị giết. Nhân chỉ đơn giản xóa bớt các trang cache cũ để nhường chỗ cho trang mới, tái chế liên tục. Với anon, cùng chạm trần 256 MB đó, nhưng tiến trình bị OOM giết (exit 137, oom_kill = 1).
Khác biệt nằm ở khả năng thu hồi. Page cache là trang sạch (bản sao của dữ liệu đã có trên đĩa) — nhân vứt đi bất cứ lúc nào rồi đọc lại từ đĩa khi cần, nên nó không bao giờ là lý do để OOM. Bộ nhớ anon thì không thể vứt: nó chỉ tồn tại trong RAM, muốn thu hồi phải đẩy ra swap, mà tôi đã tắt swap — nên khi anon chạm trần, nhân không còn cách nào ngoài giết.
Một lần tôi đo hớ: con số "đầy" không nói lên nguy hiểm
Khi thấy memory.current ở 255 trên 256 MB, phản xạ đầu tiên của tôi là hoảng: "container sắp hết bộ nhớ, OOM tới nơi rồi!". Đó là cách hầu hết người ta đọc một biểu đồ memory usage chạm trần — và ở ca page cache, nó sai hoàn toàn. Cái "đầy" đó toàn là cache sạch, thứ nhân tái chế thoải mái; container khỏe re, oom_kill = 0, đọc file không hề giật. Con số 255 MB không hề báo hiệu nguy hiểm gì.
Cùng con số 255 MB đó ở ca anon lại nghĩa là chết tới nơi. Vậy nên bài học lộ ra rất rõ: con số "đang dùng bao nhiêu" (memory.current), đứng một mình, không cho biết container có nguy hiểm hay không — chỉ thành phần của nó mới nói. Phải nhìn memory.stat để tách anon (không thu hồi được, là mối nguy thật) khỏi file cache (thu hồi được, vô hại). Đầy vì cache là chuyện bình thường và lành mạnh; đầy vì anon mới là khủng hoảng. Tôi đã suýt đọc một con số tổng và kết luận sai về sức khỏe của cả hệ thống.
Có một bộ đếm nữa dễ gây hiểu lầm y hệt: memory.events có trường max đếm số lần cgroup chạm trần. Ở ca page cache, con số này lên tới hàng nghìn — nghe như hệ thống đang vật lộn. Nhưng nó hoàn toàn vô hại: mỗi lần chạm trần, nhân chỉ thu hồi một ít cache rồi đi tiếp; max tăng nghĩa là cơ chế thu hồi đang chạy đúng, không phải đang hỏng. Chỉ oom và oom_kill mới là những con số báo động thật. Đây là kiểu lỗi khi ta gán ý nghĩa "xấu" cho một bộ đếm chỉ vì nó tăng, mà không hỏi nó đếm cái gì.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là đừng hoảng khi thấy memory usage của container chạm trần — hãy xem nó gồm gì. Rất nhiều cảnh báo "container dùng 99% bộ nhớ" là báo động giả: phần lớn là page cache, và cache đầy là tốt (nghĩa là I/O đang được đệm hiệu quả). Con số đáng theo dõi để biết nguy cơ OOM là bộ nhớ anon và working set thật, không phải tổng memory.current. Khi đặt cảnh báo, hãy dựa trên memory.stat (anon, hoặc workingset) và memory.events oom_kill, đừng dựa trên tỉ lệ current/max thô.
Hệ quả thứ hai là đặt memory limit dựa trên bộ nhớ anon thật của ứng dụng, có đệm an toàn. Vì cache co giãn được, cái mà limit thật sự phải chứa là phần không thu hồi được: heap, stack, bộ nhớ ẩn danh mà ứng dụng thực sự cần. Đặt limit quá sát working set anon là mời OOM mỗi khi có một đợt cấp phát; đặt quá rộng thì lãng phí. Và nhớ rằng một tiến trình bị cgroup OOM giết chết lặng lẽ với exit 137 — không stack trace, không dọn dẹp — nên trên máy chủ hãy kiểm dmesg hay memory.events để biết đó là OOM chứ không phải bug trong code.
Hệ quả thứ ba, về đo lường: đừng đọc một con số tổng khi cái đau nằm ở thành phần của nó. Con số mang theo: memory.max là trần cứng — chạm trần thì nhân thu hồi trước, không hạ nổi mới OOM giết (trong cgroup, exit 137); nhưng page cache và anon đều tính vào memory.current dù kết cục trái ngược: đầy vì cache thì tái chế được, oom_kill=0; đầy vì anon thì bị giết ngay — nên phải nhìn memory.stat (anon vs file), không phải mỗi memory.current, mới biết container có nguy hiểm không. Một con số 255/256 MB có thể là hoàn toàn khỏe mạnh hoặc sắp chết; chỉ thành phần mới phân biệt.
Thử ba mươi giây
Nếu một container bị cảnh báo "gần hết bộ nhớ", đừng vội tin con số tổng — bóc thành phần ra: cat /sys/fs/cgroup/memory.current (đang dùng), rồi grep -E '^anon |^file ' /sys/fs/cgroup/memory.stat để xem bao nhiêu là anon (mối nguy) và bao nhiêu là file cache (vô hại). Nếu phần lớn là file, container đang khỏe dù usage sát trần. Kiểm xem đã từng bị OOM chưa: grep oom_kill /sys/fs/cgroup/memory.events — nếu số này bằng 0 và đang tăng chỉ ở trường max, thì cgroup chỉ đang thu hồi cache bình thường, không có gì phải sợ. Đó là cách phân biệt "đầy vì cache" với "đầy vì anon" mà bài này đo — và là lý do một con số usage thô hay đánh lừa.