Phần 8 khép lại nhóm bài về bảo mật syscall. Phần này chuyển sang một loại giới hạn khác, thứ mà mọi ứng dụng backend có lưu lượng cao sớm muộn cũng đụng: file descriptor. Mỗi file, socket, pipe đang mở đều tốn một file descriptor, và mỗi tiến trình bị chặn bởi một hạn mức gọi là RLIMIT_NOFILE. Nghe đơn giản, nhưng có một điểm hay gây lẫn lộn mà bài này đo trực tiếp: khi hết fd, tiến trình có thể nhận một trong hai mã lỗi hoàn toàn khác nhau, tùy vào nó chạm trần của chính nó hay chạm trần của cả máy. Nhầm hai mã này dẫn tới sửa sai chỗ: nâng --ulimit cho một lỗi mà thật ra cần nâng fs.file-max, hoặc ngược lại. Bài đo hai cái trần đó, hai mã lỗi, và cách một tiến trình tự nới hạn mức của mình.
Hai cái trần fd, hai mã lỗi riêng

Hình 1: Hai cái trần fd — RLIMIT_NOFILE của tiến trình và fs.file-max của cả máy — cùng hai mã lỗi EMFILE và ENFILE.
File descriptor, viết tắt là fd, là một số nguyên nhỏ mà kernel cấp cho mỗi tài nguyên đang mở: một file trên đĩa, một socket mạng, một pipe giữa hai tiến trình. Một server xử lý nhiều kết nối đồng thời tiêu tốn fd rất nhanh, vì mỗi kết nối TCP là một socket, là một fd. Để một tiến trình không mở vô hạn và làm cạn tài nguyên kernel, Linux đặt hạn mức RLIMIT_NOFILE cho mỗi tiến trình. Hạn mức này có hai giá trị: soft là giới hạn thực tế đang chặn, và hard là trần mà soft có thể nâng tới. Điểm quan trọng và hữu dụng: một tiến trình có thể tự nâng soft của nó lên tới hard mà không cần đặc quyền, nhưng muốn nâng hard thì mới cần. Đọc hai giá trị này ở /proc/self/limits dòng "Max open files", hoặc bằng getrlimit(RLIMIT_NOFILE). Docker đặt mặc định rất cao, 1048576 cho cả soft lẫn hard, và chỉnh được bằng cờ --ulimit nofile=soft:hard.
Nhưng RLIMIT_NOFILE không phải cái trần duy nhất. Bên trên hạn mức per-process còn có trần của cả máy: /proc/sys/fs/file-max giới hạn tổng số fd mà toàn hệ thống mở được cùng lúc, và /proc/sys/fs/nr_open là trần tuyệt đối cho mỗi giá trị RLIMIT_NOFILE được đặt. Một tiến trình có thể chạm trần của chính nó, hoặc góp phần làm cạn trần của cả máy, và hai tình huống này báo lỗi khác nhau. Đây là chỗ hay nhầm: EMFILE, mã lỗi errno 24, "Too many open files", nghĩa là tiến trình này đã dùng hết quota per-process của nó; còn ENFILE, errno 23, "Too many open files in system", nghĩa là cả máy đã cạn fd. Hai lỗi dịch ra tiếng Việt gần như giống nhau, nhưng chỗ chạm trần khác nhau nên cách sửa khác nhau: EMFILE sửa bằng nâng --ulimit hoặc setrlimit cho riêng tiến trình đó; ENFILE thì vô ích nếu chỉ nâng ulimit, phải nâng fs.file-max ở tầng sysctl của host.
Cách đo gồm bốn bước: đọc /proc/self/limits và getrlimit dưới mặc định Docker rồi dưới --ulimit nofile=64:128; mở /dev/null trong vòng lặp tới khi gãy, đếm số fd mở được và đọc errno; dùng setrlimit nâng soft lên bằng hard rồi mở lại để xác nhận trần đã nới; và đọc trần cả máy để thấy nó nằm trên per-process. Lý do phải phân biệt EMFILE với ENFILE lộ ra ngay ở phép đo mặc định: Docker để soft cao tới 1048576, cao tới mức trên máy thử nó vượt trần cả máy, nên tiến trình chạm ENFILE trước khi kịp chạm trần của chính nó.
Đo thật: mặc định đụng ENFILE, ulimit thấp đụng EMFILE

