一个潜伏 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 不回收?因为:
init_pool每次往_pool这个全局 list 里 append 一个新 MySQL 连接(建 socket,占 FD)。- 但取连接的
_get_connection()只用_pool[0],后面那些连接创建后既不使用也不关闭。 - MySQL 那边有个
wait_timeout(默认 8 小时),空闲连接到期会被 MySQL 主动杀掉。 - 但 Python 这边的
pymysql.Connection对象还被全局 list 持着,它的 socket FD 永远不释放。 - 之前的修复(为了防并发问题)把
ping(reconnect=True)去掉了,死连接永远不会被重建/关闭。 - 累积到 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 天半后拖垮了整个网关。就这么个事。