说到服务器“假死”,干运维和后台开发的兄弟应该都不陌生。表现就是:页面打不开、接口一直转圈、SSH能连上但敲命令半天才回显,你看着CPU和内存都正常,机器明明还活着,却像被什么东西掐住了脖子。
这次要复盘的不是什么高深的架构故障,而是一个小到容易被忽略的内核参数——fs.file-max。就这一个小小的文件句柄上限,直接把我们的 JS 反爬系统整个拖垮了。故障从发生到定位花了快两个小时,真正改参数恢复只用了两分钟。这个对比让我印象特别深刻,所以专门写出来,希望对大家在服务器调优、反爬系统设计上有些帮助。
先说一下背景。我们有个面向 Web 端的流量治理服务,核心业务之一是 JS 反爬验证。简单说,就是当请求流量看上去像爬虫时,服务端会给客户端下发一段 JS 挑战,客户端执行完返回结果,服务端再校验。这个系统平时很稳,直到那天下午业务高峰,整个服务突然“假死”。
1. 事故现场:服务“假死”,机器还活着
1.1 业务反馈与第一轮排查
下午两点四十左右,运营群里开始有人反馈,说某个活动页面打不开,接口请求一直在转圈。紧接着,其他几个业务方也陆续说调用反爬验证接口超时。我第一时间登录服务器去看,心里其实做好了“CPU被打满”的准备,但实际情况完全出乎意料。
load average 很低,1分钟、5分钟、15分钟都在1以下;CPU 使用率百分之十几;内存占用虽然偏高,但还远没到耗尽的地步;磁盘 IO 也平稳。整台机器看起来非常健康,但服务就是响应不了。这时候我第一反应是网络问题,或者是不是上游某个服务把请求堵住了,于是又去查了网卡流量、TCP 连接数、Nginx 日志,都没有特别明显的异常。
1.2 看起来正常的资源却藏着异常
第一轮排查没结果,我心里开始有点慌。因为“资源正常但服务不响应”是最难受的一种故障,它意味着问题可能藏在系统栈更深的地方,而不是简单的流量突增或代码死锁。
我又跑了 free -h、df -h,内存和磁盘也都正常。这时候我不经意看了一眼 ss -s 的输出,发现 socket 数量比平时高了不少,好几万个 TCP 连接堆在那里,状态大多是 ESTABLISHED 和 TIME_WAIT。但光看数量我还没意识到严重性,因为这台机器平时连接数就不低。真正让我警觉的是,我用 ps aux --sort=-%cpu 想找找哪个进程占用高,发现反爬服务的进程 CPU 占用不高,可子进程列表里多了很多 chrome 相关的进程。
是的,我们反爬系统里用了无头浏览器执行 JS 挑战。看到一堆 Chrome 进程没退出,我瞬间明白,大概率是文件描述符出问题了。
1.3 “假死”的本质:进程活着,服务进不来
这里要先解释一下“假死”到底是怎么回事。很多人以为服务器假死是死机或内核崩溃,其实绝大多数情况是:进程还活着,但再也无法接受新的请求了。
拿我们这个反爬服务来说,它的工作模式是接收 HTTP 请求,可能要去无头浏览器里执行 JS,然后返回验证结果。每一个新请求进来,服务都需要创建新的 socket 连接、可能打开临时文件、和下游模块建立通信,这些动作全都要消耗文件描述符。如果文件描述符耗尽,进程就没办法创建新的 socket,accept() 直接失败,新的请求自然进不来。
更坑的是,日志系统本身也要写文件。当句柄耗尽时,连日志都写不进去了,故障现场一片“沉默”,这就是为什么第一轮排查时日志里几乎找不到有效报错。整个系统表现就是:进程列表还在,端口还在监听,但外部请求全部超时。这种状态和真正的死机不同,所以叫“假死”特别贴切。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 定位过程:从 socket 数量到 file-max
2.1 三条核心命令揭开真相
确定方向后,我用了三条命令,基本就把问题锁定了。
第一条是查看系统当前的文件句柄使用情况:
bash复制cat /proc/sys/fs/file-nr
输出是三个数字,大概意思是:已分配的文件句柄数、未使用(或已释放)的句柄数、系统最大文件句柄数。我们看的时候,第一个数字已经非常接近第三个数字了,也就是说系统分配出去的文件句柄数已经逼近上限。
第二条是看系统的最大句柄数配置:
bash复制cat /proc/sys/fs/file-max
输出是 65536。看到这个数字我愣了一下,因为这台服务器是 32G 内存的机器,默认情况下内核算出来的 file-max 应该远远大于这个值。后来查了历史配置才确认,是之前某次“安全加固”时被人为设置成了 65536,而且没有写进持久化配置里。
第三条命令是排查具体是哪个进程消耗了句柄:
bash复制ls /proc/<pid>/fd | wc -l
对反爬服务的主进程跑了一下,发现句柄数已经到了一万多;再看了几个 Chrome 子进程,每个都占了好几百个句柄。几个 Chrome 进程加上主进程本身,再加上 Nginx、监控 agent 之类的系统组件,65536 这个上限瞬间就被吃满了。
2.2 fs.file-max 和 fs.file-nr 到底是什么
这里展开讲一下这两个内核参数,因为它们很容易被忽略,但关键时刻能救命。
fs.file-max 是 Linux 内核级别的文件句柄上限,它控制的是整个操作系统所有进程加起来能打开的文件描述符总量。注意,是系统全局,不是单个进程。你打开一个文件、建立一个 TCP 连接、创建一个 socket、映射一个共享内存,都会消耗一个文件描述符。所以这个值如果设小了,整个系统的所有进程都会受牵连,不只是某一个应用。
fs.file-nr 是一个只读的状态文件,第一列表示当前系统已经分配出去的文件句柄数,第三列就是 fs.file-max 的值。日常监控里我们主要看第一列和第三列的距离,如果第一列长时间超过第三列的一半,甚至到 80% 以上,就得引起重视了。
还有一个容易混淆的参数是 fs.nr_open,它限制的是单个进程能打开的文件描述符数量上限。这个值通常比 file-max 大得多。如果哪天你想把某个进程的 ulimit -n 调得特别高,比如超过 fs.nr_open,那即使 file-max 足够大,设置也不会成功。所以调优时这两个参数要一起看。
2.3 文件描述符耗尽如何引发雪崩
文件描述符耗尽不是一下子把服务“杀死”,而是引发连锁式的雪崩。我先说结论:它会让系统的可用能力断崖式下降,而不是平滑地变慢。
第一阶段,系统的文件句柄还有少量剩余。这时候有些请求还能进来,但创建新 socket 时不时失败,表现为接口偶发超时,错误率开始上升。第二阶段,句柄彻底耗尽。此时 accept() 直接报 EMFILE(Too many open files),新连接完全进不来。已经建立的连接可能还会维持着,但任何需要打开新文件的动作都会失败,包括写日志、读配置文件、建立数据库连接。
最麻烦的是,很多应用层框架对 accept() 失败的处理并不完善。有的会直接抛异常,有的会进入死循环重试,还有的会表现为“卡住”——线程池里的线程都在等待 IO 资源,但资源永远等不到。这时候你从外部看,进程还在,端口还在监听,但服务实际上已经废了。我们这次就是这么个状态,而且因为日志写不进去,排查时多花了很多时间。
3. 为什么偏偏是 JS 反爬系统挂了
3.1 JS 反爬系统的常见实现
聊完了内核参数,再说回业务本身。为什么这次事故偏偏发生在 JS 反爬系统上?因为它天生就是文件描述符消耗大户。
JS 反爬验证的交互逻辑大概是这样的:客户端第一次访问时,服务端返回一个带 JS 挑战的页面;客户端执行这段 JS,计算出一个动态 token 或指纹,再带上这个结果重新请求;服务端独立校验结果是否正确,通过后才放行。
校验端有两种常见实现。轻量一点的方案,是在 Node.js 或 Java 里直接嵌入 JS 引擎,比如用 vm 模块、jsdom,甚至调用独立的 V8 引擎去执行挑战脚本;重量级的方案,就是直接起无头浏览器,比如 Puppeteer 操作 Headless Chrome,把整个挑战流程真实跑一遍。前者消耗小很多,但有些复杂的浏览器指纹或环境检测过不了;后者兼容性好,但资源开销根本不是同一个量级。
我们的系统用的是后一种方案:Node.js 服务接收到校验请求后,动态拉起 Headless Chrome 去执行 JS 挑战。这方案在功能上没毛病,问题就出在它对文件描述符的消耗速度远超预期。
3.2 无头浏览器是文件描述符消耗大户
很多人对无头浏览器的资源消耗没有概念。我举个直观的例子:一个普通的 Headless Chrome 实例,启动之后会建立 DevTools 协议连接、创建多个渲染进程、打开各种管道和共享内存文件,光这一个实例占用的文件描述符就能轻松达到一两百,复杂页面甚至更多。
我们反爬服务在流量高峰时,如果每秒来几十个校验请求,就可能同时存在几十个甚至上百个 Chrome 实例在跑。算一下:一百个实例,每个一百多个句柄,那就是一万多。这不是一次性创建后释放就完了,Chrome 的冷启动和销毁本身有延迟,句柄会重叠累积。再加上 Node.js 主进程本身的连接句柄、Nginx 的连接句柄、日志文件的句柄,一个 65536 的上限根本扛不住。
而且,Chrome 实例在创建时对文件描述符的需求是突发式的。它不是平滑增长,而是瞬间涨上去。如果你只在平时流量低的时候看过 file-nr,根本发现不了问题,一旦大促或爬虫集中攻击,几十秒内就能把句柄耗尽。
3.3 容量评估缺失与隐患叠加
这里必须承认,我们当时对反爬系统的容量评估是不到位的。我们压测过接口的 QPS,压过 CPU 和内存,但从来没人去统计过“单次 JS 校验平均消耗多少文件描述符”“高峰期并发 Chrome 实例数是多少”“系统的 file-max 够不够”。
这三个问题没搞清楚,等于在钢丝上走路。CPU 和内存是大家比较熟的资源,但文件描述符这种隐藏资源,不炸一次真的没人会在意。更讽刺的是,那个把 fs.file-max 改成 65536 的决定,本意是“安全加固”,结果成了事故的直接推手。
还有一个隐患要提一下,就是 Chrome 实例异常退出导致的句柄泄漏。正常流程里,校验完成后要调用 browser.close() 释放资源。但线上经常有超时、崩溃、被系统杀掉的情况,close() 没执行到,子进程变成僵尸或半残留状态,句柄就一直占着不释放。一次两次无所谓,积累多了就是大问题。这次事故里,我们检查进程列表时确实发现了一些没退干净的 Chrome 进程,说明泄漏问题早就存在了,只是被系统容量“兜”住了,直到 file-max 这层纸被捅破。
4. 救火与根治:从内核参数到应用层改造
4.1 第一刀:临时调大 fs.file-max
定位到问题之后,第一件事就是止血,先把 fs.file-max 临时调大。
bash复制sysctl -w fs.file-max=1000000
注意,这只是临时生效,重启后失效。同时我还顺手确认了一下 fs.nr_open,如果它不够大,单进程的句柄上限也会被卡住,但当时这台机器上 fs.nr_open 默认是 1048576,足够用,所以没动它。
调完参数后,我立刻重启了反爬服务。重启的目的是把已经泄漏的 Chrome 子进程和占用的句柄全部清掉。这里有个经验:光调大 file-max 不重启服务,只是让系统不再报句柄耗尽,但已经泄漏的句柄还在,服务依然处于亚健康状态。重启虽然粗暴,但对这种“状态已经脏了”的场景特别有效。
重启之后,我再用 cat /proc/sys/fs/file-nr 看了一眼,已分配句柄数从六万多直接掉到了几千,服务响应也恢复了。整个恢复过程,真正执行命令的时间不超过两分钟。
4.2 持久化配置:不要把参数写在命令行里
临时调参只是救火,接下来必须做持久化配置,否则服务器一重启,一切回到原点。
将参数写入 /etc/sysctl.conf:
bash复制fs.file-max = 1000000
fs.nr_open = 2000000
然后执行 sysctl -p 让配置立即生效。这里有个顺序问题:如果 fs.nr_open 设置得比 fs.file-max 小,后续想调高单个进程的句柄上限是会失败的。所以我的习惯是 fs.nr_open 永远比 fs.file-max 大,留出余量。
我还在团队内部立了一个规矩:所有内核参数的调整,必须同步到配置管理平台,不允许任何人通过命令行临时改参数后不留下记录。这次事故里 file-max 被人为改小却没人知道,就是吃了这个亏。
4.3 进程级限制:ulimit 与 systemd 的关系
调大 fs.file-max 只是解决了系统级的上限,但进程本身的句柄限制同样要处理。Linux 下每个进程还有一层 ulimit 限制,分软限制和硬限制。很多应用默认的软限制只有 1024,高并发下随便就撞上了。
如果是手工启动的进程,可以用 ulimit -n 1048576 临时调大,但更好的方式是在启动脚本或 systemd 服务里配置。
如果我们的反爬服务是用 systemd 管理的,要在 service 文件里加:
ini复制[Service]
LimitNOFILE=1048576
LimitNOFPROC=65536
然后执行 systemctl daemon-reload 和 systemctl restart。
这里容易踩坑的是:systemd 有自己的 Limit 配置,它不会直接继承 /etc/security/limits.conf 里的设置。很多人在 limits.conf 里改了 nofile,但 systemd 服务就是不起作用,原因就在这。所以,systemd 管理的服务必须在 service 文件里显式设置。
4.4 应用层改造:实例池化与泄漏兜底
只调系统参数实际上是把问题从“系统句柄不够”变成了“应用句柄太多但系统扛得住”,这是治标不治本。真正的根治,是减少应用本身对文件描述符的消耗。
我们对反爬系统做了三件事。
第一件,Chrome 实例池化。原来每个请求都拉起一个新的 Chrome,现在维护一个可复用的 Chrome 实例池,校验请求从池子里取实例,用完了还回去。这直接把 Chrome 实例的创建销毁频率降了一个数量级,文件描述符的波动也小了很多。
第二件,给所有 Chrome 操作加超时兜底。无论校验成功还是超时失败,finally 块里都必须执行 browser.close() 或释放实例,防止异常路径下的句柄泄漏。
第三件,增加并发信号量。在应用层限制同时执行的 Chrome 实例数量,比如上限 30 个,超过的请求排队等待。这样即使流量突发,也不会瞬间拉爆系统。
这几件做完,我们再看 file-nr,高峰期的已分配句柄数稳定在一两万,比之前的六万多健康多了。
4.5 参数合理值参考表
这里我整理了一份常用参数和参考值,方便大家对照检查。注意,具体数值要根据业务和内存情况调整,不要盲目照抄。
| 参数 | 作用范围 | 常见参考值 | 备注 |
|---|---|---|---|
fs.file-max |
系统全局 | 按内存估算,通常 32G 内存可设为 1000000 | 查看:cat /proc/sys/fs/file-max |
fs.nr_open |
单进程最大句柄 | 通常为 file-max 的 2 倍 |
修改 file-max 前先看这个值 |
ulimit -n(软) |
当前 shell 及子进程 | 1048576 | 临时生效,重启失效 |
ulimit -n(硬) |
当前用户上限 | 1048576 | 不能超过 fs.nr_open |
systemd LimitNOFILE |
systemd 管理进程 | 1048576 | 在 service 文件里设置 |
/etc/security/limits.conf |
用户登录会话 | * soft nofile 1048576 等 |
对 systemd 服务不直接生效 |
5. 监控告警与容量规划:别等问题上门
5.1 文件句柄监控到底该看哪个值
这次事故之后,我们做的第一件事就是补监控。监控不能只看 CPU、内存、磁盘,文件描述符这个指标必须单独拉出来。
最核心的监控指标是 /proc/sys/fs/file-nr 的第一列与第三列的比值。我一般会设置两个阈值:超过 60% 时产生警告,超过 80% 时产生严重告警。因为句柄耗尽是突发式的,等你看到 90% 再处理,可能已经来不及了。
进程级的监控同样重要。对每个 Java、Node.js、Chrome 进程,要采集 /proc/<pid>/fd 目录下的文件描述符数量。如果一个进程的句柄数持续增长但从不回落,说明有泄漏,这比任何动态告警都更值得关注。
监控方案上,最简单的做法是写个脚本用 crontab 定期采集,或者直接接入 Prometheus 之类的监控系统。不管用什么方案,核心是必须有历史趋势,否则你根本不知道“当前值”是不是“正常值”。
5.2 压测与容量评估
出了这样的事故,压测方案也得跟着升级。我们后续做了一次专门的“文件描述符压测”,流程大致是:
压测前先记录系统的基线 file-nr 值,然后逐步增加请求并发,同时观察 file-nr 的增长曲线和单实例 Chrome 的句柄消耗。通过这种方式,我们算出了一个关键数据:单次 JS 校验平均消耗约 150 个文件描述符。再结合预估的高峰 QPS,反推系统需要预留多少句柄空间,从而确定 fs.file-max 的值。
这个数字有一个特别重要的应用场景:它能帮你算清楚系统的“真实容量上限”。比如 fs.file-max 是 1000000,系统其他组件占用 200000,那留给反爬服务的只有 800000,再除以单次校验消耗 150,大概就能算出同时并发的 Chrome 实例上限。有了这个数,应用层的并发信号量就有据可依了。
5.3 巡检清单与团队操作指引
最后,我把这次事故沉淀成了一份巡检清单,每周执行一次,这里分享出来供参考。
| 检查项 | 命令或方法 | 预期结果 |
|---|---|---|
| 系统文件句柄使用率 | cat /proc/sys/fs/file-nr |
第一列与第三列比值低于 60% |
| 单进程句柄数 | ls /proc/<pid>/fd | wc -l |
稳定,不随运行时间持续增长 |
| Chrome 子进程残留 | ps aux | grep chrome |
高峰期结束后无残留进程 |
| 内核参数配置 | sysctl fs.file-max fs.nr_open |
与配置管理平台一致 |
| 系统日志错误 | dmesg -T 和 journalctl -u 服务名 |
无 Too many open files 等报错 |
这份清单不复杂,但每一条都是从真实故障里提炼出来的。尤其是“Chrome 子进程残留”这一条,如果你也用了无头浏览器做验证,强烈建议加进巡检里。
6. 复盘心得:这类小参数为什么能击垮大系统
老实说,这个坑踩过之后,我最大的感受是:越是大系统,越容易栽在“小参数”上。CPU、内存、磁盘这些资源,大家盯得很紧,出了问题也容易定位。但 fs.file-max、ulimit、连接队列长度这些底层小参数,平时没人看,一旦出问题,表现又特别隐蔽,很考验排查经验。
这里分享一个排查思路:如果你的服务出现“CPU 正常、内存正常、但请求就是进不来”的情况,别犹豫,马上看文件描述符和连接队列。命令就三条:cat /proc/sys/fs/file-nr、cat /proc/sys/fs/file-max、ss -lnt。这三条命令花不了十秒钟,却能帮你排除一类非常隐蔽的故障。
还有一点想提醒大家:不要轻易在生产环境做“安全加固”。我们那 65536 的 file-max,大概率是之前有人觉得“限制文件句柄数能防爆破”之类的思路弄的。出发点是好的,但完全没考虑业务的实际需求。任何内核参数调整,都应该有压测数据支撑,并且经过评审,而不是凭感觉写个值进去。
最后再分享一个小技巧。这次事故之后,我在所有服务器上加了这么一条监控脚本,每分钟检查一次 file-nr 使用率,超过 80% 就自动往告警群发消息:
bash复制#!/bin/bash
total=$(cat /proc/sys/fs/file-nr | awk '{print $1}')
max=$(cat /proc/sys/fs/file-nr | awk '{print $3}')
if [ $((total * 100 / max)) -ge 80 ]; then
echo "file-nr usage high: $total/$max" | tee /dev/tty
fi
脚本不复杂,但关键时刻能救命。毕竟文件描述符这种资源,等到你主动发现的时候,往往已经晚了。希望这篇复盘能帮大家少踩一次坑,哪怕只是让大家在排查“假死”问题时多想到一个方向,也值了。
