凌晨三点,内存炸了

“Dashboard 挂了。”

那是某个深夜,我正打算收工睡觉,习惯性瞄了一眼终端里的系统监控——HTTP 请求超时,内存占用 93%,Swap 使用率飙升到 75%。我叹了口气,又是熟悉的配方。

这是我的 AI Agent 系统里的一个 Dashboard 服务,功能很简单:展示系统健康状态、近期的任务记录、进程信息。一个轻量级的 Python HTTP 服务器,没有数据库依赖,没有复杂框架,按理说应该稳如老狗。

可它就是——每隔一个多小时就吃掉 1GB+ 内存,然后 OOM 被系统杀掉。

重启一时爽,一直重启一直爽——但显然这不是长久之计。于是我在一个深夜,决定跟这个内存泄漏死磕到底。

先了解敌人

Dashboard 是用 Python 内置的 http.server 写的,一个轻量级 HTTP 服务器,提供几个 REST 端点返回 JSON 数据。代码量不大,大约 200 行,逻辑也简单:

  1. 收到 HTTP 请求
  2. 收集系统数据(内存、CPU、进程等)
  3. 返回 JSON
  4. 循环

刚开始我怀疑是 Python 对象没释放——比如收集到的数据列表越来越大,或者日志积累。但检查了一遍代码,所有数据都是局部变量,函数结束后应该被 GC 回收。

等等,应该——这个词在内存泄漏排查里是最危险的。

第一回合:top 和 ps 的基本操作

我登录服务器,先用 top 看进程状态:

1
$ top -p $(pgrep -f dashboard)

输出显示 RES 一直在涨,每次请求增加约 2-3MB,但从来不降。典型的泄漏特征。

然后我看了下进程连接状态:

1
2
3
4
5
6
7
8
$ ss -tap | grep dashboard
LISTEN 0 5 0.0.0.0:8899
ESTAB 0 0 192.168.1.2:8899 192.168.1.5:54321
ESTAB 0 0 192.168.1.2:8899 192.168.1.5:54322
CLOSE_WAIT 1 0 192.168.1.2:8899 192.168.1.5:54323
CLOSE_WAIT 1 0 192.168.1.2:8899 192.168.1.5:54324
CLOSE_WAIT 1 0 192.168.1.2:8899 192.168.1.5:54325
CLOSE_WAIT 1 0 192.168.1.2:8899 192.168.1.5:54326

好家伙,CLOSE_WAIT 堆成山了!

CLOSE_WAIT 是什么?

对于不熟悉 TCP 状态机的朋友,简单解释一下:

当客户端主动关闭连接(发送 FIN),服务端收到后进入 CLOSE_WAIT 状态。这个状态意味着”客户端说再见了,但我还没说再见”。服务端应该在这个状态下调用 close() 来发送自己的 FIN,然后进入 LAST_ACK。

但如果服务端忘了调用 close(),这个连接就会一直停留在 CLOSE_WAIT。

每一个 CLOSE_WAIT 连接都对应一个打开的文件描述符和一个 socket 缓冲区——它们不会被释放。积少成多,内存就被吃光了。

我数了一下,有 47 个 CLOSE_WAIT 连接。每个连接大约占用 20-30MB(包括内核缓冲区和 Python 层的对象开销),加起来轻松超过 1GB。

第二回合:根因定位

问题是找到了,但 Python 的 http.server 怎么会不关闭连接呢?这是个标准库模块,不应该有这种低级 bug 啊。

我翻看了源码(Lib/http/server.py),发现 BaseHTTPRequestHandlerhandle_one_request() 中处理完请求后,默认行为是保持连接 alive(HTTP/1.1 默认 keep-alive)。问题在于:

  1. 客户端(监控采集脚本)期望短连接——每次请求都新建连接
  2. 但服务端在 keep-alive 模式下,不会主动关闭
  3. 客户端发送 FIN 后,服务端进入 CLOSE_WAIT
  4. 服务端没有显式调用 close() 来清理

更具体地说,http.serverhandle_one_request() 在处理完请求后会检查 self.close_connection 标志。如果为 True,才关闭连接。但默认情况下,对于 HTTP/1.1 请求,close_connection 初始为 False。

除非显式设置响应头 Connection: close,或者调用 self.close()

1
2
3
4
5
6
7
8
9
10
# 问题代码的简化版本
class MyHandler(BaseHTTPRequestHandler):
def do_GET(self):
data = collect_data()
self.send_response(200)
# ⚠️ 忘记设置 Connection: close
# ⚠️ 忘记设置 Content-Length
self.end_headers()
self.wfile.write(data)
# ⚠️ 没有关闭连接

你可能会想:那等待 keep-alive 超时自动关闭不就行了?问题是 keep-alive 超时是基于 select/poll 的,在 Python 的简单实现中,如果请求处理是同步阻塞的,超时机制并不能有效地清理连接。

第三回合:动手修复

修复方案其实很简单,三步走:

1. 设置 Connection: close 头

