Hãy nghĩ về giới hạn mềm như mức âm lượng một chiếc TV mới bật lên: nhà sản xuất đặt sẵn một giá trị vừa phải, nhưng bạn cầm điều khiển vặn to lên tới hết cỡ bất cứ lúc nào — cái trần thật sự là mức tối đa mà phần cứng chịu được, không phải con số hiện lên khi vừa bật. Rất nhiều người tưởng giới hạn mềm là hàng rào bảo vệ máy chủ, rồi ngạc nhiên khi ứng dụng cứ vượt qua nó như thể nó không tồn tại. 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 đúng 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ì. Tự cầm điều khiển vặn to hết cỡ.
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. Đó chính là ý nghĩa của "mềm": nó là giá trị khởi đầu, không phải hàng rào. Kéo theo hai hệ quả cần nhớ:
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 ngay 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ẻ
Trước khi hạ giới hạn để "tiết kiệm", hãy xem một mô tả tệp thật sự tốn bao nhiêu. Đ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 — không phải để tiết kiệm mấy chục megabyte.
Một lưu ý về phép đo, kẻo bị hiểu sai: 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 hẳn: 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 nặng hơn
Đây là điều bất ngờ nhất của cả 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, và dễ bỏ sót: 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.
Ghép hai chuyện lại thành cái bẫy: 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. Bạn "sửa" một lỗi và cấy vào một quả bom hẹn giờ.
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ã tự viết. Cách kiểm tra trước khi nâng:
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 âm thầm 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 hề thấy nó.
Mẫu số chung: "mặc định" không phải "trần", và số thứ tự không phải số lượng
Bài này gói hai cái bẫy mà bạn gặp ở khắp mọi tầng phần mềm. Cái thứ nhất: một giá trị mặc định bị nhầm là một giới hạn — trong khi nó chỉ là điểm khởi đầu, tự nới ra được.
Trong thế giới Java, đây đúng là chuyện -Xmx so với giới hạn bộ nhớ thật của cgroup, hay cỡ mặc định của connection pool (HikariCP mặc định 10) — người ta tưởng đó là trần an toàn, nhưng nó chỉ là con số khởi đầu, và một cấu hình khác ghi đè nó dễ dàng; muốn chặn thì phải đặt ở tầng không nới được (giới hạn cgroup cứng, không phải flag của ứng dụng).
Trong Go, http.DefaultClient không đặt timeout, db.SetMaxOpenConns mặc định là không giới hạn — những "mặc định" trông vô hại này không phải hàng rào, và trông cậy vào chúng như hàng rào là nguồn của rò rỉ kết nối kinh điển. Muốn an toàn phải chủ động đặt trần, y như phải hạ giới hạn cứng mới chặn được rò rỉ mô tả tệp.
Còn ở cơ sở dữ liệu, cùng hai bẫy: max_connections của Postgres là trần cứng ở tầng server (đặt đúng chỗ), nhưng cái bẫy "số thứ tự chứ không phải số lượng" thì hiện ra ở khoá tự tăng — một cột INT (trần ~2,1 tỷ) không hỏng vì có nhiều dòng, mà vì giá trị id chạm trần, kể cả khi bạn đã xoá gần hết bảng. Giống hệt select() hỏng vì số hiệu 1024, không vì mở nhiều tệp.
Mẫu số chung: một giá trị mặc định mềm không bao giờ là cơ chế bảo vệ — muốn chặn thật phải đặt ở tầng không tự nới được; và nhiều giới hạn ràng buộc giá trị/số thứ tự chứ không ràng buộc số lượng, nên chúng nổ ở những chỗ và thời điểm chẳng liên quan gì tới quy mô hiện tại. Nên câu hỏi đúng khi gặp một giới hạn không phải "nâng nó lên bao nhiêu" mà là "đây là mặc định mềm hay trần cứng, và cái ràng buộc thật là số lượng hay là giá trị" — vì trả lời sai câu đó là lúc bạn nâng một con số rồi lãnh một sự cố ở tận đâu đâu.
Muốn tự soi máy mình, so số đang mở với đúng giới hạn của từng tiến trình:
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ì.