Ở bài về cgroup CPU tôi hứa sẽ nói về OOM killer — chuyện xảy ra khi bộ nhớ của một nhóm cạn kiệt. Đây là bài đó. Nhưng nó bắt đầu bằng một câu hỏi bẫy mà nhiều lập trình viên C trả lời sai: khi malloc trả về khác NULL, có phải bạn đã chắc chắn có bộ nhớ để dùng không? Tôi đo trong một container giới hạn 100MB, và câu trả lời làm sụp đổ một giả định quen thuộc.
Cấp phát là lời hứa, không phải đặt trước
Linux cấp vượt (overcommit) bộ nhớ: khi bạn gọi malloc hay mmap, nhân thường đồng ý ngay và trả về một con trỏ khác NULL, kể cả khi nó không có đủ RAM để đỡ lượng đó. Cấp phát chỉ là một lời hứa cấp không gian địa chỉ ảo, không phải một sự đặt trước RAM thật. RAM thật chỉ được cấp khi bạn chạm vào một trang — tức ghi vào nó — đúng cơ chế cấp lười mà bài bộ nhớ ảo đã đo.
Vì sao nhân làm vậy? Vì hầu hết chương trình xin nhiều hơn chúng dùng (một mảng cấp 500MB nhưng chỉ chạm 50MB), nên đồng ý trước rồi cấp thật khi cần cho phép chạy nhiều tiến trình hơn. Nhưng nó tạo ra một tình huống nguy hiểm: điều gì xảy ra khi mọi tiến trình cùng đòi thu lời hứa, mà RAM không đủ?
Đo: malloc thành công, rồi bị giết khi chạm
Tôi chạy một container với docker run --memory=100m (đặt memory.max = 100MB), rồi cho chương trình xin 500MB và chạm từng trang:
Container giới hạn 100MB (memory.max = 104857600 byte):
malloc(500MB) trả về: KHÁC NULL (overcommit cho phép, dù chỉ có 100MB!)
chạm từng trang:
đã chạm 20 MB | memory.current = 23 MB
đã chạm 40 MB | memory.current = 43 MB
đã chạm 80 MB | memory.current = 83 MB
đã chạm 100 MB | memory.current = 99 MB (kịch trần)
đã chạm 180 MB | ...
Killed
exit code = 137
memory.events: oom_kill 1
Hai điều đáng nhớ. Thứ nhất, malloc(500MB) thành công — trả về con trỏ khác NULL — dù container chỉ có 100MB. Nếu tôi tin "malloc khác NULL nghĩa là có bộ nhớ", tôi đã sai. Thứ hai, khi tôi thật sự dùng bộ nhớ đó (chạm từng trang), memory.current leo dần tới trần 100MB, rồi khi lượng chạm vượt ngân sách RAM (cộng chút swap) mà nhóm được phép, nhân ra tay: chương trình bị giết bằng SIGKILL, in ra "Killed" và thoát với mã 137.
Con số 137 chính là chữ ký của OOM killer: 137 = 128 + 9, mà 9 là SIGKILL. Và memory.events của cgroup ghi lại oom_kill 1 — một bằng chứng khách quan rằng nhân đã giết một tiến trình vì hết bộ nhớ trong nhóm này.
OOM killer chọn nạn nhân thế nào
Khi cầu vượt cung — RAM máy cạn, hoặc một cgroup vượt memory.max — nhân không thể chỉ trả lỗi cho lời gọi cấp phát tiếp theo, vì bộ nhớ đã được hứa từ trước. Nó phải lấy lại bộ nhớ, và cách quyết liệt nhất là giết hẳn một tiến trình để giải phóng toàn bộ bộ nhớ của nó. Đó là OOM killer (Out-Of-Memory killer). Nó chọn nạn nhân theo một điểm số oom_score — đại khái ưu tiên giết tiến trình đang dùng nhiều bộ nhớ nhất (để lấy lại được nhiều nhất), có điều chỉnh bằng oom_score_adj mà bạn có thể đặt để bảo vệ hay hi sinh một tiến trình cụ thể.
Điểm quan trọng: nạn nhân nhận SIGKILL — tín hiệu không thể bắt, không thể chặn, không thể bỏ qua. Không có handler nào chạy, không có finally, không có cơ hội lưu dữ liệu hay đóng file sạch sẽ. Tiến trình đơn giản là biến mất. Đây là lý do một dịch vụ "tự nhiên chết" mà log của chính nó không có dòng lỗi nào — nó không tự crash; nó bị nhân giết từ bên ngoài.
Một lần tôi đo hớ: mã 137 không phải lỗi trong code
Cái bẫy chẩn đoán ở đây tôi đã vấp nhiều lần. Khi một tiến trình thoát với mã 137 và không để lại dấu vết lỗi, phản xạ tự nhiên là đi tìm bug trong code: một ngoại lệ chưa bắt? một segfault? Nhưng 137 không phải lỗi của chương trình — nó là dấu hiệu chương trình bị OOM killer giết. Sự vô lý "chết mà không có lỗi trong log của mình" chính là manh mối: cái giết nó đến từ bên ngoài, và bằng chứng nằm ở memory.events (đếm oom_kill) hay dmesg (nhân ghi dòng "Out of memory: Killed process..."), không phải trong log ứng dụng.
Kết hợp với cái bẫy đầu: malloc trả khác NULL khiến tôi tin mình có bộ nhớ, nên khi bị giết tôi càng bối rối. Bài học đo lường: một "thành công" (malloc khác NULL) khi thật ra không có tài nguyên chính là công cụ đang nói dối — và cái giá của lời nói dối đó không phải một lỗi trả về ngay lúc cấp, mà là một cái chết đột ngột lúc dùng. Overcommit dời điểm thất bại từ lúc malloc (trả NULL, bắt được) sang lúc chạm trang (SIGKILL, không bắt được). Kiểm if (p == NULL) là cần, nhưng nó không bảo vệ bạn khỏi OOM-kill.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: hiểu rằng "chết bí ẩn với exit 137" gần như luôn là OOM-kill. Trong container/Kubernetes, đây là một trong những sự cố phổ biến nhất: một pod đặt memory limit thấp, ứng dụng dùng vượt (một truy vấn nặng, một cache phình to), và kubelet/nhân giết nó — bạn thấy pod restart liên tục với lý do OOMKilled. Khi gặp restart hay biến mất bí ẩn, kiểm bộ nhớ và oom_kill trước, đừng đào code tìm bug không tồn tại.
Hệ quả thứ hai: kiểm malloc == NULL là cần nhưng không đủ trên Linux có overcommit. Vì cấp phát thường thành công, con đường thất bại thật là lúc chạm bộ nhớ, và nó là SIGKILL bạn không bắt được. Muốn malloc thất bại đàng hoàng (trả NULL) khi hết bộ nhớ, phải tắt overcommit (vm.overcommit_memory=2) — một lựa chọn có đánh đổi. Trong thực tế, cách phòng thủ đúng là đặt và tôn trọng giới hạn bộ nhớ: biết ứng dụng dùng bao nhiêu, đặt memory limit đủ, và giám sát để không đụng trần.
Hệ quả thứ ba là bài học bao trùm cả nhánh giới hạn tài nguyên: cấp phát bộ nhớ là một hợp đồng có thể bị hủy đơn phương. Con số mang theo: Linux cấp vượt nên malloc(500MB) thành công dù chỉ có 100MB; RAM thật chỉ được cấp khi chạm trang, và khi vượt memory.max nhân OOM-kill tiến trình bằng SIGKILL — exit 137, memory.events ghi oom_kill=1 — chứ không trả lỗi cho malloc. malloc khác NULL không phải bằng chứng bạn có bộ nhớ; cái chết đột ngột lúc dùng mới là sự thật.
Thử ba mươi giây
Tự tạo một OOM-kill an toàn: docker run --rm -m 50m python:3-slim python -c "x = bytearray(200*1024*1024)" — cấp 200MB trong container 50MB. Nó sẽ in "Killed" và docker báo exit 137, minh họa đúng chuyện bài này đo. Rồi trên máy Linux của bạn, đọc cat /proc/self/status | grep VmRSS để thấy RSS thật (RAM đang dùng) khác VSZ (đã hứa) thế nào, và nếu có tiến trình vừa bị giết, dmesg | grep -i "killed process" sẽ hiện dòng nhân ghi lại vụ OOM-kill kèm tên và điểm oom_score của nạn nhân — bằng chứng đen trắng rằng nó bị giết từ bên ngoài, không phải tự chết.