1
2
3
self.send_response(200)
self.send_header('Connection', 'close')
self.end_headers()

这告诉客户端:服务端会在响应后关闭连接。客户端收到后,不会再复用这个连接。

2. 设置 close_connection 标志

1
self.close_connection = True

这让 BaseHTTPRequestHandler 的请求循环在下次迭代时退出,触发连接关闭。

3. 主动 socket shutdown

1
2
self.request.shutdown(socket.SHUT_WR)
self.request.close()

这是最保险的一步——直接关闭底层 socket,确保无论上层状态机处于什么状态,TCP 连接都会被终止。

把三者结合到一起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class DashboardHandler(BaseHTTPRequestHandler):
def do_GET(self):
try:
data = json.dumps(self.collect_metrics()).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self.send_header('Connection', 'close')
self.end_headers()
self.wfile.write(data)
self.wfile.flush()
finally:
self.close_connection = True
try:
self.request.shutdown(socket.SHUT_WR)
except OSError:
pass
self.request.close()

验证修复

部署修复后,我每隔 10 分钟检查一次内存和连接状态:

1
$ watch -n 60 'ss -tap | grep dashboard | grep CLOSE_WAIT | wc -l'

结果

  • 修复前:CLOSE_WAIT 从 0 增长到 47(约 1.6 小时),内存从 34MB 飙升至 1.1GB(93%)
  • 修复后:CLOSE_WAIT 始终 ≤ 1,内存稳定在 34-38MB

我又跑了几个压力测试——连续发送 1000 次请求,内存波动不超过 5MB。

1.1GB → 34MB,优化了 97% 的内存占用。

排查工具清单

这次排查用了以下工具,整理出来供参考:

工具 用途
top -p <pid> 实时监控进程内存
ss -tap 查看 TCP 连接状态
strace -p <pid> -e trace=network 追踪系统调用
/proc/<pid>/fd/ 查看打开的文件描述符
lsof -p <pid> 查看打开的文件和连接
grep CLOSE_WAIT /proc/net/tcp 批量统计 CLOSE_WAIT
Python tracemalloc Python 内存分配追踪

反思:为什么 Python http.server 会有这个坑?

http.server 是 Python 标准库中最简单的 HTTP 服务器实现,设计目标是”够用就好”。它没有处理生产环境下的连接管理、超时控制、资源回收等问题。

这不是 http.server 的 bug,而是设计假设不同

  • 标准库假设:你会在开发/测试环境用,请求量小,连接数少
  • 实际使用:你在生产环境跑了几个星期,客户端重连频繁

教训:任何标准库组件,在投入生产前都要检查其连接管理行为。

更深一层的思考

这次经历让我反思了 AI Agent 系统的几个问题:

1. 系统组件不是越多越好

一开始设计时,我追求”每个功能一个独立服务”——Dashboard 一个服务,采集器一个服务,告警一个服务。结果每个服务都有潜在的资源管理问题。

后来我合并了部分服务,减少了进程间通信的复杂度,也降低了出问题的表面积。

2. 健康检查不能只看 200

之前的健康检查只检查 HTTP 返回 200,根本不看内存和连接数。修复后我加了三层监控:

1
2
3
# 1. HTTP 可达性
# 2. 内存阈值 < 80%
# 3. CLOSE_WAIT 连接数 < 5

任何一个触发告警,系统自动尝试修复,修复失败再上报。

3. 自动修复需要闭环

我写了一个简单的自愈脚本:检测到 CLOSE_WAIT 超过阈值 → 打印连接详情 → Kill 进程重启 → 验证恢复。后来发现这不够——需要知道为什么 CLOSE_WAIT 在增长,否则只是治标不治本。

从这里得到的经验是:自动修复应该先诊断、后修复、再验证、最后根除。

尾声

这次内存泄漏排查,从发现问题到彻底修复,经历了 3 轮迭代:

  1. 第一轮(临时止血):发现内存涨了→重启→循环
  2. 第二轮(症状缓解):加了自动重启脚本,检测到内存 > 80% 就重启
  3. 第三轮(根因根治):定位到 CLOSE_WAIT 连接泄漏,修复 socket 管理

每一轮都比上一轮更深入一层。

很多时候,面对系统的不稳定,我们会本能地选择”让它先跑起来”的临时方案。这本身没问题——我的第一反应也是重启。但如果停留在这一步,问题就会反复回来找你。

技术债和财务债一样,利息是按天算的。

那个凌晨,当我看到内存稳定在 34MB 不再上涨时,一种莫名的满足感涌上来——不是因为解决了多高深的技术问题,而是因为终于弄清楚了一个”奇怪的现象”背后那个简单的真相。

很多时候,最难的不是修复,而是相信问题一定有简单的原因


后记:修复后 Dashboard 已经稳定运行了 7 天,内存始终在 34-40MB 之间波动。那次之后,我对 Python 的 socket 管理多了一份敬畏,也养成了每周检查一次 CLOSE_WAIT 的习惯。