【BUG反馈】FTP存储空间插件 SFTP 上传无超时与 keepalive 机制,对端掉线后进程永久阻塞,配合 flock -xn 导致后续所有计划备份静默失效
一、问题概述
使用「计划任务 → 备份网站(所有)」+「FTP存储空间(SFTP)」将备份上传到异地 NAS 时,
若上传过程中对端网络中断,备份进程会永久阻塞不退出。由于计划任务使用 flock -xn
非阻塞锁,该僵死进程会一直持有锁,导致此后每天的备份任务启动即静默退出,
不执行、不报错、不告警。
我的站点因此连续 39 天没有任何网站备份,且期间面板无任何异常提示,
直到手动排查才发现。
二、环境信息
宝塔面板版本:13.0.0(已于 2026-08-13 升级到最新版,问题依旧存在)
FTP存储空间插件版本:6.0(插件 info.json 标注日期为 2020-05-23)
paramiko 版本:2.7.1
Python 版本:3.7.16
操作系统:Debian GNU/Linux 12 (bookworm)
内核版本:6.1.0-39-amd64
备份目标:家庭宽带 NAS,SFTP,通过 DDNS 域名连接(公网 IP 会变动)
补充说明:本次反馈前我已将面板升级至最新版本。升级后面板核心文件
(class/common.py、tools.py、BT-Panel 等)均已更新,但
plugin/ftp/ftp_main.py 的文件修改时间与 MD5 校验值均未发生变化,
grep -c "set_keepalive" 结果仍为 0,即本文所述问题在最新版中依然存在。
三、现象
计划任务「备份网站[所有]」配置为每天 02:30 执行,但自 2026-07-05 之后再无任何
新备份产生。面板计划任务页面显示任务正常,无失败标记;任务日志文件的最后写入
时间停留在 2026-07-05 02:42,之后再无任何新增内容。
任务日志最后几行:
|-备份网站:example.com
|-目录大小:16.04 GB
|-开始压缩文件:2026-07-05 02:30:06
|-文件压缩完成,耗时749.08秒,压缩包大小:15.53 GB
|-网站已备份到:/www/backup/site/example.com/web_example.com_20260705_023001_XXXXXX.tar.gz
|-正在上传到SFTP,请稍候...
|-正在上传文件到 /bt_backup/site/example.com/web_example.com_20260705_023001_XXXXXX.tar.gz
(日志到此为止,无后续输出)
NAS 端只收到约 2.26GB 的不完整文件(完整应为 15.53GB)。
四、排查过程与证据
1) 僵死进程仍然存在,已运行 39 天
# ps -p 3464252,3464254 -o pid,etime,stat,cmd
PID ELAPSED STAT CMD
3464252 39-20:13:37 S /bin/bash /www/server/cron/9e5b3566xxxxxxxx
3464254 39-20:13:37 Sl /www/server/panel/pyenv/bin/python3 -u
/www/server/panel/script/backup.py site ALL 3 9e5b3566xxxxxxxx
进程状态 Sl,WCHAN 为 futex_wait_queue,即阻塞在线程同步等待上。
2) 进程仍持有指向 NAS 的 TCP 连接,且为"半死连接"
# ss -tnp | grep <NAS_IP>
ESTAB 0 0 <本机IP>:48174 <旧NAS_IP>:22 users ("python3",pid=3464254,fd=4))
ESTAB 0 0 <本机IP>:48186 <旧NAS_IP>:22 users ("python3",pid=3464254,fd=5))
连接状态为 ESTABLISHED,但收发队列均为 0。经测试该 IP 已完全不可达
(NAS 为家庭宽带动态 IP,上传过程中 IP 发生了变更,对端未发送 FIN/RST)。
关键点:因 send-q 为 0,内核没有待重传数据,tcp_retries2 不会触发;
而 paramiko 未启用 SO_KEEPALIVE,tcp_keepalive_time 同样不生效。
因此内核层面永远无法发现对端已消失,只能依赖应用层超时——而应用层没有。
3) flock -xn 使故障永久化
计划任务的 crontab 条目:
30 2 * * * flock -xn /www/server/cron/9e5b3566xxxxxxxx.lock -c /www/server/cron/9e5b3566xxxxxxxx >> ... 2>&1
-n 表示拿不到锁立即退出。僵死进程一直持有该锁,因此从 2026-07-05 起
每天 02:30 的任务都是"启动即退出",既不执行也不留下任何日志或告警。
五、根本原因(代码定位)
文件:/www/server/panel/plugin/ftp/ftp_main.py
1) 第 1696-1697 行,paramiko 连接未设置任何超时,也未启用 keepalive:
transport = paramiko.Transport((self.__host, int(self.__port)))
transport.connect(username=self.__user, password=self.__password)
paramiko.Transport() 未传 timeout;transport.connect() 未传 timeout;
全文件未出现任何 transport.set_keepalive() 调用。
2) 第 1892 行 get_client() 虽然接收 timeout 参数,但从未向下传递:
def get_client(use_sftp=None, load_config=True, timeout=None):
...
if use_sftp:
_client = BTSFTPClient(load_config=load_config) # timeout 被丢弃
else:
_client = BTFTPClient(load_config=load_config) # timeout 被丢弃
且第 1585 行 BTSFTPClient.__init__() 的签名本身就不接收 timeout:
def __init__(self, load_config=True, config_file=None):
而父类 FTPClient.__init__()(第 1070 行)是支持 timeout 的。
因此 self.timeout 恒为 None,后续所有 subprocess 调用中的
timeout=self.timeout 实际等于"永不超时"。
对比:同文件第 1518 行的 lftp 上传路径硬编码了 timeout=3600,
说明超时保护在其他路径是有考虑的,SFTP 这条路径属于遗漏。
六、影响范围
- 任何使用 SFTP 存储空间做异地备份的用户都可能触发
- 家庭宽带/动态 IP/移动网络等不稳定链路的用户触发概率更高
- 大文件备份因传输时间长,暴露窗口更大(我的站点压缩包 15.53GB,上传需 1 小时以上)
- 最严重的是"静默失败":面板不报错、不告警、计划任务显示正常,
用户完全无法察觉,直到需要恢复数据时才发现无备份可用
自动备份的价值在于无人值守。如果需要用户每天人工核对是否成功,就失去了意义。
七、建议修复
1) 为 paramiko 连接增加超时与保活(关键)
transport = paramiko.Transport((self.__host, int(self.__port)))
transport.banner_timeout = 30
transport.connect(username=self.__user, password=self.__password)
transport.set_keepalive(30) # 每 30 秒发送保活包,对端消失时可及时感知
同时建议给底层 socket 设置读写超时。
2) 修复 get_client() 的 timeout 参数传递链
使 BTSFTPClient.__init__() 接收并向父类传递 timeout,
避免 self.timeout 恒为 None。
3) 为单次备份任务增加整体执行时长上限
超过阈值主动终止并在日志中记录失败原因,而不是无限等待。
4) 增加"僵死任务"检测与告警
计划任务启动时若发现锁被占用,应记录日志并告警,
而不是静默退出。当前实现下用户完全无从察觉。
5) 建议升级 paramiko
当前捆绑版本 2.7.1 发布于 2020 年,已较为陈旧。
6) 建议将 FTP存储空间插件纳入常规维护
该插件 info.json 标注日期为 2020-05-23,版本 6.0,
且面板升级时不会同步更新该插件文件。
但它承担着异地备份这一关键职责,建议纳入常规维护与更新计划。
八、临时规避方式(供遇到同样问题的用户参考)
1) 检查是否存在僵死进程:
ps -eo pid,etime,stat,cmd | grep "backup.py"
若发现 ELAPSED 达到数天,即为本问题。
2) 杀掉僵死进程以释放锁(会同时杀掉父 bash,否则锁不释放):
kill -9 <python进程PID> <父bash进程PID>
rm -f /www/server/cron/<任务ID>.pl
3) 确认锁已释放:
flock -xn /www/server/cron/<任务ID>.lock -c "echo 锁已释放"
处理后下一次计划任务即可正常执行。
希望官方能在后续版本中修复,感谢。
|
|