本文代码基于 Merbridge HEAD c16cc43 展开。
简介
Merbridge 是基于 eBPF 实现的一套可用于服务网格中流量拦截与高性能转发的方案,其支持多种服务网格项目(Istio、Kuma、Linkerd 等)适配,本文只以 Istio Sidecar 模式为例展开。
具体来讲(以 Istio Sidecar 模式为例),下图为原始流量路径:

在使用 Merbridge 后,可有效减少业务数据包与内核网络交互的次数,服务间的网络数据路径就只剩下代理之间的了。

甚至,若两个 Pod 位于同一个 Node 之上,它们之间的网络数据路径还能更加简洁。

组成
Merbridge 以 DaemonSet 方式运行在集群中,其运行启动时会:
- 首先加载(Load)所有 eBPF 程序
- 其次启动 Controller
- 最后关联(Attach)所有 eBPF 程序
eBPF 程序清单
其中,无论是加载(Load)还是关联(Attach)eBPF 程序,Merbridge 都是以直接执行 bpftool 命令的方法进行的,所有的 eBPF 程序都会被挂载到 /sys/fs/bpf 路径下。
Merbridge 共操作以下几种 eBPF 程序:
| name | mount path | attach to | attach type | attach prog |
|---|---|---|---|---|
| connect | /sys/fs/bpf/connect/connect |
cgroup2 | connect4/6 | /sys/fs/bpf/connect/connect/cgroup_connect 4/6 |
| sockops | /sys/fs/bpf/connect/sockops |
cgroup2 | sock_ops | /sys/fs/bpf/connect/sockops |
| get_sockopts | /sys/fs/bpf/connect/get_sockopts |
cgroup2 | getsockopt | /sys/fs/bpf/connect/get_sockopts |
| redir | /sys/fs/bpf/connect/redir |
prog | msg_verdict | /sys/fs/bpf/connect/redir |
| bind | /sys/fs/bpf/connect/bind |
cgroup2 | bind4 | /sys/fs/bpf/connect/bind |
| sendmsg | /sys/fs/bpf/connect/sendmsg |
cgroup2 | sendmsg4/6 | /sys/fs/bpf/connect/sendmsg/cgroup_sendmsg 4/6 |
| recvmsg | /sys/fs/bpf/connect/recvmsg |
cgroup2 | recvmsg4/6 | /sys/fs/bpf/connect/recvmsg/cgroup_recvmsg 4/6 |
| mb_process | /sys/fs/bpf/connect/mb_process |
- | - | - |
除此之外,Merbridge 还创建了以下 bpf map:
| name | mount path | type | 注释 | used by |
|---|---|---|---|---|
| cookie_original_dst | /sys/fs/bpf/connect/cookie_original_dst |
lru_hash | socket cookie address 与流量原始目的地址的 1:1 映射 | connect|sockops|sendmsg|recvmsg |
| local_pod_ips | /sys/fs/bpf/connect/local_pod_ips |
hash | pod IP 与 podConfig 的 1:1 映射 |
connect |
| process_ip | /sys/fs/bpf/connect/process_ip |
lru_hash | process id 与 pod IP 的 1:1 映射 | connect|sockops |
| cgroup_info_map | /sys/fs/bpf/connect/cgroup_info_map |
lru_hash | cgroup id 与 cgroup info 的 1:1 映射 | connect|bind|sendmsg|recvmsg |
| mark_pod_ips_map | /sys/fs/bpf/connect/mark_pod_ips_map |
hash | connect|sendmsg|recvmsg | |
| settings | /sys/fs/bpf/connect/settings |
hash | connect|sockops|bind | |
| pair_original_dst | /sys/fs/bpf/connect/pair_original_dst |
lru_hash | 四元组与原始目的地址的 1:1 映射 | sockops|get_sockopts |
| sock_pair_map | /sys/fs/bpf/connect/sock_pair_map |
sockhash | sock 与四元组的 1:1 映射 | sockops|redir |
| process_events | /sys/fs/bpf/connect/process_events |
perf_event_array | mb_process |
Local IP Controller
Merbridge 启动的 Controller 名为 Local IP Controller,其本质上是一个包含了对 Pod 和 Namespace 资源监听的 Informer。
由于 Merbridge 以 DaemonSet 模式运行,故每个 Node 上的 Merbridge 只监听当前节点中所有 Pod 的资源变化。并在监听到 Istio 所管理的 Pod 资源变化时(具体来说就是被注入了 Sidecar)更新 local_pod_ips 这个 bpf map,其中 map 的 key 为 Pod IP,value 为 podConfig 结构体:
type podConfig struct {
statusPort uint16
_ uint16 // pad
excludeOutRanges [MaxItemLen]cidr ===> type cidr struct {
includeOutRanges [MaxItemLen]cidr net uint32 // network order
includeInPorts [MaxItemLen]uint16 mask uint8
includeOutPorts [MaxItemLen]uint16 _ [3]uint8 // pad
excludeInPorts [MaxItemLen]uint16 }
excludeOutPorts [MaxItemLen]uint16
}
const MaxItemLen = 20
这些结构体字段记录的信息同 Istio Resource Annotations。在 Controller 的实现中,它们都是通过解析 Pod 的 anntations 获取的。
工作方式
若无特别说明,本部分只关注 IPv4 协议的网络。
回忆在 Istio 中 Sidecar 拦截流量是通过 iptables 的手段,将应用向外部的流量被 iptables 的 OUTPUT 拦截,转发至 Sidecar 的 15001 端口;外部向应用的流量则是被 iptables 中的 PREROUTING 拦截,转发至 Sidecar 的 15006 端口。
Istio 使用 iptables 的 DNAT 功能做流量转发,Merbridge 则使用 eBPF 实现,为了能够达到 iptables DNAT 能力的效果,需要:
- 修改连接发起时的目的地址,让流量能够发送至新的端口
- 让 Envoy 能够识别流量原始的目的地址
出口流量处理
本节以 TCP 连接为例,介绍从应用容器(App)到 Sidecar Envoy 的 15001 端口连接建立的过程。

