147、RDMA与TLS(传输层安全):加密RDMA通信

发布时间:2026/10/24 20:37:55
147、RDMA与TLS(传输层安全):加密RDMA通信

好的,我们继续。这是专栏的第147篇。今天聊一个让不少RDMA老兵都头疼的话题:TLS加密。别急着划走,我知道很多人第一反应是“RDMA要的是极低延迟,加TLS?那不是自废武功吗?”——没错,两年前我也是这么想的,直到我在一个金融交易系统的POC现场被客户怼得哑口无言:“你们RDMA裸数据在InfiniBand链路上跑,万一被物理层抓包了呢?合规过不去。”那一次,我不得不把TLS和RDMA硬凑到一起,踩了无数坑,才有了今天这篇笔记。

从一次“抓包现形记”说起

那是一个做极速行情分发的小型集群,用的Mellanox ConnectX-5,RoCE v2协议。开发阶段一切完美,延迟1.2微秒,吞吐量接近线速。结果安全审计一上来,直接亮红灯:所有通过RoCE v2传输的行情数据,在交换机端口镜像抓包后,Wireshark里明文可见——连TCP payload都不用解析,直接看到Order Book的增量数据。客户安全官当场要求:要么加密,要么项目暂停。

我当时的第一反应是:上IPsec。但IPsec在RoCE场景下有个致命问题——它工作在IP层,而RoCE的传输层是InfiniBand的RC(可靠连接)服务,IPsec的加密会破坏RoCE的硬件卸载能力,导致CPU飙升,延迟直接翻倍到3微秒以上。更坑的是,某些网卡固件对IPsec offload的支持只针对TCP/UDP,对RoCE的QP(队列对)报文根本不认。

于是,我被迫转向TLS。但RDMA的通信模型和TCP完全不同:它没有内核socket,没有send/recv系统调用,数据直接在用户态通过WQE(工作队列元素)提交给网卡硬件。传统的OpenSSL库根本没法直接挂上去。怎么办?只能走“应用层加密”这条路——在数据进入RDMA发送缓冲区之前,先用TLS加密,接收端再解密。听起来简单,但实现起来全是坑。

别把TLS握手塞进RDMA数据路径

第一个坑:TLS握手。有人会想,既然RDMA是可靠连接,那我在建立QP之后,直接在QP上跑TLS握手不就行了?别这样写。RDMA的QP本质上是硬件状态机,它不关心你传的是TLS record还是原始二进制流。如果你把TLS握手报文(比如ClientHello)当作普通RDMA消息发送,接收端需要自己解析并响应——这意味着你必须在用户态实现一个完整的TLS状态机,而且这个状态机不能阻塞RDMA的发送/接收轮询。我见过一个团队这么干,结果握手阶段就超时,因为RDMA的WR(工作请求)提交和完成轮询是异步的,TLS握手需要同步的请求-响应交互,两者天然冲突。

我的做法是:TLS握手走传统的TCP通道,只在数据面用RDMA。具体来说,在应用启动时,先建立一个TCP连接(或者用socket),在这个TCP连接上完成TLS握手,交换对称密钥。握手完成后,关闭TCP连接,只保留协商好的密钥材料(比如AES-GCM的key和IV)。然后,把这个密钥通过ibv_post_send的“内联数据”或者直接写入共享内存,传递给RDMA的发送端。接收端同理。这样,RDMA数据路径上只做对称加密/解密,不做握手。

加密粒度:别对整个消息加密,对“载荷”加密

第二个坑:加密粒度。RDMA的典型用法是发送一个大的连续缓冲区,比如一个4KB的行情快照。如果你对整个4KB加密,接收端必须解密后才能解析。但问题在于,RDMA的接收端通常需要知道消息边界——比如通过“立即数”或者“分段”来区分多个消息。如果你加密了整个缓冲区,接收端在解密之前无法读取任何头部信息,也就无法知道这个缓冲区里包含几个消息。

这里踩过坑:我最初把整个RDMA消息(包括自定义的头部和载荷)一起加密,结果接收端解密后才发现头部被破坏了——因为头部里包含的序列号、长度字段在加密后变成了乱码,接收端无法校验。正确的做法是:只加密载荷,头部保持明文。头部里可以放一个“加密标志位”和一个“IV索引”,告诉接收端这个载荷是用哪个IV加密的。接收端先读取头部,根据IV索引找到对应的密钥和IV,再解密载荷。这样,头部本身不敏感,即使被嗅探也泄露不了业务数据。

