Apr 26, 2026
CubeSandbox 面向的是高频创建、暂停、恢复和销毁的 agent sandbox。与传统长生命周期 VM 不同,这类 sandbox 在大多数情况下会从 snapshot 恢复启动。snapshot 恢复的核心价值是复用已经初始化好的 VM 状态:操作系统、运行时、文件系统缓存、进程环境以及网络设备状态都可以被保留下来。也正因为如此,VM 内部的网卡在恢复时很可能已经拥有一个固定 IP,例如本文后面会反复出现的 169.254.68.6。
这带来一个非常实际的网络问题:如果所有 sandbox 都从同一个 snapshot 恢复,它们在 VM 内部看到的网卡 IP 很可能相同;但在宿主机、代理层和外部网络看来,每个 sandbox 又必须具备不同的网络身份,以便实现隔离、路由、端口映射、网络策略和审计。直接在 VM 内部重新配置网卡当然可以解决这个问题,但这往往意味着热插网卡、重新下发 IP、触发 guest 内网络栈重配置,甚至处理 DHCP、路由、ARP 缓存和应用侧网络状态变化。这些操作既昂贵,也会削弱 snapshot 快速恢复的收益。
CubeVS 的核心价值就在于避免把这部分复杂度推回 VM 内部。它允许 sandbox 在 VM 内继续使用 snapshot 中已有的相同 IP 和网关配置,然后在宿主机侧通过 eBPF 做一层身份转换:VM 内部仍然看到 169.254.68.6,但包一进入宿主机 TAP,CubeVS 就根据 TAP ifindex 找到对应 sandbox 的元数据,把这个相同的内部 IP 转换为外部可区分的 sandbox 逻辑 IP,并在需要出网时进一步转换为节点侧 SNAT 地址。
因此,CubeSandbox 的网络层不是用传统 Linux bridge、iptables 或 OVS 来给每个 sandbox/VM 织一张独立二层网络,而是在 VM TAP、宿主机网卡、cube-dev 这几个边界点挂载 eBPF 程序,把转发、ARP 代理、SNAT/DNAT、端口映射、会话追踪和网络策略都放到内核态完成。它的目标不是做一套通用虚拟网络平台,而是服务一个很明确的需求:在不修改 snapshot 内 VM 网卡配置的前提下,让内部网络完全相同的 sandbox 在外部呈现为可区分、可隔离、可编排的网络实体。
本文分析的代码位于仓库实际目录 CubeNet/ 下,其中:
CubeNet/src/ 是 eBPF C 数据面代码;CubeNet/cubevs/ 是 Go 控制面库,负责加载 BPF 对象、固定 map、挂载 TC 过滤器、注册 TAP、配置策略和回收会话。版本说明:仓库里的
docs/architecture/network.md提到过filter_from_cubeXDP 程序,但当前CubeNet/src/和CubeNet/cubevs/实际代码中只看到三个 TC 程序:from_cube、from_world、from_envoy。因此本文以当前代码为准。
如果读者没有做过虚拟化网络,可以先把问题简化成一句话:
CubeSandbox 希望每个从 snapshot 恢复的 VM 都能继续使用同一套内部网络配置,但在宿主机和外部系统看来,它们又必须是彼此隔离、可寻址、可管控的不同 sandbox。
这听起来有点矛盾。VM 内部如果都用同一个 IP,例如都是 169.254.68.6,那宿主机如何区分“A 沙箱的 169.254.68.6”和“B 沙箱的 169.254.68.6”?一种思路是在恢复后修改 VM 内部网络配置,或者给 VM 热插新的网卡,再让 guest 感知新的地址。但对 snapshot 启动来说,这类动作会拉长恢复路径,也会引入 guest 内网络状态变更,复杂度和失败面都比较高。另一类传统做法则会把复杂度放在网络命名空间、bridge、iptables、路由规则或者用户态代理里。它们能工作,但对一个高频创建和销毁的 agent sandbox 系统来说,规则多、链路长、状态散,最后很容易变成一套难以观测和调优的网络拼图。
CubeVS 的想法更像是:不要让 VM 自己携带复杂身份。VM 只负责发包,宿主机在包离开 VM 的第一跳,也就是 TAP ingress 上,用 eBPF 给这个包“盖章”。这个章不是根据 VM 内部 IP 盖的,因为所有 VM 内部 IP 都一样;它根据 TAP ifindex 盖。每个 VM 都有自己的 TAP 设备,所以 TAP ifindex 天然可以作为 sandbox 的入口身份。
可以把一次出站访问理解成三层身份翻译:
| 所在位置 | 包里看到的源身份 | 谁负责翻译 | 为什么这样设计 |
|---|---|---|---|
| VM 内部 | 169.254.68.6 | VM 自己的网络栈 | 镜像和网络配置可以完全复用 |
| 宿主机虚拟网络内 | mvm_meta->ip | from_cube 根据 TAP ifindex 查 map | 编排层能区分不同 sandbox |
| 真正出外网时 | snat_iplist 里的节点 SNAT IP + 动态端口 | from_cube 的 NAT/session 逻辑 | 外部网络只需要看到可路由的节点侧地址 |
反方向也是类似的。外部回包先到宿主机网卡,from_world 根据 NAT 会话表找到对应 TAP,再把目标地址改回 VM 熟悉的 169.254.68.6。来自 overlay/proxy 的流量则从 cube-dev 进入,from_envoy 根据 sandbox 逻辑 IP 找到 TAP,也改写成 VM 内部地址后送进去。
所以 CubeVS 的精妙之处不只是“用了 eBPF”。它真正有价值的地方在于把身份转换放在了最合适的位置:
带着这个直觉再看后面的代码,会更容易理解:那些 map、NAT、checksum、redirect 其实都在服务同一件事,即让“内部完全一样的 VM”在外部表现成“可区分、可隔离、可编排的 sandbox”。
为了降低阅读门槛,先把文中几个高频词放在这里:
| 术语 | 可以先这样理解 |
|---|---|
| TAP | VM 接到宿主机的一根虚拟网线。每个 sandbox 有自己的 TAP,所以 TAP ifindex 可以代表 sandbox 来源 |
| TC | Linux 网卡收发包路径上的可编程挂载点。CubeVS 把 eBPF 程序挂在 TC ingress/egress 上处理包 |
| eBPF map | 内核里的共享表。Go 控制面写入配置,eBPF 数据面按包查表 |
| SNAT | 改写源地址。VM 发出去的包会从内部 IP 改成 sandbox 逻辑 IP,再改成节点侧出网 IP |
| DNAT | 改写目的地址。回到 VM 的包会从外部/逻辑目标地址改回 169.254.68.6 |
bpf_redirect | eBPF 告诉内核“这个包不要按默认路径走,直接送到指定接口” |
还可以用一条 HTTP 请求的旅程来记住整套方案:
example.com,包的源地址是固定的 169.254.68.6。from_cube 看到 TAP ifindex,查出这个 VM 对应的 sandbox 逻辑 IP。from_cube 先把源地址从 169.254.68.6 改成 sandbox 逻辑 IP,再检查网络策略。from_cube 分配 SNAT 端口,创建 NAT 会话,把源地址改成节点侧 SNAT IP,送到宿主机网卡。from_world 在宿主机网卡上查会话表。from_world 找到原 TAP,把目的地址和端口改回 169.254.68.6:原始端口,再 redirect 回 VM。这条路径里没有 Linux bridge 的集中转发,也没有每个包进入用户态代理。包在内核里一路被“查表、改头、转发”,这就是 CubeVS 的性能和简洁性来源。
CubeVS 的关键常量定义在 CubeNet/src/cubevs.h。这几个地址说明了设计意图:
/* IP and MAC address inside MVMs */
const volatile __u32 mvm_inner_ip = 0x0644fea9; /* 169.254.68.6 */
/* next hop of MVM */
const volatile __u32 mvm_gateway_ip = 0x0544fea9; /* 169.254.68.5 */
/* cube-dev device */
const volatile __u32 cubegw0_ip = 0x017100cb; /* 203.0.113.1 */
const volatile __u32 cubegw0_ifindex = 216;
/* Node itself; MAC 地址常量略 */
const volatile __u32 nodenic_ip = 0x020a8709; /* 9.135.10.2 */
const volatile __u32 nodenic_ifindex = 2;
注意这些变量都是 const volatile。它们在 C 源码里看起来像编译期常量,但 Go loader 会在加载 BPF object 之前把它们改写成当前宿主机真实的 IP、MAC 和 ifindex。这样做有两个好处:
Go 侧改写逻辑在 CubeNet/cubevs/miscs.go:
func rewriteConstants(vars map[string]*ebpf.VariableSpec, params Params) error {
vars[globalNameMVMInnerIP].Set(ipToUint32(params.MVMInnerIP))
vars[globalNameMVMGatewayIP].Set(ipToUint32(params.MVMGatewayIP))
vars[globalNameCubegw0IP].Set(ipToUint32(params.Cubegw0IP))
vars[globalNameNodeIP].Set(ipToUint32(params.NodeIP))
/* ...MAC、ifindex 和错误聚合略... */
}
于是,VM 内部始终可以使用同一套网络配置;真正区分 sandbox 的,是宿主机侧 TAP ifindex、sandbox 元数据以及 eBPF map。
这里要特别强调 const volatile 的意义。对没有 eBPF 背景的读者来说,可以把它理解成“加载时写入的常量”:代码编译出来时带着默认值,但真正加载到内核前,Go 控制面会把默认值替换成当前机器的真实网卡、网关和 MAC。这样数据面运行时就像读常量一样快,同时部署时又不需要为每台机器重新编译 BPF 程序。
另一个容易忽略的点是:mvm_inner_ip 和 mvm_meta->ip 不是同一层身份。mvm_inner_ip 是 VM 内部统一看到的地址;mvm_meta->ip 是 CubeSandbox 编排层分配给某个 sandbox 的逻辑地址。from_cube 入口处先把前者改成后者,这就是整套方案的第一道“身份翻译门”。
CubeVS 没有集中式 bridge,而是在三类接口边界挂载 TC 程序:
| 程序 | 文件 | 挂载点 | 方向 | 主要职责 |
|---|---|---|---|---|
from_cube | CubeNet/src/mvmtap.bpf.c | 每个 TAP 的 TC ingress | VM -> 宿主机 | ARP 代理、策略检查、端口映射优化、SNAT、会话创建 |
from_world | CubeNet/src/nodenic.bpf.c | 宿主机网卡 TC ingress,另挂到 lo | 外部 -> VM | 回包反向 NAT、远端端口映射 |
from_envoy | CubeNet/src/localgw.bpf.c | cube-dev TC egress | overlay/proxy -> VM | 把目标 sandbox 逻辑 IP DNAT 成 VM 内部 IP 并转发到 TAP |
初始化流程在 CubeNet/cubevs/miscs.go:
func Init(params Params) error {
/* 加载并固定三个 BPF object */
loadObject(params, loadLocalgw, "loadLocalgw")
loadObject(params, loadMvmtap, "loadMvmtap")
loadObject(params, loadNodenic, "loadNodenic")
/* 把程序挂到关键网络边界 */
attachTCFilter(programNameFromEnvoy, params.Cubegw0Ifindex, TCEgress)
attachTCFilter(programNameFromWorld, params.NodeIfindex, TCIngress)
attachTCFilter(programNameFromWorld, 1, TCIngress)
/* ...错误处理略... */
}
这里的 loadObject 会加载 BPF object,并把 map pin 到 /sys/fs/bpf:
spec, err := loader()
err = rewriteConstants(spec.Variables, params)
obj, err := ebpf.NewCollectionWithOptions(spec, opts)
return pinProgs(obj)
/* opts.Maps.PinPath = "/sys/fs/bpf",错误处理略 */
TC eBPF 并不是“加载后自动影响所有网卡”。当前代码里,程序挂到哪个网络设备,完全由传入的 ifindex 决定。attachTCFilter 的核心就是把 ifindex 写进 TC netlink 对象:
filter := tc.Object{
Msg: tc.Msg{
Ifindex: ifindex,
Parent: tcMakeHandle(tcHandleClsact, uint32(direction)),
},
Attribute: tc.Attribute{
Kind: "bpf",
BPF: &tc.Bpf{FD: &progFD, Name: &progName, Flags: &flags},
},
}
tcnl.Filter().Replace(&filter)
挂载前还会在同一个 ifindex 上创建 clsact qdisc。clsact 可以理解成 TC 专门给 ingress/egress filter 准备的轻量挂载容器:
qdisc := tc.Object{
Msg: tc.Msg{Ifindex: ifindex, Parent: tcHandleClsact},
Attribute: tc.Attribute{Kind: "clsact"},
}
tcnl.Qdisc().Add(&qdisc)
所以 CubeVS 的挂载控制粒度是网络设备级别:
| 挂载动作 | ifindex 来源 | 是否可控 |
|---|---|---|
from_envoy 挂到 cube-dev egress | params.Cubegw0Ifindex | 可通过初始化参数控制 |
from_world 挂到 Node 网卡 ingress | params.NodeIfindex | 可通过初始化参数控制 |
from_world 挂到 lo ingress | 固定 1 | 当前代码写死 |
from_cube 挂到 TAP ingress | AttachFilter(ifindex) 的参数 | 创建/注册 TAP 时控制 |
这也意味着,如果不希望硬件网卡被挂载 TC eBPF,就不能把 Params.NodeIfindex 设置成物理网卡的 ifindex。更理想的做法是给 CubeVS 准备一个专用的虚拟出入口设备,例如 veth、dummy、macvlan、ipvlan 或独立的 gateway 设备,让 SNAT IP、路由回包路径和 from_world 都落在这张设备上。
但这里有一个前提:外部回包必须真的从这个设备的 ingress 经过。from_world 负责反向 NAT,如果回包仍然直接进入物理网卡,而 from_world 没挂在那里,那回包就不会被改回 169.254.68.6 并重定向到 TAP。
因此,避免影响主硬件网卡性能通常有两条路:
Params.NodeIfindex 指向它。Init() 中的 from_world 挂载从单个 params.NodeIfindex 改成显式列表,例如 WorldIfindexes []uint32,只挂到用户指定的设备上。后者可以把当前代码改成类似这样:
for _, ifindex := range params.WorldIfindexes {
attachTCFilter(programNameFromWorld, ifindex, TCIngress)
}
需要注意的是,即使挂在硬件网卡上,from_world 也不是对所有流量都做完整 NAT;它会先检查 IPv4 和协议,再查 session 或端口映射。但程序仍然会在该网卡 ingress 路径上被触发,开销不是零。如果宿主机主网卡承载了大量与 sandbox 无关的业务流量,把 CubeVS 的入口拆到专用设备上会更干净。
每个新 TAP 创建后,还需要把 from_cube 挂到这个 TAP 的 ingress:
func AttachFilter(ifindex uint32) error {
prog, err := ebpf.LoadPinnedProgram(pinPath(programNameFromCube), nil)
createQdisc(ifindex)
attachFilter(ifindex, uint32(prog.FD()), programNameFromCube, TCIngress)
return initNetPolicy(ifindex)
/* ...错误处理和资源释放略... */
}
这就是 CubeVS 的“交换机端口”模型:每个 TAP 是一个接入口,cube-dev 是 overlay 网关口,宿主机网卡是外部网络口,TC 程序是交换逻辑。
如果用普通交换机来类比,CubeVS 并不是一台只看 MAC 地址的二层交换机。它更像一台“带 NAT、防火墙和服务发布能力的可编程边界设备”:
cube-dev 端口接本地 overlay/proxy,负责把逻辑 sandbox IP 翻译回 VM 内部 IP;这些逻辑如果放在用户态代理里,理解起来也许更直观,但每个包都要跨用户态和内核态边界。CubeVS 选择把它们放到 TC hook 上,代价是代码更接近内核网络栈,收益是路径短、状态集中、规则数量不会随着 sandbox 数量线性膨胀到 iptables 链上。
核心 map 定义在 CubeNet/src/map.h。先看两个最关键的设备身份映射:
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, __u32);
__type(value, __u32);
__uint(pinning, LIBBPF_PIN_BY_NAME);
} mvmip_to_ifindex SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, __u32);
__type(value, struct mvm_meta);
__uint(pinning, LIBBPF_PIN_BY_NAME);
} ifindex_to_mvmmeta SEC(".maps");
mvmip_to_ifindex 用于从 sandbox 逻辑 IP 找 TAP;ifindex_to_mvmmeta 用于从 TAP 找 sandbox 元数据。Go 侧 AddTAPDevice 注册这两张表:
func AddTAPDevice(ifindex uint32, ip net.IP, id string, version uint32, opts MVMOptions) error {
mvmIP := ipToUint32(ip)
mvmID := mvmMetadata{IP: mvmIP, UUID: stringToByteArray(id), Version: version}
ifindexToMVMMeta.Update(&ifindex, &mvmID, ebpf.UpdateAny)
mvmIPToIfindex.Update(&mvmIP, &ifindex, ebpf.UpdateAny)
return applyNetPolicy(ifindex, opts)
/* ...loadPinnedMap 和错误处理略... */
}
再看 NAT 和端口映射相关 map:
struct { /* host_port -> (TAP ifindex, VM listen_port) */
__type(key, __u16);
__type(value, struct mvm_port);
} remote_port_mapping SEC(".maps");
struct { /* sandbox-side 5-tuple -> NAT state */
__type(key, struct session_key);
__type(value, struct nat_session);
} egress_sessions SEC(".maps");
struct { /* hash(mvm_ip) -> SNAT IP entry */
__uint(max_entries, MAX_SNAT_IPS);
__type(value, struct snat_ip);
} snat_iplist SEC(".maps");
可以把这些 map 理解成虚拟交换机里的几类表:
ifindex_to_mvmmeta:接入口到 VM 身份的映射;mvmip_to_ifindex:VM 逻辑 IP 到接入口的映射;egress_sessions / ingress_sessions:双向 NAT 会话表;snat_iplist:出公网 SNAT 地址池;remote_port_mapping / local_port_mapping:服务端口映射表;allow_out / deny_out:逐 sandbox 出站策略表。VM 发出的包首先进入 TAP 的 TC ingress,也就是 from_cube。入口函数在 CubeNet/src/mvmtap.bpf.c:
SEC("tc")
int from_cube(struct __sk_buff *skb)
{
if (skb->protocol == bpf_htons(ETH_P_ARP))
return handle_arp(skb, skb->ingress_ifindex);
ifindex = skb->ingress_ifindex;
mvm_meta = bpf_map_lookup_elem(&ifindex_to_mvmmeta, &ifindex);
/* ...过滤非 IP 包,解析 L2/L3 头,读取目的地址和协议... */
daddr = l3->daddr;
proto = l3->protocol;
/* 第一道身份翻译:VM 内部 IP -> sandbox 逻辑 IP */
err = snat(skb, l3, mvm_meta->ip);
/* ...后续进入 gateway、端口映射、策略和 NAT 分支... */
}
这里最关键的动作就是 snat(skb, l3, mvm_meta->ip)。
VM 内部包的源地址原本是固定的 mvm_inner_ip,也就是 169.254.68.6。进入 TAP 后,from_cube 通过 skb->ingress_ifindex 找到 mvm_meta,然后先把源地址改成该 sandbox 的逻辑 IP。
这一步解释了“所有 VM 内部都是同一个 IP,但宿主机侧看到的是不同 IP”的核心机制:VM 里的网络栈不需要知道自己是谁;TAP ifindex 才是身份锚点;eBPF 在第一跳把统一的内部身份翻译成编排层分配的 sandbox 身份。
VM 会把 169.254.68.5 当默认网关,因此它会先发 ARP 请求。这个网关不一定是一个真实三层设备,而是由 from_cube 代理回答:
static __always_inline int handle_arp(struct __sk_buff *skb, __u32 ifindex)
{
/* 只处理 Ethernet/IPv4 ARP request */
if (arp->ar_hrd != bpf_htons(ARPHRD_ETHER) ||
arp->ar_op != bpf_htons(ARPOP_REQUEST))
return TC_ACT_SHOT;
/* request 原地改 reply:交换 sender/target IP */
arp->ar_op = bpf_htons(ARPOP_REPLY);
/* ...交换 ar_sip/ar_tip,回填目标 MAC... */
/* 用 gateway MAC 作为 ARP reply 的 sender */
macaddr->p1 = cubegw0_macaddr_p1;
macaddr->p2 = cubegw0_macaddr_p2;
return bpf_redirect(ifindex, 0);
}
这段代码把 ARP request 原地改成 ARP reply,再 redirect 回同一个 TAP。VM 于是认为默认网关存在,并继续发送 IP 包。
如果 VM 目的地址是 mvm_gateway_ip,from_cube 会把目的地址改成 cubegw0_ip,然后把包重定向到 cube-dev:
if (daddr == mvm_gateway_ip) {
/* gateway 通路只允许 ICMP 和非首包 TCP */
if (proto != IPPROTO_ICMP && !(proto == IPPROTO_TCP && !(l4->syn && !l4->ack)))
return TC_ACT_SHOT;
/* 目标从 VM 看到的 gateway 改成 cube-dev */
err = dnat(skb, l3, cubegw0_ip);
return bpf_redirect(cubegw0_ifindex, BPF_F_INGRESS);
/* ...拉取 TCP 头和错误处理略;源码中用 switch 展开... */
}
这里的过滤也很克制:放行 ICMP 和非首包 TCP,拒绝新的 TCP SYN 和其他协议。这通常用于内部 gateway/control-plane 通路,而不是让 VM 随意连宿主机网关。
普通外部流量会先被网络策略检查,核心就是 if (!check_net_policy(ifindex, daddr)) return TC_ACT_SHOT;。
之后根据协议进入 TCP、UDP 或 ICMP 的 NAT 逻辑:
if (should_do_nat(l3)) {
if (proto == IPPROTO_TCP)
if (do_tcp_nat(skb, mvm_meta, &dst_ifindex))
return bpf_redirect(dst_ifindex, 0);
if (proto == IPPROTO_UDP)
if (do_udp_nat(skb, mvm_meta, &dst_ifindex))
return bpf_redirect(dst_ifindex, 0);
if (proto == IPPROTO_ICMP)
if (do_icmp_nat(skb, mvm_meta, &dst_ifindex))
return bpf_redirect(dst_ifindex, 0);
}
TCP 的新连接只在看到 SYN 且无 ACK/FIN/RST 时创建会话:
if (syn && !ack && !fin && !rst) {
sess = bpf_map_lookup_elem(&egress_sessions, &key);
if (sess) {
if (sess->state == TCP_CONNTRACK_CLOSE || sess->state == TCP_CONNTRACK_TIME_WAIT) {
del_session(&key, sess);
goto do_create;
}
goto do_update; /* SYN 重传复用已有 session */
}
do_create:
/* 新连接:分配 SNAT 端口,并写入正反向会话表 */
snat_ip = pick_snat_ip_port(mvm_meta->ip, &key, &snat_port);
ok = create_new_sessions(&key, now, skb->ingress_ifindex, snat_ip, snat_port);
}
已有连接则直接查 egress_sessions,更新状态后改写 L4/L3/L2:
old_buff.addr = l3->saddr;
old_buff.port = l4->source;
new_buff.addr = sess->node_ip;
new_buff.port = sess->node_port;
/* 增量更新 L4 checksum 后改源端口 */
new_csum = bpf_csum_diff((void *)&old_buff, sizeof(old_buff),
(void *)&new_buff, sizeof(new_buff), ~old_csum);
l4->check = csum_fold(new_csum);
l4->source = sess->node_port;
/* 改源 IP,再改二层 MAC,最后 redirect 到宿主机网卡 */
rewrite_l3_addr(l3, &l3->saddr, sess->node_ip);
set_mac_pair(l2, nodenic_macaddr_p1, nodenic_macaddr_p2,
nodegw_macaddr_p1, nodegw_macaddr_p2);
所以出站包可能经历两级身份转换:
169.254.68.6 -> mvm_meta->ip:VM 内部统一 IP 变成 sandbox 逻辑 IP;mvm_meta->ip -> sess->node_ip:真正出宿主机时变成 SNAT 地址池中的节点侧地址。SNAT 地址池的 Go 配置入口是 CubeNet/cubevs/snat.go:
func SetSNATIPs(ips []*SNATIP) error {
ipList := make([]snatIP, len(ips))
for i, ip := range ips {
ipList[i] = snatIP{Ifindex: uint32(ip.Ifindex), IP: ipToUint32(ip.IP), MaxPort: maxPortStart}
}
/* 排序后写入 snat_iplist,最多填 4 个槽 */
return setSNATIPs(ipList)
}
func setSNATIPs(ips []snatIP) error {
for i := range maxSNATIPs {
m.Update(uint32(i), ips[i%len(ips)], ebpf.UpdateAny)
}
/* ...错误处理略... */
}
eBPF 侧最多支持 4 个 SNAT IP:
#define MAX_SNAT_IPS 4
#define MAX_PORT_START 30000
struct snat_ip {
struct bpf_spin_lock lock; /* guard max_port */
__u32 ifindex, ip;
__u16 max_port; /* the next port to be used */
/* ...padding 略... */
};
端口分配在 pick_snat_ip_port 中完成:
index = jhash_1word(mvm_ip, HASH_SEED) % MAX_SNAT_IPS;
snat_ip = bpf_map_lookup_elem(&snat_iplist, &index);
for (i = 0; i < max_retries; i++) {
/* 用 spin lock 保护端口水位线 */
bpf_spin_lock(&snat_ip->lock);
snat_port = snat_ip->max_port;
snat_ip->max_port = (snat_ip->max_port == 0xffff) ? MAX_PORT_START : snat_ip->max_port + 1;
bpf_spin_unlock(&snat_ip->lock);
/* BPF_NOEXIST 抢占反向 NAT key,避免端口碰撞 */
ikey.dst_port = bpf_htons(snat_port);
if (!bpf_map_update_elem(&ingress_sessions, &ikey, &isess, BPF_NOEXIST))
return snat_ip;
}
这里有几个设计点:
jhash(mvm_ip) % MAX_SNAT_IPS 选择,同一个 sandbox 逻辑 IP 会稳定落到同一个 SNAT IP 槽位;max_port 水位线,从 30000 起分配;bpf_spin_lock 保证多 CPU 并发分配端口时不会踩踏;BPF_NOEXIST 抢占 ingress_sessions key,避免两个连接拿到同一个反向 NAT 坐标;这比为每个 VM 生成 iptables 规则轻得多:数据面只做 map 查找、校验和增量更新和 bpf_redirect。
外部回包从宿主机网卡进入,命中 from_world:
SEC("tc")
int from_world(struct __sk_buff *skb)
{
if (l3->protocol == IPPROTO_TCP)
return do_tcp_nat(skb);
if (l3->protocol == IPPROTO_UDP)
return do_udp_nat(skb);
if (l3->protocol == IPPROTO_ICMP)
return do_icmp_nat(skb);
return TC_ACT_OK;
/* ...协议过滤和 IP 头解析略... */
}
回包的 key 是外部视角的五元组:远端 IP、节点 SNAT IP、远端端口、节点 SNAT 端口、协议。lookup_session 先查 ingress_sessions,再还原出 egress_sessions 的 key:
static __always_inline struct nat_session *lookup_session(const struct session_key *ikey)
{
isess = bpf_map_lookup_elem(&ingress_sessions, ikey);
if (!isess)
return NULL;
/* 用 ingress value 还原 egress_sessions 的 key */
key.src_ip = isess->vm_ip;
key.dst_ip = ikey->src_ip;
key.src_port = isess->vm_port;
key.dst_port = ikey->src_port;
/* ...version/protocol 略... */
return bpf_map_lookup_elem(&egress_sessions, &key);
}
找到会话后,目标地址和目标端口被改回 VM 内部视角:
old_buff.addr = l3->daddr;
old_buff.port = l4->dest;
new_buff.addr = mvm_inner_ip;
new_buff.port = sess->vm_port;
/* 目标端口改回 VM 原始端口 */
/* ...bpf_csum_diff 更新 checksum... */
l4->dest = sess->vm_port;
/* 目标 IP 改回 169.254.68.6,再送回原 TAP */
rewrite_l3_addr(l3, &l3->daddr, mvm_inner_ip);
return bpf_redirect(sess->vm_ifindex, 0);
于是 VM 收到的包仍然是发给 169.254.68.6:原始端口 的包。它完全不知道中间发生了两次 NAT,也不需要感知宿主机上的多租户网络。
cube-dev egress 上做 DNATfrom_envoy 处理的是另一条路径:来自 overlay、Envoy 或本地代理的流量,目标地址通常是 sandbox 逻辑 IP。它在 cube-dev 的 TC egress 上执行:
SEC("tc")
int from_envoy(struct __sk_buff *skb)
{
daddr = l3->daddr;
/* overlay 目标 IP -> VM 内部统一 IP */
err = dnat(skb, l3, mvm_inner_ip);
/* 源地址伪装成 VM 默认网关 */
err = snat(skb, l3, mvm_gateway_ip);
/* 用原始目标 IP 找到 sandbox TAP */
ifindex = bpf_map_lookup_elem(&mvmip_to_ifindex, &daddr);
return bpf_redirect(*ifindex, 0);
/* ...头解析和错误处理略... */
}
这段逻辑特别能体现“虚拟交换机”的味道:
daddr,也就是 sandbox 逻辑 IP;mvm_inner_ip;mvm_gateway_ip;daddr 查 mvmip_to_ifindex,找到具体 TAP;bpf_redirect 到该 TAP。对于 VM 来说,包来自默认网关,目的地是自己固定的 169.254.68.6。对于 proxy/overlay 来说,它访问的是不同 sandbox 的不同逻辑 IP。两边看到的是两套地址空间。
CubeVS 支持把宿主机端口映射到 VM 内部端口。Go 侧会同时维护两张表:
func AddPortMapping(ifindex uint32, listenPort uint16, hostPort uint16) error {
listenPort = htons(listenPort)
hostPort = htons(hostPort)
mvmPort := MVMPort{Ifindex: ifindex, ListenPort: listenPort}
/* host_port -> (TAP ifindex, VM listen_port) */
err = m1.Update(&hostPort, &mvmPort, ebpf.UpdateAny)
/* (TAP ifindex, VM listen_port) -> host_port */
err = m2.Update(&mvmPort, &hostPort, ebpf.UpdateAny)
}
remote_port_mapping 用于外部访问宿主机端口时找到 VM:
dport = l4->dest;
mvm_port = bpf_map_lookup_elem(&remote_port_mapping, &dport);
if (mvm_port)
return tcp_nat_proxy(l2, l3, l4, mvm_port);
tcp_nat_proxy 把目的端口和目的地址改成 VM 内部监听地址:
new_buff.addr = mvm_inner_ip;
new_buff.port = mvm_port->listen_port;
/* 目的端口改成 VM 监听端口 */
l4->check = csum_fold(new_csum);
l4->dest = mvm_port->listen_port;
/* 目的 IP 改成 VM 内部统一 IP,然后送到目标 TAP */
rewrite_l3_addr(l3, &l3->daddr, mvm_inner_ip);
return bpf_redirect(mvm_port->ifindex, 0);
/* ...checksum diff 和 L2 MAC 改写略... */
反方向也有优化。VM 如果从被映射的监听端口发包,from_cube 会查 local_port_mapping:
mvm_port.ifindex = ifindex;
mvm_port.listen_port = l4->source;
host_port = bpf_map_lookup_elem(&local_port_mapping, &mvm_port);
if (host_port) {
err = snat_tcp(skb, ifindex, l2, l3, l4, l4->source, *host_port);
if (err)
return TC_ACT_SHOT;
return bpf_redirect(nodenic_ifindex, 0);
}
这样端口映射流量不需要完整 NAT session 创建,也不需要用户态 proxy 转发,直接在内核态完成 TCP 源端口和源地址改写。
CubeVS 的出站网络策略是逐 TAP 的。C 侧 map 结构是 hash-of-maps,内层是 LPM trie:
struct {
__uint(type, BPF_MAP_TYPE_HASH_OF_MAPS);
__type(key, __u32);
__uint(pinning, LIBBPF_PIN_BY_NAME);
/* value 是内层 LPM trie,用来存 CIDR */
__array(values, struct {
__uint(type, BPF_MAP_TYPE_LPM_TRIE);
__type(key, struct lpm_key);
__type(value, __u32);
__uint(map_flags, BPF_F_NO_PREALLOC);
});
} allow_out SEC(".maps");
deny_out 结构相同。Go 侧会为每个 ifindex 创建内层 LPM trie:
func ensureInnerMap(outerMap *ebpf.Map, ifindex uint32, mapName string) error {
err := outerMap.Lookup(&ifindex, &innerMapID)
if err == nil {
return nil /* 这个 TAP 已有自己的策略 trie */
}
inner, err := newInnerLPMMap()
outerMap.Put(&ifindex, inner)
/* ...错误处理和资源释放略... */
}
策略规则在 applyNetPolicy 中落表。默认会加入一组不可访问的私有/本地网段:
var alwaysDeniedSandboxCIDRs = []string{
"10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.168.0.0/16",
}
func applyNetPolicy(ifindex uint32, opts MVMOptions) error {
if opts.AllowInternetAccess != nil && !*opts.AllowInternetAccess {
denyOut = []string{"0.0.0.0/0"}
} else {
denyOut = append(userDenyOut, alwaysDeniedSandboxCIDRs...)
}
/* ...把 allowOut/denyOut 写入对应内层 LPM trie... */
}
eBPF 数据面执行策略的顺序是 allow 优先于 deny,最后默认放行:
static __always_inline bool check_net_policy(__u32 ifindex, __u32 daddr)
{
struct lpm_key key = { .prefixlen = 32, .ip = daddr };
if (daddr == mvm_gateway_ip)
return true;
/* allow 优先,然后 deny,最后默认放行 */
inner_map = bpf_map_lookup_elem(&allow_out, &ifindex);
if (inner_map && bpf_map_lookup_elem(inner_map, &key))
return true;
inner_map = bpf_map_lookup_elem(&deny_out, &ifindex);
if (inner_map && bpf_map_lookup_elem(inner_map, &key))
return false;
return true;
}
这里有一个值得注意的细节:BPF 侧查策略时构造的 key 是 /32,也就是 struct lpm_key key = { .prefixlen = 32, .ip = daddr };。
LPM trie 的查找语义会用这个完整 IP 去匹配 map 中已插入的任意前缀,比如 10.0.0.0/8 或 0.0.0.0/0。因此策略天然支持 CIDR,而不需要 BPF 程序自己实现掩码匹配。
会话 key/value 结构在 CubeNet/src/cubevs.h:
struct session_key {
__u32 src_ip, dst_ip;
__u16 src_port, dst_port;
__u32 version; /* 0 for ingress session */
__u8 protocol;
/* ...padding 略... */
};
struct nat_session {
__u64 access_time;
__u32 node_ifindex, node_ip;
__u32 vm_ifindex, vm_ip;
__u16 node_port, vm_port;
__u8 state, active_close;
/* ...padding 略... */
};
struct ingress_session { __u32 version, vm_ip; __u16 vm_port; /* ... */ };
创建会话时,pick_snat_ip_port 先占住 ingress_sessions 的反向 key,然后 create_nat_session 创建正向 egress_sessions:
static __always_inline bool create_nat_session(struct session_key *ekey,
__u64 now_ns, __u32 vm_ifindex,
struct snat_ip *snat_ip, __u16 snat_port,
__u8 initial_state)
{
struct nat_session sess = { /* node_ip/port, vm_ip/port, state, access_time */ };
err = bpf_map_update_elem(&egress_sessions, ekey, &sess, BPF_NOEXIST);
if (err) {
/* 正向 session 创建失败时,回滚前面占住的反向 key */
bpf_map_delete_elem(&ingress_sessions, &ikey);
return false;
}
}
这个“双表设计”很实用:
egress_sessions;ingress_sessions,再还原 egress_sessions key;nat_session 只保存一份完整状态,避免正反两个方向重复维护。TCP 状态机复用了 Linux conntrack 的思路。CubeNet/src/tcp.h 中有完整状态转移表:
static const u8 tcp_conntracks[2][6][TCP_CONNTRACK_MAX] = {
/* ORIGINAL: SYN/SYNACK/FIN/ACK/RST/NONE x 当前 TCP 状态 */
/* REPLY: SYN/SYNACK/FIN/ACK/RST/NONE x 当前 TCP 状态 */
/* ...完整矩阵省略,核心是根据方向和 TCP flags 推进 conntrack state... */
};
UDP 和 ICMP 则使用简单的 UNREPLIED -> REPLIED:
static __always_inline void update_udp_session(enum ip_conntrack_dir dir,
struct nat_session *sess,
__u64 now_ns)
{
session_lazy_refresh(sess, now_ns);
session_mark_replied(dir, sess, UDP_CT_UNREPLIED, UDP_CT_REPLIED);
}
数据面完全在 eBPF 中,但过期会话回收由 Go goroutine 做。这是一个合理分工:每包转发不能回用户态,周期性扫描 map 则可以放在控制面。
CubeNet/cubevs/reaper.go 定义了超时:
const (
reapSessionsInterval = time.Second * 5
maxSessions = 1048576
maxSessionPercentage = 0.8
)
var tcpTimeouts = map[tcpConntrackState]time.Duration{
tcpCTSynSent: time.Minute,
tcpCTEstablished: time.Hour * 3,
tcpCTTimeWait: time.Minute * 2,
tcpCTClose: time.Second * 10,
/* ...其他 TCP 状态略... */
}
var udpTimeouts = map[udpConntrackState]time.Duration{
udpCTUnreplied: time.Second * 30,
udpCTReplied: time.Second * 180,
}
扫描逻辑会同时删除正反两张表:
func deleteSessions(egressSessions, ingressSessions *ebpf.Map,
egressKey *sessionKey, sess *natSession,
) error {
ingressKey := sessionKey{
SourceIP: egressKey.TargetIP,
TargetIP: sess.NodeIP,
SourcePort: egressKey.TargetPort,
TargetPort: sess.NodePort,
Protocol: egressKey.Protocol,
/* ...version/padding 略... */
}
ingressSessions.Delete(&ingressKey)
egressSessions.Delete(egressKey)
}
如果 map 数量超过 80%,reportCount 会向事件通道写入 ErrSessionsTooMany,提醒控制面当前连接数已经接近上限。
这也是 CubeVS 的一个典型取舍:快路径极简,慢路径可观测、可报警。
把上面的路径合在一起,可以得到如下模型:
flowchart LR
VM["VM 内部网络<br/>169.254.68.6<br/>gw 169.254.68.5"]
TAP["专属 TAP<br/>TC ingress: from_cube"]
MAPS["Pinned BPF Maps<br/>ifindex_to_mvmmeta<br/>mvmip_to_ifindex<br/>sessions<br/>policy"]
CUBEDEV["cube-dev<br/>TC egress: from_envoy"]
NODE["宿主机网卡/lo<br/>TC ingress: from_world"]
EXT["外部网络"]
PROXY["Overlay / CubeProxy / Envoy"]
VM --> TAP
TAP <--> MAPS
TAP -- "外网流量: policy + session + SNAT" --> NODE
NODE --> EXT
EXT -- "回包: reverse NAT" --> NODE
NODE -- "redirect to TAP" --> TAP
PROXY --> CUBEDEV
CUBEDEV <--> MAPS
CUBEDEV -- "DNAT to 169.254.68.6" --> TAP
这里最关键的不是某一个 NAT 函数,而是身份边界:
169.254.68.6;mvm_meta->ip,由 TAP ifindex 决定;snat_iplist 中的 SNAT IP 和动态端口;remote_port_mapping 中的宿主机端口。同一个包跨过不同边界时,会被 eBPF 改写成该边界应该看到的身份。
如果只看最终效果,CubeVS 像是在做“网络魔术”:每个 VM 都以为自己是 169.254.68.6,但代理能访问不同 sandbox,外部回包也能准确回到原来的 VM。拆开看,它其实只做了三件非常工程化的事:
mvmip_to_ifindex、ifindex_to_mvmmeta、session map 把“该去哪”变成 O(1) 查表。这也是为什么这套方案读起来会比普通 NAT 更“精妙”:它不是单纯把内网地址改成公网地址,而是把 VM 身份、sandbox 身份、节点出网身份拆成三层,并在恰当的内核 hook 上切换。
理解 CubeVS 的另一个好办法,是把它和更常见的虚拟化/容器网络方案放在一起比较。这里的“传统方案”并不是说它们落后,而是说它们更通用:Linux bridge、iptables、OVS、network namespace、用户态代理、CNI 插件都能解决大量场景。CubeVS 则明显更偏专用:它不是要做一套通用网络平面,而是针对 agent sandbox 的生命周期、隔离模型和性能要求,做了一条更短的内核态路径。
先看一张总览表:
| 方案 | 基本思路 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| Linux bridge + veth/TAP + iptables | VM/容器接入 bridge,通过 iptables 做 NAT 和策略 | 成熟、资料多、排障工具完善、协议覆盖广 | 规则数量容易膨胀,iptables 链路长;高频创建/销毁时状态管理复杂 | 通用容器网络、小规模 VM 网络 |
| OVS/Open vSwitch | 用可编程虚拟交换机管理转发、隧道、ACL | 功能强,支持复杂拓扑、流表和 overlay | 组件重,控制面复杂;对简单 sandbox 可能过度设计 | 多租户云网络、复杂 SDN |
| CNI + network namespace | 每个 workload 独立 netns,CNI 负责连通和策略 | Kubernetes 生态成熟,插件选择多 | VM 内部仍要维护独立网络配置;对非容器 VM sandbox 集成成本更高 | Kubernetes 容器工作负载 |
| 用户态 proxy/NAT | 包或连接进入用户态进程,由代理转发 | 逻辑直观,开发调试简单,协议层可见性强 | 每连接/每包可能多一次上下文切换;吞吐和延迟更难压低 | 低并发、强 L7 逻辑、快速原型 |
| CubeVS/eBPF TC | 在 TAP、宿主机网卡、cube-dev 上用 eBPF 查表、改包、重定向 | 快路径短;VM 内部配置统一;逐 sandbox 策略在内核态执行;规则不堆到 iptables | eBPF 调试门槛高;协议支持要自己写;依赖内核能力;代码可读性低于用户态 | 高密度、短生命周期、强隔离的 VM agent sandbox |
传统 VM 网络很容易想到 bridge:每个 TAP 接到一个 Linux bridge,再配合 iptables 做 SNAT、DNAT 和安全策略。这种方案最大的优势是成熟。tcpdump、bridge、ip route、conntrack、iptables-save 都是运维人员熟悉的工具,遇到问题时也容易逐层定位。
但它和 CubeSandbox 的目标有一个天然冲突:agent sandbox 往往生命周期短、数量多、策略差异大。如果每个 sandbox 都要生成或更新一批 iptables 规则,规则链会变长,创建和销毁时还要小心清理状态。性能问题未必来自某一条规则,而是来自大量规则叠加后的匹配路径和控制面维护成本。
CubeVS 的优势是把规则表达成 BPF map 查找。出站策略是 allow_out / deny_out 的 hash-of-maps,NAT 会话是 egress_sessions / ingress_sessions,SNAT 地址池是 snat_iplist。新增 sandbox 时主要是写 map 和挂 TAP TC filter,而不是改一串全局 iptables 链。对高频创建/销毁的沙箱来说,这种局部化状态更舒服。
它的代价也很明显:iptables 方案的大量能力由内核网络栈和 conntrack 免费提供,而 CubeVS 需要自己实现 TCP/UDP/ICMP 的会话追踪、超时和 checksum 更新。也就是说,CubeVS 把复杂度从“系统规则编排”转移到了“自研 eBPF 数据面”。
OVS 是更强大的虚拟交换机。它适合复杂拓扑、多租户 overlay、流表控制、隧道封装、ACL 和 SDN 控制器集成。如果目标是构建通用云网络,OVS 的能力边界明显更宽。
CubeVS 则选择了更窄的边界:它不做通用二层交换,不做复杂流表协议,也不试图承载任意拓扑。它只关心几条路径:VM 出站、外部回包、overlay/proxy 到 VM、端口映射。这种“只做必要路径”的结果是组件更轻,数据面逻辑更贴近 sandbox 需求。
所以两者不是谁替代谁。OVS 像一套完整的虚拟网络平台;CubeVS 更像一个为 CubeSandbox 定制的内核态边界设备。如果系统未来需要复杂多节点 overlay、租户间东西向网络、可编程流表或和现有 SDN 对接,OVS 的通用性会更有优势。但在当前的 agent sandbox 场景里,CubeVS 的专用性反而减少了不必要的层次。
容器网络常用 network namespace 隔离每个 workload 的网络栈,再通过 CNI 插件接入主机网络。这对容器非常自然,因为容器共享宿主机内核,netns 就是 Linux 原生隔离边界。
CubeSandbox 的 workload 是基于 cloud-hypervisor 的 VM。VM 里面有自己的内核和网络栈,宿主机上的 network namespace 并不能像容器那样直接成为 VM 内部网络命名空间。你仍然需要 TAP 作为 VM 和宿主机之间的边界。
CubeVS 的关键取舍是:既然 TAP 是天然边界,那就把身份注入点放在 TAP ingress。VM 内部不用为每个 sandbox 生成独立 IP 配置,宿主机也不用为每个 VM 建一套复杂网络 namespace。TAP ifindex 加 BPF map 就能完成身份识别。
缺点是它不直接继承 CNI 生态。很多 Kubernetes 网络插件、NetworkPolicy 语义、可观测工具不能无缝套用。CubeVS 要自己提供策略 API、map 可观测性和排障路径。
用户态代理是最容易理解的方案:VM 流量出来后交给一个进程,进程根据 sandbox ID、端口和策略决定转发到哪里。它的优势是开发体验好,日志和指标容易做,甚至可以直接在 L7 解析 HTTP、WebSocket 等协议。
但如果代理在每条连接甚至每个包的路径上,就会引入额外上下文切换和数据拷贝风险。对于大量 agent 并发执行代码、拉包、访问 API 的场景,这条路径的成本会持续放大。
CubeVS 刚好反过来:L3/L4 的高频动作全部在 eBPF 里完成,用户态只做初始化、策略更新和会话回收。这样快路径很短,但调试难度更高。用户态代理里一条日志就能说明“我为什么拒绝这个连接”;eBPF 里要看 map、看 verifier 限制、看 TC attach 点、看 checksum 和 redirect 行为,排障门槛明显更高。
CubeVS 最适合的,是当前项目这种“高密度、短生命周期、强隔离、网络模型相对固定”的 sandbox 场景:
169.254.68.6/169.254.68.5 配置,镜像构建和启动流程更简单。allow_out / deny_out 以 TAP ifindex 为外层 key,每个 sandbox 的策略可以独立维护。cube-dev、宿主机网卡这些自然边界上。也正因为 CubeVS 是专用数据面,它的缺点需要被明确认识:
from_world 直接挂到主硬件网卡 ingress,所有入站包都会触发一次 TC eBPF。对性能敏感的节点,最好用专用虚拟设备或独立网卡承载 CubeVS 回包路径。因此,CubeVS 不是“传统网络虚拟化方案的全面替代”,而是一个很明确的工程判断:当场景足够聚焦,且每个包的快路径成本非常重要时,把虚拟交换、NAT 和策略压进 eBPF,值得承担更高的数据面实现复杂度。
agent sandbox 的网络需求和普通容器有一些差异:
CubeVS 的设计刚好围绕这些点展开:
换句话说,CubeVS 的核心取舍是:把高频、重复、必须很快的动作放进 eBPF;把低频、管理型、可以慢一点的动作留给 Go 控制面。
高频动作包括:
低频动作包括:
这个分工很适合 agent sandbox:agent 执行任务时可能产生大量网络请求,但创建、销毁和策略变更相对没那么频繁。快路径越短,单机承载更多 sandbox 时越稳。
CubeSandbox 的网络层本质上是在宿主机内核里实现了一台专为 VM sandbox 定制的虚拟交换机。它不追求通用二层交换能力,而是把 sandbox 场景里真正需要的几件事做得很直接:
cube-dev 上把 overlay/proxy 流量送回正确 TAP;如果要给没有网络背景的读者留一个最短总结,可以是:
CubeVS 让 VM 内部永远保持同一张简单网络;包一出 VM,宿主机 eBPF 就根据 TAP 识别真实 sandbox,并在不同网络边界上改写成对应身份。
这套架构的精妙之处在于,它让 VM 内部网络保持极度一致,同时又让宿主机侧、overlay/proxy 侧看到不同的、可编排的 sandbox 网络身份;真正出公网时,则再统一落到 SNAT 地址池。对 agent sandbox 来说,这比把复杂度塞进 VM 镜像或用户态代理里更干净,也更接近“基础设施层应该做的事”。