Ngân hàng cấp cho bạn hạn mức thẻ tín dụng 500 triệu — một con số đẹp in trên hợp đồng. Nhưng nó không có nghĩa 500 triệu đó đang nằm sẵn chờ bạn; nó là một lời hứa sẽ ứng, cược rằng bạn không rút hết cùng lúc. Ngày bạn thật sự quẹt tới giới hạn mà tài khoản không kham nổi, giao dịch không "báo lỗi lịch sự" — thẻ bị khóa cứng giữa lúc thanh toán. malloc trên Linux cấp bộ nhớ đúng kiểu hạn mức thẻ đó, và tôi đã đo được khoảnh khắc "thẻ bị khóa" — nó tàn nhẫn hơn tôi tưởng. Ở 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 đó, và 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?

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ã 137 không phải bug trong code — và tôi đã đào nhầm chỗ

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. Điều rút ra: 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ề đến code hằng ngày

Điều đầ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.

Điều 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.

Và cái bẫy "thành công giả rồi chết đột ngột lúc dùng" này đội lốt ở mọi runtime cấp cao — biết nó đỡ bạn hàng giờ đào nhầm chỗ. Một app Java trong container thường bị OOM-kill dù heap chưa đầy: cgroup đếm cả bộ nhớ ngoài heap (metaspace, thread stack, direct buffer, mã JIT), nên -Xmx vừa vặn vẫn có thể vượt memory.limit và ăn exit 137 — thủ phạm không phải OutOfMemoryError của JVM (cái đó có stacktrace) mà là SIGKILL không dấu vết. Go cũng vậy: runtime xin bộ nhớ ảo thoải mái, RSS mới là cái cgroup đếm. Ngay cả một pod chạy PostgreSQL: work_mem nhân với số kết nối có thể vượt limit lúc tải cao, và Postgres biến mất không kịp ghi log. Mẫu số chung: "cấp phát thành công" ở tầng ngôn ngữ không phải bằng chứng bạn có tài nguyên vật lý; điểm thất bại thật bị dời tới lúc chạm, và ở đó nó thường là một cái chết đột ngột từ bên ngoài, không phải một exception bắt được. Gói lại bằng số: Linux cấp vượt nên malloc(500MB) thành công dù container chỉ 100MB; RAM thật chỉ được cấp khi chạm trang (memory.current leo 23→43→83→99MB), và khi vượt memory.max nhân OOM-kill bằng SIGKILL — exit 137 (=128+9), memory.events ghi oom_kill=1 — chứ không trả lỗi cho malloc.

Muốn tự thấy thì 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.