性能优化:别让加密成为瓶颈

第三个坑:性能。AES-GCM是TLS 1.3推荐的加密套件,支持硬件加速。但问题在于,RDMA的发送端通常用CPU把数据拷贝到注册的内存区域(MR),然后网卡DMA读取。如果你在拷贝之前做加密,加密操作本身会占用CPU周期。在100Gbps的链路上,每微秒要处理约12.5KB数据,如果加密速度跟不上,就会形成瓶颈。

我的优化思路是:利用CPU的AES-NI指令集,并且把加密和内存拷贝合并。具体来说,在将应用数据拷贝到RDMA发送缓冲区时,同时进行AES-GCM加密。这需要手动实现一个“加密拷贝”函数,而不是先拷贝再加密。另外,对于小消息(比如小于256字节),可以用内联数据(inline data)直接嵌入WQE,避免额外的DMA操作。但注意:内联数据本身不加密,你需要在内联之前就完成加密。我写过一个宏,专门处理这种场景:

// 内联发送时,先加密到栈上临时缓冲区,再提交WQE// 别直接加密原始数据,否则原始数据会被修改#defineRDMA_SEND_INLINE_ENCRYPT(send_wr,data,len,key,iv)do{\uint8_ttmp_buf[256];\aes_gcm_encrypt(data,len,key,iv,tmp_buf);\send_wr->sg_list->addr=(uint64_t)tmp_buf;\send_wr->sg_list->length=len;\send_wr->send_flags|=IBV_SEND_INLINE;\}while(0)

注意:这个宏假设len不超过256字节,且tmp_buf在WQE提交完成前必须有效。实际工程中,你需要确保WQE完成轮询后才释放tmp_buf,否则网卡可能读到栈上被覆盖的数据。这里踩过坑:我忘了加内存屏障,结果在弱内存序的ARM服务器上,网卡读到了加密前的数据。

密钥轮换:别在QP上做

第四个坑:密钥轮换。TLS标准要求定期更换密钥,防止长期使用同一密钥被破解。但在RDMA场景下,密钥轮换不能通过QP本身来做——因为QP的通信是连续的,你没法在中间插入一个“密钥更新消息”而不破坏数据流。我的做法是:在应用层维护一个密钥版本号,每个RDMA消息的头部都携带这个版本号。接收端根据版本号查找对应的密钥。当需要轮换时,发送端先停止发送新消息(通过一个标志位),等待所有在途消息被确认(通过RDMA的完成事件),然后通过带外通道(比如共享内存或另一个QP)下发新密钥。接收端确认收到新密钥后,发送端再恢复发送。这个过程中,QP本身不需要重建,只是应用层的密钥表更新了。

个人经验性建议

如果你现在要做一个需要加密的RDMA系统,我的建议是:先评估是否真的需要TLS级别的加密。很多场景下,InfiniBand的物理层隔离(比如分区密钥、子网管理器访问控制)已经足够,加上TLS反而引入复杂度和延迟。但如果合规要求必须端到端加密(比如金融、医疗),那么别试图在RDMA上跑完整的TLS协议栈——那会把你拖入状态机的泥潭。用“带外握手+带内对称加密”的方案,把加密逻辑放在应用层,但一定要用硬件加速的AES-GCM,并且注意内存拷贝和加密的合并。

另外,测试时别只测小包延迟。大包吞吐量下,加密的CPU开销会暴露无遗。我见过一个系统,64字节消息延迟只增加0.5微秒,但4KB消息吞吐量直接腰斩——因为加密成了瓶颈。最后,别忘了在接收端做解密后的完整性校验。RDMA的CRC只保证链路层不丢包,不保证应用层数据没被篡改。AES-GCM的认证标签(authentication tag)就是干这个的,别为了省那16字节而省略它——否则你加密了个寂寞。

好了,这篇就到这里。下一期我们聊聊RDMA与DPDK的“爱恨情仇”——如何在同一个网卡上同时跑RDMA和DPDK,而不让它们打架。