从 LB 节点负载均衡到特定主机
主机内:将流量负载均衡到不同 Socket
基于 XDP 的四层负载均衡器(L4LB) Katran[2],从 2017 年开始,每个进入 facebook.com 的包都是经过 XDP 处理的;
基于 XDP 的防火墙(挡在 Katran 前面)。
边界层(edge tiers),位于 PoP 点
数据中心层,我们称为 Origin DC
每层都有一套全功能 LB(L4 + L7)
Edge PoP 和 Origin DC 之间的 LB 通常是长链接
用户连接(user connections)在边界终结
Edge PoP LB 将 L7 流量路由到终端主机
Origin DC LB 再将 L7 流量路由到最终的应用,例如 HHVM 服务
宏观层面:LB 节点 -> 后端主机
微观层面(主机内):主机内核 -> 主机内的不同 Socket
BPF TCP header options[3]:解决主机外(宏观)负载均衡问题;
BPF_PROG_TYPE_SK_REUSEPORT(及相关 map 类型 BPF_MAP_TYPE_REUSEPORT_SOCKARRAY):解决主机内(微观)负载均衡问题。
实现了一个 Maglev Hash 变种,通过一致性哈希选择后端;
在一致性哈希之上,还维护了自己的一个本地缓存来跟踪连接。这个设计是为了在某些后端维护或故障时,避免其他后端的哈希发生变化,后面会详细讨论。
int pick_host(packet* pkt) {
if (is_in_local_cache(pkt))
return local_cache[pkt]
return consistent_hash(pkt) % server_ring
}
横轴表示 backend 挂掉的百分比
纵轴是哈希表项(entries)变化的百分比,对应受影响连接的百分比
对于短连接来说,例如典型的 HTTP 应用,这个问题可能影响不大;
但对于 TCP 长连接,例如持续几个小时的视频流,这种扰动就不能忍了。
int pick_host(packet* pkt) {
if (is_in_local_cache(pkt)) // 场景一:ECMP shuffle 时(例如 LB 节点维护或故障),这里会 miss
return local_cache[pkt]
return consistent_hash(pkt) % server_ring // 场景二:后端维护或故障时,这里的好像有(较小)概率发生变化
}
如果 LB 升级、维护或发生故障,会导致路由器 ECMP shuffle,那原来路由到某个 LB 节点的 flow,可能会被重新路由到另一台 LB 上;虽然我们维护了 cache,但它是 LB node local 的,因此会发生 cache miss;
如果后端节点升级、维护或发生故障,那么根据前面 maglev 容错性的实验结果,会有一 部分(虽然比例不是很大)的 flow 受到影响,导致路由错误。
在 server 端将路由信息(routing information)嵌入到 connection_id 字段,
要求客户端必须将这个信息带回来。
编写一段 BPF_PROG_TYPE_SOCK_OPS 类型的 BPF 程序,attach 到 cgroup:
在 LISTEN,CONNECT,CONN_ESTD 等事件时会触发 BPF 程序的执行
BPF 程序可以获取包的 TCP Header,然后往其中写入路由信息(这里是 server_id),或者从中读取路由信息
在 L4LB 侧维护一个 server_id 缓存,记录仍然存活的 backend 主机
1) 客户端发起一个 SYN;
2) L4LB 第一次见这条 flow,因此通过一致性哈希为它选择一台 backend 主机,然后将包转发过去;
3) 服务端应答 SYN+ACK,其中 服务端 BPF 程序将 server_id 嵌入到 TCP 头中;
图中这台主机获取到自己的 server_id 是 42,然后将这个值写到 TCP header;
客户端主机收到包后,会解析这个 id 并存下来,后面发包时都会带上这个 server_id;
4) 客户端流量将被(数据中心基础设施)转发到另一台 L4LB;
5) 这台新的 L4LB 解析客户端包的 TCP header,提取 server_id,查询 server_id 缓存(注意不是 Katran 的 node-local 连接缓存)之后发现这台机器还是 active 的,因此直接转发给这台机器。
struct tcp_opt {
uint8_t kind;
uint8_t len;
uint32_t server_id;
}; // 6-bytes total
switch (skops->op) {
case BPF_SOCK_OPS_TCP_LISTEN_CB:
case BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB:
case BPF_SOCK_OPS_TCP_CONNECT_CB:
case BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB:
case BPF_SOCK_OPS_PARSE_HDR_OPT_CB:
case BPF_SOCK_OPS_HDR_OPT_LEN_CB:
case BPF_SOCK_OPS_WRITE_HDR_OPT_CB:
. . .
}
对于建连包特殊处理
建连之后会维护有 flow 信息(例如连接跟踪)
对于建连成功后的普通流量,从 flow 信息就能直接映射到 server_id,不需要针对每个包去解析 TCP header。
对典型的数据中心内部访问比较有用;
要用于数据中心外的 TCP 客户端,就要让后者将带给它们的 server_id 再带回来,但这个基本做不到;即使它们带上了,网络中间处理节点(middleboxes)和防火墙(firewalls)也可能会将这些信息丢弃。
这是一个完全无状态的方案
额外开销(CPU/memory)非常小,基本感知不到
其他竞品方案都非常复杂,例如在 hosts 之间共享状态,或者将 server_id 嵌入到 ECR (Echo Reply) 时间戳字段。
发布前状态:Proxygen 实例上有一些老连接,也在不断接受新连接
拉出:拉出之后的实例不再接受新连接,但在一定时间窗口内,继续为老连接提供服务
这个窗口称为 graceful shutdown(也叫 draining) period,例如设置为 5 或 10 分钟;
拉出一般是通过将 downstream service 的健康监测置为 false 来实现的,例如在这个例子中,就是让 Proxygen 返回给 katran 的健康监测是失败的。
发布新代码:graceful 窗口段过了之后,不管它上面还有没有老连接,直接开始升级。
部署新代码,
关闭现有进程,创建一个新进程运行新代码。
一般来说,只要 graceful 时间段设置比较合适,一部分甚至全部老连接能够在这个 窗口内正常退出,从而不会引起用户可见的 spike;但另一方面,如果此时仍然有老连接,那这些客户端就会收到 TCP reset。
监听并接受新连接:升级之后的 Proxygen 开始正常工作, 最终达到和升级之前同等水平的一个连接状态。
发布过程中,系统容量会降低。从 graceful shutdown 开始,到新代码已经接入了正常量级的流量,这段时间内系统容量并没有达到系统资源所能支撑的最大值,例如三个 backend 本来最大能支撑 3N 个连接,那在升级其中一台的时间段内,系统能支撑的最大连接数就会小于 3N,在 2N~3N 之间。这也是为什么很多公司都避免在业务高峰(而是选择类似周日凌晨五点这样的时间点)做这种变更的原因之一。
发布周期太长。假设有 100 台机器,分成 100 个批次(phase),每次发布一台,如果 graceful time 是 10 分钟,一次发布就需要 1000 分钟,显然是不可接受的。本质上来说,这种方式扩展性太差,主机或实例数量一多效率就非常低了。
TCP socket 分为两部分:已接受的连接(编号 1~N)和监听新连接的 listening socket
UDP socket,bind 在 VIP 上
创建一个新实例
将 TCP listening socket 和 UDP VIP 迁移到新实例;老实例仍然 serving 现有 TCP 连接(1 ~ N),
新实例开始接受新连接(N+1 ~ +∞),包括新的 TCP 连接和新的 UDP 连接
老实例等待 drain
在发布期间不会导致系统容器降低,因为我们完全保留了老实例,另外创建了一个新实例
发布速度可以显著加快,因为此时可以并发发布多个实例
老连接被 reset 的概率可以大大降低,只要允许老实例有足够的 drain 窗口
TCP 的状态维护在内核。
UDP 协议 —— 尤其是维护连接状态的 UDP 协议,具体来说就是 QUIC —— 所有状态维护在应用层而非内核,因此内核完全没有 QUIC 的上下文。
TCP 是有一个独立线程负责接受连接,然后将新连接的文件描述符转给其他线程,这种机制在负载均衡器中非常典型,可以认为是在 socket 层做分发;
UDP 状态在应用层,因此内核只能在 packet 层做分发,负责监听 UDP 新连接的单个线性不但要处理新连接,还负责包的分发,显然会存在瓶颈和扩展性问题。
在内核中实现流量的无损切换,以便客户端完全无感知;
过程能做到快速和可扩展,不存在明显性能瓶颈。
理论上:只要我们能控制主机内包的路由过程(routing of the packets within a host),那以上需求就很容易满足了。
实现上:仍然基于 SO_REUSEPORT 思想,但同时解决 UDP 的一致性路由和瓶颈问题。
在 Socket 层 attach 一段 BPF 程序,控制 TCP/UDP 流量的转发(负载均衡);
通过一个 BPF map 维护配置信息,业务进程 ready 之后自己配置流量切换。
通用,能处理多种类型的协议。
在 VIP 层面,能更好地控制新进程(新实例)启动后的流量接入过程,例如:Proxygen 在启动时经常要做一些初始化操作,启动后做一些健康检测工作, 因此在真正开始干活之前还有一段并未 ready 接收请求/流量的窗口 —— 即使它此时已经 bind 到端口了。在新方案中,我们无需关心这些,应用层自己会判断新进程什么时候可以接受流量并通知 BPF 程序做流量切换;
性能方面,也解决了前面提到的 UDP 单线程瓶颈;
在包的路由(packet-level routing)方面,还支持根据 CPU 调整路由权重(adjust weight of traffic per-cpu)。例如在多租户环境中,CPU 的利用率可能并不均匀,可以根据自己的需要实现特定算法来调度,例如选择空闲的 CPU。
最后,未来迭代非常灵活,能支持多种新场景的实验,例如让每个收到包从 CPU 负责处理该包,或者 NUMA 相关的调度。
key:<VIP>:<Port>
value:Socket 的文件描述符,与业务进程一一对应
已经维护了 flow -> socket 映射
如果 flow 存在,就就转发到对应的 Socket;不存在在创建一个新映射,转发给新实例的 Socket。
一条是已发布的 server 百分比
另一个条是同一时间的丢包数量
控制组/对照组(左边):3x 流量时开始丢包
实验组(右边):30x,因此还没有到分发瓶颈但 CPU 已经用满了,但即使这样丢包仍然很少。
简化了运维流程,去掉脆弱和复杂的进程间通信(IPC),减少了故障;
效率大幅提升,例如 UDP 性能 10x;
可靠性提升,例如避免了 UDP misrouting 问题和 TCP 三次握手时的竞争问题。
bind("[::1]:443"); /* without SO_REUSEPORT. Succeed. */
bind("[::2]:443"); /* with SO_REUSEPORT. Succeed. */
bind("[::]:443"); /* with SO_REUSEPORT. Still Succeed */
// include/net/inet_hashtables.h
static inline struct sock *__inet_lookup(struct net *net,
struct inet_hashinfo *hashinfo,
struct sk_buff *skb, int doff,
const __be32 saddr, const __be16 sport,
const __be32 daddr, const __be16 dport,
const int dif, const int sdif,
bool *refcounted)
{
u16 hnum = ntohs(dport);
struct sock *sk;
// 查找是否有 ESTABLISHED 状态的连接
sk = __inet_lookup_established(net, hashinfo, saddr, sport, daddr, hnum, dif, sdif);
if (sk)
return sk;
// 查找是否有 LISTENING 状态的连接
return __inet_lookup_listener(net, hashinfo, skb, doff, saddr, sport, daddr, hnum, dif, sdif);
}
首先会查找是否有 ESTABLISHED 状态的 Socket,如果没有
再确认是否有 LISTENING 状态的 socket;这一步会查一下 listen hashtable,
它的 bucket 数量非常小,内核宏定义为 32[8],此外,
这个哈希表 只根据目的端口(dst_port)来做哈希,因此 IP 不同但 dst_port 相同的 Socket 都会哈希到同一个 bucket(在 Cloudflare 的场景中,有 16K entry 会命中同一个 bucket,形成一个非常长的链表)。
// net/ipv4/inet_hashtables.c
struct sock *__inet_lookup_listener(struct net *net,
struct inet_hashinfo *hashinfo,
struct sk_buff *skb, int doff,
const __be32 saddr, __be16 sport,
const __be32 daddr, const unsigned short hnum,
const int dif, const int sdif)
{
struct inet_listen_hashbucket *ilb2;
struct sock *result = NULL;
unsigned int hash2;
// 如果这里 attach 了 BPF 程序,直接让 BPF 程序来选择 socket
/* Lookup redirect from BPF */
if (static_branch_unlikely(&bpf_sk_lookup_enabled)) {
result = inet_lookup_run_bpf(net, hashinfo, skb, doff, saddr, sport, daddr, hnum);
if (result)
goto done;
}
// 没有 attach BPF 程序或 BPF 程序没找到 socket:fallback 到常规的内核查找 socket 逻辑
hash2 = ipv4_portaddr_hash(net, daddr, hnum);
ilb2 = inet_lhash2_bucket(hashinfo, hash2);
result = inet_lhash2_lookup(net, ilb2, skb, doff, saddr, sport, daddr, hnum, dif, sdif);
if (result)
goto done;
/* Lookup lhash2 with INADDR_ANY */
hash2 = ipv4_portaddr_hash(net, htonl(INADDR_ANY), hnum);
ilb2 = inet_lhash2_bucket(hashinfo, hash2);
result = inet_lhash2_lookup(net, ilb2, skb, doff, saddr, sport, htonl(INADDR_ANY), hnum, dif, sdif);
done:
if (IS_ERR(result))
return NULL;
return result;
}
This series proposes a new BPF program type named BPF_PROG_TYPE_SK_LOOKUP,
or BPF sk_lookup for short.
BPF sk_lookup program runs when transport layer is looking up a listening
socket for a new connection request (TCP), or when looking up an
unconnected socket for a packet (UDP).
This serves as a mechanism to overcome the limits of what bind() API allows
to express. Two use-cases driving this work are:
(1) steer packets destined to an IP range, fixed port to a single socket
192.0.2.0/24, port 80 -> NGINX socket
(2) steer packets destined to an IP address, any port to a single socket
198.51.100.1, any port -> L7 proxy socket
sk_select_reuseport 与 IP 地址所属的 socket family 是紧耦合的
sk_lookup 则将 IP 与 Socket 解耦 —— lets it pick any / netns
https://linuxplumbersconf.org/event/11/contributions/950/
https://engineering.fb.com/2018/05/22/open-source/open-sourcing-katran-a-scalable-network-load-balancer/
https://lwn.net/Articles/827672/
https://lwn.net/Articles/542629/
https://github.com/torvalds/linux/blob/v5.10/net/ipv4/inet_connection_sock.c#L376
https://lore.kernel.org/lkml/[email protected]/
https://blog.cloudflare.com/revenge-listening-sockets/
https://github.com/torvalds/linux/blob/v5.10/include/net/inet_hashtables.h#L122
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...