为什么通过eBPF可以提高性能

压平规则

这里的核心原理是:kube-proxy 的 iptables 模式把 Service 转发规则编译成一串 netfilter 规则链,而 Traffic Manager 把 Service 查找变成一次 eBPF Map 查询。当 Service 数量变大时,这两种查找路径的增长方式不一样。

在 kube-proxy 的 iptables 模式下,每个 Service、每个端口、每个 Endpoint 都会生成对应的 iptables 规则。客户端访问一个 ClusterIP 时,数据包会进入内核网络协议栈,然后经过 netfilter 的 PREROUTING/OUTPUT 等链,沿着一组规则逐条匹配:这个目标 IP 是不是某个 Service?端口是不是匹配?协议是不是匹配?命中了以后再跳到对应的 Service chain,然后根据概率规则选择某个 Endpoint,最后做 DNAT。

问题在于,iptables 的匹配本质上更接近“规则链顺序扫描”。集群里的 Service 越多,规则越多,包在找到目标 Service 前可能要经过更多匹配判断。即使每条规则判断很快,数量上来以后,累计成本会变明显。尤其是短连接场景,每个新连接都要重新走这段路径,所以开销会被放大。

Traffic Manager 的做法不一样。它在 socket 层的 connect4 hook 拦截应用的 connect() 调用,拿到目标地址,也就是 Service 的 ClusterIP 和端口,然后直接用这个 (VIP, Port, Proto) 作为 key 去 eBPF Hash Map 里查 Service 元信息。查到之后,再根据后端数量和调度策略选择一个 backend,把 socket 目标地址直接改成 Pod IP 和 Pod Port。

所以它绕过了两部分成本:

  1. 绕过 iptables/netfilter 规则链匹配
    1. 不再让连接请求在大量 Service 规则里逐条判断,而是直接用 key 查 Map。
  2. 更早地改写目标地址
    1. 它在应用发起 connect() 的时候就把目标 VIP 改成真实 Pod IP,后续包天然就是发往后端 Pod,不需要每个包都依赖复杂的 DNAT 路径。

简单说,iptables 像是在一本很长的规则表里从头往后找“这个 Service 是谁”;eBPF Map 像是拿着 Service IP 和端口直接查哈希表。Service 少的时候,两者差距可能不大;Service 多的时候,规则链扫描成本增长,而 Hash Map 查找成本基本稳定,所以 eBPF 的相对优势就会变大。

这也是为什么脚本里有 --rule-bloat 这个测试:它会创建很多 dummy Service,人为把 kube-proxy 的规则数量撑大。这样做不是为了“作弊”,而是在模拟真实大集群里 Service 数量很多时 kube-proxy iptables 规则膨胀的情况。这个场景越明显,Traffic Manager 的设计优势越容易体现出来。

还有一个关键点是这个项目测的是 短连接压测。短连接里大量请求都会反复触发连接建立路径,connect() 和首次路由/NAT 的成本占比更高。Traffic Manager 正好把 Service 选择前移到 connect() 阶段,并且用 eBPF Map 直接完成后端选择,所以在 Siege 这种大量短连接的 benchmark 里提升会比较明显。

当然,这里也要诚实地说,性能更高不是无条件成立。它取决于场景:

  • 如果 Service 数量很少,iptables 规则不多,收益可能有限。
  • 如果业务是长连接,连接建立成本被摊薄,QPS 提升可能没短连接明显。
  • 如果 eBPF 里加了很复杂的权重计算、状态维护或 Map 查询过多,也会吃掉一部分收益。
  • 如果 kube-proxy 使用 IPVS 或 nftables,和 iptables 模式相比差距也会变化。
  • 如果瓶颈在应用本身、Pod 网络、CPU 或后端服务,而不是 Service 转发路径,那么绕过 iptables 也不会带来很大提升。

所以我会把这个结论表述得更准确一点:

在 kube-proxy iptables 模式、Service 数量较多、短连接较多的场景下,把 Service 规则链匹配替换成 eBPF Hash Map 查找,可以减少连接建立路径上的匹配和 NAT 开销,因此性能更高,而且 Service 规模越大,相对收益越明显。

为什么能跳过 kube-proxy 的 Service DNAT 规则

这个项目把 eBPF 程序挂到了 socket connect4 钩子上,在应用真正发包、进入 kube-proxy/iptables 转发路径之前,就把目标地址从 Service VIP 改成了 Pod IP,所以后续流量看起来根本不是访问 Service,自然也就不会命中 kube-proxy 的 Service DNAT 规则。

正常 kube-proxy iptables 模式大概是这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
应用 connect(Service ClusterIP:80)
        |
        v
内核网络栈
        |
        v
netfilter / iptables 规则
        |
        v
kube-proxy 维护的 Service 规则命中
        |
        v
DNAT: Service VIP:80 -> PodIP:PodPort
        |
        v
发往真实 Pod

也就是说,kube-proxy 本身不直接转发每个包,它主要负责把 Kubernetes Service/Endpoint 信息同步成 iptables/IPVS/nftables 规则。真正转发时,包经过内核网络栈,由这些规则做 DNAT。

而这个项目的路径是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
应用 connect(Service ClusterIP:80)
        |
        v
eBPF cgroup/connect4 hook
        |
        v
查 eBPF Map: Service VIP:80 -> backend PodIP:PodPort
        |
        v
直接改写 socket 目标地址
        |
        v
应用这次连接实际变成 connect(PodIP:PodPort)
        |
        v
发往真实 Pod

关键点在于 hook 的位置更早

cgroup/connect4 这个 eBPF hook 发生在进程调用 connect() 的时候。你的 eBPF 程序能通过 bpf_sock_addr 看到原始目标地址,也就是应用想连的 ClusterIP:Port。如果它发现这个地址是一个 Kubernetes Service,就直接调用类似逻辑把 ctx->user_ip4ctx->user_port 改成后端 Pod 的 IP 和端口。

项目里的 eBPF 程序就是在这里做的:

1
2
ctx_set_dst_ip(ctx, backend.address);
ctx_set_dst_port(ctx, backend.port);

所以原本应用以为自己连的是:

1
10.96.x.x:80  // Service ClusterIP

但经过 eBPF hook 后,内核后续处理的目标已经变成:

1
10.244.x.x:8080  // 某个后端 Pod

这时候 kube-proxy 的规则通常是匹配 Service ClusterIP 的,比如:

1
-d 10.96.x.x --dport 80

但目标地址已经不是 10.96.x.x:80 了,所以这些 Service 规则就不会命中。换句话说,不是 eBPF 把 kube-proxy 禁用了,而是它把流量提前改道了,让流量不再走 kube-proxy 管的那条 Service VIP 路径。

这也是为什么这个项目需要自己维护 eBPF Map。因为跳过 kube-proxy 后,谁来知道 Service 后面有哪些 Pod?答案就是 Traffic Manager 的 Go 控制器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Kubernetes Service / EndpointSlice
        |
        v
Informer 监听变化
        |
        v
Controller 计算后端列表
        |
        v
写入 eBPF Map
        |
        v
connect4 程序查 Map 并改写目标地址

所以它本质上是在做一个 kube-proxy 的替代数据面:

  • kube-proxy:监听 Service/Endpoint,然后写 iptables/IPVS 规则。
  • Traffic Manager:监听 Service/EndpointSlice,然后写 eBPF Map。
  • eBPF 程序:在 socket 层查 Map,直接改写目标地址。

但这里也要注意几个边界:

  1. 只能跳过被这个 eBPF 程序处理到的流量 你的项目挂的是 connect4,主要处理本机进程发起的 IPv4 TCP connect 路径。没有经过这个 hook 的流量,不会被它改写。

  2. kube-proxy 仍然可以存在 它没有被卸载,也没有失效。只是这部分流量提前被改成 PodIP,所以不再命中 Service VIP 规则。

  3. 如果 eBPF Map 里没有这个 Service,还是会走原路径 你的代码里如果查不到 Service,就返回 miss,不做改写。那目标仍然是 ClusterIP,后面还是可能由 kube-proxy 处理。

  4. 这不是通用意义上跳过所有 Kubernetes 网络路径 它跳过的是 Service VIP 到后端 Pod 的 kube-proxy 负载均衡/DNAT 逻辑。后续 Pod 网络、CNI 路由、veth、bridge/overlay 等路径还是要走。

一句话总结就是:

因为 eBPF 挂在 connect4 这个比 kube-proxy Service DNAT 更早的位置,提前把 Service ClusterIP 改成了真实 Pod IP。后续内核看到的目的地址已经不是 Service VIP,所以 kube-proxy 的 Service 规则没有机会命中,于是这条流量就绕过了 kube-proxy。

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计