1. 事故回放:业务告警一堆,服务器却显示“一切正常”
那天晚上大概十一点半,手机上连续弹出十几条告警,全是同一个集群的“健康检查失败”“HTTP 5xx 比例超阈”。我打开监控面板扫了一圈,第一反应是“这不对劲”:CPU 使用率不到 8%,内存还剩 60% 多,磁盘 IO 也近乎一条直线,没有任何容器被 OOM Kill。这个集群上跑的是公司的 JS 反爬校验服务,核心逻辑是用 Node.js 执行一组 JavaScript 挑战与指纹判定,负载模型属于典型的网络 IO 密集型,平时 CPU 不太高是正常的,可一个请求也回不了,就完全说不通了。
当时我先试着 SSH 登录服务器。结果更诡异:TCP 三次握手能通,但输入密码之后要卡十几秒才进到 shell,敲个 top 都要等半天。换句话说,机器没有“死”,内核还活着,网络栈也能响应连接请求,可就是什么都慢,新请求也处理不了。再看集群里的节点状态,负载均衡器已经开始把流量从这台机器上摘除,于是剩余机器压力迅速翻倍,眼看着也要被拖垮。
这基本就是大家常说的“服务器假死”现场。它不是 CPU 被打满或者内存耗尽那种一眼能看穿的问题,而是某些内核资源到了临界点之后,整个系统服务水平断崖式下降。我后来把所有日志和内核信息翻了一遍,锁定到一个平时几乎没人会盯的参数:fs.file-max。就是这个控制“整个 Linux 能打开多少文件句柄”的全局上限,把整个 JS 反爬服务给推到了悬崖边上。
先说一下这套服务为什么容易踩这个坑。JS 反爬校验服务自身的业务链路非常长:访客浏览器发起带用户行为特征的请求到 Node.js 网关,Node 需要读取请求体里的 JS 执行结果,可能还要去 Redis 里取会话状态,同时要向后端风控服务发起 HTTP 调用做联合判定,再把最终校验结果写回日志系统。每一条请求链路都会打开新的 socket 连接,而 Node.js 的异步模型决定了它在高并发下会同时维持非常多的连接。某些爬虫方发的请求还会故意打开长连接不关闭,或者不断用新的 user-agent 和指纹来试探,这会让每个请求占用的连接和临时文件更难以被及时回收。
这台出事的服务器配置是 8C16G,跑了两个 Node.js 实例,对外承担 JS 挑战的校验任务。系统内核版本是 5.4 的长期维护版,默认的 fs.file-max 内核会根据内存大小自动给一个估算值,我在那台机器上查到的值是 1048576。这个数字看起来很大,但在极端流量下,如果进程级限制和连接回收都没有兜底,一天之内用光并不是不可能。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 初期排查:监控没错,是排查方向错了
先说我踩过的弯路。当时看到 CPU 和内存都没问题,我一度把怀疑重点放在应用代码死锁和 Node.js 事件循环阻塞上,甚至怀疑是不是某个版本的依赖包因为 DNS 解析超时卡住了整个进程。于是我先让运维把流量切干净,然后通过带外管理控制台连进去跑了 journalctl -u node-service 和 Node 的堆快照分析,结果是:进程没崩、事件循环延迟正常、GC 也没异常。
2.1 健康检查为什么失败?先从“文件打开失败”开始查
真正让我转向的是一条应用日志:Node.js 进程在某个瞬间开始疯狂输出 EMFILE: too many open files。这是操作系统在尝试创建新的文件描述符时失败时抛出的错误。Node.js 的 HTTP server 在 accept 新连接、读写文件、建立出站 socket 时都会触发这个错误。一旦监听端口无法 accept,服务端的请求队列马上就会灌满,表现为健康检查接口返回 503。
我当时在服务器上执行的第一组命令是这样的:
bash复制# 查看系统级文件句柄整体水位
cat /proc/sys/fs/file-nr
# 查看系统级上限
cat /proc/sys/fs/file-max
# 查看指定进程当前打开的句柄数量
ls /proc/<PID>/fd | wc -l
# 查看进程级软硬限制
cat /proc/<PID>/limits | grep -i nofile
file-nr 的输出是三列数字,含义分别是:系统已经分配的文件句柄数、尚未使用的句柄数、以及系统最大句柄数。正常状态下第一列会远小于第三列。但当时我们看到的第一列值已经逼近第三列,也就是说整个系统的文件句柄池即将见底。
再配合 for pid in /proc/[0-9]*; do echo $pid $(ls $pid/fd 2>/dev/null | wc -l); done | sort -k2 -n | tail,就能一眼看出哪些进程是“吃句柄大户”。结果毫无悬念,两个 Node.js 进程累计占用了差不多 80% 的已分配句柄。这时候排查方向才终于从“代码死锁”转向了“文件描述符泄漏与耗尽”。
2.2 dmesg 里的关键证据,比任何监控面板都诚实
Linux 内核在很多资源即将耗尽时会往内核日志里写警告。dmesg -T 的尾部能看到类似这样的记录:
text复制[Thu Oct 17 23:19:41 2025] fs: file-max limit 1048576 reached
虽然这条日志不一定每次都出现,不同内核版本的打印位置略有差异,但只要看到 file-max limit reached 或 too many open files 相关字样,基本就可以确认系统级文件描述符已经顶到天花板了。有些场景下你还会看到某个进程 fork 失败、cannot allocate memory 之类的报错,其实也是因为新进程需要分配新的文件描述符,而全局名额已经没了。
很多长期运行的服务器,它的 /proc/sys/fs/file-max 是很大的,所以很少有人会主动去查 file-nr。但一旦业务代码存在连接回收不及时的情况,而 Node.js 这种异步 IO 密集型的服务又特别擅长同时打开大量连接,file-nr 的上涨会非常快。从曲线图上看,它是那种“平时平缓、一到流量高峰就垂直起飞”的形状。
2.3 “假死”的本质:不是 CPU 不够,是“门把手”被占光了
用一个比较生活化的比喻来解释这件事:服务器本身像是运营良好的餐厅,厨师(CPU)、食材(内存)都很充足。fs.file-max 相当于餐厅的门把手数量。每个顾客进门都要握住一个门把手,如果门把手全被前面的人握着不松开,后面的顾客就进不来,新顾客只能在门外排队。最要命的是,连服务员想开门去库房取东西,也被卡在门口。服务器的“假死”并不是因为它不会干活了,而是它没法再建立新的连接、打开新的文件、启动新的进程。而现代服务的每个动作几乎都依赖文件描述符,没了它,什么都做不了。
3. 文件描述符三兄弟:file-max、nr_open 与 nofile 的真实关系
排查结束后,下一个问题就是:为什么系统级上限还有富余的时候,服务就已经开始报 EMFILE?这就要把 Linux 文件描述符的几层限制彻底理清楚。很多单机服务只调大了进程级 ulimit -n,没有调系统级参数,导致内核总水位一满照样全线崩溃;反过来,也有人只调了 fs.file-max,忽略了进程级 nofile,最终所有句柄都被某一个进程独占。
3.1 三个参数的分工与优先级
用表格整理一下最核心的几个限制层级:
| 限制层级 | 对应参数 | 作用范围 | 查看方式 |
|---|---|---|---|
| 系统级 | /proc/sys/fs/file-max |
整个 Linux 系统所有进程累计可打开的文件描述符数量 | cat /proc/sys/fs/file-max |
| 单进程硬上限 | fs.nr_open |
单个进程可设置的文件描述符上限,不能被超过 | cat /proc/sys/fs/nr_open |
| 进程级 nofile | 进程的 RLIMIT_NOFILE | 单个进程实际可打开的文件描述符数量 | cat /proc/<PID>/limits 或 ulimit -n |
fs.file-max 是整个系统的总闸门。不管你的进程是 Nginx、Node.js 还是 Redis,它们在这个系统里打开的所有 socket、文件、管道最终都要算进同一个总数里。nr_open 是内核限制单个进程能设置 nofile 的最大值,它不能小于任何进程的硬性 nofile 设置。进程级 nofile 则是在 Linux 为每个进程准备的小型配额表。
要特别说明一个容易误解的点:/proc/sys/fs/file-max 的值并不是静态不变的,内核在启动时会根据物理内存和系统负载做估算,部分发行版也允许通过 systemd-tmpfiles 或 sysctl 配置覆盖。4GB 内存的机器,默认 file-max 会在 40 万左右;16GB 内存的机器,通常能到 160 万以上。但这只是个估算值,它“默认很大”不代表不会出问题,更不代表你应该放任业务无限量地占用句柄。
3.2 进程级 nofile 的经典误区
很多 Node.js 服务部署文档会让你在 /etc/security/limits.conf 里写:
ini复制* soft nofile 1048576
* hard nofile 1048576
这确实能提高某个用户启动的进程的 nofile 限制。但在 systemd 管理的服务环境下,这个文件不一定生效。systemd 服务默认会忽略 limits.conf 里的配置,你得在 service 单元文件里显式写:
ini复制[Service]
LimitNOFILE=1048576
改完还要 systemctl daemon-reload && systemctl restart。这个细节是很多人调了半天 ulimit,重启完发现又变回 1024 的原因。
3.3 反爬服务为什么更容易顶到系统上限
这里必须说一下业务特殊性。JS 反爬服务跟普通 CRUD API 有一个很大的区别:它要跟“对方的浏览器环境”做大量交互,并且也要在服务端模拟并发访问来校验 JS 逻辑。一套典型的校验流程会同时产生这些句柄:
- 访客浏览器到 Node.js 网关的一个 TCP 连接 fd
- Node.js 到 Redis 连接池里一个或多个 fd
- Node.js 内部日志记录打开的日志文件 fd
- Node.js 作为客户端去请求规则中心或风控后端的 HTTP 连接 fd(如果使用 keep-alive,每个目标 host 至少 1 个连接)
- 如果服务中用了无头浏览器做更完整的 JS 环境渲染,每个浏览器实例还会额外占用 IPC socket、共享内存临时文件和日志 fd
每处理一次访客校验,系统至少要打开 4 到 6 个文件描述符。如果这个校验服务还承担着“批量预跑 JS 规则”的任务,并发一拉起来,瞬间就会产生成千上万个临时连接。再加上部分反爬流量请求方会刻意模拟真实用户的长连接行为,让 Node.js 网关侧的 socket 迟迟无法关闭,句柄数自然只涨不跌。
我后来在复盘时统计过,那次事故里两个 Node.js 进程的文件描述符数量从正常的 1.5 万左右涨到 32 万以上,只用了不到三个小时。而系统全局 file-max 是 1048576,看似够用,但 Nginx、Redis、监控采集 agent、SSH 守护进程等基础组件还要占用一部分,真正的“可分配余量”并没有想象中充裕。
4. 根因分析:谁把文件描述符“借走”了却不还
锁定到文件描述符耗尽之后,接下来的问题是:这些句柄是谁打开的,为什么没有及时释放?为了搞清楚这一点,我抓取了故障时间段 Node.js 进程的 fd 快照,然后按类型做了分类统计。最终得出的结论是:没有任何一个单一的“Bug”在大量泄漏,而是好几个环节叠加在一起,形成了“每个环节看起来都合理,合在一起就爆了”的局面。
4.1 从现场抓 fd 快照,看清元凶
在服务还没完全假死的时候,我执行了这样的操作:
bash复制# 先记录当前进程打开的 fd 列表
ls -l /proc/<PID>/fd > /tmp/fd_snapshot_1.txt
# 隔 30 秒再拍一次
sleep 30
ls -l /proc/<PID>/fd > /tmp/fd_snapshot_2.txt
# 对比差异,持续增长的连接类型一目了然
diff /tmp/fd_snapshot_1.txt /tmp/fd_snapshot_2.txt
fd 列表里每一条都是形如 socket:[123456]、pipe:[789]、/path/to/log/file 这样的链接。我当时的统计结果非常典型:增长最快的是 socket 类型的 fd,其中大半指向同一个目标地址段,也就是规则中心服务的地址。这意味着 Node.js 进程作为 HTTP 客户端去访问规则中心时,连接没有被及时复用或关闭。
原因也找到了:我们的 HTTP 客户端使用的是 Node.js 默认的 globalAgent,它的 keep-alive 策略在连接池中保留了大量空闲连接,但代码里没有为不同目标 host 设置合理的 maxSockets 和空闲超时。流量高峰时一个 Node.js 进程会同时向规则中心发起成千上万次请求,每个请求如果采用短连接模式,接受响应后连接应当立即关闭;但如果服务端或客户端有一边没正确关闭,连接就会一直留在池子里,直到超时或空闲回收机制把它清掉。在我们的场景里,超时配置设得太长,而 Node.js 默认的 keep-alive 会尽量复用连接,结果就是空闲连接数量一路滚雪球。
4.2 连接池、超时和 Socket 状态的三角关系
要理解这个雪球,得看一下 TCP 连接在服务端的生命周期。一个连接从 accept 到 close,中间会经历 ESTABLISHED、CLOSE_WAIT、TIME_WAIT 等状态。不同状态意味着谁负责关闭连接:
- ESTABLISHED:连接正被使用或保持空闲。如果大量 ESTABLISHED 连接长时间空闲,说明对端或本端设置了过长的 keep-alive 时间,连接既不传数据也不关闭。
- CLOSE_WAIT:对端已经发起关闭,但本进程还没调用 close。这种情况通常是应用代码里处理响应后忘记关闭 socket。在 Node.js 里,这通常表现为“流没有消费完”或者“响应没有调用 destroy”。
- TIME_WAIT:本端主动关闭连接,在内核里等待一段时间确保重传包消失。TIME_WAIT 本身也在占用系统 fd,只不过它会自动过期。
我们的问题主要出在 CLOSE_WAIT 数量的增长上。由于规则中心服务的响应体是二进制 JS 引擎编译产物,代码里偶尔会直接丢弃响应而没有消费完整数据,导致 socket 残留。另一个来源是代理池调度模块,它在切换代理时新建了大量连接,但对旧的连接没有执行 socket.destroy(),只是扔掉了引用。在 Node.js 的垃圾回收机制里,如果一个 socket 还在等待事件,就算没有变量引用它也不会被自动 GC,必须显式关闭。
4.3 浏览器渲染实例的句柄贡献不可小觑
除了纯网络连接,这套 JS 反爬服务里还有一小部分任务会用到无头浏览器来验证 JS 挑战在真实浏览器环境里的执行结果。每个无头浏览器实例不是简单的库调用,它通常对应一个独立的 Chromium 渲染进程。启动一个浏览器实例,至少会带来这些句柄消耗:
- 与浏览器主进程通信的 pipe fd
- 渲染进程之间共享的 socketpair fd
- 浏览器自身的日志文件 fd
- DevTools 协议调试端口对应的 TCP fd
如果代码里每次任务都 launch() 一个新的浏览器实例,但任务结束时没有干净利落地调用 browser.close(),或者只关闭了页面而没关闭浏览器进程,句柄就会像没拧紧的水龙头一样持续滴水。这部分泄漏在低并发时看不到,一旦高并发任务批量触发,几百个僵尸 Chromium 进程同时挂在系统里,句柄数就是几千几万地往上跳。
我在复盘代码时发现,确实有一个定时任务在批量跑 JS 混淆对抗用例,每次跑完只做页面关闭,没有退出浏览器进程。这属于典型的“代码逻辑没问题,但资源管理不彻底”。
4.4 内核参数太低只是压死骆驼的最后一根稻草
把上面几个因素叠加起来,整体画面就很清晰了:服务本身存在若干连接回收不够及时的习惯,流量高峰又放大了这些问题的速度;等到系统的 file-nr 因为其他基础服务占用而逼近 file-max 时,任何新建连接的尝试都会直接失败。最坑的地方在于,这种失败不是按比例慢慢发生的,而是断崖式的。因为当系统已经打开 100 万个 fd 时,新的 fd 连一个都分配不出来,所有进程的 accept、connect、open 操作会同时报错。
这种“全或无”的特性,是服务假死的直接原因。如果只是连接池里的几十条连接失败,Node.js 顶多重试几次就过去了;但整个系统所有进程同时无法新建文件描述符时,连最基本的日志滚动、健康检查、新进程拉起的操作都会失败,服务器就进入了那个表面看似正常、实际拒绝服务的状态。
5. 应急恢复与长期修复:参数、代码、监控三层缺一不可
找到根因之后,恢复其实分了两步走:先让业务活过来,再避免它再次发生。第一步相对粗暴,就是临时把系统级限制调大,然后重启句柄占用异常的服务进程;第二步则需要把系统配置、代码写法、监控告警都补齐。
5.1 应急恢复:临时调参加重启进程
登录到服务器之后(或者通过云控制台 VNC 进入),第一时间执行:
bash复制# 临时调大系统级 file-max,立即生效
sysctl -w fs.file-max=2097152
# 同时调大单进程可打开文件数上限
sysctl -w fs.nr_open=2097152
这一步能立刻释放一部分系统压力,让新连接可以建立起来。之后按句柄占用降序重启相关业务进程,让已经被占用的 fd 释放掉。重启的顺序有讲究:先重启占用最多的 Node.js 实例,再重启旁边的 Nginx 等组件。如果直接先重启 Nginx,可能会导致正在进行的转发被中断,产生更多的 5xx 错误。
这里要提醒一点:临时调大 file-max 只能缓解“当前分配不出去”的问题,它不能修复业务代码里的连接泄漏。如果你的应用确实在泄漏句柄,调大参数带来的只是把故障从三小时延长到三天的效果,不治本。
5.2 系统级与进程级配置:一次写到位
应急恢复之后,我把系统配置固化到了文件里,避免重启服务器后参数回退:
bash复制cat > /etc/sysctl.d/99-file-descriptor.conf <<EOF
fs.file-max = 2097152
fs.nr_open = 2097152
EOF
sysctl -p /etc/sysctl.d/99-file-descriptor.conf
同时检查 Node.js 服务对应的 systemd 单元文件,确保进程级限制是合适的:
ini复制[Service]
LimitNOFILE=1048576
应用重启后,验证一下进程实际拿到的限制:
bash复制cat /proc/<PID>/limits | grep -i "open files"
这里的关键是:不要让单进程的 nofile 超过 fs.nr_open,否则进程根本起不来。另外,虽然我们本地设置了很大值,但不建议所有服务器都无脑统一调高,要根据业务实际需要的句柄量留出至少 30% 的富余。比如一个进程稳定情况下最多需要 20 万句柄,那给它设置的 nofile 上限可以到 30 万,系统级的 file-max 至少是所有可能吃掉句柄的进程总和的两倍。
5.3 代码层修复:让连接能借也能还
这次事故里面代码需要改的点有三个。第一个是 HTTP 客户端的连接管理。Node.js 里使用内置 http 模块时,可以为不同目标 host 设置独立的 Agent:
javascript复制const http = require('http');
const keepAliveAgent = new http.Agent({
keepAlive: true,
maxSockets: 200,
maxFreeSockets: 20,
timeout: 60000,
freeSocketTimeout: 30000
});
maxSockets 决定了这个 Agent 最多能同时为某个 host 建立多少连接;maxFreeSockets 和 freeSocketTimeout 则是让空闲连接尽快被回收。以前我们不设置这些,Node.js 就会为所有请求建立新连接,还默认把所有 keep-alive 连接都留着,句柄数当然压不住。
第二个是请求超时必须覆盖“连接建立、响应读取、空闲”三个阶段。Node.js 的 http.request 本身有 timeout 选项,但它只是“一段时间内没有活动就触发”的机制,不能保证请求一定会被终止。更可靠的做法是配合 AbortController 或 request.destroy():
javascript复制const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
const res = await fetch(url, { signal: controller.signal });
如果使用第三方请求库比如 axios,还需要配置 httpAgent 和 httpsAgent,并给每个请求设置 timeout,否则连接同样可能挂住。
第三个是无头浏览器任务的生命周期管理。正确做法是任务无论成功还是失败,都要在 finally 里关闭浏览器实例,不让任何异常路径导致进程残留。批量任务还应该控制最大并发浏览器数量,避免一次性启动几百个 Chromium。
javascript复制async function runJsVerification(task) {
const browser = await puppeteer.launch({ headless: true });
try {
// 执行 JS 挑战逻辑
} finally {
await browser.close();
}
}
5.4 独立打开的日志文件也要有意识管理
这里有一个容易被忽略的细节:日志文件本身也是文件描述符。如果 Node.js 进程把日志按天切割,并且每天打开一个新的 fd,那么运行一段时间后,日志相关的 fd 数量会积累起来。更隐蔽的是 pm2 这类进程管理器,它默认会接管 stdout/stderr,日志文件错误处理不当会导致 fd 被持续占用。我建议在代码里不要自己用 fs.createWriteStream 开一堆日志文件,统一交给日志库去管理,并且配置好 maxsize 和 maxFiles,让日志轮转机制帮你关掉旧文件的 fd。
5.5 监控指标的设计,要从“水位”而不是“状态”出发
最后一步是监控。我们原来的监控只覆盖了 CPU、内存、磁盘、网络,没有覆盖文件描述符这种底层指标,所以故障发生时监控面板完全无感知。修复之后,我在监控系统里增加了几个指标:
| 指标 | 数据来源 | 告警阈值 |
|---|---|---|
| 系统文件句柄使用率 | cat /proc/sys/fs/file-nr 第一列 / 第三列 |
> 80% 持续 5 分钟 |
| 进程句柄使用数 | ls /proc/<PID>/fd | wc -l |
超过进程 nofile 的 70% |
| Node.js EMFILE 错误计数 | 应用日志关键字 | > 0 |
| CLOSE_WAIT 连接数 | ss -tan state close-wait |
> 500 |
同时给日志采集加了一个关键字规则,一旦应用日志里出现 EMFILE、file-max limit reached 就立刻告警。这些指标平时看起来不重要,但它们往往是比 CPU 更早反映服务健康状况的前置信号。
实用的紧急排查命令
为了下次遇到类似问题能更快定位,我把排查命令整理成了一个小脚本,放在服务器上,随手可以执行:
bash复制#!/bin/bash
# 快速查看文件描述符健康状态
echo "===== 系统 file-max / file-nr ====="
cat /proc/sys/fs/file-max
cat /proc/sys/fs/file-nr
echo "===== 进程 nofile 限制 ====="
for pid in $(pgrep -f node); do
echo "PID: $pid"
grep "open files" /proc/$pid/limits
echo "已打开 fd 数量: $(ls /proc/$pid/fd | wc -l)"
done
echo "===== socket 状态统计 ====="
ss -tan state close-wait | wc -l
ss -tan state established | wc -l
6. 复盘后的几条实在建议
这次事故给我最大的教训是:高并发服务的可观测性不能只停留在“业务层”和“CPU/内存层”,文件描述符这种操作系统底层资源的指标同样关键。JS 反爬系统本质上是一个频繁创建和销毁网络连接的 IO 密集型服务,它的资源瓶颈往往不在 CPU 上,而在那些不会出现在常规监控面板上的系统配额里。
另外,像 fs.file-max 这类内核参数,虽然默认值看起来很大,但默认值不会帮你区分“正常占用”和“异常占用”。连接池的复用策略、空闲超时的长短、无头浏览器实例的关闭路径,每一项都需要在代码层面做明确管理。单纯把参数调大,只是给问题争取了更多的反应时间。
在后来给团队做的分享里,我反复强调一个思路:遇到服务假死,先别急着扩容或重启,优先去看三类数据——系统句柄水位、进程句柄占用、内核日志。这三类数据几乎能覆盖绝大多数“机器看着没死,但服务已经死了”的场景。工具链上,sysctl、/proc 文件系统和 ss 命令永远是排查这类问题的第一梯队,不要一上来就上堆栈分析器。
这套检查手段建完以后,我们后面又经历了一次流量翻倍的促销活动,同样的 JS 反爬服务,文件描述符最高只到了系统上限的 65%,而且监控曲线会在涨到 70% 之前就提前告警,给了运维充足的扩容和清理时间。这个状态,比出事之后再抢救舒服太多了。
