4

[译] Socket listen 多地址需求与 SK_LOOKUP BPF 的诞生(LPC, 2019)

 1 year ago
source link: http://arthurchiao.art/blog/birth-of-sk-lookup-bpf-zh/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

[译] Socket listen 多地址需求与 SK_LOOKUP BPF 的诞生(LPC, 2019)

Published at 2022-12-11 | Last Update 2022-12-11

本文组合翻译 Cloudflare 的几篇分享,介绍了他们面临的独特网络需求、解决方案的演进, 以及终极解决方案 SK_LOOKUP BPF 的诞生:

由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。

以下是译文。


1.1 现状:Cloudflare 边缘架构

Cloudflare 的边缘服务器里运行着大量程序,不仅包括很多内部应用, 还包括很多公网服务(public facing services),例如:

这些应用通过横跨 100+ 网段100 万个 Anycast 公网 IPv4 地址 提供服务。为了保持一致性,Cloudflare 的每台服务器都运行所有服务, 每台机器都能处理每个 Anycast 地址的请求,这样能充分利用服务器硬件资源, 在所有服务器之间做请求的负载均衡,如下图所示,

edge_data_center.png

之前的分享中已经介绍了 Cloudflare 的边缘网络架构,感兴趣可移步:

1.2 需求:如何让一个服务监听至少几百个 IP 地址

如何能让一个服务监听在至少几百个 IP 地址上,而且能确保内核网络栈稳定运行呢?

这是 Cloudflare 工程师过去几年一直在思考的问题,其答案也在驱动着我们的网络不断演进。 特别是,它让我们更有创造性地去使用 Berkeley sockets API, 这是一个给应用分配 IP 和 port 的 POSIX 标准。

下面我们来回顾一下这趟奇妙的旅程。

2 场景需求与解决方案演进

2.1 简单场景:一个 socket 监听一个 IP 地址

mapping_1_to_1.png

Fig. 最简单场景:一个 socket 绑定一个 IP:Port

这是最简单的场景,(ip,port) 与 service 一一对应。

  • Service 监听在某个已知的 IP:Port 提供服务;
  • Service 如果要支持多种协议类型(TCP、UDP),则需要为每种协议类型打开一个 socket

例如,我们的权威 DNS 服务会分别针对 TCP 和 UDP 创建一个 socket:

    (192.0.2.1, 53/tcp) -> ("auth-dns", pid=1001, fd=3)
    (192.0.2.1, 53/udp) -> ("auth-dns", pid=1001, fd=4)

但是,对于 Cloudflare 的规模来说,我们需要在至少 4K 个 IP 上分别创建 socket,才能满足业务需求,也就是说 DNS 这个服务需要监听一个至少 /20 的网段。

mapping_many_1_to_1.png

Fig. 为了支撑业务规模,需要为 DNS 这样的服务在至少 4000 个 IP 地址上创建 socket

如果用 ss 之类的工具看,就会看到非常长的 socket 列表:

$ sudo ss -ulpn 'sport = 53'
State  Recv-Q Send-Q  Local Address:Port Peer Address:Port
...
UNCONN 0      0           192.0.2.40:53        0.0.0.0:*    users:(("auth-dns",pid=77556,fd=11076))
UNCONN 0      0           192.0.2.39:53        0.0.0.0:*    users:(("auth-dns",pid=77556,fd=11075))
UNCONN 0      0           192.0.2.38:53        0.0.0.0:*    users:(("auth-dns",pid=77556,fd=11074))
UNCONN 0      0           192.0.2.37:53        0.0.0.0:*    users:(("auth-dns",pid=77556,fd=11073))
UNCONN 0      0           192.0.2.36:53        0.0.0.0:*    users:(("auth-dns",pid=77556,fd=11072))
UNCONN 0      0           192.0.2.31:53        0.0.0.0:*    users:(("auth-dns",pid=77556,fd=11071))
...

显然,这种方式非常原始和粗暴;不过也有它的优点:当其中一个 IP 遭受 UDP 泛洪攻击时, 其他 IP 不受影响。

