FTP协议在传输文件时使用两条连接:控制连接和数据连接。在主动模式(PORT模式)下,客户端需要把自己的IP地址和端口通过PORT命令告诉服务器,由服务器主动反向连接客户端建立数据连接。这个机制在单IP环境下工作良好,但如果客户端主机绑定了多个IP地址(比如多块网卡、虚拟IP、容器中的双栈网络),Net::FTP默认选择的通告地址很可能不是服务器能够路由到的那一个,数据连接就会一直建立不起来。这篇文章就来分析这个问题产生的原因,并给出几种可行的解决方案。

FTP主动模式的工作原理与问题根源
FTP主动模式的流程是:客户端先建立控制连接,登录成功后,在需要传输数据时,客户端在本机监听一个临时端口,然后通过控制连接发送PORT h1,h2,h3,h4,p1,p2命令,其中h1到h4是客户端IP地址的四个字节,p1和p2编码了监听端口号。服务器收到后,从自己的20端口主动连接到客户端通告的地址和端口上。
问题的关键在于,客户端通告的IP必须是服务器能路由到达的地址。而Ruby的Net::FTP在构造PORT命令时,默认使用的是控制连接本地端的地址。来看一下Net::FTP内部的核心逻辑,简化后的代码大致如下:
def sendport_command(host, port)
af = (@sock.peeraddr)[0] # 控制连接对端地址族
host = sendport_getaddr(host, af)[3]
port = port.divmod(256)
host = host.split(/\./).map(&:to_i)
cmd = "PORT " + (host + port).join(",")
sendcmd(cmd)
end
def makeport
host = @sock.addr[3] # 注意这里:取的是控制连接的本地IP
sock = TCPServer.open(host, 0)
port = sock.addr[1]
sendport_command(host, port)
sock
endmakeport方法通过@sock.addr[3]获取控制连接绑定的本地地址,然后在这个地址上创建监听socket,并把同一个地址通告给服务器。问题在于,当主机有多个IP时,操作系统为控制连接选择的源IP可能是一个内部地址、容器地址或者虚拟网卡地址,服务器从这个地址反向连接就会失败,典型的表现是超时或者收到425 Cannot build data connection错误。
另外一个常见的坑是:控制连接的源IP选择和数据连接的源IP选择由操作系统路由决定,两者可能并不一致。即使控制连接正常工作,PORT命令中通告的地址仍然可能因为取值方式的不同而出错,所以必须显式地控制这个地址。
方案一:继承Net::FTP并重写makeport方法
最干净的解决方式是不修改库源码,而是写一个Net::FTP的子类,重写makeport方法,让它在我们指定的IP上监听。这样既保持了升级兼容性,又能精确控制通告地址。示例如下:
require 'net/ftp'
class BoundFTP < Net::FTP
# 指定用于数据连接的本地IP地址
attr_accessor :data_bind_address
def makeport
host = data_bind_address || @sock.addr[3]
# 在指定IP上监听一个临时端口
sock = TCPServer.open(host, 0)
begin
port = sock.addr[1]
sendport_command(host, port)
return sock
rescue
sock.close
raise
end
end
end
# 使用示例
ftp = BoundFTP.new
ftp.data_bind_address = "192.168.10.20" # 服务器可达的那个本地IP
ftp.connect("192.168.10.5", 21)
ftp.login("user", "pass")
ftp.gettextfile("README.txt", "README.txt")
ftp.close这种写法的好处是数据监听socket和PORT命令通告的地址保证一致,都是data_bind_address指定的地址。要注意这个地址必须是服务器能够路由到的那块网卡上的地址,否则重写也没有意义。判断方法很简单:在服务器上执行ping或者telnet 192.168.10.20 端口验证连通性。
还有一个细节需要注意,部分Net::FTP版本中方法名或内部实现有细微差异,重写前建议先查看当前Ruby版本对应的源码,可以用Net::FTP.instance_method(:makeport).source_location定位源文件位置,确认方法签名后再动手。
方案二:直接修补与被动模式的替代方案
如果不想引入子类,也可以用模块扩展的方式对实例打补丁,适合临时排查问题的场景:
require 'net/ftp'
ftp = Net::FTP.new
# 对单个实例做monkey patch
def ftp.makeport
host = "10.0.0.8"
sock = TCPServer.open(host, 0)
port = sock.addr[1]
sendport_command(host, port)
sock
end
ftp.connect("10.0.0.2")
ftp.login("user", "pass")
ftp.passive = false # 确保关闭被动模式
puts ftp.list这里要强调一点:passive = false必须显式设置,因为Net::FTP默认就是被动模式,很多开发者以为自己配置了主动模式的参数,实际上环境变量ftp_passive或默认值把模式悄悄改回了被动,导致排查方向错误。
当然,如果网络条件允许,直接改用被动模式(PASV)是最省事的方案。被动模式下由客户端主动连接服务器开出的数据端口,客户端不需要通告自己的地址,多IP选择问题自然消失。但在防火墙只放行20端口、或服务器明确要求主动模式的场景下,被动模式行不通,只能回到前面绑定源IP的方案。
复杂网络环境下的验证与调试技巧
在容器、NAT、双栈环境中排查FTP数据连接问题,建议按以下步骤定位:第一,确认控制连接本地绑定的IP,可以在建立连接后检查ftp.sock.addr[3];第二,用Net::FTP#debug_mode = true打开调试输出,观察PORT命令实际发送的地址是否是预期的那个;第三,在服务器侧抓包确认SYN包到达的源地址。
ftp = BoundFTP.new
ftp.debug_mode = true
ftp.data_bind_address = "172.18.0.5"
ftp.connect("172.18.0.2", 21)
ftp.login("user", "pass")
ftp.passive = false
ftp.list # 调试输出会打印PORT命令内容开启调试后,你能清楚地看到类似PORT 172,18,0,5,213,119的命令。如果这里的IP不对,说明绑定逻辑没有生效;如果IP正确但仍连接失败,问题多半出在防火墙策略或者路由层面,需要检查服务器到该IP的连通性以及客户端主机上临时端口的放行范围。
最后补充一点,如果需要更底层的控制,可以在创建控制连接时就用TCPSocket.new(host, port, local_host, local_port)四参数形式绑定源地址,让控制连接和数据连接使用同一个本地IP,从根上保证一致性。综合来看,重写makeport加显式指定源IP是最稳妥的工程实践,配合调试模式验证,基本可以解决绝大多数多IP主机的FTP主动模式连接问题。
Ruby Net::FTP主动模式源IP绑定修改时间:2026-08-31 05:00:38