WebSocket连接泄漏导致FD耗尽,我用lsof + ss脚本5分钟定位根因
这次故障从开始排查到定位根因,实际只花了不到 5 分钟。不是因为运气好,而是因为lsof + sslsof告诉你 FD 被谁占了;ss告诉你这些 socket 是什么状态;两者一交叉,CLOSE_WAIT爆炸 = 连接泄漏,几乎不用猜。WebSocket 的坑不在于协议本身,而在于「长连接」这三个字带来的心智负担。短连接出问题,超时自动回收;长连接出问题,它真的会一直占着。如果数字大于 100,你
WebSocket连接泄漏导致FD耗尽,我用lsof + ss脚本5分钟定位根因
凌晨两点,监控群里突然炸出一条告警:
「某核心服务连接拒绝率突增 40%,CPU 正常,内存正常,但新请求全部超时。」
我爬起来连上 VPN,第一反应是端口占满了。结果 netstat 一看,端口还剩大把。再查 dmesg,一条红字跳了出来:
TCP: too many open files for socket
好家伙,文件描述符(FD)耗尽了。问题是,这服务平时的并发也就几百,FD 上限我明明配了 65535,怎么就用完了?
排查现场:FD 到底被谁吃了
我先跑了最基础的两条命令:
# 看进程开了多少 FD
ls /proc/$(pgrep -f my-service)/fd | wc -l
# 返回:65532
几乎满了。但服务本身的业务连接数远不到这个量级,肯定有泄漏。
接下来用 lsof 看这些 FD 到底是什么:
lsof -p $(pgrep -f my-service) | awk '{print $5}' | sort | uniq -c | sort -rn | head
输出让我愣了一下:
65123 SOCK
89 REG
12 PIPE
8 anon_inode
六万多个 socket,但只有大概 800 个是 ESTABLISHED 状态的 TCP 连接。剩下的六万多去哪了?
lsof + ss 组合拳:5 分钟锁定 WebSocket 幽灵连接
我写了条组合拳脚本,专门用来排查这种「FD 爆炸但活跃连接不多」的场景:
#!/bin/bash
# fd_suspect.sh —— 快速定位异常 socket 分布
PID=$(pgrep -f my-service)
echo "=== 进程 $PID 的 FD 总量 ==="
ls /proc/$PID/fd | wc -l
echo "=== 按 socket 状态统计 ==="
ss -np | grep "$PID" | awk '{print $1}' | sort | uniq -c | sort -rn
echo "=== lsof 中 CLOSE_WAIT / FIN_WAIT2 数量 ==="
lsof -p $PID | grep -E 'CLOSE_WAIT|FIN_WAIT2' | wc -l
echo "=== 按连接对端 IP 聚合,看是否有单 IP 堆积 ==="
ss -np | grep "$PID" | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head -10
跑完第三条,我找到罪犯了:
=== lsof 中 CLOSE_WAIT / FIN_WAIT2 数量 ===
64891
六万多个 CLOSE_WAIT。
CLOSE_WAIT 意味着什么?对端已经发了 FIN,但我方没有关闭 socket。放在 HTTP 短连接里这很少见,因为我们有连接池和超时。但放在 WebSocket 上,问题就很清晰了:
WebSocket 长连接断开后,服务端没有正确调用 close(),导致 socket 卡在 CLOSE_WAIT,FD 永远不被回收。
深挖:WebSocket 为什么没释放
我们的服务用 Go 写的,WebSocket 库是 gorilla/websocket。代码大概长这样:
func handleWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { return }
// 原本这里该 defer conn.Close(),但被某人删了
for {
_, msg, err := conn.ReadMessage()
if err != nil {
log.Println("read error:", err)
return // 直接 return,没 close
}
// 处理消息...
}
}
问题出在两个地方:
- 没有
defer conn.Close():客户端断开时,ReadMessage返回 error,函数直接return,连接没人管。 - 心跳超时逻辑缺失:有些客户端是「僵尸」——网络层断了,但应用层没发 close。这种连
CLOSE_WAIT都没有,直接变成ESTABLISHED死连接。
更坑的是,Go 的 net/http 在处理 WebSocket upgrade 后,底层 TCP 连接已经脱离 HTTP 连接池的管理。如果你自己不 Close,内核不会帮你收拾。
修复方案:两招止血 + 一招根治
第一招:立即止血,加 defer
func handleWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { return }
defer conn.Close() // 必须加
// 设置读写超时
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return // defer 会在这里触发 close
}
// ...
}
}
第二招:心跳保活,杀僵尸连接
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
go func() {
for range ticker.C {
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}()
客户端不响应 Ping,ReadMessage 就会超时 error,触发 defer conn.Close(),连接释放。
第三招:监控脚本,防止复发
我把之前那条排查脚本扩展成了常驻监控,跑在 Prometheus node_exporter 的 textfile 目录:
#!/bin/bash
# ws_fd_monitor.sh —— 输出 Prometheus 格式
PID=$(pgrep -f my-service)
FD_TOTAL=$(ls /proc/$PID/fd 2>/dev/null | wc -l)
CLOSE_WAIT=$(lsof -p $PID 2>/dev/null | grep CLOSE_WAIT | wc -l)
FIN_WAIT2=$(lsof -p $PID 2>/dev/null | grep FIN_WAIT2 | wc -l)
NODE_EXPORTER_DIR="/var/lib/node_exporter/textfile_collector"
cat <<EOF > "$NODE_EXPORTER_DIR/ws_fd.prom.$$"
# HELP ws_fd_total WebSocket 服务 FD 总量
# TYPE ws_fd_total gauge
ws_fd_total{pid="$PID"} $FD_TOTAL
# HELP ws_close_wait_close WebSocket CLOSE_WAIT 数量
# TYPE ws_close_wait_close gauge
ws_close_wait_close{pid="$PID"} $CLOSE_WAIT
# HELP ws_fin_wait2 WebSocket FIN_WAIT2 数量
# TYPE ws_fin_wait2 gauge
ws_fin_wait2{pid="$PID"} $FIN_WAIT2
EOF
mv "$NODE_EXPORTER_DIR/ws_fd.prom.$$" "$NODE_EXPORTER_DIR/ws_fd.prom"
Prometheus 告警规则:
- alert: WebSocketFDLeak
expr: ws_close_wait_close > 1000
for: 5m
labels:
severity: critical
annotations:
summary: "WebSocket CLOSE_WAIT 堆积,可能存在连接泄漏"
说到 WebSocket 库的选择,这里也提一嘴。gorilla/websocket 在 Go 生态里算是老大哥,但很多人没注意它的一个坑:默认没有读写超时。也就是说,如果你不手动 SetReadDeadline,一个静默断开的连接可以永远挂在那里,FD 不释放,内存也不释放。
另一种选择是 nhooyr/websocket,它自带了 Context 支持,超时控制更自然。但我们存量代码已经是 gorilla,迁移成本太高,所以改动最小化的方案就是补 defer conn.Close() 和心跳。
如果你从零开始选型,我建议把「连接生命周期管理」作为选型指标之一,别只看 API 好不好用。
一张表:socket 状态速查
排查时 ss 和 lsof 的状态字段很容易搞混,我整理了一张速查表,现场排查时直接对照:
| 状态 | 含义 | 我方动作 |
|---|---|---|
| ESTABLISHED | 连接正常 | 无需处理 |
| CLOSE_WAIT | 对方已关闭,我方未响应 | 我方必须调用 close() |
| FIN_WAIT2 | 我方已关闭,等待对方 ACK | 等内核回收或调 tcp_tw_reuse |
| TIME_WAIT | 四次挥手完成,等 2MSL | 正常,量大时调 tcp_tw_reuse |
| SYN_RECV | 收到 SYN,还没建立 | 可能 SYN Flood,需看来源 IP |
现场排查时,看到 CLOSE_WAIT 堆积,99% 是你方代码没 close。看到 TIME_WAIT 堆积,一般是高并发短连接,调内核参数就行。别搞混了,不然修错方向能折腾一晚上。
另一个坑:lsof 看到的 socket 数 ≠ ss 看到的连接数
这里有个很反直觉的点:lsof 统计的是 文件描述符,而 ss 统计的是 内核 TCP 连接状态。如果程序里有连接池或者自己 dup() 了 FD,这两个数字会对不上。
排查时我习惯两条命令一起跑,互相印证:
# FD 视角
lsof -p $PID | grep -c SOCK
# 内核连接视角
ss -np | grep -c $PID
# 如果 lsof >> ss,说明有 FD 泄漏或者 dup 了
# 如果 ss >> lsof,说明你看到的可能不是同一个进程(容器/多进程模型)
这次我们的问题是 lsof 六万多、ss 只有八百,CLOSE_WAIT 六万多,直接锁定连接泄漏,根本不需要猜。
更多推荐


所有评论(0)