用Ruby的Net::SSH库做远程运维或文件抓取时,很多人习惯把on_data回调里的内容直接当成字符串拼接,结果一旦远端输出的是压缩包、图片、证书这类二进制内容,轻则文件损坏,重则抛出Encoding::UndefinedConversionError异常。本文围绕Channel的on_data回调,从数据到达机制、编码处理、缓冲策略三个层面,系统地讲清楚二进制数据该如何正确且高效地处理。

先搞清楚on_data回调的数据到达机制
Net::SSH底层基于SSH协议的channel机制工作。当远端命令产生标准输出时,数据并不是一次性整包送达,而是被协议层切分成若干个数据包,按照窗口大小(window size)和包大小(packet size)的限制分批推送过来。每次到达一批,Net::SSH就会触发一次on_data回调。这意味着如果你的命令输出500MB的tar包,on_data可能被调用成千上万次,每次只拿到一小块数据。
很多初学者误以为回调拿到的data就是完整的输出内容,于是在回调里做解析,结果解析必然失败。正确的理解是:on_data拿到的是流的一个片段,必须由开发者自己负责把片段按顺序累积成完整数据。理解这一点是所有后续优化的前提。
下面是一个最基础的累积写法,用实例变量保存缓冲区:
require 'net/ssh'
buffer = String.new(encoding: 'BINARY')
done = false
Net::SSH.start('192.168.0.10', 'root', password: 'secret') do |ssh|
ssh.open_channel do |channel|
channel.on_data do |ch, data|
buffer << data
end
channel.on_close do |ch|
done = true
end
channel.exec('cat /var/backups/db.tar.gz')
end
ssh.loop { !done }
end注意这里创建缓冲区时显式指定了BINARY编码,这是处理二进制的第一步,后面会详细展开。另外on_extended_data回调对应标准错误输出,如果远端命令可能往stderr写内容,也别忘了单独处理,否则错误信息会和正常数据混在一起。
编码问题:为什么必须用ASCII-8BIT
Ruby 1.9之后字符串带有编码属性,而Net::SSH传回来的数据片段默认是ASCII-8BIT(也就是BINARY)编码。如果拼接时缓冲区是UTF-8编码,Ruby会尝试做编码兼容检查,遇到非法UTF-8字节序列就可能抛异常,或者在后续写入文件时触发UndefinedConversionError。这是二进制处理翻车的最常见原因。
解决办法有两种。第一种是上面演示的,缓冲区一开始就声明为BINARY编码,拼接时两边编码一致,不会触发转换。第二种是在写文件时以二进制模式打开文件描述符,这样即使字符串编码有些混乱,写盘时也不会做换行转换和编码校验:
File.open('output.tar.gz', 'wb') do |f|
f.write(buffer)
endwb模式中的b至关重要。如果只写w,Windows平台下Ruby会把\n转换成\r\n,二进制数据就此损坏;同时某些版本的Ruby还会做编码转换。坚持wb模式加上BINARY缓冲,两端配合才能保证字节级别的完整性。
还有一个容易忽略的点:如果确实需要在传输过程中检查数据内容(比如判断文件魔数),不要对整个缓冲区做正则匹配UTF-8模式,可以用data.byteslice(0, 4)取出头部字节,用unpack比较:
if buffer.bytesize >= 4 && buffer.byteslice(0, 4) == "\x1F\x8B\x08\x00".b puts '检测到gzip格式' end
byteslice按字节切分,不受字符编码影响,比普通slice安全得多。
内存优化:流式写入代替全量缓冲
全量缓冲的方案在小文件场景没有问题,但当传输的是几百MB甚至几GB的大文件时,把所有数据堆在内存里会导致Ruby进程内存暴涨,GC压力剧增,甚至触发OOM。这时候应该改成流式处理:在回调里直接把data追加写入本地文件,缓冲区变量完全不需要。
require 'net/ssh'
Net::SSH.start('192.168.0.10', 'root', password: 'secret') do |ssh|
ssh.open_channel do |channel|
file = File.open('large_dump.bin', 'wb')
received = 0
channel.on_data do |ch, data|
file.write(data)
received += data.bytesize
# 简单进度提示
print "\r已接收 #{received} 字节"
end
channel.on_close do |ch|
file.close
puts "\n传输完成,共 #{received} 字节"
end
channel.exec('dd if=/dev/sda bs=1M')
end
ssh.loop
end流式方案的内存占用恒定为一个小缓冲块大小(通常几KB到32KB),与总传输量无关。代价是数据落盘后才能做二次处理,如果你需要在传输过程中实时解析协议内容,可以在写入文件的同时保留一个小小的滑动窗口做检查。
如果必须在内存中处理(比如数据需要解密后再转发到另一个服务),推荐用队列解耦:on_data回调里只做入队,真正的处理逻辑放到工作线程。这样可以避免回调阻塞影响SSH窗口滑动,从而提升整体吞吐:
require 'net/ssh'
require 'thread'
queue = Queue.new
worker = Thread.new do
File.open('processed.bin', 'wb') do |f|
while (chunk = queue.pop) != :eof
f.write(chunk)
end
end
end
Net::SSH.start('192.168.0.10', 'root', password: 'secret') do |ssh|
ssh.open_channel do |channel|
channel.on_data { |ch, data| queue << data }
channel.on_close { |ch| queue << :eof }
channel.exec('cat /data/export.bin')
end
ssh.loop
end
worker.joinQueue本身是线程安全的,不需要额外加锁。这种生产者消费者模型在大流量场景下通常能把传输速度提升20%到40%,因为网络接收与磁盘写入可以并行进行。
传输速度优化与校验完整性
Net::SSH的channel默认窗口大小是2MB左右,包大小32KB。对于高带宽低延迟的内网环境,可以适当调大窗口减少往返等待:
channel.exec('cat bigfile.iso') do |ch, success|
ch.adjust_window(4 * 1024 * 1024) rescue nil
end不过要谨慎,窗口过大在不可靠网络下反而会放大重传成本。一般内网传输调大窗口收益明显,公网建议保持默认。
最后一定要做完整性校验。二进制传输最怕静默损坏,建议在远端计算md5或sha256,本地接收完毕后同样计算并比对:
require 'digest'
# 远端执行两条命令:先传文件,再算校验值
remote_sum = ssh.exec!('sha256sum /data/export.bin').split.first
local_sum = Digest::SHA256.file('processed.bin').hexdigest
if remote_sum == local_sum
puts '校验通过'
else
puts "校验失败: 远端#{remote_sum} 本地#{local_sum}"
end总结一下,处理Net::SSH的on_data二进制数据,核心原则是四条:把数据当流而不是整包、缓冲区和文件句柄都用BINARY和wb模式、大文件走流式或队列解耦、传输结束做哈希校验。掌握这几点,无论是抓取数据库备份还是镜像文件,都能做到稳定可靠。
Net::SSHChannel on_data二进制数据处理修改时间:2026-09-16 05:16:01