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

Ảnh chụp đoạn mã nền tối minh hoạ rò rỉ file descriptor vì sao app chết vì too many open files, mỗi file socket mở bằng một fd có giới hạn số fd mở file socket pipe đều tốn một file descriptor kernel giới hạn số fd mỗi tiến trình ulimit -n ulimit -n xem giới hạn soft hiện tại vượt open báo lỗi EMFILE too many open files, rò rỉ fd mở mà quên đóng lỗi kinh điển mở file kết nối trong vòng lặp không đóng for i in range f bằng open x fd tăng dần không bao giờ trả lại quên f.close số fd tăng mãi tới khi chạm ulimit app chết, cách đúng luôn đóng with defer try finally with open x as f Python tự đóng khi ra khỏi block defer f.Close Go fd được trả lại ngay số fd ổn định dù lặp triệu lần, chẩn đoán đếm fd đang mở ls proc pid fd wc -l đếm fd của tiến trình lsof -p pid liệt kê chi tiết fd mở fd tăng dần theo thời gian bằng dấu hiệu rò rỉ

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:

Ảnh chụp bảng kết quả chạy thật rò rỉ fd cộng ulimit output thật, một rò rỉ mở etc hostname liên tục không đóng ulimit -n 256 da mo 0 lan fd dang mo 5 da mo 200 lan fd dang mo 205 fd tăng đều theo số lần mở OSError Errno 24 too many open files etc hostname chạm giới hạn 256 open thất bại app crash, hai đúng dùng with tự đóng fd không tăng da xu ly 0 lan fd dang mo 4 da xu ly 40000 lan fd dang mo 4 da xu ly 80000 lan fd dang mo 4 hoan tat 100000 lan khong ro ri fd phẳng lì, ba đếm fd của một tiến trình ls proc pid fd wc -l số fd đang mở theo dõi số này tăng dần bằng rò rỉ fd, kết fd rò rỉ bằng quả bom hẹn giờ chạy tốt lúc đầu chết sau vài giờ ngày khi chạm ulimit luôn đóng fd theo dõi proc pid fd để phát hiện sớm

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 256 và 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 -l cho số fd đang mở của một tiến trình (hoặc lsof -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ề

  1. 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 256 và 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.
  2. Luôn đóng fd: đo thật, dùng with (hay defer/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.
  3. Phát hiện sớm bằng /proc: ls /proc/<pid>/fd | wc -l (hay lsof -p) đếm fd đang mở; fd tăng dần = rò rỉ; và nhớ tăng ulimit chỉ 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

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.