2.2 进阶场景:一个 socket 监听多个 IP 地址

以上做法显然太过粗糙,一个服务就使用这么多 IP 地址。但更大的问题是: 监听的 socket 越多,hash table 中的 chain 就越长,socket lookup 过程就越慢。 我们在 The revenge of the listening sockets 中经历了这一问题。那么,有没有更好的办法解决这个问题呢?有。

2.2.1 bind(INADDR_ANY)bind(0.0.0.0)

Socket API 中有个叫 INADDR_ANY 的东西,能让我们 避免以上那种 one-ip-per-socket 方式,如下图所示:

mapping_inaddr_any.png

Fig. INADDR_ANY socket:监听这台机器上所有 IP 地址的某个端口

当指定一个 socket 监听在 INADDR_ANY0.0.0.0 时, 这个 socket 会监听这台机器上的所有 IP 地址, 此时只需要提供一个 listen port 就行了:

    s = socket(AF_INET, SOCK_STREAM, 0)
    s.bind(("0.0.0.0", 12345))
    s.listen(16)

除此之外,还有没有其他 bind 所有 local 地址的方式?有,但不是使用 bind()

2.2.2 listen() unbound socket

直接在一个 unbound socket 上调用 listen(), 其效果等同于 INADDR_ANY,但监听在哪个 port 是由内核自动分配的

看个例子:首先用 nc -l 创建一个 listening socket,用 strace 跟踪其中的几个系统调用,

$ strace -e socket,bind,listen nc -l
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
listen(3, 1)                            = 0
^Z
[1]+  Stopped                 strace -e socket,bind,listen nc -l

然后查看我们创建的 socket:

$ ss -4tlnp
State      Recv-Q Send-Q Local Address:Port               Peer Address:Port
LISTEN     0      1            *:42669

可以看到 listen 地址是 *:42669, 其中

  • * 表示监听这个主机上所有 IP 地址,与 INADDR_ANY 等价;
  • 42669 就是内核为我们分配的 port。

2.2.3 技术原理:内核 socket lookup 逻辑

这里介绍下内核中的 socket lookup 逻辑,也就是当 TCP 层收到一个包时, 如何判断这个包属于哪个 socket。逻辑其实非常简单: 两阶段, 先精确匹配,再模糊匹配:

tcp_socket_lookup.png

Fig. 内核 socket lookup 逻辑:判断一个包应该送到哪个 socket

  1. 首先是 (src_ip,src_port,dst_ip,dst_port) 4-tuple 精确匹配,看能不能找到 connected 状态的 socket;如果找不到,
  2. 再尝试 (dst_ip,dst_port) 2-tuple,寻找有没有 listening 状态的 socket;如果还是没找到,
  3. 再尝试 (INADDR_ANY) 1-tuple,寻找有没有 listening 状态的 socket

2.2.4 优缺点比较

如果我们给每个服务器都分配一个 IP 段(例如 /20),那通过以上两种方式, 我们都能实现一次 socket 调用就监听在整个 IP 段。 而且 INADDR_ANY 的好处是无需关心服务器的 IP 地址,加减 IP 地址不需要重新配置服务。

