云原生网络性能优化:service mesh篇
背景
众所周知,Service Mesh的总体架构如下图所示,主要由控制平面(control plane)和数据平面(data plane)两部分组成。
在数据平面中,如上图红框所示,两个服务Pod(这里我们沿用Kubernetes Pod的叫法,后续一律简称为Pod)间的数据交换就是Service Mesh中最基本的通信场景。如果我们将其放大来看,其典型架构如下图所示:
客户端Pod和服务端Pod(客户端和服务端是相对的叫法,这里为了方便描述)都含有一个sidecar用来做代理。其中Node X和Node Y可以是同一个节点。一次request/responce的基本流程如下:
客户端Pod内所有对外部发出的流量①都会被同Pod下的sidecar拦截并代理发送流量②到服务端,在这里是即Server Pod。
Server Pod中的sidecar会将所有入站流量拦截,并代理发送流量③到最终的Server Container。
Server Pod中由Server Container接收到来自客户端的请求后发送响应数据到客户端,即Server Container发出的响应流量④会被同Pod下的sidecar拦截并代理发送流量⑤到Client Pod
Client Pod中的Sidecar会将入站流量拦截并代理发送流量⑥到Client Container
熟悉代理服务的同学都知道,在传统实现中,代理需要从一个socket中将客户端的报文读出来(每一次读取意味着从内核态到用户态的一次数据拷贝),然后通过另一个socket将数据发送给目标设备(每一次发送意味着从用户态到内核态的一次数据拷贝)。这个过程涉及到两次系统调用(sys_read/sys_write)和两次用户态/内核态之间的数据拷贝。很显然,这个代理转发行为会非常消耗性能。
所以在上述Service Mesh中两个Pod间的数据交换服务中,由于sidecar代理的存在会导致Service Mesh中数据交换存在很明显的性能瓶颈。
代理服务性能瓶颈已有解决方案
如上所述,加速socket之间的数据转发过程是解决代理服务性能瓶颈的一个重点。
在sockmap提出之前,对加速socket之间数据转发过程已经有很多解决方案被提出,但是这些方案都或多或少存在一些的致命的缺陷。
sendfile
sendfile用于加速两个文件描述符之间的数据传输,避免了用户态/内核态之间的数据拷贝操作,但是该系统调用不支持socket到socket的数据转发。
splice
splice实现了在文件描述符之间直接进行数据拷贝操作,避免了用户态/内核态之间的数据拷贝操作,因此该系统调用可以用来做socket之间的数据转发加速。但是使用splice依然需要唤醒用户态程序,每转发一份数据依然需要在唤醒的进程上下文中进行两次splice系统调用。
io_submit
io_submit是为了异步提交 IO 任务而设计的接口。对于socket之间转发数据这个场景,尽管io_submit无法避免用户态/内核态之间的数据拷贝,但是通过该接口进行socket数据的批处理操作可以减少上下文切换操作以及减少系统调用。
sockmap的提出
在上述背景下,Linux内核在4.14版本引入了一个基于eBPF的新特性sockmap,该特性允许将TCP连接之间的数据转发过程卸载到内核中,从而绕过复杂的Linux网络协议栈直接在内核完成socket之间的数据转发操作,减少了上下文切换以及用户态和内核态之间的数据拷贝操作,极大的优化了TCP连接之间socket数据转发的性能。
本质上sockmap只是一种BPF map类型,该类型map的值代表一个指向数据结构struct sock的引用。然后我们就可以使用BPF程序来通过该类型的map完成socket间的数据流的重定向,使得数据流无需经过复杂的内核网络协议栈,也不需要进行用户态/内核态的数据拷贝,并且也不需要多余的系统调用。
下面我们回想下传统实现方式下socket间数据的转发过程。
用户态进程先从socket中读取数据(内核中的sk_data_ready回调函数是内核协议栈和进程上下文的之间的数据读取通道接口,当用户态进程有数据可以从socket读取时,sk_data_ready唤醒用户态进程并把数据从内核协议栈转交给了持有socket的用户态进程,这意味着一次读取操作会有一次上下文切换和一次内核态到用户态的数据拷贝过程),然后用户态进程将刚才读取到的数据再写入到另一个socket里(内核中的sk_write_space回调函数时进程上下文和内核协议栈之间的数据写入通道接口,当用户态进程向socket写入数据时,sk_write_space唤醒内核态进程并把数据从用户态进程转交到内核协议栈,这意味着一次读取操作会有一次上下文切换和一次用户态到内核态的数据拷贝过程)。
// net/core/sock.c
void sock_def_readable(struct sock *sk)
{
struct socket_wq *wq;
rcu_read_lock();
wq = rcu_dereference(sk->sk_wq);
if (skwq_has_sleeper(wq))
// 同步唤醒进程,监听可读的事件
wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN | EPOLLPRI |
EPOLLRDNORM | EPOLLRDBAND);
sk_wake_async(sk, SOCK_WAKE_WAITD, POLL_IN);
rcu_read_unlock();
}
static void sock_def_write_space(struct sock *sk)
{
struct socket_wq *wq;
rcu_read_lock();
if ((refcount_read(&sk->sk_wmem_alloc) << 1) <= READ_ONCE(sk->sk_sndbuf)) {
wq = rcu_dereference(sk->sk_wq);
if (skwq_has_sleeper(wq))
// 同步唤醒进程,监听可写的事件
wake_up_interruptible_sync_poll(&wq->wait, EPOLLOUT |
EPOLLWRNORM | EPOLLWRBAND);
/* Should agree with poll, otherwise some programs break */
if (sock_writeable(sk))
sk_wake_async(sk, SOCK_WAKE_SPACE, POLL_OUT);
}
rcu_read_unlock();
}
void sock_init_data(struct socket *sock, struct sock *sk)
{
...
sk->sk_data_ready = sock_def_readable;
sk->sk_write_space = sock_def_write_space;
...
}
现在来看sockmap的处理过程。
sockmap直接替换了sk_data_ready和sk_write_space回调函数的实现,通过一种叫Stream Parser的机制将从内核协议栈收取到的数据包的控制权转移到eBPF处理程序,eBPF处理程序通过bpf_sk_redirect_map将收到的socket数据包重定向到指定的socket中最后经过内核协议栈将数据包发送出去。前面我们说了sockmap本质上是一个BPF的map,bpf_sk_redirect_map做的事很简单,就是去这个map里查询我们要用来发送数据的socket对应的struct sock*数据结构存不存在,如果找到了,那么就把数据重定向给该socket。这就完成了socket间的数据转发。甚至你用来接受数据的socket和用来发送数据的socket可以是同一个socket,也就是说你可以从一个socket接受数据然后通过sockmap再把收取到的数据通过同一个socket再发送出去。
// net/core/sk_msg.c
void sk_psock_start_strp(struct sock *sk, struct sk_psock *psock)
{
struct sk_psock_parser *parser = &psock->parser;
if (parser->enabled)
return;
parser->saved_data_ready = sk->sk_data_ready;
// 替换回调函数
sk->sk_data_ready = sk_psock_strp_data_ready;
sk->sk_write_space = sk_psock_write_space;
parser->enabled = true;
}
可以看到,使用sockmap来进行socket间的数据转发不会存在用户态/内核态之间的数据拷贝的开销,也不会存在唤醒用户态进程进行上下文切换的开销。相比与上述的所有方案都更轻便更完美并且提供更好的转发性能。
使用sockmap加速Service Mesh
尽管Cilium在其1.4版本已经开始使用sockmap这一特性来加速同Node下Pod和L7 Proxy之间的通信,以及同Node下普通Pod到普通Pod间的通信,但是根据我们对Cilium(v1.7版本)的测试来看,该功能无法正常工作且存在以下问题:
1. 缺少对Service Mesh场景下Pod内部的普通容器和sidecar之间通信加速的支持;
2. Cilium用来控制sockmap加速的基于label的Policy对Service Mesh场景下的sidecar拦截规则不友好;
因此我们团队基于Cilium已有的sockmap实现开发并支持了Service Mesh场景下的Pod内部容器和sidecar的通信(也就是加速了上图2中的①③④⑥流量),同时在控制面上支持通过Network Policy控制Service Mesh的sockmap加速。
性能评估
我们在阿里云上购买了2台型号为ecs.ebmg5s.24xlarge的神龙服务器,内核版本号为5.4.10,开启numa, 其他(如网卡/磁盘等)均为默认配置。
场景一:本机直接加速
在同一台服务器内,使用netperf和iperf工具部署服务端和客户端,直接在本机内测试使用sockmap和不使用sockmap两种情况下的带宽和时延,其中netperf工具用于测试时延,iperf用于测试带宽。
考虑到NUMA亲和性的问题,分设两组测试:
服务端和客户端同NUMA node
bandwidth(Gbits/s) | mean latency(us) | P99 latency(us) | |
---|---|---|---|
without sockmap | 42.3 | 12.34 | 14 |
with sockmap | 65.2 | 8.28 | 9 |
比例 | 54.1% | 32.9% | 36% |
服务端和客户端不同NUMA node
bandwidth(Gbits/s) | mean latency(us) | P99 latency(us) | |
---|---|---|---|
without sockmap | 25.2 | 16.161 | 19.5 |
with sockmap | 33.5 | 11.264 | 13.5 |
提升比例 | 32.9% | 30.3% | 30.7% |
从以上数据可以看出,在本机直接加速的场景下:
1. sockmap对带宽有很大提升,客户端和服务端在同一NUMA节点上,带宽提升大约在50%-60%之间。即使是跨NUMA节点的情况下,也有约33%的提升;
2. sockmap显著降低时延,降低大约30%-40%之间;
3. NUMA亲和性对带宽和时延影响很大,跨NUMA的情况下,性能数据都明显比同NUMA的情况低。
场景二:service mesh下fortio测试
本测试使用fortio工具在QPS固定为2000以及server playload为16k的前提下,测试不同并发度在以下三种数据路径的RT结果。
直连(baseline)
位于A服务器的客户端直接访问位于B服务器的服务端envoy代理
分别在客户端和服务端所在机器上部署一个envoy代理,劫持来自80端口的流量envoy + sockmap
在上述envoy代理的情况下,在两台机器上都使用sockmap加速envoy代理到客户端/服务端的流量
从以上数据可以看出:
1. server mesh中添加代理(比如envoy)会成倍提高RT值,并且随着并发度的增长进行指数级的增长;
2. sockmap对由于代理而带来的RT值增长有一定的改善, 提升比例大概在10~15%之间。
参考
https://www.servicemesher.com/istio-handbook
往期精彩回顾: