I/O, Lưu trữ & Tài nguyên/OOM kill và ulimit — khi kernel ra tay
24/26
Bài 24 / 26~14 phútTài nguyên & Đo lườngMiễn phí lượt xem

OOM kill và ulimit — khi kernel ra tay

Đọc một dòng OOM trong dmesg: ai bị giết và vì sao, oom_score tính thế nào, OOM cấp cgroup khác OOM toàn máy ra sao, và trần fd làm hỏng service thế nào.

TL;DR: Khi không còn cách nào cấp thêm bộ nhớ, kernel chọn một tiến trình và gửi SIGKILL — không exception, không stack trace, chỉ có một khối log trong dmesg. Khối log đó trả lời hai câu quan trọng nhất: trường constraint cho biết đây là OOM toàn máy hay OOM trong phạm vi một cgroup, và anon-rss cho biết nạn nhân thật sự giữ bao nhiêu bộ nhớ không thể thu hồi. Song song với trần bộ nhớ còn một họ trần khác, RLIMIT_NOFILE, giết service theo kiểu êm hơn nhưng cũng khó hiểu không kém: EMFILE: too many open files ở mức mặc định 1024.

3 giờ sáng, một service biến mất. Log ứng dụng cụt giữa chừng, không exception, không stack trace. Container hiện exit code 137. Đội trực khởi động lại rồi ghi biên bản: "chưa rõ nguyên nhân".

Nguyên nhân thì rõ, chỉ là nó không được ghi ở nơi mọi người quen nhìn. 137128 + 9: tiến trình bị hạ bằng SIGKILL, thứ không cho ai kịp bắt hay ghi log. Người duy nhất còn nhớ chuyện gì đã xảy ra là kernel.

1. Analogy — chiếc thuyền và cái khoang

Thuyền quá tải thì thuyền trưởng ném bớt hàng xuống nước, và ông ta chọn kiện nặng nhất chứ không chọn kiện xấu tính nhất. Đó đúng là triết lý của OOM killer.

Nhưng có hai kiểu quá tải rất khác nhau: cả con thuyền sắp chìm, hay riêng khoang hàng bạn thuê đã đầy trong khi thuyền còn trống một nửa. Cách xử lý của thuyền trưởng giống nhau, còn thứ cần sửa thì trái ngược hẳn.

Trên thuyềnTrong hệ thống
Cả thuyền sắp chìmOOM toàn máy — constraint=CONSTRAINT_NONE
Riêng khoang bạn thuê đã đầyOOM cấp cgroup — constraint=CONSTRAINT_MEMCG
Chọn kiện nặng nhấtoom_score tính theo bộ nhớ đang giữ
Đánh dấu "tuyệt đối không ném"oom_score_adj bằng -1000

Hai loai OOM khac nhau o cho tran bi cham la tran may hay tran cua rieng mot nhom

2. Đọc một lần OOM kill trong dmesg

python3 invoked oom-killer: gfp_mask=0x140cca(GFP_HIGHUSER_MOVABLE|__GFP_COMP),
    order=0, oom_score_adj=0
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,
    global_oom,task_memcg=/user.slice/user-1000.slice/session-1.scope,
    task=python3,pid=8340,uid=1000
Out of memory: Killed process 8340 (python3) total-vm:7825136kB,
    anon-rss:7680048kB, file-rss:0kB, shmem-rss:0kB,
    UID 1000 pgtables:15232kB oom_score_adj:0
oom_reaper: reaped process 8340 (python3), now anon-rss:0kB, file-rss:0kB

Bốn dòng log đó đã được ngắt xuống cho vừa cột chữ. Đọc theo thứ tự:

Dòng 1 cho biết ai chạm phải bức tường: python3 xin bộ nhớ và kernel không cấp nổi. Tiến trình gọi và tiến trình bị giết không nhất thiết là một.

Dòng 2 là dòng đắt nhất, vì constraint= phân loại toàn bộ sự việc. CONSTRAINT_NONE (kèm global_oom) nghĩa là cả máy hết bộ nhớ khả dụng. Nếu chỗ đó là CONSTRAINT_MEMCG thì câu chuyện đổi hẳn: máy còn dư RAM, chỉ có một cgroup chạm memory.max của nó. Trường task_memcg= cho biết nạn nhân thuộc nhóm nào, và đó là đường truy ngược ra đúng container.

Dòng 3 là bản khai tài sản của nạn nhân, và mỗi con số nói một chuyện khác nhau:

  • total-vm:7825136kB — không gian địa chỉ ảo, tức VSZ, gần như không dùng để kết luận gì.
  • anon-rss:7680048kB — trang ẩn danh đang cư trú, khoảng 7,3 GB. Đây mới là con số thật. Trang ẩn danh không có bản nào trên đĩa, nên không swap thì kernel không thu hồi được, và nó là thứ khiến tiến trình này thành mục tiêu.
  • file-rss:0kB — không có trang nào có file nền để bỏ đi. Không có đường thoát nhẹ nhàng nào cả.
  • pgtables:15232kB — riêng bảng trang tốn 15 MB, con số đáng nhớ khi ai đó định chạy vài nghìn tiến trình nhỏ.

Ba dòng RssAnon, RssFile, RssShmem của bài đó xuất hiện lại ở đây dưới tên anon-rssfile-rss. Chia được hai loại trang ấy thì đọc dòng OOM là hiểu ngay vì sao đúng tiến trình này bị chọn.

Dòng 4 cho biết oom_reaper đã thu hồi xong bộ nhớ, tức máy đã thoát hiểm chứ không còn giãy giụa.

3. Kernel chọn nạn nhân thế nào?

Mỗi tiến trình có một điểm số, đọc được ở oom_score trong thư mục /proc của nó. Điểm càng cao càng dễ bị chọn: nó xấp xỉ lượng bộ nhớ đang giữ, cộng phần điều chỉnh của oom_score_adj.

oom_score_adj nằm trong dải từ -1000 tới 1000 và là núm xoay dành cho người vận hành:

cat /proc/self/oom_score        # diem hien tai
echo -500 > /proc/self/oom_score_adj   # giam kha nang bi chon

Giá trị -1000 khiến tiến trình gần như miễn nhiễm, và đây là chỗ dễ bắn vào chân mình: bảo vệ một tiến trình đúng nghĩa là đẩy án tử sang tiến trình khác, mà ứng viên kế tiếp rất có thể là sshd — mất luôn đường vào máy để cứu chữa.

Ở cấp cgroup còn một công tắc thẳng thắn hơn: memory.oom.group bằng 1 thì kernel giết cả nhóm một lượt. Nghe hung bạo nhưng thường đúng, vì container mất tiến trình lõi mà vẫn "đang chạy" là trạng thái tệ hơn chết hẳn để được khởi động lại.

4. Tự điền — hai lần OOM này khác nhau ở đâu?

Hai trích đoạn dưới lấy từ hai sự cố khác nhau, đã lược bớt phần không liên quan:

Su co A:
oom-kill:constraint=CONSTRAINT_NONE,...,global_oom,
    task_memcg=/user.slice/...,task=python3,pid=8340
Out of memory: Killed process 8340 (python3)
    total-vm:7825136kB, anon-rss:7680048kB, file-rss:0kB

Su co B:
oom-kill:constraint=CONSTRAINT_MEMCG,...,
    task_memcg=/system.slice/docker-4f2a....scope,task=java,pid=2211
Out of memory: Killed process 2211 (java)
    total-vm:9214000kB, anon-rss:2036120kB, file-rss:12kB

A: loai OOM = ______   thu can sua = ______
B: loai OOM = ______   thu can sua = ______
Tự điền trước khi xem đáp án

Ba câu hỏi định hướng, không phải đáp án. Thứ nhất: trường nào trong hai dòng trên nói cho bạn biết bức tường bị chạm là trần của máy hay trần của một nhóm? Thứ hai: ở sự cố B, anon-rss chỉ khoảng 2 GB trên một máy chủ hiện đại — con số đó có mâu thuẫn với việc bị giết vì hết bộ nhớ không, và nếu không thì vì sao? Thứ ba: với mỗi ca, cùng một hành động "thêm RAM cho máy chủ" có tác dụng như nhau không? Viết bốn ô trả lời của bạn ra trước.

Sự cố A — OOM toàn máy. CONSTRAINT_NONE cùng global_oom nghĩa là toàn bộ máy đã cạn bộ nhớ khả dụng. Nạn nhân giữ 7,3 GB trang ẩn danh và không có trang nào có file nền để kernel bỏ đi, nên nó vừa là nguyên nhân vừa là mục tiêu. Hướng sửa nằm ở phía nhu cầu thật: giảm mức tiêu thụ của chính tiến trình đó, thêm RAM, hoặc tách nó sang máy khác.

Sự cố B — OOM cấp cgroup. CONSTRAINT_MEMCG cùng task_memcg trỏ vào một scope của Docker nghĩa là container đó chạm memory.max của riêng nó, trong khi máy chủ hoàn toàn có thể còn trống hàng chục GB. Con số anon-rss 2 GB không hề mâu thuẫn: nó chỉ cần vượt trần của cái khoang, không cần vượt sức chứa của con thuyền. Thêm RAM cho máy chủ ở đây không giúp được gì, vì trần vẫn nguyên đó.

Đáng chú ý ở ca B là nạn nhân tên java — kiểu sự cố kinh điển khi -Xmx được đặt đúng bằng memory.max, trong khi JVM còn tốn metaspace, ngăn xếp mỗi thread và bộ đệm ngoài heap. Tiến trình bị giết ở mức cgroup trong khi heap chưa hề đầy, nên không có OutOfMemoryError, không heap dump, không gì trong log. Cách sửa: cho heap khoảng 70–75% trần, hoặc dùng tuỳ chọn đặt heap theo phần trăm bộ nhớ khả dụng để JVM tự đọc giới hạn cgroup.

5. ulimit — cái trần giết theo kiểu khác

Mỗi tiến trình còn mang theo một bộ giới hạn tài nguyên khác, và cái hay giết service nhất là RLIMIT_NOFILE: số file descriptor được mở cùng lúc.

ulimit -n        # tran mem hien tai, rat hay la 1024
ulimit -Hn       # tran cung, tren systemd doi moi co the toi 512K

ulimit chỉ là lệnh dựng sẵn của shell, gói lại lời gọi hệ thống đặt và đọc các giới hạn ấy. Trần mềm 1024 là di sản tương thích với select(), ở lại lâu hơn hẳn lý do tồn tại của nó. Vượt trần thì mọi lời gọi mở file hay chấp nhận kết nối mới trả về EMFILE: too many open files.

Triệu chứng khó chịu ở chỗ nó không giết tiến trình: service vẫn sống, vẫn qua health check, chỉ là không nhận thêm kết nối nào. Với server dùng epoll để canh hàng vạn kết nối, trần này chặn bạn trước cả khi kiến trúc kịp thể hiện — mỗi kết nối là một fd, và 1024 fd thì epoll chẳng có gì để tỏa sáng.

6. Pitfall của riêng concept này

Nhầm 1 — tìm nguyên nhân trong log ứng dụng sau một lần OOM.

SIGKILL không cho tiến trình chạy thêm dòng nào. Nơi cần nhìn là dmesg -T hoặc journalctl -k, và trong container thì log đó nằm ở máy chủ chứ không nằm trong container.

Nhầm 2 — thấy máy còn dư RAM rồi loại bỏ giả thuyết OOM.

✅ OOM cấp cgroup xảy ra trong lúc máy còn trống. Trường constraint là chỗ phân xử, không phải biểu đồ RAM của máy chủ.

Nhầm 3 — đặt -Xmx của JVM đúng bằng memory.max.

✅ Ngoài heap còn metaspace, ngăn xếp mỗi thread và bộ đệm ngoài heap. Đặt trùng nhau là mời gọi bị giết mà không có OutOfMemoryError nào để lần theo.

Nhầm 4 — nâng ulimit -n bằng cách gõ lệnh trong shell rồi tưởng service đã được nâng.

✅ Giới hạn đi theo tiến trình và di truyền cho con cháu, nên nó phải được đặt ở nơi service được khởi động: LimitNOFILE= trong unit file của systemd, hoặc cấu hình tương ứng của trình quản lý container.

7. 📚 Đào sâu (tuỳ chọn)

📚 Nguồn kỹ thuật

Còn một núm đáng biết ở mức khái niệm: chính sách overcommit quyết định malloc có được hứa nhiều hơn bộ nhớ thật hay không. Chế độ nghiêm ngặt khiến cấp phát thất bại ngay thay vì thành công rồi bị giết sau, tức đổi kiểu hỏng sang thứ chương trình bắt được.

8. Liên hệ các bài khác

9. Tóm tắt

  • exit code 137 là chữ ký của SIGKILL; gặp nó thì mở dmesg trước khi mở log ứng dụng.
  • Trường constraint chia đôi thế giới: hết RAM thật, hay chạm trần của một cgroup trong khi máy còn dư.
  • anon-rss là con số đáng đọc trong bản khai của nạn nhân, vì trang ẩn danh không có đường thu hồi nào ngoài swap.
  • Bảo vệ một tiến trình bằng oom_score_adj luôn có nghĩa là đẩy rủi ro sang tiến trình khác — cân nhắc trước khi miễn nhiễm cho thứ gì.
  • Trần fd mặc định 1024 không giết tiến trình mà làm nó câm lặng, và phải được nâng ở nơi service được khởi động.

10. Tự kiểm tra

Tự kiểm tra
Q1
Một container thoát với mã 137 lúc 3 giờ sáng, log ứng dụng không có gì bất thường ở dòng cuối. Bạn kiểm tra theo thứ tự nào, và vì sao log ứng dụng không thể có manh mối?

Mã 137 là 128 cộng 9, tức tiến trình nhận SIGKILL — tín hiệu không thể bắt hay xử lý, nên log cụt là triệu chứng chứ không phải thiếu thông tin. Thứ tự kiểm tra: mở dmesg -T hoặc journalctl -k trên máy chủ (không phải trong container) quanh mốc thời gian đó, tìm khối oom-kill, rồi đọc trường constraint để biết OOM toàn máy hay OOM cấp cgroup, và task_memcg để truy ra đúng container.

Q2
Vì sao phân biệt CONSTRAINT_NONE với CONSTRAINT_MEMCG lại đổi hẳn hành động khắc phục?

CONSTRAINT_NONE nghĩa là cả máy đã cạn bộ nhớ khả dụng: nhu cầu thật vượt phần cứng thật. Hướng xử lý là giảm tiêu thụ, thêm RAM, hoặc dời bớt tải sang máy khác.

CONSTRAINT_MEMCG nghĩa là một cgroup chạm memory.max của riêng nó trong khi máy chủ có thể còn trống rất nhiều. Thêm RAM cho máy chủ không đổi được gì vì trần vẫn nguyên; thứ cần sửa là hạn mức của container hoặc mức tiêu thụ của chính ứng dụng bên trong nó.

Q3
Hai tiến trình cùng RSS 4 GB, một cái gần như toàn file-rss, cái kia gần như toàn anon-rss. Cái nào dễ bị OOM killer chọn hơn và vì sao?

Tiến trình nhiều anon-rss dễ bị chọn hơn. Trang ẩn danh là heap và ngăn xếp, không có bản sao nào trên đĩa, nên kernel chỉ có thể đẩy chúng sang swap; máy không bật swap thì hoàn toàn không thu hồi được. Bộ nhớ nó giữ vì thế là bộ nhớ bị khoá chặt.

Tiến trình nhiều file-rss thì khác: kernel bỏ trang có file nền đi bất cứ lúc nào rồi đọc lại từ đĩa, nên áp lực được giải toả trước khi OOM killer phải khởi động.

Q4
Một service Java trong container 4 GB đặt -Xmx4g và bị giết định kỳ, nhưng không lần nào có OutOfMemoryError hay heap dump. Giải thích cơ chế và nêu cách sửa.

Bộ nhớ mà cgroup tính không chỉ có heap: JVM còn dùng metaspace, ngăn xếp cho từng thread, bộ đệm ngoài heap và vùng chứa mã đã biên dịch. Đặt -Xmx đúng bằng memory.max nghĩa là tổng thực tế vượt trần trong khi heap vẫn còn chỗ. Trần bị chạm ở mức cgroup nên kernel gửi SIGKILL; JVM không hề hết heap nên không ném OutOfMemoryError và không kịp ghi heap dump. Cách sửa là chừa biên: cho heap khoảng 70–75% trần, hoặc dùng tuỳ chọn đặt heap theo phần trăm bộ nhớ khả dụng để JVM tự đọc giới hạn cgroup.

Q5
Một server báo hàng loạt lỗi too many open files nhưng tiến trình vẫn sống và vẫn qua health check. Chuyện gì đang xảy ra, và vì sao gõ ulimit -n 65535 trong shell rồi lại không sửa được?

Tiến trình đã chạm trần mềm RLIMIT_NOFILE, mặc định vẫn thường là 1024 vì lý do tương thích cũ. Mọi lời gọi mở file hay chấp nhận kết nối mới trả về EMFILE, nhưng bản thân tiến trình không bị giết — nên nó vẫn sống, vẫn trả lời health check, chỉ là không phục vụ được ai mới.

ulimit trong shell chỉ đổi giới hạn của chính shell đó và các tiến trình nó sinh ra sau đấy; service đang chạy đã nhận giới hạn từ lúc khởi động. Phải đặt ở đúng chỗ khởi động — LimitNOFILE= trong unit file của systemd hoặc cấu hình tương ứng của trình quản lý container — rồi khởi động lại.

Bài tiếp theo: Mini-challenge — máy chậm, nghẽn ở đâu?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Mini-challenge — máy chậm, nghẽn ở đâu?