在流量审计、入侵检测、网关协议分析等场景中,单网卡抓包往往无法覆盖全部流量。一台服务器通常配有多个网络接口,比如一块连接外网的eth0、一块连接内网的eth1,甚至还有绑定VLAN的虚拟接口。如果只用传统方式监听一个接口,就会漏掉其他接口上的通信数据。Ruby生态中虽然没有Wireshark那样的图形化工具,但通过pcaprub和packetfu这两个库的组合,配合Ruby天然擅长的多线程模型,可以非常优雅地实现多网卡并发抓包。本文将从不依赖第三方库的原始套接字方案讲起,再过渡到基于libpcap的封装库,最后给出一套完整的多线程采集架构。

一、多网卡抓包的三种技术路线对比
在动手写代码之前,先梳理一下Ruby环境下实现数据包捕获的几种可行方案,它们各有适用场景:
- 原始套接字方案:使用Ruby内置的Socket库创建SOCK_RAW类型套接字,不依赖任何第三方库,可移植性靠自己对协议头的解析来保证,代码量较大,适合理解底层原理。
- pcaprub方案:这是libpcap的Ruby绑定,直接复用C语言世界的抓包引擎,性能接近原生,接口简单,适合只需要拿到原始字节流再自行解析的场景。
- packetfu方案:在pcaprub之上构建的高级封装,提供了Packet类、TCPPacket类、UDPPacket类等完整的协议对象模型,可以像操作属性一样读写IP头、TCP头字段,适合需要深度解析甚至构造数据包的场景。
三种方案并非互斥。一个常见的实践是:用pcaprub做底层捕获,用packetfu做协议解析,两者配合可以同时兼顾性能与开发效率。对于多网卡场景,无论选择哪条路线,核心思路都是一致的——为每块网卡建立一个独立的捕获通道,再通过线程或队列把数据汇总到统一的处理逻辑中。
二、用原始套接字实现基础的多接口监听
先看最朴素的做法。Ruby标准库中的Socket支持创建原始套接字,配合Socket.getifaddrs可以枚举系统上所有网络接口。下面的代码演示了如何发现网卡并为每块网卡启动一个监听线程:
require 'socket'
# 枚举所有网络接口,过滤出处于运行状态的
def list_interfaces
Socket.getifaddrs.map(&:name).uniq
end
# 为指定接口创建原始套接字并循环读取
def sniff_interface(ifname)
# SOCK_RAW 表示原始套接字,ETH_P_ALL 表示捕获所有协议
socket = Socket.new(Socket::AF_PACKET, Socket::SOCK_RAW, 0x03)
# 将套接字绑定到指定网卡
sockaddr = Socket.pack_sockaddr_ll(0x03, ifname)
socket.bind(sockaddr)
loop do
data = socket.recvfrom(65535)
raw_packet = data[0]
puts "[#{ifname}] 捕获到 #{raw_packet.bytesize} 字节"
end
rescue => e
puts "接口 #{ifname} 监听失败: #{e.message}"
ensure
socket&.close
end
interfaces = list_interfaces - ['lo']
threads = interfaces.map do |ifname|
Thread.new { sniff_interface(ifname) }
end
threads.each(&:join)
这段代码的关键点有两个。第一是Socket.getifaddrs,它返回系统中所有接口地址信息,通过name方法去重后就能得到网卡列表,实际使用时建议排除lo回环接口,并根据flags判断接口是否处于UP状态。第二是pack_sockaddr_ll,它构造的是链路层地址结构,其中0x03代表ETH_P_ALL,即监听所有以太网协议类型的数据帧。
原始套接字方案的缺点也很明显:拿到的是未经解析的原始字节,以太网头、IP头、TCP头都需要手工按偏移量切分,一旦涉及VLAN标签或IP分片,解析逻辑会迅速膨胀。另外在Linux上创建原始套接字需要root权限,macOS上则默认不可用。因此生产环境更推荐下面的libpcap路线。
三、基于pcaprub和packetfu的多线程采集架构
pcaprub封装了libpcap的全部核心能力,包括网卡枚举、过滤器编译、混杂模式控制等。先安装依赖:
gem install pcaprub gem install packetfu
安装完成后,可以用Pcap.lookupdev获取默认设备,也可以用Pcap.findalldevs枚举全部接口。下面的代码实现了一个完整的多网卡采集器,每块网卡一个线程,捕获到的数据包经过packetfu解析后投入线程安全的队列,由专门的工作线程统一消费:
require 'pcaprub'
require 'packetfu'
# 汇总队列,使用标准库的线程安全队列
packet_queue = Queue.new
# 采集线程:每块网卡一个
def capture_worker(ifname, queue)
cap = Pcap.new(
ifname,
snaplen: 65535, # 单包最大捕获长度
promisc: true, # 开启混杂模式,接收非本机流量
timeout: 1 # 读超时,毫秒
)
cap.setfilter('tcp port 80 or tcp port 443') # 只关注HTTP/HTTPS
cap.each do |raw|
queue << { iface: ifname, time: Time.now, raw: raw }
end
rescue => e
puts "接口 #{ifname} 采集异常: #{e.message}"
end
# 工作线程:从队列消费并解析
worker = Thread.new do
loop do
item = queue.pop
pkt = PacketFu::Packet.parse(item[:raw])
next unless pkt.is_a?(PacketFu::TCPPacket)
puts format('[%s] %s:%d -> %s:%d seq=%d',
item[:iface],
pkt.ip_src, pkt.tcp_src,
pkt.ip_dst, pkt.tcp_dst,
pkt.tcp_seq)
end
end
# 启动所有网卡的采集线程
(Pcap.findalldevs - ['lo', 'any']).each do |dev|
Thread.new { capture_worker(dev, packet_queue) }
end
worker.join
这个架构的精妙之处在于采集与解析的解耦。采集线程只负责把原始字节扔进队列,解析这种相对耗CPU的工作交给独立的工作线程,这样即使某块网卡瞬时流量很大,也不会因为解析慢而导致内核缓冲区溢出丢包。如果解析压力更大,可以启动多个工作线程共同消费同一个队列,天然形成生产者消费者模型。
关于过滤器,setfilter接收的是标准BPF语法表达式,例如tcp port 80 or tcp port 443只保留HTTP流量,host 192.168.1.100只保留特定主机的流量。在多网卡场景下,建议在采集层就尽可能收紧过滤条件,因为BPF过滤在内核态执行,比在Ruby层过滤原始字节快几个数量级,能显著降低用户态程序的负担。
四、常见问题排查与性能优化建议
多网卡抓包在实践中最容易遇到以下几类问题:
- 权限不足:libpcap和原始套接字都需要root或CAP_NET_RAW能力。生产环境不建议直接用root运行Ruby脚本,可以给可执行文件授予能力:执行
setcap cap_net_raw,cap_net_admin=eip /path/to/ruby即可。 - 丢包统计:libpcap底层有内核缓冲区,流量突增时会丢弃来不及读取的包。pcaprub提供了
cap.stats方法返回接收数与丢弃数,监控这个指标可以判断是否需要增大缓冲区或减少每包的处理逻辑。 - 虚拟接口干扰:
findalldevs会返回veth、docker0、br-xxx等虚拟接口,容器化环境下网卡可能有几十个。建议通过配置文件或命令行参数明确指定要监听的接口列表,而不是全量监听。 - 线程异常静默退出:某块网卡被拔出或down掉时,对应线程可能抛异常退出而主程序毫无感知。务必在线程内做异常捕获,并配合心跳机制监控线程存活状态,必要时自动重连。
性能方面还有两个值得注意的点。一是snaplen不必总是设成65535,如果只关心头部信息,设置为128或256字节足以覆盖常见协议头,捕获数据量会成倍减少;二是避免在采集回调中做字符串拼接、日志写入等重操作,把这类工作全部推迟到工作线程,必要时用批量写入代替逐条写入,整体吞吐可以有明显的提升。按照这套架构搭建的多网卡采集器,在千兆带宽下处理几十万PPS的流量是完全没有压力的。