一个潜伏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/fd 和 ss 的区别,这是排查这类问题的分水岭:

  • /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/fd 和 ss 的差值是排查 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 天半后拖垮了整个网关。就这么个事。

相关推荐
这个DBA有点耶12 小时前
ELT 工具选型完整指南:从 ETL 差异分析到工程落地
数据库·架构·代码规范
这个DBA有点耶13 小时前
分布式事务深度:TCC空回滚、悬挂与Saga补偿风暴的根因与解法
数据库·mysql·代码规范
这个DBA有点耶3 天前
为什么UPDATE比SELECT危险得多?锁范围等于扫描行数
数据库·架构·代码规范
沈麽鬼3 天前
纸上代码
代码规范
深圳老胡5 天前
STM32CubeMX 生成 CMake 工程,用 VSCode 编译与调试的完整流程
笔记·stm32·单片机·代码规范
星星泡饭吃5 天前
【信息安全】越权防护设计评审指南
代码规范
GlobalSign数字证书5 天前
申请 EV 代码签名证书需要准备哪些材料?
代码规范
深圳老胡5 天前
STM32F4 OTA 升级双 App 方案设计与实现
笔记·stm32·单片机·代码规范
深圳老胡5 天前
STM32F4 OTA升级:单App与双App方案优缺点对比
笔记·stm32·单片机·代码规范
这个DBA有点耶6 天前
Change Buffer深入:二级索引写入的隐形加速器与它的代价
数据库·mysql·代码规范