Hình dung một chiếc thuyền chở quá tải sắp chìm. Thuyền trưởng phải quẳng bớt đồ xuống biển để nổi lại — và ông ta không đi tìm ai vừa mang thừa hành lý lên thuyền, ông ta quẳng cái kiện nặng nhất đang có trên khoang, bất kể nó của ai. Kẻ mang thừa đồ lên có thể đứng ngay đó, tay không, thản nhiên nhìn hành lý của người khác bị ném đi. OOM killer của Linux hành xử đúng như vị thuyền trưởng ấy, và bài này đo xem cái logic "ném thứ nặng nhất" dẫn tới hậu quả oái oăm thế nào.
Phần 15 kết thúc ở chỗ tắt swap nghĩa là bị giết ngay khi vượt giới hạn. Ta đo xem ai bị giết.
Bố trí
Container giới hạn 700 MB, không swap. Ba tiến trình lần lượt cấp và chạm vào bộ nhớ:
A-nho 100 MB oom_score 670
B-vua 200 MB oom_score 673
C-lon 250 MB oom_score 674
Tổng 550 MB, còn dư. Rồi tiến trình thứ tư xin thêm 300 MB — tổng lên 850 MB, vượt giới hạn.
Kẻ gây tràn không phải kẻ bị giết
còn sống sau khi OOM:
300 D-giot-nuoc-tran rss 308432 KB
100 A-nho rss 103632 KB
200 B-vua rss 206032 KB
C-lớn chết. D — tiến trình vừa làm tràn giới hạn — sống, và giữ trọn 300 MB nó xin. Chạy lại lần thứ hai, kết quả giống hệt.
Đây là điều quan trọng nhất cần nhớ về OOM killer: nó không trừng phạt kẻ gây ra vấn đề. Nó tìm cách giải phóng nhiều bộ nhớ nhất với ít lần giết nhất, và tiêu chí đó dẫn tới việc chọn tiến trình lớn nhất tại thời điểm đó — quẳng cái kiện nặng nhất, không phải kẻ mang thừa lên.
Hệ quả thực tế rất khó chịu: một tác vụ nền chạy sai — một lệnh import, một script dọn dữ liệu — có thể làm chết chính cơ sở dữ liệu, rồi tự nó chạy tiếp bình thường. Nhật ký sẽ ghi cơ sở dữ liệu bị giết, và không có gì trỏ về thủ phạm.
oom_score gần như vô dụng trong container
Bốn tiến trình chênh nhau ba lần về bộ nhớ. Điểm của chúng:
| Tiến trình | Bộ nhớ | oom_score |
|---|---|---|
| A-nho | 100 MB | 670 |
| B-vua | 200 MB | 673 |
| C-lon | 250 MB | 674 |
| D | 300 MB | 676 |
6 điểm chênh lệch cho 3 lần chênh bộ nhớ.
Lý do: oom_score trong /proc được tính theo phần trăm tổng RAM của máy — ở đây 16 GB — chứ không theo giới hạn của cgroup. 100 MB và 300 MB đều là những phần nhỏ xíu của 16 GB, nên chúng gần như bằng nhau. Con số 670 là phần nền chung. Nghĩa là đọc oom_score để đoán ai sẽ chết là vô ích khi tiến trình chạy trong container. Muốn đoán, hãy nhìn thẳng vào memory.current của từng cgroup con, hoặc VmRSS của từng tiến trình trong cùng cgroup.
oom_score_adj có tác dụng, nhưng không cứu được ai
Đặt oom_score_adj = 1000 cho A-nho — nghĩa là "giết cái này trước":
100 A-nho-adj-1000 adj 1000 score 1336
250 C-lon adj 0 score 674
A chết trước, đúng như mong đợi. Nhưng:
oom_kill = 2
Giải phóng 100 MB không đủ, nên OOM killer quay lại và giết luôn C-lớn. Kết cục: mất hai tiến trình thay vì một. Đưa một tiến trình nhỏ lên đầu danh sách hy sinh không cứu được tiến trình lớn. Nó chỉ thêm một lần giết nữa trước khi chuyện cũ vẫn xảy ra.
Chiều ngược lại — bảo vệ tiến trình quan trọng — mới là cách dùng đúng:
echo -900 > /proc/$(pidof postgres)/oom_score_adj
Giá trị âm cần CAP_SYS_RESOURCE; trong container thường không có, và lệnh sẽ báo I/O error — tôi vấp đúng chuyện đó khi thử. Với systemd thì khai trong unit:
[Service]
OOMScoreAdjust=-900
Dải giá trị là −1000 đến 1000. −1000 nghĩa là miễn nhiễm hoàn toàn, và đó là thứ nên dùng rất dè dặt: nếu tiến trình miễn nhiễm chính là cái đang rò rỉ bộ nhớ, nhân sẽ giết hết mọi thứ khác rồi bó tay, và máy treo cứng.
Ba con số trong memory.events
cat /sys/fs/cgroup/memory.events
max 86
oom 4
oom_kill 1
| Trường | Nghĩa |
|---|---|
max |
Số lần chạm trần giới hạn. Lớn nhưng oom bằng 0 nghĩa là nhân thu hồi kịp — sát nút nhưng chưa nguy. |
oom |
Số lần không còn gì để thu hồi. |
oom_kill |
Số tiến trình thật sự bị giết. |
max cao mà oom_kill bằng 0 là tín hiệu cảnh báo sớm tốt nhất bạn có được: giới hạn đang quá chặt, và chỉ cần một cú tăng tải là có người chết. Với Kubernetes, max cao tương ứng với việc pod liên tục sát trần limits.memory, và điều đó không hiện ra trong sự kiện của pod cho tới khi có kill thật.
Tìm dấu vết sau khi đã xảy ra
dmesg -T | grep -iE 'killed process|out of memory|oom-kill'
journalctl -k --since "1 hour ago" | grep -i oom
Dòng nhật ký của nhân liệt kê đầy đủ mọi tiến trình trong cgroup kèm RSS của từng cái tại thời điểm đó — đây là ảnh chụp duy nhất bạn có về hiện trường, và nó thường trả lời được câu hỏi "cái gì đã phình ra".
Với container, thêm một chỗ nữa:
docker inspect <ten> --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
kubectl get pod <ten> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
Mã thoát 137 kèm OOMKilled: true là chữ ký không thể nhầm.
Mẫu số chung: hệ thống dọn khủng hoảng tối ưu cho thoát hiểm, không cho công bằng
Cái làm người ta ngã ngửa ở đây — nạn nhân không phải thủ phạm — không riêng gì OOM killer: gần như mọi cơ chế dọn dẹp khẩn cấp đều được thiết kế để thoát khỏi khủng hoảng nhanh nhất, chứ không phải để quy trách nhiệm đúng người. Trách nhiệm là việc của công cụ điều tra, không phải của cơ chế cứu hoả.
Trong thế giới Java, đây đúng là OutOfMemoryError: cái ngoại lệ được ném ra ở chỗ vừa xin thêm bộ nhớ và không được cấp, chứ không phải ở chỗ đã rò rỉ — nên stack trace trỏ vào một dòng vô can (thường là một lệnh cấp phát nhỏ xíu tình cờ là giọt nước tràn ly), còn kẻ giữ hàng GB thật sự thì không xuất hiện. Muốn tìm thủ phạm phải mở heap dump ra soi, đúng như phải đọc dòng dmesg liệt kê RSS từng tiến trình.
Trong Go, cùng chuyện: khi runtime không cấp được bộ nhớ nó gọi thẳng fatal error: out of memory và tiến trình chết tại lời gọi cấp phát hiện thời — không phải tại goroutine leak đã âm thầm tích luỹ; phải dựa vào pprof heap chụp trước đó mới thấy kẻ giữ bộ nhớ.
Còn ở cơ sở dữ liệu, đây là cách xử lý deadlock: khi hai giao dịch khoá chéo nhau, engine chọn một "nạn nhân" để rollback theo tiêu chí cuộn lại rẻ nhất (ít thay đổi nhất), không theo ai sai; giao dịch bị huỷ có thể là cái vô tội, còn cái gây ra vòng khoá vẫn chạy tiếp. Tối ưu cho thoát bế tắc, không cho công bằng.
Mẫu số chung: cơ chế dọn khủng hoảng của mọi hệ thống được thiết kế để phục hồi nhanh và rẻ nhất — giết tiến trình lớn nhất, ném lỗi ở chỗ xin cuối, rollback giao dịch rẻ nhất — nên nạn nhân nó chọn tối ưu cho việc thoát hiểm, gần như không bao giờ là kẻ gây ra vấn đề; và tín hiệu tại thời điểm sập luôn trỏ nhầm. Nên khi đọc một sự cố kiểu này, câu hỏi đúng không phải "cái gì vừa chết / lỗi ném ở đâu" mà là "cái gì đang giữ nhiều tài nguyên nhất trước thời điểm sập" — và câu đó chỉ trả lời được bằng ảnh chụp trạng thái (dmesg liệt kê RSS, heap dump, pprof), không bằng chính thông báo lỗi.
Muốn tự soi tiến trình nào đang đứng đầu danh sách hy sinh trên máy bạn:
for p in $(ls /proc | grep -E '^[0-9]+$'); do
sc=$(cat /proc/$p/oom_score 2>/dev/null) || continue
adj=$(cat /proc/$p/oom_score_adj 2>/dev/null)
rss=$(awk '/^VmRSS:/{print $2}' /proc/$p/status 2>/dev/null)
[ -n "$rss" ] && printf "%6s %6s %9s %s\n" "$sc" "$adj" "$rss" \
"$(tr -d '\0' < /proc/$p/cmdline | cut -c1-45)"
done 2>/dev/null | sort -rn | head -10 | \
awk 'BEGIN{print " score adj rss(KB) lenh"} {print}'
echo "--- lich su ---"
dmesg -T 2>/dev/null | grep -i "killed process" | tail -5
Dòng đầu tiên là tiến trình sẽ chết trước nếu máy hết bộ nhớ ngay bây giờ. Nếu đó là cơ sở dữ liệu hoặc dịch vụ chính của bạn, hãy hạ oom_score_adj của nó xuống — và quan trọng hơn, hãy tìm xem cái gì đang tranh bộ nhớ với nó.
Phần sau: cgroup v2 — cách nhân thực sự giới hạn CPU, bộ nhớ và I/O.