Too many open files là một trong những lỗi hay gặp nhất trên máy chủ, và cách chữa được nhắc nhiều nhất — ulimit -n 65536 — thường không làm điều người ta nghĩ.
Giới hạn mềm không phải một giới hạn
Chạy container với giới hạn mềm 1.024 và giới hạn cứng 1.048.576:
giới hạn mềm: 1024 | giới hạn cứng: 1048576
mở được 1021 tệp trước khi lỗi: [Errno 24] Too many open files
Rồi cùng tiến trình đó gọi một dòng:
resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard))
đã nâng giới hạn mềm lên 1048576
mở được 1048573 tệp trước khi lỗi
Gấp 1.027 lần, không cần quyền gì.
Bất kỳ tiến trình nào cũng nâng được giới hạn mềm của chính nó lên tới giới hạn cứng. Đó là ý nghĩa của "mềm": nó là giá trị khởi đầu, không phải hàng rào.
Hệ quả:
ulimit -ntrong script khởi động không bảo vệ máy chủ khỏi một ứng dụng rò rỉ mô tả tệp. Ứng dụng ghi đè được, và nhiều runtime làm đúng vậy lúc khởi động.- Muốn chặn thật thì phải hạ giới hạn cứng, và hạ giới hạn cứng cần quyền
CAP_SYS_RESOURCE. Hạ rồi thì không nâng lại được cho tới khi tiến trình chết.
Ba nơi giá trị này được đặt:
ulimit -Sn; ulimit -Hn # phien shell hien tai
grep -E 'Max open files' /proc/<pid>/limits # cua mot tien trinh dang chay
[Service]
LimitNOFILE=65536:1048576 # systemd: mem:cung
ulimits:
nofile: { soft: 65536, hard: 1048576 } # docker compose
Mô tả tệp rất rẻ
Đo RSS theo số tệp đang mở:
| Số tệp mở | RSS tăng thêm | Mỗi tệp |
|---|---|---|
| 10.000 | 240 KB | 24,6 byte |
| 100.000 | 3.560 KB | 36,5 byte |
| 500.000 | 19.232 KB | 39,4 byte |
| 1.000.000 | 38.824 KB = 38 MB | 39,8 byte |
Một triệu mô tả tệp tốn 38 MB.
Nên đặt giới hạn thấp để "tiết kiệm bộ nhớ" là vô nghĩa. Lý do thật để đặt giới hạn là chặn một vòng lặp rò rỉ trước khi nó ăn hết tài nguyên toàn hệ thống.
Lưu ý về phép đo: 40 byte này là phần hiện ra trong RSS của tiến trình — bảng mô tả tệp. Ổ cắm mạng thì khác: mỗi ổ cắm còn kéo theo bộ đệm gửi và nhận trong nhân, và phần đó không nằm trong RSS. Trong phép đo của tôi, 50.000 ổ cắm chưa kết nối chỉ thêm 58 byte mỗi cái vào RSS; một ổ cắm đang truyền dữ liệu tốn hàng chục KB bộ nhớ nhân mà con số RSS không hề thấy.
Đây lại là bài học của phần 11: RSS không trả lời câu hỏi bạn đang hỏi.
Nâng giới hạn có thể làm chương trình hỏng thêm
Đây là điều bất ngờ nhất của bài. Cùng một chương trình, cùng một ổ cắm, chỉ khác số hiệu của mô tả tệp:
| Ổ cắm có số hiệu | select() |
poll() |
epoll |
|---|---|---|---|
| 603 | OK | OK | OK |
| 1.203 | filedescriptor out of range |
OK | OK |
select() dùng một bitmap có kích thước cố định FD_SETSIZE, mặc định 1024. Nó không thể biểu diễn mô tả tệp số 1024 trở lên.
Điều quan trọng: giới hạn nằm ở số hiệu, không ở số lượng. Một chương trình chỉ theo dõi một ổ cắm vẫn hỏng, miễn là ổ cắm đó tình cờ nhận số hiệu 1.024 trở lên.
Nghĩa là: nâng ulimit -n cho một dịch vụ dùng select() khiến nó mở được nhiều tệp hơn, số hiệu vượt 1023, và rồi hỏng ở một chỗ khác hẳn — thường là một lỗi mơ hồ trong thư viện, hàng giờ sau khi khởi động, và chỉ khi tải cao.
Ngôn ngữ hiện đại phần lớn đã dùng epoll, nhưng select() vẫn còn trong nhiều thư viện C cũ, trong một số trình điều khiển cơ sở dữ liệu, và trong mã nguồn tự viết. Cách kiểm tra:
strace -f -e trace=select,poll,epoll_wait -p <pid> 2>&1 | head -5
Thấy select( là dấu hiệu cần xem lại trước khi nâng giới hạn.
Giới hạn toàn hệ thống
Ngoài giới hạn mỗi tiến trình còn hai con số của cả máy:
cat /proc/sys/fs/file-max # tong so tep mo toan he thong
cat /proc/sys/fs/nr_open # tran cho gioi han CUNG cua moi tien trinh
cat /proc/sys/fs/file-nr # dang mo / trong / toi da
Trên máy tôi đo, file-max là 9.223.372.036.854.775.807 — tức là nhân hiện đại đã bỏ hẳn giới hạn này. nr_open là 1.048.576, và đó chính là trần mà giới hạn cứng không vượt qua được:
sysctl -w fs.nr_open=2097152 # phai nang cai nay TRUOC khi dat ulimit cao hon
Đặt LimitNOFILE lớn hơn fs.nr_open sẽ không báo lỗi rõ ràng — nó chỉ bị cắt xuống, và bạn nghĩ mình đã đặt được.
Tìm chỗ rò rỉ
# tien trinh nao mo nhieu nhat
for p in /proc/[0-9]*; do
n=$(ls $p/fd 2>/dev/null | wc -l)
[ "$n" -gt 100 ] && echo "$n $(tr -d '\0' < $p/cmdline | cut -c1-50)"
done | sort -rn | head -5
# no mo cai gi
ls -l /proc/<pid>/fd | awk '{print $NF}' | sed 's/[0-9]*$//' | sort | uniq -c | sort -rn | head
Dòng thứ hai là chỗ trả lời được câu hỏi thật. Hàng nghìn mục socket:[...] là kết nối không được đóng; hàng nghìn mục trỏ vào cùng một tệp log là một handle bị mở lại trong vòng lặp; hàng nghìn pipe:[...] là tiến trình con không được thu hồi.
Và một loại đặc biệt hay bị bỏ sót: (deleted) ở cuối đường dẫn. Đó là tệp đã bị xoá mà vẫn còn ai đó giữ mô tả tệp — nó chiếm cả mô tả tệp lẫn dung lượng đĩa, và du không thấy nó.
Thử ba mươi giây
echo "shell nay : mem $(ulimit -Sn) cung $(ulimit -Hn)"
echo "nr_open : $(cat /proc/sys/fs/nr_open)"
echo "dang mo : $(awk '{print $1}' /proc/sys/fs/file-nr)"
echo "--- top 5 tien trinh mo nhieu tep nhat ---"
for p in /proc/[0-9]*; do
n=$(ls $p/fd 2>/dev/null | wc -l)
[ "${n:-0}" -gt 50 ] && printf "%6d %-6s %s\n" "$n" "${p#/proc/}" \
"$(tr -d '\0' < $p/cmdline 2>/dev/null | cut -c1-45)"
done 2>/dev/null | sort -rn | head -5
echo "--- gioi han that cua mot tien trinh cu the ---"
grep 'Max open files' /proc/<pid>/limits
So số đang mở với cột "Soft Limit" của chính tiến trình đó — không phải với ulimit của shell bạn đang gõ. Hai con số này thường khác nhau, và nhầm chúng là lý do phổ biến khiến người ta "đã nâng giới hạn rồi mà vẫn lỗi".
Phần sau: cgroup v2 — đo giới hạn CPU và bộ nhớ thật sự làm gì.