基于asyncore的SMTPServer套接字未关闭致文件描述符持续增长问题排查
从你的描述和代码来看,套接字描述符持续增长的问题,核心大概率和Python标准库smtpd.SMTPServer的连接回收机制缺陷以及特定客户端的异常断开行为有关,具体可以拆成这几个关键点:
1. smtpd依赖的asyncore框架对异常断开的处理不足
smtpd.SMTPServer是基于asyncore实现的异步网络服务,它的连接回收逻辑严重依赖客户端发送的正常关闭信号(比如SMTP的QUIT命令,或者TCP层的FIN包)。
但线上的邮件服务商场景很复杂:
- 有些服务商可能使用长连接池,但连接闲置后没有主动发送
QUIT就直接断开; - 部分情况下可能出现网络波动、服务器崩溃,导致TCP连接变成半开状态(服务器端认为连接还存活,但客户端已经离线);
- 还有些客户端可能发送不符合SMTP规范的数据,导致解析阶段出错,
asyncore没有触发连接关闭逻辑。
而你本地测试时,客户端进程退出会主动发送FIN包,asyncore能正确识别并回收FD,所以不会出现问题,但线上这些异常场景就会导致套接字残留。
2. 你的异常捕获范围只覆盖了邮件处理环节,没覆盖连接生命周期全程
你在process_message里用try-except包裹了业务逻辑,但smtpd处理连接的流程远不止这一步:从接收客户端连接、解析SMTP命令(比如EHLO/MAIL FROM/RCPT TO)到最终关闭连接,任何一个环节抛出未被捕获的异常,都可能导致asyncore无法正常关闭套接字。
比如,如果客户端发送了格式错误的RCPT TO命令,在process_message执行之前的解析阶段就抛出异常,这时候你的try-except根本捕获不到,连接就会被留在那里。
3. 未启用TCP Keepalive检测死连接
默认情况下,TCP连接不会主动检测对方是否存活。如果某个邮件服务商的连接意外断开(比如机房断电),你的服务器端套接字会一直处于ESTABLISHED状态,直到系统的TCP超时(通常是几十分钟甚至几小时)才会回收,这期间FD会一直被占用。
快速验证方法
你可以用ss -tp | grep <你的进程PID>查看这些残留套接字的状态:
- 如果大量处于
ESTABLISHED但没有数据传输,大概率是半开连接; - 如果是
CLOSE_WAIT状态,说明客户端已经关闭,但你的服务器端没有调用close()。
建议的修复方向
这些不是临时补丁,而是从根本上解决问题的方案:
- 重写
smtpd的连接处理逻辑:继承SMTPServer后,重写handle_close、handle_error方法,确保无论是否出现异常,都主动关闭套接字; - 给服务器套接字添加TCP Keepalive:在
__init__里获取底层套接字,设置SO_KEEPALIVE相关选项,让系统定期检测死连接:import socket class MyMXServer(SMTPServer): def __init__(self, host='127.0.0.1', port=25): self.srs = SRS.new(os.getenv('SRS_KEY', 'some passwd')) super().__init__((host, port), None) # 启用TCP Keepalive self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) # 设置检测间隔(以Linux为例,不同系统参数可能有差异) self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 300) # 5分钟无数据开始检测 self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 60) # 每隔1分钟检测一次 self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5) # 检测5次失败则关闭连接 - 替换
smtpd模块:如果对稳定性要求高,建议用更现代的异步框架(比如aiosmtpd),它们的连接管理机制更健壮,原生支持超时和连接回收。
内容的提问来源于stack exchange,提问作者Cyril N.

