Một service chạy hoàn hảo khi test, deploy lên production, và... vài giờ (hoặc vài ngày) sau bỗng sập với lỗi bí ẩn "Too many open files". Restart lại thì ổn — cho tới lần sập tiếp theo. Đây là dấu hiệu kinh điển của rò rỉ file descriptor: chương trình mở file, socket, hay kết nối mà quên đóng, và số file descriptor tăng dần cho tới khi chạm giới hạn kernel. Bài này (phần 4 loạt Debug) chạy thật để thấy một rò rỉ diễn ra tới lúc crash, cách sửa, và cách phát hiện sớm bằng /proc.
file descriptor có giới hạn
Mỗi khi mở một file, socket, pipe... hệ điều hành cấp một file descriptor (fd) — một số nguyên đại diện cho tài nguyên đó. Kernel giới hạn số fd mỗi tiến trình được giữ, xem bằng ulimit -n:
ulimit -n # giới hạn "soft" (số fd tối đa hiện tại)
Khi tiến trình đã giữ đủ số fd giới hạn và cố mở thêm, open() (hay socket()) thất bại với lỗi EMFILE — "Too many open files". Nếu app không xử lý lỗi này, nó crash.
Rò rỉ: mở mà quên đóng
Nguyên nhân gần như luôn giống nhau — mở tài nguyên trong vòng lặp / mỗi request mà không đóng:
for i in range(...):
f = open("x") # mở, fd tăng
# ... dùng f nhưng QUÊN f.close()
# fd tăng mãi -> tới lúc chạm ulimit -> chết

Hình 1: Mỗi file/socket mở tốn một fd; kernel giới hạn qua ulimit -n, vượt là EMFILE; rò rỉ là mở mà quên đóng; cách đúng là luôn đóng (with/defer); chẩn đoán bằng đếm /proc/<pid>/fd.
Đo thật: rò rỉ tới lúc crash, và bản sửa
Mình chạy một vòng lặp mở /etc/hostname liên tục không đóng, dưới ulimit -n 256 (hạ giới hạn để chạm nhanh), và đếm fd qua /proc/<pid>/fd:

Hình 2: Chạy thật — rò rỉ: fd tăng 5 → 205 theo số lần mở, rồi OSError: [Errno 24] Too many open files khi chạm giới hạn 256; bản dùng with (tự đóng): fd phẳng lì ở 4 qua 100000 lần, không rò rỉ.
- Rò rỉ đến crash: fd đếm được tăng đều — 5, rồi 205 sau 200 lần mở — cho tới khi chạm
ulimit -n 256vàopen()ném[Errno 24] Too many open files. Con số fd tăng tuyến tính theo số lần mở là bằng chứng rò rỉ. - Bản sửa: dùng
with open(...)(Python tự đóng file khi ra khỏi block), fd giữ nguyên ở 4 suốt 100000 vòng lặp. Tài nguyên được trả lại ngay sau mỗi lần dùng, nên số fd ổn định dù lặp bao nhiêu lần. - Đếm fd:
ls /proc/<pid>/fd | wc -lcho số fd đang mở của một tiến trình (hoặclsof -p <pid>để xem chi tiết). Theo dõi con số này tăng dần theo thời gian là cách phát hiện rò rỉ trước khi app sập.
Đánh đổi cần cân nhắc
Tăng ulimit không sửa rò rỉ — chỉ dời thời điểm sập. Khi gặp "too many open files", phản xạ sai là nâng ulimit -n lên thật cao. Điều đó chỉ khiến app sập muộn hơn (và tốn nhiều tài nguyên hơn trước khi sập). Nếu app thật sự cần nhiều fd (server nhiều kết nối đồng thời), nâng ulimit là đúng; nhưng nếu fd tăng không ngừng, đó là rò rỉ cần sửa ở code, không phải ở ulimit. Phân biệt: fd đạt mức ổn định = cần ulimit đủ; fd tăng mãi = rò rỉ.
Không chỉ file — socket, pipe, kết nối DB đều là fd. Rò rỉ fd hay gặp nhất thực ra ở kết nối mạng không đóng: mỗi HTTP request tạo một kết nối, không dùng connection pool hoặc không đóng response. lsof -p cho thấy phần lớn fd là socket hay TCP chứ không phải file. Dùng connection pool và luôn đóng response/kết nối.
soft và hard limit khác nhau. ulimit -Sn là giới hạn mềm (áp dụng hiện tại, tiến trình tự nâng được tới hard), ulimit -Hn là trần cứng (chỉ root nâng được). Trong container, giới hạn còn phụ thuộc cấu hình Docker/systemd. Khi cần nhiều fd cho service thật, đặt cả soft và hard phù hợp ở nơi khởi động service (systemd LimitNOFILE, Docker --ulimit).
Ba ý mang về
- fd có giới hạn, vượt là "Too many open files": đo thật, mở file liên tục không đóng làm fd tăng 5→205 rồi chạm
ulimit -n 256và ném[Errno 24]— app crash. Rò rỉ là quả bom hẹn giờ: chạy tốt lúc đầu, chết sau vài giờ/ngày. - Luôn đóng fd: đo thật, dùng
with(haydefer/try-finally) giữ fd phẳng ở 4 qua 100000 lần — tài nguyên trả lại ngay nên fd ổn định dù lặp bao nhiêu. - Phát hiện sớm bằng
/proc:ls /proc/<pid>/fd | wc -l(haylsof -p) đếm fd đang mở; fd tăng dần = rò rỉ; và nhớ tăngulimitchỉ dời thời điểm sập chứ không sửa rò rỉ (socket/kết nối DB cũng là fd).
Nguồn
- man7.org — getrlimit(2) / RLIMIT_NOFILE: https://man7.org/linux/man-pages/man2/getrlimit.2.html
- man7.org — open(2) — EMFILE: https://man7.org/linux/man-pages/man2/open.2.html
- man7.org — lsof(8): https://man7.org/linux/man-pages/man8/lsof.8.html
Phần sau ta đọc đúng bộ nhớ của tiến trình: RSS vs VSZ — vì sao "app dùng 2GB bộ nhớ ảo" không có nghĩa nó chiếm 2GB RAM thật, và cột nào mới đáng lo.