对于从应用容器的出口流量,需要将其重定向到 Sidecar Envoy 的 15001 端口(即 127.0.0.1:15001)。
1. 在应用向外发起连接时,connect eBPF 程序会将目的地址修改为 127.x.y.z:15001 ,并使用 cookie_original_dst map 保存流量原始的目的地址。不修改目的地址为 127.0.0.1 的原因是:避免不同 Pod 中产生冲突的四元组信息。
static __u32 outip = 1;
static inline int tcp_connect4(struct bpf_sock_addr *ctx)
{
// 通过从 cgroup_info_map 中获取的 cgroup_info 来判断是否为服务网格中 Pod 的流量
// ...
__u32 curr_pod_ip;
__u32 _curr_pod_ip[4];
set_ipv6(_curr_pod_ip, cg_info.cgroup_ip);
curr_pod_ip = get_ipv4(_curr_pod_ip);
__u64 uid = bpf_get_current_uid_gid() & 0xffffffff;
__u32 dst_ip = ctx->user_ip4;
if (uid != SIDECAR_USER_ID) { // 1337 是 Istio 为 sidecar 预留的 Application UIDs
// 忽略目的地址为 127 开头的本地流量
if ((dst_ip & 0xff) == 0x7f) {
return 1;
}
__u64 cookie = bpf_get_socket_cookie_addr(ctx);
// 即将重定向流量至 Envoy,此处把重定向之前真正要发往的目的地信息记录下来,即原始目的地址
struct origin_info origin;
memset(&origin, 0, sizeof(origin));
set_ipv4(origin.ip, dst_ip);
origin.port = ctx->user_port;
origin.flags = 1;
if (bpf_map_update_elem(&cookie_original_dst, &cookie, &origin, BPF_ANY)) {
return 0;
}
if (curr_pod_ip) {
struct pod_config *pod = bpf_map_lookup_elem(&local_pod_ips, _curr_pod_ip);
if (pod) {
/* 根据各种 Exclude/Include Out Ports/Ranges 信息来判断是否还进一步向后执行;
podConfig 中的各种 Pod 配置信息是经由 Local IP Controller 获取的。
*/
// ...
}
// 对于存在 Pod IP 的情况,将与 ctx 关联的 socket 绑定到 Pod 的 IP 地址上
struct sockaddr_in addr = {
.sin_addr =
{
.s_addr = curr_pod_ip,
},
.sin_port = 0, // 端口由内核随机指定一个未被使用的
.sin_family = 2, // aka. AF_INET
};
bpf_bind(ctx, &addr, sizeof(struct sockaddr_in))
ctx->user_ip4 = localhost; // 修改数据包目的地址
} else {
// 对于无法获取 Pod IP 的情况,目的地址为自定义地址
// The reason we try the IP of the 127.128.0.0/20 segment instead of
// using 127.0.0.1 directly is to avoid conflicts between the
// quaternions of different Pods when the quaternions are
// subsequently processed.
ctx->user_ip4 = bpf_htonl(0x7f800000 | (outip++)); // 修改数据包目的地址
if (outip >> 20) {
outip = 1;
}
}
ctx->user_port = bpf_htons(OUT_REDIRECT_PORT); // 修改数据包目的端口,即 sidecar 的 15001 端口
}
// ...
return 1;
}
2. 在应用的 Socket 侧,当执行到 sockops eBPF 程序时,其会将当前 socket 和四元组保存在 sock_pair_map map 中,同时将四元组和对应流量的原始目的地址写入 pair_original_dst map 中。
static inline int sockops_ipv4(struct bpf_sock_ops *skops)
{
__u64 cookie = bpf_get_socket_cookie_ops(skops);
struct pair p;
set_ipv4(p.sip, skops->local_ip4);
p.sport = bpf_htons(skops->local_port);
set_ipv4(p.dip, skops->remote_ip4); // 在应用侧 socket,拿到的目的地址和端口已经是发往 envoy 15001 的地址和端口
p.dport = skops->remote_port >> 16;
struct origin_info *dst =
bpf_map_lookup_elem(&cookie_original_dst, &cookie);
if (dst) {
struct origin_info dd = *dst;
// ...
bpf_map_update_elem(&pair_original_dst, &p, &dd, BPF_ANY);
bpf_sock_hash_update(skops, &sock_pair_map, &p, BPF_NOEXIST); // key 为四元组
} else if (skops->local_port == OUT_REDIRECT_PORT ||
skops->local_port == IN_REDIRECT_PORT ||
skops->remote_ip4 == envoy_ip) {
// 在 envoy 侧 socket,同样将其 socket 与对应的四元组写入 map
bpf_sock_hash_update(skops, &sock_pair_map, &p, BPF_NOEXIST);
}
// ...
return 0;
}
值得注意的是,该段程序由于挂载在 sockops 挂载点,故会有多次执行,根据不同的执行侧可以分为:处理应用侧 socket 和 envoy 侧的 socket。
当在 Sidecar envoy 侧执行时,四元组的原地址和原端口对应 envoy:15001,目的地址和目的端口对应于应用。envoy 侧的 socket 对应于 cookie_original_dst map 中不存在任何原始地址信息,故会落入上述程序的第二段 if 语句,即只更新 sock_pair_map ,保存当前四元组与 envoy 侧 socket 的映射关系,便于后期转发流量时使用。
3. Envoy 接受到应有连接之后会调用 get_sockopts eBPF 程序获取当前连接的目的地址,该程序会依据四元组信息从 pair_original_dast map 中获取原始目的地址并保存。至此,出口向流量的连接建立完毕。
__section("cgroup/getsockopt") int mb_get_sockopt(struct bpf_sockopt *ctx)
{
// ...
struct pair p;
memset(&p, 0, sizeof(p));
p.dport = bpf_htons(ctx->sk->src_port); // 15001 端口,作为四元组的目的端口,顺序交互是为了能通过四元组查找出原始地址信息
p.sport = ctx->sk->dst_port;
struct origin_info *origin;
switch (ctx->sk->family) {
case 2: // ipv4
set_ipv4(p.dip, ctx->sk->src_ip4); // envoy 地址,作为四元组的目的地址
set_ipv4(p.sip, ctx->sk->dst_ip4);
// 四元组准备完毕
// 根据四元组获取上一步中保存的原始目的地址
origin = bpf_map_lookup_elem(&pair_original_dst, &p);
if (origin) {
// 重写当前 socket
ctx->optlen = (__s32)sizeof(struct sockaddr_in);
if ((void *)((struct sockaddr_in *)ctx->optval + 1) > ctx->optval_end) {
return 1;
}
ctx->retval = 0;
struct sockaddr_in sa = {
.sin_family = ctx->sk->family,
.sin_addr.s_addr = get_ipv4(origin->ip),
.sin_port = origin->port,
};
*(struct sockaddr_in *)ctx->optval = sa; // 写入请求选项的 buffer
}
break;
case 10: // ipv6
// ...
}
return 1;
}
4. 在发送数据阶段,redir eBPF 程序会根据四元组信息,从 sock_pair_map 中读取到 Sidecar envoy 的 socket,并通过 bpf_msg_redirect_hash 直接对流量进行转发。
__section("sk_msg") int mb_msg_redir(struct sk_msg_md *msg)
{
struct pair p;
memset(&p, 0, sizeof(p));
p.dport = bpf_htons(msg->local_port);
p.sport = msg->remote_port >> 16; // 目的端口 15001 作为四元组的原端口,为了获取四元组对应的 socket 信息
switch (msg->family) {
#if ENABLE_IPV4
case 2:
// ipv4
set_ipv4(p.dip, msg->local_ip4);
set_ipv4(p.sip, msg->remote_ip4);
break;
#endif
#if ENABLE_IPV6
case 10:
// ipv6 ...
#endif
}
long ret = bpf_msg_redirect_hash(msg, &sock_pair_map, &p, BPF_F_INGRESS);
return 1;
}
入口流量处理
入口流量的处理与出口流量类似,只需将目的地址的端口改为 15006 即可。

由于 eBPF 程序全局生效,对于不为 Istio 所管理的 Pod,就不允许外部流量向其建立连接。所以 Merbridge 维护了一个 local_pod_ips 的 map(通过 Local IP Controller 更新)。当 Merbridge 在做入口流量处理时,若目的地址不在该 map 中,则不做任何处理。
当外部流量抵达一个 Pod 时,只要其目的地址的 Pod 在当前 Node 所维护的 local_pod_ips 之中,并且不为当前处理 Pod 时,才需要将流量重定向到 Envoy 的 15006 端口。具体过程如下,主要还是修改流量的目的地址,并记录原始地址信息。其余的流程同出口流量处理,不再赘述。
static inline int tcp_connect4(struct bpf_sock_addr *ctx)
{
// ...
if (uid != SIDECAR_USER_ID) {
// 见上文
// ...
} else {
__u32 _dst_ip[4];
set_ipv4(_dst_ip, dst_ip);
struct pod_config *pod = bpf_map_lookup_elem(&local_pod_ips, _dst_ip);
// 若目的地址非本地 Node 中的 Pod IP,则跳过处理
if (!pod) {
return 1;
}
// 目的地址在本地,但并非当前 Pod
// 记录原始目的地址信息,以便后续修改数据包信息
struct origin_info origin;
memset(&origin, 0, sizeof(origin));
set_ipv4(origin.ip, dst_ip);
origin.port = ctx->user_port;
if (curr_pod_ip) {
// 对于目的地址非当前 Pod 的流量,需要重定向数据包端口
if (curr_pod_ip != dst_ip) {
/* 根据各种 Exclude/Include Out Ports 信息来判断是否还进一步向后执行;
podConfig 中的各种 Pod 配置信息是经由 Local IP Controller 获取的。
*/
// ...
ctx->user_port = bpf_htons(IN_REDIRECT_PORT); // 修改目的端口为 15006
}
origin.flags |= 1;
} else {
// 若 Pod IP 获取失败,则使用传统方式获取 Pod IP
__u32 pid = bpf_get_current_pid_tgid() >> 32; // tgid
void *curr_ip = bpf_map_lookup_elem(&process_ip, &pid);
if (curr_ip) {
if (*(__u32 *)curr_ip != dst_ip) {
ctx->user_port = bpf_htons(IN_REDIRECT_PORT); // 修改目的端口为 15006
}
origin.flags |= 1;
} else {
// 若 Pod IP 仍然获取失败,envoy 向自身 pod 发送了流量
origin.flags = 0;
origin.pid = pid;
ctx->user_port = bpf_htons(IN_REDIRECT_PORT); // 修改目的端口为 15006
}
}
__u64 cookie = bpf_get_socket_cookie_addr(ctx);
if (bpf_map_update_elem(&cookie_original_dst, &cookie, &origin, BPF_NOEXIST)) {
return 0;
}
}
return 1;
}
小结
作为一个使用 eBPF 替代 iptables,并且加速 Istio 流量路径的项目,其不会对 Istio 有任何侵略式的修改。在完全卸载 Merbridge 后,Istio 还能依然保持使用 iptables 作为流量的劫持手段。从使用 eBPF 替代 iptables DNAT 的能力来说,ORIGINAL_DST 概念是贯穿全文的核心,其本质上就是记录被拦截流量的原始目的地址。
Merbridge 项目的整体规模虽然不大,但是非常具备学习意义,可以作为一个很好理解 eBPF 工作机理的入手项目。