eBPF增强Nginx负载均衡

Nginx 负载均衡基准测试

1
2
3
4
5
6
7
8
9
/ # wrk -c100 "http://172.17.0.5"
Running 10s test @ http://172.17.0.5
  2 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    12.31ms   12.66ms  84.49ms   82.31%
    Req/Sec     5.71k     4.96k   12.98k    63.00%
  113583 requests in 10.04s, 17.87MB read
Requests/sec:  11313.86
Transfer/sec:      1.78MB

Latency(延迟)

  • Avg 12.31ms:平均每个请求的响应时间为 12.31 毫秒
  • Stdev 12.66ms:标准差说明响应时间波动较大
  • Max 84.49ms:最长请求花了 84.49 毫秒
  • 82.31% +/- Stdev:82% 的请求延迟在 ±1 标准差范围内(说明大部分请求分布较广)

Req/Sec(每秒请求数)

  • Avg 5.71k:平均每个线程每秒发出约 5710 个请求
  • Stdev 4.96k:标准差说明不同时间点吞吐波动较大
  • Max 12.98k:单个线程的最高瞬时请求速率为 12.98k/s
  • 63.00% +/- Stdev:约 63% 的样本在这个范围内

接下来我们利用套接字 eBPF 程序优化

理解 Nginx 负载均衡的本质

Nginx 作为反向代理或负载均衡器,本质上是:

  1. 从客户端接收请求(通过 accept() 建立 TCP 连接)
  2. 选择一个上游后端(upstream server)
  3. 在用户态发起一个新的 TCP 连接 到该后端
  4. 在两个 socket 之间中转数据(前端 <-> 后端)

关键点:Nginx 的负载均衡策略(如 round robin、ip_hash)在用户态决定;而实际的数据流是两个独立的 socket,中间的数据转发也在用户态进行(效率上有损)。

当我们引入 eBPF(extended Berkeley Packet Filter) 时,目标往往是:

  • 把负载均衡逻辑下放到内核态
  • 减少 Nginx 用户态转发开销
  • 实现更快的流量分发(零拷贝、低延迟)

例如:

  • 用 eBPF 程序在 XDPTC ingress hook 阶段直接选择后端;
  • 或者使用 sockmap/sockhash 在内核中直接“转发”两个 socket 之间的数据。

使用 socket 映射转发网络包

socket 映射(socket map)是 eBPF 中的一种 BPF map 类型,它可以在内核中保存 socket 的引用(即 TCP 连接对象), 从而允许 eBPF 程序直接操作和转发这些连接上的数据。它有两种形式:

类型名称特点
BPF_MAP_TYPE_SOCKMAPsockmap数组结构,按索引存 socket
BPF_MAP_TYPE_SOCKHASHsockhash哈希结构,可按 key 查找 socket

可以理解为:sockmap/sockhash = 一个“连接路由表”,存放 <key, socket> 映射关系。 让 eBPF 程序在内核中知道“哪个 key 对应哪个 socket”


传统用户态转发的性能瓶颈,以 Nginx 为例:

1
client socket → Nginx 用户态 → upstream socket

数据流需要经过:

  • 2 次系统调用(recv + send)
  • 2 次上下文切换(内核↔用户态)
  • 1 次数据拷贝(内核缓冲区↔用户缓冲区)

当并发量很高(比如 wrk 10k QPS)时,这个过程就成为瓶颈。

于是 Linux 引入了 socket 映射 + SK_MSG eBPF 程序可以直接在内核中完成转发:

  1. 把所有连接(client 和 upstream)的 socket 引用注册进 sockmap;
  2. eBPF 程序在发送路径中(BPF_PROG_TYPE_SK_MSG)执行;
  3. 程序从 sockmap 中查找目标 socket;
  4. 内核直接把数据从一个 socket 发送到另一个 socket —— 零拷贝转发

在 eBPF 优化体系中:

  • 控制面(Control Plane):决定怎么转发(谁转发给谁) → 用户态(Nginx)
  • 数据面(Data Plane):实际执行转发 → 内核态(eBPF)

socket 映射就是两者的“桥梁”:Nginx 负责把 socket 注册进 sockmap(告诉内核:client_fd 对应 upstream_fd),eBPF 程序负责从 sockmap 中查找目标并完成转发。

这种模式的好处是:

  • eBPF 不需要自己维护连接;
  • 用户态应用可以动态调整映射;
  • 内核可以高性能地完成数据转发。

数据流简化示意图如下:

  1. 传统 Nginx 转发
1
2
3
4
5
[Client Socket]
   ↓ recv()
[用户态缓冲区]
   ↓ send()
[Upstream Socket]
  1. eBPF + socket 映射优化后
1
2
3
4
5
[Client Socket]
   ↓  eBPF 程序 (SK_MSG)
   ↘︎ 查找 sockmap[key]
   [Upstream Socket]

整个转发在内核中完成,无需进入 Nginx 用户态。


那优化的步骤如下:

  1. 创建套接字映射;
  2. BPF_PROG_TYPE_SOCK_OPS 类型的 eBPF 程序中,将新创建的套接字存入套接字映射中;
  3. 在流解析类的 eBPF 程序 (如 BPF_PROG_TYPE_SK_SKBBPF_PROG_TYPE_SK_MSG ) 中,从套接字映射中提取套接字信息,并调用 BPF 辅助函数转发网络包;
  4. 加载并挂载 eBPF 程序到套接字事件。

socket 映射

这里使用 socket 映射(socket map)中的一种 BPF Map 类型:BPF_MAP_TYPE_SOCKHASH,它的值总是套接字文件描述符,而键则需要我们去定义。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
typedef struct {
    __u32 source_ip;
    __u32 dest_ip;
    __u16 source_port;
    __u16 dest_port;
    __u32 protocol;
} SockKey;

struct {
    __uint(type, BPF_MAP_TYPE_SOCKHASH);
    __uint(key_size, sizeof(SockKey));
    __uint(value_size, sizeof(int));
    __uint(max_entries, 65535);
    __uint(map_flags, 0);
} sock_ops_map SEC(".maps");

BPF_PROG_TYPE_SOCK_OPS内核套接字操作回调类型的 eBPF 程序,它能在 TCP 连接的生命周期事件被触发时执行,包括:

  • BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB:被动连接建立(如服务器 accept)
  • BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB:主动连接建立(如客户端 connect)
  • BPF_SOCK_OPS_TCP_CLOSE_CB:连接关闭
  • BPF_SOCK_OPS_STATE_CB:状态变化

也就是说,它可以跟踪 TCP socket 的整个生命周期,在连接建立/关闭时获取 socket 的五元组信息,并把 socket 存入 sockhash,从而让 内核态 eBPF 数据面程序(如 SK_MSG)可以直接高效地查找和转发 socket 数据

需要注意的是,BPF_PROG_TYPE_SOCK_OPS 程序跟踪了所有类型的套接字操作,我们只需要把新创建的套接字更新到映射中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
SEC("sockops")
int bpf_sockmap(struct bpf_sock_ops* sockops) {
    // 只处理 IPv4 连接
    if (sockops->family != AF_INET) {
        return BPF_OK;
    }

    // 只处理已建立的连接
    if (sockops->op != BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB &&
        sockops->op != BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB) {
        return BPF_OK;
    }

    SockKey key = {
        .dest_ip = sockops->remote_ip4,
        .source_ip = sockops->local_ip4,
        .dest_port = sockops->remote_port,
        .source_port = bpf_htonl(sockops->local_port),
        .protocol = sockops->family,
    };

    bpf_sock_hash_update(sockops, &sock_ops_map, &key, BPF_NOEXIST);
    return BPF_OK;
}

转发 socket

转发 socket 可以使用 BPF_PROG_TYPE_SK_MSG 类型的 eBPF 程序,它在内核中的定义是这样的:

1
2
BPF_PROG_TYPE(BPF_PROG_TYPE_SK_MSG, sk_msg,
	      struct sk_msg_md, struct sk_msg)

捕获 socket 中的发送数据包,并根据之前的 socket 映射进行转发:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
SEC("sk_msg")
int bpf_sock_redirect(struct sk_msg_md* msg) {
    SockKey key = {
        .source_ip = msg->remote_ip4,
        .dest_ip = msg->local_ip4,
        .source_port = msg->remote_port,
        .dest_port = bpf_htonl(msg->local_port),
        .protocol = msg->family,
    };

    bpf_msg_redirect_hash(msg, &sock_ops_map, &key, BPF_F_INGRESS);
    return SK_PASS;
}

测试结果

1
2
3
4
5
6
7
8
9
/ # wrk -c100 "http://172.17.0.5"
Running 10s test @ http://172.17.0.5
  2 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     7.88ms    6.22ms  55.43ms   68.53%
    Req/Sec     7.03k     4.99k   15.03k    69.00%
  140073 requests in 10.05s, 22.04MB read
Requests/sec:  13940.22
Transfer/sec:      2.19MB
  • 吞吐提升 ≈ 23.2%
  • 平均延迟下降 ≈ 36.0%
使用 Hugo 构建
主题 StackJimmy 设计