分块上传S3大文件至SFTP报Net::SFTP::StatusException(5,"bad message")错
问题描述
从S3向SFTP服务器分多块传输大文件时,抛出Net::SFTP::StatusException (5, "bad message")错误,仅需单块即可传输的小文件可正常上传。
场景与复现代码
在RoR应用中需要将大文件发送至客户的SFTP服务器,读取S3文件使用Fog::Storage组件,原有实现代码如下:
file_paths = get_file_names() Net::SFTP.start(sftp_domain, sftp_user, :password => sftp_password) do |sftp| file_paths.each do |file_to_upload| filename = nil if Rails.env == "production" filename = file_to_upload.file.path.split("/").last # 初始化S3连接 connection = Fog::Storage.new(provider: 'AWS'...) # 获取目标桶 bucket = connection.directories.get(ENV['MY_S3']) # 分块逐次上传文件 sftp.file.open(sftp_folder + "/" + filename, "w") do |sftp_file| bucket.files.get(file_to_upload.file.path) do |chunk| sftp_file.write(chunk) end end end end end
触发条件
仅当单文件大小超过默认分块大小、需要分多块传输时,抛出如下错误:
Excon::Error::Socket: Net::SFTP::StatusException (5, "bad message") (Net::SFTP::StatusException)
环境信息
- 框架版本:rails 4.2.5.1
- Ruby版本:ruby 2.3.4p301 (2017-03-30 revision 58214) [aarch64-linux]
- 依赖版本:Gem锁定net-sftp (3.0.0)、net-ssh (6.1.0)
- 单块可容纳的小文件上传无异常
已尝试方案
- 强制指定文件编码,未解决问题,仍报相同错误
- 尝试将大文件一次性单块读取上传,触发内存错误无法执行
根因分析
错误码5对应SFTP协议的SSH_FX_BAD_MESSAGE,本质是服务端收到了顺序/格式不符合协议预期的报文,核心诱因是net-sftp默认的异步写入逻辑没有做流控:
调用sftp_file.write时方法不会阻塞等待服务端写入确认,会直接把写请求加入本地发送队列后返回。Fog拉取S3分块的速度远快于SFTP公网传输的速度,多块传输时会瞬间在发送队列堆积大量未确认的写请求,导致net-ssh层的SSH包序号错乱,服务端收到乱序报文后直接返回bad message错误。小文件仅触发1次写入,不存在队列堆积,因此不会报错。
解决方案
不要自己实现裸的分块写入逻辑,直接用net-sftp内置的流式上传能力,它内部已经实现了写入流控、包顺序校验,不会出现乱序问题,同时天然支持IO流式读取,不会把整个文件加载到内存触发OOM。
修正后代码
file_paths = get_file_names() # S3连接初始化移到循环外,避免重复建连开销 s3_connection = Fog::Storage.new(provider: 'AWS'...) s3_bucket = s3_connection.directories.get(ENV['MY_S3']) Net::SFTP.start(sftp_domain, sftp_user, password: sftp_password) do |sftp| file_paths.each do |file_to_upload| next unless Rails.env.production? filename = file_to_upload.file.path.split("/").last remote_path = File.join(sftp_folder, filename) # 获取S3文件的流式IO对象,不会一次性加载全量内容到内存 s3_file = s3_bucket.files.get(file_to_upload.file.path) # 调用net-sftp内置的流式上传,内部自动处理分块、流控、顺序校验 sftp.upload!(s3_file, remote_path) end end
自定义分块场景的适配方案
如果必须手动控制分块写入流程,每次写入后必须阻塞等待当前块写入完成再拉取下一块,避免请求堆积:
sftp.file.open(remote_path, "w") do |sftp_file| s3_bucket.files.get(file_to_upload.file.path) do |chunk| sftp_file.write(chunk) # 阻塞等待当前所有待处理写请求完成,再处理下一块 sftp.loop unless sftp_file.pending_writes.empty? end end
额外优化点
- 原有代码在每个文件的循环内重复初始化S3连接,会产生大量不必要的TLS握手开销,移到循环外即可大幅提升上传效率
- 路径拼接建议用
File.join代替字符串拼接,自动处理路径分隔符问题,避免多写/漏写斜杠的bug
内容的提问来源于stack exchange,提问作者MordechayS
相关产品推荐
相关产品推荐