但另一方面,缺点也比较多:

  1. 不是每个服务都需要 4000 个地址,因此 0.0.0.0 是浪费的, 可能还会不小心将一些重要内部服务暴露到公网;
  2. 安全方面:任何一个 IP 被攻击,都有可能导致这个 socket 的 receive queue 被打爆

    这是因为现在一台机器上只有一个 socket,监听在 4000 个地址上,攻击可能会命中任何一个 IP 地址。 这种情况下 TCP 还有办法应对, 但 UDP 就麻烦很多,需要特别关注,否则非法流量泛洪很容易将 socket 打挂。

  3. INADDR_ANY 使用的 port 是全局独占的,一个 socket 使用了之后, 这台机器上的其他 socket 就无法使用了。

    常见的是 bind() 时报 EADDRINUSE 错误:

     bind(3, {sa_family=AF_INET, sin_port=htons(12345), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
     bind(4, {sa_family=AF_INET, sin_port=htons(12345), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EADDRINUSE (Address already in use)
    

    除非是 UDP-only 应用,否则设置 SO_REUSEADDR 也没用。

2.3 魔鬼场景:同一台机器上不同 service 使用同一个 port(IP 不重叠)

这就是上面提到的 “不是每个服务都需要 4000 个地址” 场景:如果两个服务使用了不同的一组 IP, 那它们使用相同的端口,也应该没有问题 —— 这正是 Cloudflare 的现实需求。

边缘服务器中确实存在多个服务使用相同的端口号,但监听在不同的 IP 地址段。 典型的例子

  1. 1.1.1.1:recursive DNS resolver
  2. 权威 DNS 服务:提供给所有客户使用

这两个服务总是相伴运行的。但不幸的是, Sockets API 不支持同一主机上的多个服务使用不同的 IP 段,而共享相同的端口号。 好在 Linux 的开发历史表明,任何不支持的功能,都能通过一个新的 socket option 来支持(现在已经有 60 多个 options)。

因此,2016 年我们内部引入了 SO_BINDTOPREFIX,能 listen 一个 (ipnetwork, port)

mapping_bindtoprefix.png

但这个功能不够通用,内核社区不接受,我们只能内部维护 patch。

2.4 地狱场景:一个 service 监听所有 65535 个端口

前面讨论的都是一个 socket 监听在多个 IP,但同一个 port。虽然有点怪,但常人还能理解。

  • —— 听说过一个 socket 同时监听在多个 port 吗?
  • —— 多个是多少个?
  • —— 整个目的端口空间(16bit dst port),65535 个
  • —— 好家伙!

这也是 Cloudflare 的现实需求。这个产品是个反向代理(reverse proxy),叫 Spectrum

bind() 系统调用显然并未考虑这种需求,在将一个给定的 socket 关联到一个 port 时,

  • 要么自己指定一个 port;
  • 要么让系统网络栈给你分一个;

我们不禁开个脑洞:能不能和 INADDR_ANY 类似,搞个 INPORT_ANY 之类的东西来选中所有 ports 呢?

2.4.1 iptables + TPROXY

利用一个叫 TPROXYnetfilter/iptables extension是能做到的,在 forward path 上拦截流量,

$ iptables -t mangle -I PREROUTING \
         -d 192.0.2.0/24 -p tcp \
         -j TPROXY --on-ip=127.0.0.1 --on-port=1234

更多信息见 How we built spectrum

mapping_tproxy.png

Fig. TPROXY 拦截不同端口的流量,透明转发到本机最终 socket

2.4.2 TPROXY 方案缺点

TPROXY 方案是有代价的:

首先,服务需要特殊权限才能创建支持 TPROXY 功能的 socket,见 IP_TRANSPARENT

其次,需要深入理解 TPROXY 和流量路径之间的交互,例如

  1. TPROXY 重定向的 flow,会不会被 connection tracking 记录?
  2. is listening socket contention during a SYN flood when using TPROXY a concern?
  3. 网络组其他部分,例如 XDP programs,是否需要感知 TPROXY 重定向包这件事情?

虽然我们把这个方案最终推到了生产,但不得不说,这种方式太 hack 了,很难 hold 住。

2.4.3 有没有银弹?

上面提到的 TPROXY 方案虽然 hack,但传达出一个极其重要的思想

不管一个 socket 监听在哪个 IP、哪个 port,我们都能通过在更底层的网络栈上“做手脚”,将任意连接、任意 socket 的包引导给它。 socket 之上的应用对此是无感的。

先理解这句话,再往下走。

意识到这一点是相当重要的,这意味着只需要一些 TPROXY(或其他)规则,我们就可以 完全掌控和调度 (ip,port) 和 socket 之间的映射关系。这里我们所说的 socket 是在本机内的 socket。

而要更好地调度这种映射关系,就轮到 BPF 出场了

BPF is absolutely the way to go here, as it allows for whatever user specified tweaks, like a list of destination subnetwork, or/and a list of source network, or the date/time of the day, or port knocking without netfilter, or … you name it.

—— Suggestions from the kernel community

3 SK_LOOKUP BPF:对 socket lookup 过程进行编程

3.1 设计思想

想法很简单:编写一段 BPF 程序来决定如何将一个包映射到一个 socket —— 不管这个 socket 监听在哪个地址和端口。

idea_program_socket_lookup_with_bpf.png

Fig. 通过自定义 BPF 程序将数据包送到期望的 socket

如上图例子所示,

  • 所有目的地址是 192.0.2.0/24 :53 的包,都转发给 sk:2
  • 所有目的地址是 203.0.113.1 :* 的包,都转发给 sk:4

3.2 引入新的 BPF 程序类型 SK_LOOKUP

要实现这个效果,就需要一个新的 BPF 程序类型

3.2.1 程序执行位置

bpf_inet_lookup_hook.png
  • 在收包路径上给一个包寻找(lookup)合适的 socket,因此叫 SK_LOOKUP;
  • 位置是在包到达 socket 的 rxq 之前

3.2.2 工作原理

前面提到过 Linux 内核的两阶段 socket lookup 过程

  1. 先用 4-tuple 查找有没有 connected 状态 socket;如果没有,
  2. 再用 2-tuple 查找有没有 listening 状态的 socket。

SK_LOOKUP 就是对上面第二个过程进行编程,也就是查找 listening socket 过程。

tcp_socket_lookup_with_bpf.png
  • 如果 BPF 程序找到了 socket(s),就选择一个合适的 socket,然后终止内核 lookup 过程(HIT);
  • BPF 也可以忽略某些包,不做处理,这些包继续走内核原来的逻辑。

具体 BPF 信息:

3.3 BPF 程序示例

Pidfd and Socket-lookup (SK_LOOKUP) BPF Illustrated (2022)

3.4 Demo

3.4.1 效果:单个 socket 同时监听 4 个端口

单个 TCP socket 同时监听在 7, 77, 777, 7777 四个端口。

3.4.2 创建服务端 echo server

两个工具:

  • ncat:Concatenate and redirect sockets
  • nc: arbitrary TCP and UDP connections and listens
NAME
       ncat - Concatenate and redirect sockets

SYNOPSIS
       ncat [OPTIONS...] [hostname] [port]

OPTIONS SUMMARY
             -4                         Use IPv4 only
             -e, --exec <command>       Executes the given command
             -l, --listen               Bind and listen for incoming connections
             -k, --keep-open            Accept multiple connections in listen mode
             ...

注意在有的发行版上,可能是用 nc 命令,二者的大部分参数都是一样的:

NAME
     nc — arbitrary TCP and UDP connections and listens

如果你的环境上 nc 支持 -e 选项,那可以直接用 nc 即可。 不过实际测试发现在 Ubuntu 20.04 上, nc 不支持 -e 参数。因此我们这里用 ncat

$ sudo apt install ncat

现在创建 server

$ ncat -4lke $(which cat) 127.0.0.1 7777

查看 listening socket 信息:

$ ss -4tlpn sport = 7777
State    Recv-Q   Send-Q  Local Address:Port   Peer Address:Port      Process
LISTEN   0        1       127.0.0.1:7777       0.0.0.0:*              users:(("nc",pid=91994,fd=3))

3.4.3 客户端访问测试

$ nc 127.0.0.1 7777
hello
hello
^C

3.4.4 编译、加载 BPF 程序

Pidfd and Socket-lookup (SK_LOOKUP) BPF Illustrated (2022)

加载完 BPF 程序之后,测试:

$ echo 'Steer'    | timeout 1 nc -4 127.0.0.1 7;   \
  echo 'on'       | timeout 1 nc -4 127.0.0.1 77;  \
  echo 'multiple' | timeout 1 nc -4 127.0.0.1 777; \
  echo 'ports'    | timeout 1 nc -4 127.0.0.1 7777
Steer
on
multiple
ports

本文整理了 Cloudflare 三篇文章,介绍了他们面临的独特需求、解决方案的演进,以及 终极解决方案 SK_LOOKUP BPF 的诞生。对资深网络工程师和网络架构师有较大参考价值。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK