一个潜伏3天半才爆的bug-从502到Too-many-open-files的排查记录

一个潜伏 3 天半才爆的 bug:从 502 到 Too many open files 的排查记录

上周五下午,正准备收拾东西下班,群里突然炸了------线上 AI 对话全挂,用户疯狂反馈 502。

arduino 复制代码
❌ 连接失败:Server error '502 Bad Gateway'
for url 'https://ai.winste.com/v1/chat/completions'

打开日志一看,Python 网关进程在刷一串报错:

arduino 复制代码
OSError: [Errno 24] Too many open files: '/www/wwwroot/llm-transfer/config.yaml'

第一反应是------"这报错有点怪,config.yaml 打不开?文件权限被改了?" 差点就往"检查配置文件"的方向去查了。幸好多看了一眼,这个判断差点让我走完全错的方向。

这篇记录一下完整的排查过程。核心想说的一点是:看到报错先别急着顺着它查,先分清它是"症状"还是"根因"

先搞清楚 502 是从哪一层冒出来的

我们这个 AI 平台的链路是这样的:

scss 复制代码
客户端
  │
  ▼
ai.winste.com (nginx 反向代理, 80 端口)
  │   proxy_pass http://127.0.0.1:8000;
  ▼
Python 网关 (uvicorn + FastAPI, 8000 端口, 4 个 worker)
  │
  ▼
真正的上游 LLM 厂商 (中移臻泽 *.cmecloud.cn)

这里有个关键的认知ai.winste.com 不是上游 LLM 厂商,是我们自己的域名 ,nginx 反代到本机的 Python 网关。所以这个 502 根本没走到真正的 LLM 上游,是发生在 nginx → Python 网关 这一段的。

nginx 配置(宝塔面板管的)大概长这样:

nginx 复制代码
location /v1/ {
    proxy_pass http://127.0.0.1:8000;
    # SSE 相关配置...
    proxy_read_timeout 300s;
}

注意是 proxy_pass 单点直连,没有 proxy_next_upstream。意味着 Python 网关一旦接不了连接,nginx 立刻返回 502,没有重试。

那 Python 网关为什么接不了连接?日志说的 Too many open files 就是线索。

Too many open files 到底是个啥

这个报错的意思是:进程打开的文件描述符(FD)数量到上限了

Linux 里"一切皆文件",这句话不是说说而已------打开一个普通文件、建一个 TCP 连接、监听一个端口、开一个管道,都要占一个 FD。每个进程有个 FD 上限(通常是 1024,ulimit -n 查),到顶了之后,连打开一个普通的 yaml 文件都会失败,更别说接新的网络连接了。

所以 config.yaml 打不开 这事,config.yaml 本身是无辜的------它只是恰好在这个时间点被打开,撞上了 FD 耗尽。真凶是别的什么东西占满了 FD。顺着 config.yaml 查纯属浪费时间。

第一步:看 FD 到底被什么占了

上了服务器,先看进程的 FD 情况:

bash 复制代码
PID=$(pgrep -f "app.main:app" | head -1)

echo "FD 总数: $(ls /proc/$PID/fd 2>/dev/null | wc -l)  /  上限: $(cat /proc/$PID/limits 2>/dev/null | grep 'open files' | awk '{print $4}')"
echo "socket 数: $(ls -la /proc/$PID/fd 2>/dev/null | grep -c socket)"

输出:

yaml 复制代码
FD 总数: 1024  /  上限: 1024
socket 数: 1011

好家伙,1024/1024,顶死了。而且 1011 个都是 socket。基本可以确定是 socket 泄漏------有什么东西在不停建 socket 但不回收。

到这里,我本能地想:"socket 泄漏呗,肯定是 httpx 流式响应没关。" 因为这个项目里 Python 网关转发 LLM 请求用的就是 httpx,流式响应如果忘了 aclose(),确实是经典的 FD 泄漏源。

第二步:差点被误导(关键转折)

为了验证"httpx 流式响应泄漏"的猜想,我去看了下这些 socket 的状态和对端 IP:

bash 复制代码
echo "=== socket 状态分布 ==="
ss -tanp | grep "pid=$PID" | awk '{print $1}' | sort | uniq -c | sort -rn

echo "=== 对端 IP 分布 ==="
ss -tnp | grep "pid=$PID" | awk '{print $5}' | sed 's/:[0-9]*$//' | sort | uniq -c | sort -rn | head -10

输出让我愣了一下:

diff 复制代码
=== socket 状态分布 ===
      2 CLOSE-WAIT
      1 LISTEN
      1 ESTAB

=== 对端 IP 分布 ===
      2 36.134.88.x
      1 36.139.125.x

等等,这数字不对劲

/proc/fd 里明明有 1011 个 socket,但 ss 看到的活 TCP 连接只有 4 个?剩下 1007 个去哪了?

这个矛盾点特别关键。如果我的"httpx 流式响应没关"猜想是对的,那这些 socket 应该是活跃的 TCP 连接(连着上游 LLM 厂商),ss 应该能看到大量 ESTABLISHED 状态的连接、对端是 LLM 厂商的 IP。但现在 ss 里几乎啥都没有------说明这些 socket 对应的 TCP 连接早就断了,只是 Python 进程这边还占着 FD 没释放。

这里要解释一下 /proc/fdss 的区别,这是排查这类问题的分水岭:

  • /proc/<pid>/fd :列出进程持有的文件描述符。只要应用没调 close(),这个 FD 就一直在,哪怕底层连接早被对方断了
  • ss :看到的是内核网络栈里当前真实存在的连接

两者的差值,就是"泄漏的、没回收的 FD"。1007 个差值,就是 1007 个"TCP 连接已断、但 Python 端 FD 没关"的死连接。

顺着这个思路再确认一下 FD 类型分布:

bash 复制代码
ls -la /proc/$PID/fd 2>/dev/null | grep -oE 'socket|pipe|anon_inode|/www/[^ ]*|/[^ ]+\.log' | sed 's#:\[.*##' | sort | uniq -c | sort -rn | head -15
bash 复制代码
   1011 socket
      4 pipe
      4 anon_inode
      2 /opt/wwwlogs/python/llm-transfer/error.log
      1 /opt/wwwroot/llm-transfer/jsc_log/jsc.log
      1 /dev/null

清一色 socket,没有别的。铁证------1007 个死 socket 没回收

稍微算一下:1007 ÷ 每次泄漏 1 个 ≈ 1007 次泄漏。如果泄漏源是某个定时任务,每 5 分钟漏一次,那就是 1007 × 5 分钟 ≈ 5035 分钟 ≈ 83.9 小时 ≈ 3 天半

而进程确实是 3 天半前重启的。时间线完全吻合。

第三步:找泄漏源

既然是"建了 socket 但不关",而且数量随时间线性增长,最可能的就是某个定时任务在反复建连接但不回收。

项目里有两个后台循环:

  • sync_tasks:每 5 分钟跑一次,处理卡住的视频任务。
  • probe:每 3 分钟跑一次,探活下线的渠道。

时间间隔对得上 5 分钟,嫌疑指向 sync_tasks。翻代码一看,找到了。

先看出问题的 init_pool

python 复制代码
# app/db.py ------ 出问题的版本
_pool: list[pymysql.Connection] = []

def init_pool(config_dict: dict) -> None:
    """Initialize connection pool."""
    global _db_config
    _db_config = config_dict
    try:
        conn = _create_connection()
        _pool.append(conn)          # ← 每次都 append!没有"已初始化就跳过"的判断
    except Exception as e:
        raise

再看谁在调它:

python 复制代码
# app/cron/sync_tasks.py
async def start_sync_tasks_loop(interval_seconds: int = 300) -> None:
    """每 5 分钟运行一次巡检。"""
    while True:
        try:
            await main()            # ← main() 里会调 init_pool()!
        except Exception as e:
            logger.exception("Error in background task sync loop: {}", e)
        await asyncio.sleep(interval_seconds)

async def main() -> None:
    # ...
    from app.db import init_pool
    init_pool(db_cfg)               # ← 每 5 分钟调一次

而这个循环是在应用启动时挂到主进程上的后台任务:

python 复制代码
# app/main.py
@asynccontextmanager
async def lifespan(application: FastAPI):
    init_pool(config.database.model_dump())   # 启动时第 1 次

    from app.cron.sync_tasks import start_sync_tasks_loop
    asyncio.create_task(start_sync_tasks_loop(300))  # ← 每 5 分钟再调一次

破案了。泄漏链条是这样的:

diff 复制代码
启动:       _pool 有 1 个连接
+5 分钟:    _pool 有 2 个(sync_tasks 第一次 init_pool)
+10 分钟:   _pool 有 3 个
...
+3.5 天:    _pool 有 ~1005 个 → FD 耗尽 → 崩

为什么 FD 不回收?因为:

  1. init_pool 每次往 _pool 这个全局 list 里 append 一个新 MySQL 连接(建 socket,占 FD)。
  2. 但取连接的 _get_connection() 只用 _pool[0],后面那些连接创建后既不使用也不关闭
  3. MySQL 那边有个 wait_timeout(默认 8 小时),空闲连接到期会被 MySQL 主动杀掉
  4. 但 Python 这边的 pymysql.Connection 对象还被全局 list 持着,它的 socket FD 永远不释放。
  5. 之前的修复(为了防并发问题)把 ping(reconnect=True) 去掉了,死连接永远不会被重建/关闭。
  6. 累积到 1024 上限 → Too many open files → nginx 502。

其实这跟团队之前修的"MySQL Too many connections"是同一个泄漏的两种表现 :连接数先撑爆 MySQL(MySQL 侧报错),那次改了 record_channel_call 用短连接修了 MySQL 侧;但 sync_tasks 反复 init_pool 这条漏网了,于是换个方向,从进程的 FD 侧爆出来。

修复

核心就一行判断------让 init_pool 幂等:

python 复制代码
def init_pool(config_dict: dict) -> None:
    """Initialize connection pool.

    幂等:重复调用不会追加连接。sync_tasks 每 5 分钟调用一次本函数,
    若不幂等会不断 append 新连接,旧的连接被 MySQL wait_timeout 杀掉后
    Python 侧的 socket FD 仍不回收 → 进程 FD 泄漏到 Too many open files。
    """
    global _db_config
    _db_config = config_dict
    if _pool:                                   # ← 就加了这个判断
        logger.debug("Database pool already initialized (size={}), skip", len(_pool))
        return
    try:
        conn = _create_connection()
        _pool.append(conn)
    except Exception as e:
        raise

已初始化就 return,不重复 append。就这么简单。

验证

重启 + 打补丁后,盯 FD 曲线:

perl 复制代码
# 重启后立刻
FD 总数: 17  /  上限: 1024
socket 数: 4

# 等 10 分钟(让 sync_tasks 跑两轮)
FD 总数: 17  /  上限: 1024
socket 数: 4      # ← 纹丝不动

修复前每 5 分钟涨 1 个,3.5 天崩;修复后跑了 10 分钟(sync_tasks 已经触发 2 轮),FD 停在 17 不动了。根因确认,修复生效。

几点感想

这次排查有几个教训值得记住。

第一,看到报错先问"这是症状还是根因"Too many open files: 'config.yaml' 这种报错太有迷惑性了,config.yaml 是个完全无辜的受害者,它只是恰好在这个时刻被打开。真正的凶手是占了 1007 个 FD 的死连接。如果顺着 config.yaml 查,能查到地老天荒。

第二,/proc/fdss 的差值是排查 FD 泄漏的利器。两者一个是"进程持有的 FD",一个是"内核里活着的连接"。差值大 = 死连接没回收 = 泄漏。这个认知让我没有一头扎进"httpx 流式响应没关"的错误方向------如果一开始就坚信是 httpx 的锅,可能去翻半天流式代码都找不到问题。

第三,初始化函数必须幂等init_pool 这种带 init/setup 语义的函数,一定要假设它会被调用多次。内部加个"是否已初始化"的判断,几乎零成本,但能避免这类灾难性泄漏。

第四,潜伏期长的 bug 最可怕。这个 bug 不是一上线就炸,而是跑 3 天半才爆发。意味着测试环境很难复现(除非正好跑满 3 天半),上线后像定时炸弹。这类 bug 靠测试发现很难,靠监控发现才靠谱------以后得给进程 FD 数加个告警,超 500 就报警,别等 1024 崩了才知道。

排查命令备忘

下次遇到 Python 服务 502 / Too many open files,按这个顺序查:

bash 复制代码
# 1. FD 是不是满了
PID=$(pgrep -f "your.app" | head -1)
echo "FD: $(ls /proc/$PID/fd | wc -l) / $(cat /proc/$PID/limits | grep 'open files' | awk '{print $4}')"

# 2. FD 都是啥类型(socket 多还是文件多)
ls -la /proc/$PID/fd | grep -oE 'socket|/[^ ]+\.(log|db)' | sort | uniq -c | sort -rn

# 3. 活连接有多少(对照 /proc/fd,找泄漏)
echo "活 TCP: $(ss -tanp | grep pid=$PID | wc -l)"

# 4. /proc/fd 的 socket 数 远大于 活 TCP 数 = 死连接没回收 = 泄漏
#    去查连接池/资源管理代码

# 5. 临时止血
ulimit -n 65535    # 提高 FD 上限(治标)
supervisorctl restart your-service    # 重启清空泄漏(治标)

顺手提一句,这个修复已经提交了:fix(db): init_pool 幂等化,修复 sync_tasks 反复初始化导致的 FD 泄漏,在 Gitee 仓库

一个不幂等的 init_pool,被定时任务每 5 分钟调一次,3 天半后拖垮了整个网关。就这么个事。

相关推荐
烬羽3 小时前
ESLint 10 把 .eslintrc 删了:flat config 到底怎么配,我帮你踩完了
代码规范·eslint·前端工程化
_约书亚_4 小时前
Chapter 4 并发同步操作 · 归纳总结
代码规范
Asize6 小时前
ESLint 到底在帮我们守住什么?从规则配置到工程实践
代码规范·eslint
嘟嘟07177 小时前
ESLint 入门:从 npm run lint 到 --fix 与规则级别一次讲清
javascript·代码规范·eslint
不好听6137 小时前
ESLint 从零到落地:把"代码规范"变成机器的强制检查
代码规范
用户9385156350719 小时前
ESLint 代码规范完全指南——从 AST 原理到 flat config 逐行解析
javascript·后端·代码规范
梦梦代码精2 天前
连锁品牌数字化:从门店扩张到用户资产运营的技术底座
大数据·人工智能·低代码·docker·开源·代码规范
嘟嘟07172 天前
用单例模式管理弹窗:从一段原生 JS 理解 Singlet
前端·javascript·代码规范
Patrick_Wilson5 天前
代码重构中的蚕食方式是什么
程序员·架构·代码规范