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
        }
        // 处理消息...
    }
}

问题出在两个地方:

  1. 没有 defer conn.Close():客户端断开时,ReadMessage 返回 error,函数直接 return,连接没人管。
  2. 心跳超时逻辑缺失:有些客户端是「僵尸」——网络层断了,但应用层没发 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
        }
    }
}()

客户端不响应 PingReadMessage 就会超时 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 状态速查

排查时 sslsof 的状态字段很容易搞混,我整理了一张速查表,现场排查时直接对照:

状态 含义 我方动作
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 六万多,直接锁定连接泄漏,根本不需要猜。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