“服务器假死”复盘:一个小小的 fs.file-max 如何击垮 JS 反爬系统
凌晨两点,群里突然炸了锅——所有采集任务全部失败,服务器 SSH 连不上,ping 能通但一登录就卡住,整整三台业务机器同时“僵”在原地。重启后刚跑十分钟,同样的症状又来一遍。那天晚上我盯着监控面板,看着 CPU 和内存都只有不到 30% 的占用,却死活找不出机器有“病”的原因,最后才把目光落在一个平时根本不会多看一眼的内核参数:fs.file-max。
这个参数的名字听起来平平无奇,但它直接决定了整个服务器能打开多少个文件句柄。而我们的 JS 反爬系统恰恰是文件描述符(fd)消耗大户。当它把系统的 fd 池子抽干之后,不只是业务进程,连 SSH、监控 agent、系统日志服务这些“保命”组件都会全部卡死,于是服务器就进入了那种让人头皮发麻的“假死”状态:进程在,但什么都不干活,怎么敲命令都不出结果。这篇文章就完整复盘这次故障的来龙去脉,包括定位过程、参数计算方式,以及最后我们做的长期治理方案。
1. 故障现场:先聊聊“假死”到底长什么样
1.1 现象描述:你以为的“宕机”其实只是睡死过去
很多运维同学判断服务器是不是挂了,第一反应是看 ping、看 CPU、看内存。但这套思路在“假死”面前会非常坑。机器明明还活着——IP 能通,控制台能看到 CPU 和磁盘 IO 都不高,但你就是没法正常用。我们当时遇到的典型场景是这样的:
- 服务器仍然响应 ICMP 协议,外网控制台显示实例状态正常;
- 通过 SSH 登录时会出现长时间无响应,或者登录成功但敲命令后光标一直闪烁;
- 已经建立好的长连接(比如数据库连接、消息队列连接)可以正常收发数据,但新连接一律建立不了;
- 重启服务后短时间内恢复正常,但一旦流量恢复,十几分钟后又“僵住”;
- 系统日志里没有任何明显的 OOM、磁盘满或 CPU 过载记录。
这里有个非常重要的认知:服务器“假死”不是真的宕机,而是核心资源被耗尽到连系统管理通路都跑不起来。 它和 CPU 飙满、内存耗尽的表现很不一样。CPU 满的时候你会看到负载飙升,内存满的时候有 OOM killer 来杀进程,但 fd 耗尽时内核不会主动“杀”任何东西,只会默默拒绝新的资源分配。而像 SSH、systemd、cron 这些关键服务如果要正常工作,也离不开 fd,一旦它们拿不到 fd,系统就进入了一种“还能 ping 通,但你已经无法控制它”的微妙状态。
1.2 第一轮排查:为什么常规手段全部失灵
刚开始我们完全没往 fd 方向想,因为经验上服务器卡死优先排查顺序是:CPU 负载、内存占用、磁盘空间、IO 等待。我们团队当时登录不了系统,只能用云厂商的 VNC 或者控制台去尝试操作,结果每个尝试都卡在登录环节。实在没办法,只能强制重启,重启之后系统一切正常,业务也恢复了。
但这恰恰是陷阱所在。重启会释放所有已分配的 fd,所以看起来问题“消失”了。但只要你没有找到根因,重新压上流量之后,同一个坑会再踩一遍。我们第一次重启后不到十五分钟,同样的“假死”就回来了。那时候我才警觉,这不是简单的进程问题,而是系统级资源瓶颈。
当时我做的第二件事是尝试用控制台执行远程命令,看能不能抓到系统的最后日志。幸好我们的监控 agent 会把系统基础指标(CPU、内存、磁盘、网络连接数)上报到外部,我翻出故障发生前半小时的数据,发现一个关键线索:网络连接数和进程数出现了明显的线性飙升,而 CPU 和内存几乎没什么波动。这说明服务器并不是在“算不过来”,而是可能在资源分配上出了问题。但那时候我还没意识到是 fs.file-max,因为我连 /proc/sys/fs/file-nr 都没来得及看,系统已经进不去了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解开 fs.file-max 的真面目:它为什么能让整台机器瘫痪
2.1 文件描述符到底是个什么概念
要理解这次故障,必须先搞明白文件描述符(File Descriptor,fd)在 Linux 里扮演的角色。简单说,Linux 世界里“一切皆文件”,一个 fd 就是一个指向打开资源的数字编号,它可能是真正的磁盘文件,也可能是一个网络连接、一个管道、一个设备节点。当你操作一个 socket 连接时,背后就是一个 fd;当你打开日志文件时,也是一个 fd;甚至每个进程自身启动时,也会天然占用 stdin、stdout、stderr 三个 fd。
fs.file-max 这个内核参数定义了整个系统范围内可以同时打开的文件描述符总量。注意是全局的,不是按进程算的。它决定了这台机器最多能同时持有的 fd 数量。当系统里所有进程的 fd 之和达到这个上限时,任何进程想要再创建新的 socket、打开新文件、fork 新进程(因为 fork 也需要复制父进程的 fd),都会直接被内核拒绝,报 Too many open files。
可以把它类比成一个酒店的总床位数。CPU 是餐厅的翻台速度,内存是水电供应,而 fd 就是房间总数。一个酒店如果餐厅和水电都充足,但房间被全部住满,新来的客人就只能睡大堂。而更严重的是,连酒店自己的保安系统(SSH)、保洁系统(日志服务)也需要房间支持,最后整个酒店就像“瘫痪”了一样,管理无门。这个类比虽然粗浅,但用来理解 fd 耗尽导致的全局假死非常贴切。
2.2 为什么 fd 耗尽会让整个系统连带崩溃
这里有个关键机制,很多人容易忽略:fd 不是某个进程自己扛着就能躲过系统上限的。 哪怕你的业务进程只用了 500 个 fd,但如果整个系统 100 万的总上限被另一个进程、或一群进程的共同消耗填满了,你的业务进程同样无法分发新的 fd。
真实故障场景里,JS 反爬系统通常是多进程架构:Node.js 主服务、Puppeteer 浏览器实例池、Redis 客户端、MySQL 连接池、日志写入流。每一个浏览器实例可能打开几十上百个 fd(页面资源、WebSocket、渲染进程的 IPC 管道),一旦并发任务暴增,fd 数量就会像滚雪球一样疯涨。
问题在于,当系统 fd 耗尽时:
- 业务进程无法建立新的 HTTP 连接,所有请求立刻失败;
- Node.js 的异步事件循环里堆积大量失败回调,进程进入 loop 风暴;
- 负责心跳上报的 agent 连不上监控平台,失联;
- SSH 验证需要 fork 进程,fork 失败,登录挂死;
- journald、syslog 等日志服务写不了文件,无法落盘记录。
于是这个系统就从“局部不可用”快速演变为“全局假死”。最可怕的地方在于:当你想去排查和登录时,排查工具本身也需要 fd 才能运行。 就是这一点让故障处置变得特别难受,你在一个在线的系统上做不了任何诊断,只能重启。这也是我这次踩坑后最深刻的警醒之一:资源治理不能只看 CPU、内存和磁盘,fd 这种“隐形资源”同样能一票否决整台服务器。
2.3 fs.file-max、nr_open、ulimit 有什么区别
这里是新手特别容易混淆的三角关系,我先一次性讲清楚:
fs.file-max:系统级全局 fd 上限,所有进程共享;fs.nr_open:单个进程可以打开 fd 数量的硬上限,它必须大于等于进程级ulimit的设置;ulimit -n:shell 启动进程时可以分配给某个进程的 fd 上限,分为 soft limit 和 hard limit;- systemd 服务的
LimitNOFILE:如果你用 systemd 方式管理 Node.js 服务,ulimit的值会被它的LimitNOFILE覆盖,这是最多人忽略的一个坑。
实际使用中,你修改 fs.file-max 只是改了系统级总池子,但某个进程能拿多少 fd 还受 ulimit 限制。反过来说,你把 ulimit 调大了,但系统总池子不够,进程照样开不了那么多 fd。所以我们这次修复不是只改一个参数,而是四个层级全部都做了同步调整,后面我会把具体数值和计算方式列出来。
2.4 现场验证:dmesg 和 file-nr 才是定位核心
到了这一步,我们终于能确定排查方向了。因为系统进不去,我只能在重启后的“黄金窗口”里快速检查历史线索。重新登录后立刻执行:
bash复制dmesg | grep -i "file-max limit"
输出里出现了一行关键日志:
code复制VFS: file-max limit 1048576 reached
这条日志说明内核已经明确告诉过我们:文件句柄数达到限制值 100 万,后续分配请求被拒。这个日志默认可能被淹没在大量信息里,尤其当业务日志非常嘈杂时,没人会注意到。紧接着执行:
bash复制cat /proc/sys/fs/file-nr
输出类似:
code复制1048576 0 1048576
三个数字的含义分别是:系统当前已分配 fd 数量、已分配但未使用的 fd 数量(不用太关注)、系统总上限。当第一个数字等于第三个数字时,就是一个很确定的信号:fd 池子已经见底了。 你甚至可以不用看 dmesg,只靠 file-nr 的数值就能判断系统是不是处于 fd 耗尽阶段。
另外还有一个需要排查的点:/proc/sys/fs/file-nr 是全局计数器,但如果你想知道哪个进程是消耗大户,得在系统还能登录的时候用 lsof 或者 /proc/pid/fd 目录去统计。如果在假死过程中已经无法 SSH,可能只能等重启后查历史记录。这一轮我们重启后第一时间执行了:
bash复制for pid in $(ls /proc | grep -E '^[0-9]+$'); do
count=$(ls /proc/$pid/fd 2>/dev/null | wc -l)
echo "$pid $count $(cat /proc/$pid/comm 2>/dev/null)"
done | sort -k2 -n -r | head -20
结果排在第一位的果然是 Node.js 主进程,后面跟着一大串 chrome 子进程。到这里,真相基本浮出水面了。
3. JS 反爬系统为什么是 fd 耗尽的重灾区
3.1 反爬业务场景的资源消耗特征
说到这,必须解释一下为什么出问题的偏偏是 JS 反爬系统,而不是普通的 Web 应用或 API 网关。普通 Web 服务的 fd 模型通常是“一个请求一个连接”,请求处理完就关闭,fd 的生存周期很短,就算峰值并发很高,只要连接池清理及时,总量翻不起大浪。
但 JS 反爬系统的工作模式完全不同。它要模拟真实用户的行为去渲染页面、执行 JavaScript、处理验证码、采集动态加载的数据。核心工具通常是 Puppeteer、Playwright 这类无头浏览器。这意味着服务端的每一个采集任务,都对应着一个甚至多个完整的 Chromium 进程。每个进程不是一个简单的 fd,而是包含页面渲染管道、GPU 进程、网络子进程、渲染子进程等一系列配套组件,每一个组件都会打开大量 fd。当并发任务上百个时,fd 数量就会轻松冲破几万,甚至十万级别。
更麻烦的是,反爬场景经常需要和目标网站斗智斗勇。目标站点一旦启用更严格的 JS 校验或人机验证,采集方只能提高重试次数、增加并发来“硬抗”。于是 fd 消耗曲线会从平时的平缓直线变成一个陡峭的上升曲线,就像这次故障前 30 分钟监控面板上展示的那样,连接数和进程数同时拉高,几分钟内就顶到了系统上限。
3.2 无头浏览器实例泄漏:代码里的隐形杀手
结合我们的代码复盘,这次故障还有一个不能忽视的技术债务:部分浏览器实例没有正确关闭。
在 Puppeteer 的编程模型里,browser.close() 和 page.close() 是两个极易被遗漏的调用。特别是当代码里出现异常时,如果用的是同步编程思维而不是 try...finally 或 using 自动释放,异常路径会直接跳出,实例就一直挂着。一个挂着的 Chromium 进程不仅占用内存,还会保持网络连接不释放,fd 被牢牢攥在手里。
我们排查后发现,任务的调度代码里确实有两条异常分支漏掉了浏览器关闭逻辑。平时并发低,漏几个实例问题不大。但故障当天正好赶上目标站点出了新的验证机制,大量任务在同一个时间窗口进入异常分支,等于给服务器开了一个“无限泄漏”的口子。
这里补一句经验之谈:Puppeteer 实例的清理必须放到 finally 块里,或者用 Promise 的 settled 语义确保无论如何都会走到 close。不能依赖业务逻辑“正常”时才会执行的清理代码。同时,还应该在创建实例时给每个实例打上唯一标签,通过进程名区分不同业务线,方便线上快速定位哪些实例是异常残留。
3.3 连接池与 keep-alive:长期占用 fd 的隐蔽源头
除了浏览器实例,还有一类 fd 占用容易被忽视:各种长连接。JS 反爬系统通常要依赖 Redis 做任务队列、依赖 MySQL 做数据持久化、依赖消息队列做结果回传。这些组件的客户端默认会维护连接池,每个连接池条目对应一个 fd。
如果连接池设置过大,而实际请求量并没有那么大,这些 fd 就是“僵尸连接”,一直躺在池子里占着系统资源。更麻烦的是 keep-alive 机制——HTTP 客户端复用同一个 TCP 连接发送多次请求,这对业务来说效率很高,但 fd 的释放时间也被大幅拉长。在高并发场景下,如果连接的空闲超时设置得不够短,系统里会积压大量 TIME_WAIT 状态的 socket,它们虽然没有被进程持有,但在某些内核视角下仍然会占用资源。
这些 fd 看起来“每个都不多”,但量变引发质变。尤其当系统总池子本来就不富余时,这些长期驻留的连接会挤压新任务的生存空间。我们在复盘时还发现,某几个采集子模块的 HTTP keep-alive 超时设置长达 300 秒,这明显太高了。在反爬这种“短而高频”的请求模型里,keep-alive 超时反而会让 fd 积压得更严重。
3.4 故障链路重构:一次并发抖动引发的雪崩
把这些因素串起来,这次事故的完整链路可以这样重构:
- 目标网站上线了新的 JS 校验逻辑,部分采集请求开始返回验证失败;
- 反爬系统的自动重试机制被触发,同一个任务被重新派发到新的浏览器实例;
- 因为异常路径没有关闭浏览器,旧的失败实例没有释放,新的实例又在不断创建;
- 连接池和网络连接持续累积,fd 数量在 30 分钟内冲到 100 万上限;
- 内核拒绝分配新 fd,所有新建连接失败,业务进程陷入重试循环;
- 日志、SSH、监控组件也拿不到 fd,服务器进入“假死”;
- 运维无法远程登录,只能强制重启,重启后短暂释放所有 fd,但代码问题没有修复,流量一上来又重复第 3 步。
这个链路里没有单一的大故障点,也没有 CPU 或内存打满的提醒,就是一堆“小问题”叠加在一起,最后让系统总资源耗尽。这也是这类问题最隐蔽也最可怕的地方:排查起来像“灵异事件”,实际上只是资源桶被慢慢灌满了。
4. 完整修复过程与参数调优实操
4.1 第一步:先把系统拉回可用状态
遇到服务器假死,第一目标永远是“恢复可用”。如果 SSH 已经进不去,处理手段有限,只能通过云厂商的 VNC 或者重启按钮来恢复。但重启之前如果条件允许,建议先拍一张虚拟机快照,或者至少记录下控制台的资源指标,方便事后分析。
我们当时的处置顺序是:
bash复制# 重启后立即确认当前 fd 使用情况
cat /proc/sys/fs/file-nr
# 看看是不是还没顶满,如果在慢慢涨,说明代码层泄漏可能继续
watch -n 1 'cat /proc/sys/fs/file-nr'
在确认 fd 的数量逐渐上升后,我们先做了一次临时性的参数调大,让系统有喘息的空间:
bash复制sysctl -w fs.file-max=2097152
这个命令立刻生效,不需要重启。但注意它只对当前系统运行周期有效,重启后会被重置。所以紧接着必须把它写入持久化配置:
bash复制echo 'fs.file-max = 2097152' >> /etc/sysctl.conf
sysctl -p
当时我们把 fs.file-max 从 1048576 调到 2097152,这只是“拆弹”的临时措施,真正核心的还是代码层面的修复,后面会详细说。
4.2 第二步:计算合理的文件描述符上限
很多人以为 kernel 参数调得越大越好,其实不然。fs.file-max 太大会带来一个问题:每个 fd 在内核中都有一个对应的 struct file,它需要占用内存。如果无限制调大,极端情况下可能引发内核内存压力。所以合理的做法是评估业务需要,再留出 30% 到 50% 的余量。
我们当时的评估逻辑:
- 单个 Puppeteer 浏览器实例在渲染复杂页面时,打开 fd 数约在 40 到 80 之间;
- 并发浏览器实例峰值设计为 600 个,按单实例 80 个 fd 上限计算,约需要 4.8 万个 fd;
- Node.js 主进程的 HTTP 客户端连接池、Redis 连接池、数据库连接池合计约 5000 个 fd;
- 日志、监控、系统服务、SSH 等其他进程约占用 1 万个 fd;
- 系统整体基线需要约 6 万到 7 万个 fd。
这个数值其实不大,跟一百万的系统上限差距还远。所以代码没问题时,100 万上限完全够用。真正导致事故的是异常泄漏,而不是正常峰值。但我们仍然决定把上限提高到 200 万,主要是为了给未来的业务扩展留余量,同时降低极端峰值时的风险。如果你的服务器内存只有 2GB 或 4GB,不要盲目设置很大的 fs.file-max,可以先按每个 fd 约 1KB 内核内存占用估算,12 万到 20 万是 4GB 内存机器比较安全的值。
不过这里要特别说明一下:临时用 sysctl -w 调大,只能让系统先缓过来,不代表问题解决了。 如果代码里的泄漏点没有被修复,即使你把上限调到 500 万,耗尽也只是时间问题。下次冲击的时候,内存可能也会跟着爆掉,问题只会更严重。所以参数调整只是第一步,真正的救命稻草是找到泄漏源,把它修掉,然后引入监控和告警。
4.3 第三步:同步修改进程级限制和 systemd 配置
调完系统级参数还不够。不要忘了还有进程级限制,也就是 ulimit -n。我们当时检查 Node.js 服务的 systemd 单元文件,发现 LimitNOFILE 没有设置,默认只有 1024,这实际上把所有业务进程的 fd 上限卡死在了 1024。虽然系统总池子还有余量,但单进程 1024 的限制让 Node.js 在稍微有点并发时就报 EMFILE 错误。
顺便说一句,Puppeteer 启动 Chromium 的 fd 需求比普通 Node.js 服务更高,如果你直接把这个参数调到 65535,就足够支撑常见的反爬并发规模了。如果单台机器要支撑几千个并发采集任务,则需要评估后调得更高,但那样会更建议拆分成多台机器横向扩展,而不是单机死磕。
systemd 服务文件里需要显式加上:
ini复制[Service]
LimitNOFILE=1048576
然后执行:
bash复制systemctl daemon-reload
systemctl restart <service-name>
确认一下当前进程的 fd 限制是否生效:
bash复制cat /proc/<pid>/limits | grep "open files"
如果你用的是 Docker 容器部署,还需要在 docker run 时加 --ulimit nofile=1048576:1048576,或者在 docker-compose.yml 里配置 ulimits。否则容器内的进程会继承 Docker 守护进程的限制,而 Docker 的默认限制可能不够用。
4.4 第四步:完善 go 代码(如果是 Node.js 则改 JS 代码)里的资源释放
到这里,核心问题还是代码层面的泄漏。我们的采集系统中有一个构建 Puppeteer 实例的模块,正常的流程是:
javascript复制const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.goto(url);
const html = await page.content();
// 业务处理
} finally {
await browser.close();
}
finally 块保证了无论成功还是异常,浏览器都会被关闭。而在泄漏代码里,browser.close() 只写在业务处理之后,一旦 page.goto 抛异常,close 根本不会执行。这就是免费给服务器批量“开后门”的根源。
另外,我们还发现有些代码创建了 page 对象,但没有保存 browser 引用,导致最后无法显式关闭浏览器。这在调试时看起来是个小问题,线上却是致命的。如果你也写 Puppeteer 或者 Playwright 代码,建议现在就自查一遍:每个 browser.launch() 和 browser.newPage() 是否都有对应的 close 调用,并且放在 finally 或者等价机制里。
4.5 第五步:给连接池和 keep-alive 做瘦身
修完浏览器实例的问题,我们顺便把连接池参数也一并优化了:
- Redis 连接池从 200 降到 50,因为实际并发查询量远没那么高;
- MySQL 连接池从 100 调到 200(受限于机器规格),但增加了空闲连接回收策略;
- HTTP keep-alive 超时从 300 秒降到 60 秒,避免大量 socket 长时间挂着;
- Node.js 服务增加了
agent.maxSockets的限制,防止某个子模块一次性建太多连接。
这些调优看似不起眼,但叠加起来效果显著。根据后续几天的监控数据,系统整体 fd 的使用量从故障时的近 100 万降到了 8 万左右,波动范围也很平稳,再也没有出现过“突然飙升”的曲线。
5. 长期治理:如何把“假死”消灭在萌芽阶段
5.1 把 fd 使用拉进监控体系
故障恢复后第一件事,不是欢呼,而是把 fd 用量纳入核心监控指标。以往监控面板上只关注 CPU、内存、磁盘、网络,这次事件让我们意识到“隐形资源”同样需要盯防。
我们利用 node_exporter 加 Prometheus 方案实现监控。node_exporter 自带 filefd 相关的采集项,数据源正是 /proc/sys/fs/file-nr,对应的指标名称是 node_filefd_allocated 和 node_filefd_maximum。我们在 Grafana 上新建了一个面板,展示 fd 分配数量的实时趋势和使用率百分比。
告警规则比较简单:
yaml复制groups:
- name: fd_alerts
rules:
- alert: FileDescriptorHighUsage
expr: node_filefd_allocated / node_filefd_maximum > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "fd usage > 80% on {{ $labels.instance }}"
当 fd 使用率超过 80% 时,我们就收到企业微信告警;超过 95% 时,直接电话通知。这样至少不会再出现“等到服务器完全假死才发现”的被动局面。
5.2 建立进程级前置配额
除了监控告警,我们还做了一层进程级保护。之前的问题在于:某个异常业务线可以无限制地创建浏览器进程,把系统总 fd 池子冲垮。为了防止类似事情重演,我们在 systemd 单元里给每条业务线设置 LimitNOFILE,比如某个子采集服务固定限制在 10000,另一个分发给 5000。这样就算某条业务线出现泄漏,最多也只能占用自己额度内的 fd,不会影响其他模块和整台机器。
这个思想本质上是“故障隔离”:不让单点问题演化为全局灾难。和内存 cgroup 限制、CPU 配额类似,fd 配额就是多了一道保险。
5.3 代码审计与链接收机制的双保险
代码层面的修复是治本。我们组织了一次针对性的代码评审,把所有涉及浏览器实例创建和 HTTP 连接创建的地方都过了一遍,重点检查异常路径上的资源释放。另外,在写新代码时约定一个硬性规范:创建任何需要显式关闭的资源(浏览器、文件流、数据库连接),必须在同一个作用域的 finally 或 defer 里释放。 这条规矩写进了团队的 Code Review Checklist。
在运行时层面,我们还加了一个兜底任务,每隔 5 分钟扫描一次系统中的浏览器进程数量。如果发现进程数超过阈值 1.5 倍,自动重启采集服务并记录上下文快照。虽然这种方式比较“暴力”,但在紧急情况下,它保证了系统的可用性优先于一切。
5.4 故障分级和应急响应流程
最后是应急流程的变化。以前的故障应急手册只写了“服务器卡死查 CPU、内存、磁盘”,这次之后我们把“fd 检查”列入了前三步排查动作。当发现服务器假死时,执行顺序变为:
- 尝试远程登录;如果能登录,立刻查看
/proc/sys/fs/file-nr确认 fd 水位; - 用
dmesg | grep -i limit快速找内核日志,确认是否资源限制触发; - 确认进程杀手,用 lsof 或
/proc/*/fd统计占用大户; - 先临时调大系统参数恢复可用,再定位具体代码问题;
- 修复后观察监控曲线,确认问题不再反复。
这个流程看起来简单,但在故障发生的压力下,有没有能力快速定位资源型问题,完全是两种处置效果。至少我们这次事件后,团队再没有出现过“只能重启,不知道该干嘛”的窘境。
6. 常见问题与排查技巧速查
6.1 关于 fs.file-max 的常见误区
整理了几个我在这次实战中碰到的误区,以及对应的正确认知:
| 误区 | 实际情况 |
|---|---|
修改 fs.file-max 后立即修改 /etc/sysctl.conf 就永久生效 |
需要执行 sysctl -p 才能在当前系统里加载新配置文件 |
调大 fs.file-max 只对 root 生效 |
它是系统级参数,对所有用户和进程生效 |
每次重启后都会坚持上次的 sysctl -w 值 |
不会,重启后恢复原默认值,必须依赖持久化配置 |
ulimit -n 的值就是系统 fd 上限 |
不是,ulimit 是单进程上限,系统上限看 fs.file-max 和 fs.nr_open |
| Docker 容器内不需要调这个参数 | 容器经常共享宿主内核参数,有些场景下宿主 fs.file-max 不够,容器内一样报错 |
这些误区如果没搞清楚,排查的时候会走很多弯路。尤其是 systemd 服务里 LimitNOFILE 那一层,很多人改了 ulimit 但忘了改 systemd,结果服务重启后又被打回原形,反复发作却找不到原因。
6.2 排查 fd 的常用命令清单
如果你现在就想检查自己的服务器有没有 fd 隐患,把下面几条命令记下来:
bash复制# 查看系统已分配 fd 和总上限
cat /proc/sys/fs/file-nr
# 查看单个进程 fd 使用数
ls /proc/<pid>/fd 2>/dev/null | wc -l
# 列出当前 fd 打开数量最多的前 10 个进程
for pid in $(ls /proc | grep -E '^[0-9]+$'); do
count=$(ls /proc/$pid/fd 2>/dev/null | wc -l)
echo "$pid $count $(cat /proc/$pid/comm 2>/dev/null)"
done | sort -k2 -n -r | head -10
# 查看系统日志里有没有 fd 达到上限的证据
dmesg | grep -i "file-max limit"
# 查看全系统 fd 打开明细(需要有权限且 lsof 已安装)
lsof | wc -l
另外,ss -tan | wc -l 可以快速估算 TCP 连接数,而 netstat -tan | wc -l 也可以。如果连接数的量级异常大,结合 fd 的老化日志基本就能猜出问题所在。
6.3 如果没有找到具体泄漏源怎么办
有些时候你检查了一大圈,还是找不到哪个模块在泄漏。这时候可以采取一个土办法:在每台服务器的 cron 里加一条定时任务,把各个进程的 fd 统计写到一个带时间戳的文件里:
bash复制*/5 * * * * ps -e -o pid,rss,comm --no-headers | tee -a /tmp/fd_snapshot_$(date +\%Y\%m\%d).log
下次系统假死时,你可以从快照里反推故障前 5 分钟是哪个进程的 fd 在异常增长。这个办法虽然看起来简陋,但在真实环境里特别有效,因为故障发生的那一刻你往往是进不去系统的,而历史快照是事后分析唯一可靠的依据。很多正式的可观测性方案(比如 eBPF 追踪)也可以做到精确到函数级别的动态跟踪,但那需要投入额外的人力做埋点和指标建设,对大多数团队来说有点重。先用简单的 shell 定时统计撑住,再逐步完善成正式的监控指标,是性价比最高的路径。
7. 写在最后:这次事故教会我的几件事
这次“假死”故障最终以我们调高参数、修复代码、建立监控、优化流程收尾,前后折腾了将近 10 个小时。但回看整个过程,最有价值的部分不是最后改的那几行代码,而是对“服务器健康”这件事的理解发生了一次根本性的变化。
以前我以为只要 CPU、内存、磁盘三大件正常,服务器就算健康。这次事件让我意识到,系统里还隐藏着很多“小水管”,比如 fs.file-max,比如 inode,比如 TCP 连接表项,它们平时默默无闻,一旦被堵住,就能瞬间让所有业务崩盘,而且因为表面指标都正常,排查起来特别像在解谜。
作为一个常年写业务代码和脚本的工程师,我以前对内核参数这类东西是“不惹我也懒得碰”的态度。经历这次故障后我养成了一个习惯:上线前先检查系统的默认限制,尤其是部署 Node.js 或浏览器自动化这类高 fd 消耗服务的机器,一定要提前把 fs.file-max、ulimit、systemd 的 LimitNOFILE 和监控告警全部配齐,不要等到线上出事再追悔莫及。
最后再分享一个小技巧:如果你部署的服务有“不断有新进程起来、又有旧进程不退出”的特征,建议在压测阶段就故意制造一次并发高峰,观察 file-nr 的曲线走向。一次小小的压测,也许就能帮你避免一次深夜两点的“服务器假死”大战。
