1Panel SFTP 上传速度异常问题解决办法
前言
用 1Panel 做网站备份、再通过 SFTP 上传到远端,是个很常见的方案。但我这边的备份任务一直慢得离谱:一个 439MB 的备份包,上传要 29 分钟,速度只有 0.25 MB/s 左右。
一开始以为是 VPS 带宽小,或者远端机房慢。查下来才发现:这台机器出口带宽实测 40+ MB/s,链路 RTT 只有几十毫秒、零丢包——问题出在 1Panel 自己身上。
下面记录完整的排查过程和修复方式,最后给出可以直接用的解决方案。
一、先排除网络问题
遇到”上传慢”,第一反应通常是网络。所以先把网络这一侧的证据收集齐:
| 检查项 | 结果 |
|---|---|
| 本机出口带宽(上传) | 42 MB/s(约 336 Mbps) |
| 到目标机 RTT | 35 ~ 68 ms |
| 丢包率 | 0% |
| 传输中的 TCP 状态 | bytes_retrans 0/29、send-q 0、cwnd 远未打满 |
这组数据说明:
- 带宽完全够用,跑满一次备份只需要几秒;
- 链路很干净,没有任何重传、没有排队积压;
- 发送方的拥塞窗口(cwnd)远没打满,也就是说”数据没喂满”——瓶颈在应用层,不在网络。
这一步很关键。很多人看到”上传慢”就先去换机房、换线路,但如果是发送端喂不满,换到哪里都一样慢。
二、A/B 对照:换个客户端就快 60 倍
为了确认问题出在哪个环节,我用同一个文件、同一台目标机、同一条链路,分别用不同客户端测了一遍。
测试文件是一个 96MiB 的安装包(已经压缩过的数据,排除压缩率干扰),目标是同一台 SFTP 服务器(RTT 68ms):
| 客户端 | 协议 | 耗时 | 速率 |
|---|---|---|---|
| 1Panel 内置上传 | SFTP | 6 分 40 秒 | 0.25 MB/s |
| rclone | SFTP | 6 秒 | 16 MB/s |
| OpenSSH sftp | SFTP | 5 秒 | 19 MB/s |
| OpenSSH scp -O | legacy scp | 5 秒 | 19 MB/s |
结论非常清楚:SFTP 协议没问题、目标服务器没问题、链路没问题——系统自带的 sftp 比 1Panel 快 76 倍。
那问题就只剩一个可能:1Panel 自己的 SFTP 客户端实现。
(顺便说明:1Panel 的 SFTP 通信是编译进 1panel-agent 这个 Go 二进制里的,没法”重装 sftp”或者替换成系统自带的客户端。)
三、定位到代码:io.Copy 的一个陷阱
既然是实现问题,就只能去看源码。1Panel 的上传逻辑在:
agent/utils/cloud_storage/client/sftp.go
关键代码长这样(v2.3.2):
client, err := sftp.NewClient(sshClient)
...
dstFile, err := client.Create(target)
...
if _, err := io.Copy(dstFile, srcFile); err != nil {
return false, err
}
看起来非常普通的 io.Copy,问题恰恰出在这里。
Go 标准库的 io.Copy(dst, src) 有两条优化路径:
- 如果 src 实现了 io.WriterTo → 调用 src.WriteTo(dst);
- 否则如果 dst 实现了 io.ReaderFrom → 调用 dst.ReadFrom(src)。
它优先选择第 1 条。
而这里的 srcFile 是 *os.File——它正好实现了 io.WriterTo。于是 io.Copy 走了 os.File.WriteTo,最终变成”从本地读约 32KB → 发一个 SFTP WRITE 包 → 同步等待服务端确认 → 再发下一个”。
也就是说,吞吐被死死卡在「包大小 ÷ RTT」:
32 KB ÷ 68 ms ≈ 470 KB/s
这和我们实测的 0.25 MB/s 完全对得上。链路再快也没用,因为同一时刻只有一个包在路上。
有意思的是,pkg/sftp(1Panel 已经依赖的库)本身就有并发写的实现:File.ReadFrom() 会配合 UseConcurrentWrites 使用管线化写入,并发上限由 MaxConcurrentRequestsPerFile 控制(默认 64)。只是这里没启用,所以白白的性能被浪费掉了。
四、修复
改动非常小,一共两处:
// 1) 上传时启用并发写
client, err := sftp.NewClient(sshClient, sftp.UseConcurrentWrites(true))
// 2) 用管线化的 ReadFrom 替代 io.Copy
// (失败时把远端文件截断到实际写入长度,避免留下空洞)
written, err := dstFile.ReadFrom(srcFile)
if err != nil {
_ = dstFile.Truncate(written)
...
}
需要说明两点:
- 只改上传路径。 下载那条路径没有动——因为下载时源对象是 *sftp.File,它的 WriteTo 默认已经启用了并发读,本来就是快的。
- 并发写失败时要做截断。 这是 pkg/sftp 官方文档明确提醒的:并发写如果中途出错,远端文件可能留下”空洞”,所以出错时截断到实际写入的字节数。
五、验证效果
打上补丁、重新编译 1panel-agent 并重启服务后,同一个备份任务:
| 修复前 | 修复后 | |
|---|---|---|
| 单站 439MB 上传 | 约 29 分钟 | 约 17 秒 |
| 三站点合计 ~950MB | 约 2 小时 40 分 | 2 分 04 秒 |
监控到的出口速率峰值 29.8 MB/s。
数据完整性也要验证——并发写最怕文件损坏。把上传到远端的备份包再流式读回来做 gzip -t 校验:
rclone cat remote:/backup/website/xxx.tar.gz | gzip -t
三个备份包全部通过,没有空洞、没有损坏。
六、怎么用上这个修复
我把它整理成了一个开源仓库,三种方式任选:
仓库地址:https://github.com/4kercc/1panel-agent-sftp-fix
方式一:一键安装(推荐)
# 先预览会做什么(不修改系统) curl -fsSL https://raw.githubusercontent.com/4kercc/1panel-agent-sftp-fix/main/install.sh | bash -s -- --dry-run # 正式安装 curl -fsSL https://raw.githubusercontent.com/4kercc/1panel-agent-sftp-fix/main/install.sh | bash
脚本会自动:识别面板版本 → 下载对应二进制 → 校验 sha256 → 备份 → 替换 → 重启,启动失败会自动回滚。
回滚命令:
curl -fsSL https://raw.githubusercontent.com/4kercc/1panel-agent-sftp-fix/main/install.sh | bash -s -- --rollback
方式二:手动替换(适用于 1Panel v2.3.2)
# 确认版本 1pctl version # 应输出 v2.3.2 uname -m # 应为 x86_64 # 下载 + 校验 curl -fLO https://github.com/4kercc/1panel-agent-sftp-fix/releases/download/v2.3.2/1panel-agent curl -fLO https://github.com/4kercc/1panel-agent-sftp-fix/releases/download/v2.3.2/1panel-agent.sha256 sha256sum -c 1panel-agent.sha256 # 备份 → 替换 → 重启 cp -a /usr/local/bin/1panel-agent /root/1panel-agent.orig systemctl stop 1panel-agent install -m755 1panel-agent /usr/local/bin/1panel-agent systemctl start 1panel-agent 1pctl status
方式三:自己编译(任意版本)
./build.sh v2.3.2 # 填你的面板版本
需要 Go 1.26.6+。有个小提示:1panel-agent 是纯 Go 且不内嵌前端,编译不需要 Node 和前端构建(只有 1panel-core 才需要),所以在普通 VPS 上几分钟就能编完。
七、几个注意点
- 版本必须匹配。 1panel-agent 与面板版本是绑定的(启动时会做数据库结构迁移),不要跨版本替换。其他版本请用 build.sh 自行编译。
- 升级会覆盖。 1Panel 每次升级都会覆盖 /usr/local/bin/1panel-agent,升级后需要重新替换或编译。
- 建议先备份再动手,并确认自己知道回滚步骤。
八、小结
这次排查最大的收获是:遇到”网络慢”,先把网络排除干净,再去怀疑程序。
证据链其实很清晰:
- 出口带宽 42 MB/s、RTT 68ms、零丢包 → 网络没问题;
- TCP 层零重传、发送队列为空、cwnd 没打满 → 是发送端喂不满;
- 换客户端后同样的链路快 76 倍 → 问题在 1Panel 的实现;
- 看代码发现 io.Copy 因为接口优先级走了同步写的分支 → 根因确认。
而这类”一行代码导致的性能问题”,修复成本往往比绕开它更低——这次就是两行代码换来 100 倍提升。
上游 PR 已经提交:1Panel-dev/1Panel#13976,希望后续版本能直接内置这个修复。
本文提到的补丁基于 1Panel(GPL-3.0)修改,遵循同一许可证。