原文链接:https://blog.cloudflare.com/oxy-fish-bumblebee-splicer-subsys...

本文基于 Cloudflare 官方博客,介绍其内部代理框架 Oxy 的三个关键子系统:Splicer、Bumblebee 与 Fish,以及它们背后的工程设计思想。

背景:一个代理进程越来越重

Cloudflare 在内部使用一个叫做 Oxy 的代理框架,承载着海量流量。随着业务演进,他们需要让 WARP(Cloudflare 的 VPN 产品)支持一种叫做"soft-unicast"的新型 IP 分配机制——简单说,就是服务器不再"拥有"固定 IP,而是动态从共享 IP 池中借用地址出口。

这个改造带来了一个问题:原本的代理进程要承载越来越多的功能,状态越来越复杂,升级时需要保留的上下文越来越多,整体可靠性和可维护性开始下降。

Cloudflare 的解法很经典,也很务实——回归 Unix 哲学:每个程序只做一件事,并把它做好

他们把大型代理进程拆分成若干独立的小型服务,本文要介绍的就是其中三个核心子系统:SplicerBumblebeeFish


Splicer:专职数据管道

代理的本质,很多时候就是把数据从一个 socket 搬到另一个 socket——读进来,写出去,循环往复。这个模式在 Cloudflare 内部被大量项目重复实现,各自处理缓冲、刷新、断连逻辑,还得各自处理进程重启时的连接保活。

Splicer 把这个共性操作抽象成一个独立服务。

使用方式很简单:

  1. 代理应用把两个 socket 交给 Splicer;
  2. Splicer 接管数据转发,代理进程本身可以安心重启;
  3. 任务结束后,Splicer 把原始 socket 和请求元数据还给调用方,供其做最终审计(比如通过 TCP_INFO 读取连接状态)。

这样,长连接的维护责任就从代理进程转移到了 Splicer,代理自身的重启不再影响正在传输中的数据流。


Bumblebee:把 IP 包升级为 TCP Socket

Cloudflare 的很多接入点工作在第三层(IP 层),而大多数服务需要的是第四层(TCP/UDP)的 socket。这中间有一个"升级"的过程。

传统做法是在用户态实现一个 TCP 协议栈,但这既复杂又有性能损耗。Bumblebee 的方案更巧妙:

  1. 为每个 IP 流在一个匿名网络命名空间(通过 unshare 系统调用创建)中启动一个线程;
  2. 利用 tun 设备和 NAT,在内核中完成 TCP 三次握手;
  3. 最终把一个标准的内核 TCP socket 返回给调用方。

调用方只需要传入一个承载 IP 流的 socket,Bumblebee 会返回一个可以直接使用的 TCP socket,全程不涉及用户态 TCP 栈。拿到这个 TCP socket 之后,可以直接交给 Splicer 去做数据转发,整个链路干净清晰。

当代理进程重启时,Bumblebee 继续维护 IP socket,Splicer 继续维护 TCP socket,两者均不受影响。


Fish:软单播出口的 IP 包转发

Fish 负责解决软单播场景下的出口问题。

在软单播模式中,一台服务器可能拥有大量可用出口 IP,且端口分配是动态的。以前 Cloudflare 用 iptables + conntrack 来做包转发和 NAT,但这种方式有明显缺陷:规则管理复杂、调试困难,而且当 conntrack 无法按预期改写数据包时,故障模式非常晦涩难排查。

Fish 的改进在于,不依赖 iptables 规则,而是直接用 netlink 协议来配置 conntrack、改写数据包

工作流程如下:

  1. 代理应用把客户端 IP 流 socket,连同期望的出口 IP 和端口范围,一起发给 Fish;
  2. Fish 确保数据包能正确转发到目的地;
  3. Fish 在根网络命名空间内维护唯一的五元组(源IP、目的IP、源端口、目的端口、协议),并负责必要的包改写来保持网络隔离;
  4. Fish 的内部状态在进程重启后依然保留。

这种设计让出口 IP 的管理逻辑集中在一个专职服务中,大大降低了调试难度,也消除了 iptables 那种"配置型失败"的隐患。


三个服务的共同基础:文件描述符传递

这三个服务虽然业务逻辑各不相同,但有一个共同的底层机制:通过 Unix Domain Socket 在进程间传递文件描述符(File Descriptor)

传递文件描述符的标准方式是使用 SCM_RIGHTS 辅助消息。相比另一种方式(pid_getfd 系统调用),SCM_RIGHTS 更适合"代理主动把 socket 交出去"这种场景,且不需要特殊权限。

Cloudflare 内部用 Rust 实现了一个名为 hot-potato 的库来封装这一机制。他们还分享了几个生产经验:

  • 每条消息能传递的文件描述符数量有上限(内核常量 SCM_MAX_FD,自 2.6.38 起为 253);
  • 获取对端 socket 的凭证信息(peer credentials)对多租户场景的可观测性很有价值;
  • 利用 memfd_create 创建匿名文件描述符,可以绕过最大缓冲区限制,并实现零拷贝消息传递。

优雅重启:状态无损迁移

上述架构的一个关键优势是支持优雅重启(Graceful Restart)——进程升级时,正在处理中的连接不会断开,也不会遗留僵尸进程。

与 NGINX 那种"老进程继续处理旧连接、新进程处理新连接"的方式不同,Cloudflare 采用的是状态迁移方式:

  1. 收到升级信号(通常是 SIGHUP)后,暂停所有任务;
  2. 等待所有任务组进入暂停状态;
  3. 通过文件描述符传递,把所有挂起的任务迁移到新进程;
  4. 新进程同时接管旧任务和新请求,旧进程几乎立即退出。

这种方式对可观测性更友好,也避免了连续重启后旧进程堆积的问题。

用 JoinSet 实现 WaitGroup

在 Rust 的异步运行时 Tokio 中,Cloudflare 使用 JoinSet 来管理任务组,实现类似 Go 语言 WaitGroup 的语义。他们还特别注意了在处理新请求的同时及时清理已完成任务的 JoinSet 条目,以控制内存占用:

loop {
    tokio::select! {
        biased;
        _task_result = task_group.join_next(), if !task_group.is_empty() => {}
        req = listener.recv() => {
            let Some(request) = req else { break; };
            task_group.spawn(process_request(request));
        }
    }
}
while task_group.join_next().await.is_some() {}

在 Bumblebee 中,一个被暂停任务的状态包括:环境文件描述符、客户端 socket、IP 流代理 socket,以及 NAT 表。NAT 表可能超过 socket 缓冲区大小,所以被编码进一个匿名文件描述符后再传递——既绕过了大小限制,也实现了零拷贝。


小结:拆分换来的是可预期的系统行为

这套架构的核心思路可以用一句话概括:把大系统拆成小的、职责单一的服务,让每个服务的失败模式都是可理解的,从而让整体系统变得可靠。

当然,微服务化本身也有成本:需要分布式追踪、需要处理进程间通信的开销。但对于 Cloudflare 这种规模的基础设施来说,这些代价是值得的。

三个服务(Splicer 管管道、Bumblebee 管协议升级、Fish 管出口路由),加上文件描述符传递和状态迁移式重启,共同构成了一套在超大规模下依然能平滑升级、从容重启的代理架构。

对于同样面临"单一进程越来越臃肿"问题的工程团队来说,这套思路有很强的借鉴价值。


标签: none

添加新评论