Hình 2: Mặc định Docker chạm ENFILE vì trần cả máy thấp hơn soft, --ulimit thấp chạm EMFILE, và setrlimit nâng soft không cần đặc quyền.
| phép đo | kết quả |
|---|---|
| soft/hard mặc định Docker | 1048576 / 1048576 |
| mở fd tới khi gãy (mặc định) | 810817 fd rồi ENFILE (errno 23) |
| trần cả máy | file-max 811758, nr_open 1048576 |
soft/hard với --ulimit nofile=64:128 |
64 / 128 |
| mở fd tới khi gãy (ulimit thấp) | 61 fd, tổng ~65 rồi EMFILE (errno 24) |
| setrlimit nâng soft | 64 → 128 (không đặc quyền) |
| mở lại sau khi nâng | thêm 125 fd, tổng ~129 rồi EMFILE |
Mặc định Docker để hạn mức cao tới mức tiến trình chạm trần cả máy trước. Trong container mặc định, /proc/self/limits báo "Max open files" là 1048576 cho cả soft lẫn hard. Mở /dev/null trong vòng lặp, tiến trình mở thêm được 810817 fd rồi dừng, nhưng lỗi trả về là errno=23, tức ENFILE, "Too many open files in system", không phải EMFILE. Lý do rõ khi đọc trần cả máy: fs.file-max là 811758, thấp hơn soft 1048576. Nghĩa là trước khi tiến trình dùng hết quota 1048576 của riêng nó, cả hệ thống đã cạn fd ở mốc ~811k. Đây là một tình huống thật và dễ gây bối rối trong vận hành: ulimit của container trông rộng rãi, log vẫn báo "too many open files", nhưng nâng ulimit thêm nữa chẳng giúp gì vì nút thắt nằm ở fs.file-max của host, không ở container.
Hạ ulimit xuống thấp cho đúng mã lỗi per-process, EMFILE. Chạy lại container với --ulimit nofile=64:128, giờ /proc/self/limits báo soft 64 và hard 128. Tiến trình bắt đầu với 4 fd đã mở sẵn cho stdin, stdout, stderr và một fd nội bộ, rồi mở thêm 61 fd nữa thì gãy, tổng khoảng 65, xấp xỉ soft 64. Lần này lỗi là errno=24, tức EMFILE, "Too many open files", đúng mã lỗi của trần per-process. Hai lần chạy cùng một đoạn mã, cùng thao tác mở fd, nhưng mã lỗi khác nhau vì chạm hai cái trần khác nhau. Đây là lý do khi gặp "too many open files" trong log, việc đầu tiên nên làm là đọc errno chính xác: 24 nghĩa là tiến trình hết quota, 23 nghĩa là cả máy cạn, và hai con số chỉ sang hai nơi khác nhau để sửa.
Tiến trình tự nâng soft lên hard mà không cần đặc quyền. Phép đo cuối cho thấy một kỹ thuật phổ biến trong server thật. Trong cùng tiến trình đang bị chặn ở soft 64, gọi setrlimit(RLIMIT_NOFILE) để nâng soft lên 128, bằng với hard; thao tác thành công dù tiến trình chạy dưới user thường, không có đặc quyền nào. Để xác nhận trần đã thực sự nới, mở /dev/null lại trong vòng lặp: lần này tiến trình mở thêm được 125 fd, tổng khoảng 129, xấp xỉ soft mới 128, rồi mới chạm EMFILE. Điều này chứng minh soft mới là cái đang chặn thực tế, còn hard chỉ là trần cho soft. Nhiều server như Nginx hay cơ sở dữ liệu làm đúng việc này lúc khởi động: chúng tự nâng soft lên bằng hard để tận dụng toàn bộ hạn mức mà quản trị viên đã cho qua --ulimit, thay vì chạy với soft mặc định thấp hơn.
Đánh đổi cần cân nhắc
Đọc errno trước khi đổi cấu hình, đừng đoán. Bài học vận hành rõ nhất từ phép đo này: "Too many open files" không phải một lỗi mà là hai, và chúng cần hai cách sửa khác nhau. Nếu là EMFILE (errno 24), nâng --ulimit nofile cho container hoặc để ứng dụng tự setrlimit. Nếu là ENFILE (errno 23), ulimit vô can; phải nâng fs.file-max của host qua sysctl. Một đội vận hành đoán nhầm sẽ nâng ulimit mãi mà lỗi không dứt, vì họ đang vặn cái van không nối với đường ống bị nghẽn. Lấy errno chính xác tốn vài giây và tiết kiệm hàng giờ.
Đặt --ulimit nofile hợp lý cho server có nhiều kết nối, nhưng đừng để quá cao vô nghĩa. Mặc định 1048576 của Docker rộng rãi tới mức, như đo được, trên nhiều host nó còn vượt cả fs.file-max. Với một web server hay proxy xử lý hàng chục nghìn kết nối đồng thời, hạn mức này thường đủ và không cần chỉnh. Vấn đề ngược lại cũng có thật: một số image base hoặc cấu hình systemd đặt soft thấp như 1024, và ứng dụng đông kết nối sẽ chạm trần sớm; khi đó nâng --ulimit hoặc để ứng dụng tự nâng soft là cần thiết. Điểm cân bằng là đặt đủ cho tải thật, không phải đặt cao nhất có thể, vì fd vẫn là tài nguyên kernel chung của host.
Phạm vi đo. Bài đo RLIMIT_NOFILE và trần fs.file-max, fs.nr_open, không đi vào epoll hay cách một event loop ghép nhiều kết nối trên ít luồng, thứ quyết định hiệu năng thực của server đông kết nối. Số fd ban đầu là 4 trên tiến trình Python này; ứng dụng khác bắt đầu với số khác tùy runtime. Chưa đo tương tác giữa RLIMIT_NOFILE với cgroup, vốn giới hạn tài nguyên theo nhóm tiến trình chứ không theo từng tiến trình, và chưa đo /proc/sys/fs/nr_open như trần chặn setrlimit nâng hard vượt quá nó, những chi tiết của mô hình đầy đủ nhưng không đổi kết luận về hai mã lỗi.
Ba ý mang về
- Có hai cái trần fd với hai mã lỗi.
EMFILE(errno 24) là trần per-processRLIMIT_NOFILE;ENFILE(errno 23) là trần cả máyfs.file-max. Đọc errno chính xác để biết sửa ở đâu. - Mặc định Docker để soft 1048576, có khi vượt cả fs.file-max của host. Khi đó container chạm
ENFILEtrước, và nâng ulimit thêm nữa không giúp gì; nút thắt nằm ở sysctl của host. - Tiến trình tự nâng soft lên hard không cần đặc quyền.
softlà cái đang chặn,hardlà trần của soft; server thường tựsetrlimitlúc khởi động để dùng hết hạn mức được cấp.
Nguồn
- Linux
getrlimit(2)/setrlimit(2)— RLIMIT_NOFILE, soft và hard limit: https://man7.org/linux/man-pages/man2/getrlimit.2.html - Linux
open(2)— mã lỗi EMFILE và ENFILE khi mở file: https://man7.org/linux/man-pages/man2/open.2.html - Linux
proc(5)—/proc/sys/fs/file-max,/proc/sys/fs/nr_open,/proc/PID/limits: https://man7.org/linux/man-pages/man5/proc.5.html - Docker — tùy chọn
--ulimitcho container: https://docs.docker.com/engine/containers/run/#operator-exclusive-options errno(3)— danh sách mã lỗi chuẩn, EMFILE 24 và ENFILE 23: https://man7.org/linux/man-pages/man3/errno.3.html
Phần sau chuyển sang một đặc thù khác của tiến trình trong container: PID 1. Tiến trình đầu tiên trong container mang PID 1 và nhận một vai trò đặc biệt của kernel, gồm việc thu dọn tiến trình con mồ côi và xử lý tín hiệu khác thường. Xem vì sao một container không có init thật sẽ tích tụ zombie và không shutdown mượt khi nhận SIGTERM, đo bằng tín hiệu và trạng thái tiến trình thật.