Ở bài về overcommit ta thấy nhân Linux hứa cấp bộ nhớ nhiều hơn nó có, và khi một tiến trình chạm vào quá nhiều trang, RAM thật cạn — lúc đó OOM killer (kẻ giết khi hết bộ nhớ) ra tay, bắn một SIGKILL không thể chặn. Câu hỏi thực tế và đáng sợ: khi RAM cạn, nhân chọn giết ai? Trực giác nói "kẻ nào vừa xin làm tràn thì kẻ đó chết". Bài này dựng một tình huống có kẻ gây ra và kẻ ngoài cuộc rõ ràng trong container, đọc điểm số của nhân và cả bản án trong nhật ký kernel — và phát hiện trực giác đó sai, sau khi chính phép đo của tôi suýt lừa tôi tin vào một kết quả loạn.
OOM killer chọn nạn nhân thế nào
Khi một nhóm bộ nhớ (cgroup) vượt giới hạn mà nhân không đòi lại được trang nào để giải phóng, OOM killer được gọi để giết bớt một tiến trình cho RAM nhẹ đi. Nó không chọn ngẫu nhiên: nó chấm cho mỗi tiến trình một điểm badness, xấp xỉ tỉ lệ với lượng RAM tiến trình đang chiếm (RSS cộng vài thứ như bảng trang). Càng chiếm nhiều, điểm càng cao, càng dễ bị chọn — vì giết một tiến trình to sẽ giải phóng được nhiều RAM nhất. Điểm này đọc được ở /proc/<pid>/oom_score (thang 0–1000).
Có một núm chỉnh tay: /proc/<pid>/oom_score_adj, khoảng -1000 tới +1000. Đặt -1000 khiến oom_score về 0, tức tiến trình gần như miễn nhiễm (nhân sẽ không giết trừ khi hết cách); đặt +1000 đẩy nó lên đầu danh sách tử. Đây là cách bảo vệ một tiến trình quan trọng (cơ sở dữ liệu, tiến trình giám sát) khỏi bị giết oan khi máy hết RAM.
Điểm mấu chốt tôi muốn kiểm: nhân giết theo kích thước (badness), không theo kẻ nào vừa gây ra tràn. Nghe thì hợp lý, nhưng nó dẫn tới một hệ quả phản trực giác cần đo tận mắt.
Đo: kẻ gây ra gọi, kẻ to nhất chết
Tôi chạy một container với giới hạn cgroup 400 MB, tắt swap, rồi thả vào đó hai tiến trình:
- HOG — kẻ ngoài cuộc: chiếm 300 MB rồi ngồi yên, không làm gì nữa.
- GROW — kẻ gây ra: lớn dần từng bước 10 MB, chính nó sẽ đẩy tổng vượt quá 400 MB và châm ngòi OOM.
Ngay trước lúc tràn, điểm số như sau — HOG to hơn nên điểm cao hơn:
oom_score: HOG = 688 > GROW = 673
Rồi GROW cấp thêm một bước nữa, tổng vượt 400 MB, và OOM killer nổ. Đây là bản án nhân ghi trong dmesg:
grow invoked oom-killer: ... oom_score_adj=0
Memory cgroup out of memory: Killed process <hog>
total-vm:309408kB, anon-rss:307292kB, oom_score_adj:0
Đọc kỹ hai dòng này: GROW là kẻ "invoked" (châm ngòi) — chính cú cấp phát của nó làm tràn — nhưng kẻ bị "Killed" lại là HOG, tiến trình 300 MB đang ngồi yên, hoàn toàn ngoài cuộc. Nhân giết kẻ to nhất để thu lại nhiều RAM nhất, không giết kẻ vừa gây ra. Chạy 8 lần, 7 lần chỉ HOG bị giết còn GROW (thủ phạm) sống sót thoát ra bình thường (mã thoát 0). Kẻ đập vỡ bình thì đứng nhìn, người vô can đứng cạnh bị bắn.
Và để chứng minh điểm số quyết định, tôi đặt oom_score_adj của HOG về -1000 (miễn nhiễm), khiến oom_score của nó về 0. Lập tức nạn nhân lật sang GROW, cả 3/3 lần — vì giờ HOG bất khả xâm phạm, kẻ to nhất có thể giết chính là GROW. Kích thước, qua điểm badness, mới là thứ quyết định, không phải "ai gây ra".
Một lần tôi đo hớ: khi số liệu loạn vì chính vòng đo
Kết quả gọn gàng ở trên không đến ngay. Lần đầu đo, tôi cho GROW lớn trong một vòng lặp sít (cấp phát liên tục không nghỉ), in RSS ra kèm bộ đệm, và trong script tôi chỉ đọc dòng Killed cuối cùng trong dmesg. Kết quả thu về loạn xạ: chạy năm lần thì cả năm lần GROW chết; chạy lại thì khi HOG chết, khi GROW chết, không theo quy luật nào. Tôi suýt kết luận: "nạn nhân là ngẫu nhiên" — hoặc tệ hơn, "nhân giết chính kẻ gây ra".
Hai con số mâu thuẫn (HOG điểm cao hơn mà GROW lại chết) đáng lẽ phải là hồi chuông báo: tôi đang đo nhầm. Và đúng là nhầm, ở hai chỗ, cả hai đều nằm trong dụng cụ đo của chính tôi:
Thứ nhất, vòng lặp cấp phát chạy quá nhanh. Giữa con số RSS mà GROW vừa in ra (ví dụ 100 MB) và khoảnh khắc OOM thật sự nổ, GROW đã kịp cấp thêm nhiều bước nữa mà chưa in kịp (đầu ra bị đệm). RSS thật của GROW lúc bị chấm điểm lớn hơn nhiều con số tôi thấy, có lúc vượt cả HOG. Tôi đang so một con số cũ của GROW với con số hiện tại của HOG — so hai đại lượng ở hai thời điểm khác nhau. Chính vòng đo là biến ẩn.
Thứ hai, tôi chỉ đọc dòng Killed cuối. Trong ca hiếm, nhân giết HOG trước (đúng luật), nhưng RAM chưa kịp trả xong thì GROW vẫn đang cấp tiếp, châm một OOM lần hai tóm luôn GROW. Có hai bản án, tôi đọc mỗi cái cuối (GROW), nên tưởng GROW mới là nạn nhân — che mất việc HOG đã chết trước đó.
Cách chữa đúng hai lỗi này lộ ra ngay khi tôi nghĩ về chúng: làm chậm vòng lặp (thêm một nhịp nghỉ giữa mỗi bước) để RSS của GROW là con số xác định lúc OOM, và đọc đủ mọi dòng án trong dmesg thay vì mỗi dòng cuối. Làm vậy xong, kết quả ổn định hẳn: 7/8 lần chỉ HOG chết, và những ca "GROW chết" hóa ra đều là giết kép — HOG trước, GROW sau. Luật thật rõ ràng và nhất quán: nhân luôn giết kẻ to nhất trước. Bài học đo lường: hai số mâu thuẫn không có nghĩa hiện tượng ngẫu nhiên — nó có nghĩa tôi đang đo nhầm đại lượng, quan sát chưa đủ lâu, và đọc bản ghi chưa đủ đầy. Một lần nữa, chính cái vòng đo và cách tôi đọc kết quả là thứ nói dối, không phải hệ điều hành.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là một tiến trình bị giết đột ngột chưa chắc là thủ phạm rò rỉ. Khi một dịch vụ "tự nhiên" biến mất với mã thoát 137 (128 + tín hiệu 9), phản xạ thường là đi tìm lỗi trong chính dịch vụ đó. Nhưng OOM killer giết kẻ to nhất, nên thủ phạm thật có thể là một tiến trình khác đang rò rỉ bộ nhớ và đẩy cả máy tới bờ vực, còn nạn nhân chỉ là kẻ to nhất đang ngồi yên. Muốn tìm đúng thủ phạm, đọc dmesg (hay log OOM của cgroup): dòng "invoked oom-killer" chỉ ra kẻ châm ngòi, còn dòng "Killed process" chỉ ra nạn nhân — hai kẻ này thường khác nhau, và chính khoảng cách đó là manh mối.
Hệ quả thứ hai là hãy dùng oom_score_adj để bảo vệ cái không được chết. Nếu trên một máy có một tiến trình tối quan trọng — cơ sở dữ liệu, tiến trình điều phối, agent giám sát — đừng để nó ngang hàng với mọi tiến trình khác trong mắt OOM killer chỉ vì nó to. Đặt oom_score_adj âm cho nó (nhiều hệ thống init như systemd có OOMScoreAdjust=) để nhân né nó ra và giết thứ khác trước. Ngược lại, với các tiến trình phụ, dùng được, giết cũng chẳng sao (một worker có thể khởi động lại), đặt điểm dương để chúng làm "vật tế" trước.
Hệ quả thứ ba là đặt giới hạn bộ nhớ rõ ràng để cô lập thiệt hại. Một tiến trình rò rỉ không giới hạn có thể lôi cả máy vào cảnh OOM và kéo theo những nạn nhân vô can. Đặt mỗi dịch vụ trong một cgroup có memory.max riêng (điều mà docker run --memory hay systemd MemoryMax= làm) khiến khi nó phình ra thì OOM chỉ nổ trong cgroup của nó và chỉ giết chính nó, không đụng hàng xóm. Con số mang theo: khi hết RAM, nhân giết theo điểm badness ≈ RSS, tức kẻ TO NHẤT — không phải kẻ vừa cấp phát làm tràn; kẻ gây ra "invoke" OOM nhưng một tiến trình ngoài cuộc đang ngồi yên mới là kẻ bị "Killed" (7/8 lần trong phép đo), và oom_score_adj=-1000 đưa một tiến trình ra khỏi tầm ngắm. Biết luật này biến một cú chết bí ẩn thành một chuỗi nhân quả đọc được.
Thử ba mươi giây
Trên một máy Linux, xem điểm tử của các tiến trình nặng nhất: for p in $(ls /proc | grep -E '^[0-9]+$'); do s=$(cat /proc/$p/oom_score 2>/dev/null); echo "$s $p $(cat /proc/$p/comm 2>/dev/null)"; done | sort -rn | head. Kẻ đứng đầu bảng chính là kẻ OOM killer sẽ nhắm tới trước — thường là tiến trình ngốn RAM nhất, không nhất thiết là thứ bạn nghĩ. Muốn thấy tận mắt cú giết, chạy một tiến trình ghi dần vào một vùng lớn dưới một cap nhỏ (docker run --memory=256m ...) song song với một tiến trình khác đang chiếm sẵn nhiều RAM, rồi đọc dmesg | grep -i oom để xem ai "invoked" và ai bị "Killed" — đúng khoảng cách giữa thủ phạm và nạn nhân mà bài này đo.