最近在哔哩哔哩,我们开发了一种改进的 BBR 拥塞控制算法,需要在真实环境中进行测试。该算法本身以内核模块的形式存在,因此将其安装到服务器上不是问题。然而,在快节奏的迭代过程中,我们遇到了一系列问题,最终发现了一个内核错误。本文将带您了解我们解决问题的整个过程,从拥塞控制算法热交换到内核错误修复。下方列出了本文所处的实验环境,可以帮助您复现实验。
实验环境
我们使用的 Linux 版本是 5.10。为了隔离测试环境,我们使用 ip netns 创建一个名为 ns 的网络命名空间,并创建一对 veth ve_o 和 ve_i 来运行 TCP 连接。
ip netns add ns
ip link add ve_o type veth peer name ve_i
ip link set ve_i netns ns
ip link set ve_o up
ip addr add dev ve_o 192.168.0.2/24
ip -n ns link set ve_i up
ip -n ns addr add dev ve_i 192.168.0.1/24
通过这样做,大多数情况下我们可以在 ns 命名空间中运行 ss 命令而无需指定任何过滤器。
第一个问题:
内核模块 (kmod) 加载和卸载
加载和使用 kmod 很简单:
加载模块
insmod tcp_bbr_bili.ko
使其成为默认的拥塞控制算法
sysctl -w net/ipv4/tcp_congestion_control=bbr_bili
借助 ss 的强大功能,我们可以看到拥塞控制算法的实际效果:
$ ip netns exec ns ss -npti
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 192.168.0.1:1000 192.168.0.2:50916 users:(("socat",pid=692883,fd=6))
bbr_bili ...
在上面的示例中,我们使用 socat 来模拟 TCP 连接,可以看到拥塞控制算法是 bbr_bili。
现在假设我们有了一个修复了一些错误的新版本算法,我们来加载它:
$ insmod tcp_bbr_bili.ko
insmod: ERROR: could not insert module tcp_bbr_bili.ko: File exists
糟糕,我们无法加载更新后的模块,因为它与旧模块同名。为了迭代算法,我们需要卸载旧模块并加载新模块。
rmmod tcp_bbr_bili
rmmod: ERROR: Module tcp_bbr_bili is in use
这是有道理的;某个进程正在使用该模块,所以我们无法卸载它。lsmod 也证实了该模块正在使用中:
lsmod | grep bili
tcp_bbr_bili 20480 2
在这种情况下,我们可以将拥塞控制算法更改为 cubic 或 bbr,等待使用 bbr_bili 的套接字关闭,然后卸载模块。或者我们可以用不同的名称重新编译模块,但这会很麻烦。由于我们迭代算法的速度比较快,等待套接字关闭不是一个好选择;重新编译模块会在内核中产生大量垃圾。我想知道是否有更好的方法可以在不等待或重新编译的情况下卸载模块? 有的兄弟,有的。
第二个问题:算法热交换和套接字窃取
有一种方法可以在不等待套接字关闭的情况下释放模块。我们可以使用 setsockopt 直接更改套接字的拥塞控制算法。
setsockopt(sockfd, IPPROTO_TCP, TCP_CONGESTION, "bbr_bili", strlen("bbr_bili"));
然而,这需要我们拥有该套接字才能执行 setsockopt 系统调用,而且我们无法修改每个使用该算法的程序来添加此代码。因此,我们需要一种方法从使用它的进程中“窃取”套接字。这就是 pidfd_getfd 发挥作用的地方。
不久前在浏览 Cloudflare 博客时,我遇到了一种称为“套接字窃取”的技术,它使用 pidfd_getfd 系统调用从另一个进程复制套接字。我将从演讲(https://www.usenix.org/system/files/srecon23emea-slides_sitnicki.pdf)中“窃取”一张幻灯片。该演讲本身是关于“SOCKMAP”的,与我们的主题无关,但我建议您阅读一下,了解一些 eBPF 的魔力。
如幻灯片所示,为了从另一个进程“窃取”(复制)套接字,我们需要目标进程的 PID 和套接字的文件描述符。幸运的是,我们可以从 ss 的 Process 列中获取所有这些信息:
$ ip netns exec ns ss -npt
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 192.168.0.1:1000 192.168.0.2:50916 users:(("socat",pid=692883,fd=6))
pid=692883 是进程的 PID,fd=6 是套接字的文件描述符。我们可以使用 pidfd_open 获取进程的 PIDFD,然后使用 pidfd_getfd 复制套接字。结合这些步骤,代码如下所示:
// 获取目标进程的 PIDFD
pidfd = syscall(SYS_pidfd_open, pid, 0);
// 复制套接字 fd
fd = syscall(SYS_pidfd_getfd, pidfd, targetfd, 0);
// 设置拥塞控制算法
setsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, "bbr_bili", strlen("bbr_bili"));
我们将其制作成一个小工具,名为 changeling,它接受 ./changeling <pid> <fd> <congestion_algorithm> 作为参数,并更改目标套接字的拥塞控制算法。代码可在 Github(https://github.com/kuroa-me/bilibili-blog) 上找到。让我们看看它的实际效果:
$ ./changeling 6928836 cubic
setsockopt success
$ ip netns exec ns ss -npti
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 192.168.0.1:1000 192.168.0.2:50916 users:(("socat",pid=692883,fd=6))
cubic ...
妙!我们成功更改了一个不属于我们的套接字的拥塞控制算法。现在,让我们将其编写成脚本,并在每个使用 bbr_bili 的套接字上调用它,然后就可以收工了。
等等,那是什么?一个没有进程的套接字?
加载模块
insmod tcp_bbr_bili.ko
使其成为默认的拥塞控制算法
sysctl -w net/ipv4/tcp_congestion_control=bbr_bili
0
第三个问题:孤立套接字
孤立套接字是“由系统持有但未附加到任何用户文件句柄的套接字”(LARTC:https://lartc.org/howto/lartc.kernel.obscure.html)。当进程退出并留下一个由于某种原因内核未清理的套接字时,可能会发生这种情况。我们在生产环境中只观察到少数此类孤立套接字。然而,即使只有一个孤立套接字也足以将模块的使用计数提高到 1,从而阻止我们卸载模块。
系统中的罪魁祸首是 TCP 窗口,它导致一些孤立套接字存活时间过长而成为问题。让我们一起看看这个问题,参考下面的 TCP 有限状态机(http://www.tcpipguide.com/free/t_TCPOperationalOverviewandtheTCPFiniteStateMachineF-2.htm)。
在 ESTABLISHED 状态下,用户进程可以调用 close() 来关闭套接字。然后内核会将一个 FIN 附加到套接字的发送队列,并将状态更改为 FIN-WAIT-1。然后内核将等待对等方 ACK 该 FIN。但是由于 FIN 位于发送队列的末尾,如果 TCP 窗口非常小或为零,则需要很长时间才能发送 FIN,从而阻止对等方 ACK 它,并使套接字停滞在 FIN-WAIT-1 状态。
上一节中的示例是通过使用 2 个 socat 命令模拟零窗口场景创建的。一个是“坏坏”服务器,在接受连接后不会从套接字读取任何数据。引自 socat 手册页(http://www.dest-unreach.org/socat/doc/socat.html):
加载模块
insmod tcp_bbr_bili.ko
使其成为默认的拥塞控制算法
sysctl -w net/ipv4/tcp_congestion_control=bbr_bili
1
另一个是客户端,它只是连接到服务器并不断从 /dev/zero 向服务器转储 0。
加载模块
insmod tcp_bbr_bili.ko
使其成为默认的拥塞控制算法
sysctl -w net/ipv4/tcp_congestion_control=bbr_bili
2
由于服务器没有在套接字上调用接收,因此接收队列 (Recv-Q) 没有被清空,从而阻止发送队列 (Send-Q) 清空,有效地模拟了零窗口 TCP 连接。几秒钟后,我们可以手动终止客户端进程,剩下的将是一个孤立的类零窗口套接字。
加载模块
insmod tcp_bbr_bili.ko
使其成为默认的拥塞控制算法
sysctl -w net/ipv4/tcp_congestion_control=bbr_bili
3
幸运的是,内核最终会超时并清理孤立套接字。(请注意上面输出中的 timer:(persist,1min9sec,0))。这主要由 tcp_orphan_retries sysctl (https://sysctl-explorer.net/net/ipv4/tcp_orphan_retries/)控制。如果我们不等待那么长时间怎么办?或者如果套接字是一个不会超时的近零窗口套接字怎么办?
ss 是一个不断带来惊喜的宝库。它有一个 -K 选项可用于终止套接字。
加载模块
insmod tcp_bbr_bili.ko
使其成为默认的拥塞控制算法
sysctl -w net/ipv4/tcp_congestion_control=bbr_bili
4
ss 向我们显示了它找到并成功终止的套接字。现在我们可以修改我们最初的脚本,在调用 changeling 之后对每个孤立套接字调用 ss -K,太棒了!
等等,为什么孤立套接字仍然存在?为什么在多次调用 ss -K 后它仍然存在?
加载模块
insmod tcp_bbr_bili.ko
使其成为默认的拥塞控制算法
sysctl -w net/ipv4/tcp_congestion_control=bbr_bili
5
第四个问题:
“套接字已死,套接字万岁!”
无法终止套接字是一个问题,但我必须专注于手头的任务,所以我决定给它一天时间让它超时。第二天,我回到办公室,发现套接字仍然存在。惊恐之下,我开始调查到底发生了什么。
起初,我以为这是 ss 中的一个 bug,并检查了 ss 实际是如何终止套接字的。代码位于 https://github.com/iproute2/iproute2/blob/main/misc/ss.c
加载模块
insmod tcp_bbr_bili.ko
使其成为默认的拥塞控制算法
sysctl -w net/ipv4/tcp_congestion_control=bbr_bili
6
从代码中我们可以看到 ss 正在使用 Netlink 公开的 SOCK_DIAG 基础结构。当调用 show_one_inet_sock 时,它将尝试通过发送带有 SOCK_DESTROY (kill_inet_sock) 的 nlmsg 来终止套接字。成功后,它将始终打印已终止套接字的信息,这与我们在上一节中看到的最后输出相匹配。也就是说,内核向 ss 确认它已经终止了套接字。现在我们需要查看内核代码以了解发生了什么。下面的函数按我跟踪整个过程的方式排序;更有经验的开发人员可能有更好的方法来执行此操作。(主要查看 IPv4 TCP 代码)。
加载模块
insmod tcp_bbr_bili.ko
使其成为默认的拥塞控制算法
sysctl -w net/ipv4/tcp_congestion_control=bbr_bili
7
这里的关键角色是 tcp_abort 和 tcp_done。它们负责在 TCP 的不同状态下关闭套接字;为简洁起见,我省略了不相关的代码。SOCK_DEAD 是一个重要的标志,它决定了代码的流向。要找出它在正在运行的机器中的值,我们可以使用 bpftrace(https://bpftrace.org/) 来打印 sock_flag 的值。
加载模块
insmod tcp_bbr_bili.ko
使其成为默认的拥塞控制算法
sysctl -w net/ipv4/tcp_congestion_control=bbr_bili
8
加载模块
insmod tcp_bbr_bili.ko
使其成为默认的拥塞控制算法
sysctl -w net/ipv4/tcp_congestion_control=bbr_bili
9
内核将 SOCK_DEAD 放在 enum sock_flags 的最低有效位,因此 0x301 表示设置了 SOCK_DEAD。我们可以尝试相应地遵循代码路径。 在 tcp_abort 中,由于设置了 SOCK_DEAD,它只会使用 tcp_write_queue_purge 清除队列,而不会通过调用 tcp_done 实际关闭套接字。这就解释了为什么在多次成功调用 ss -K 后套接字仍然存在。但是为什么套接字不会超时呢?
答案在于 tcp_timer.c 文件。
$ ip netns exec ns ss -npti
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 192.168.0.1:1000 192.168.0.2:50916 users:(("socat",pid=692883,fd=6))
bbr_bili ...
0
在这里,如果 packets_out 为 0,tcp_probe_timer 将提前返回,而不会检查计数器以决定是使套接字超时还是发送另一个探测。而我们的 tcp_write_queue_purge 恰好清除了 packets_out 计数器。因此,在当前计时器到期后,套接字将不会获得另一个计时器或超时,从而变得不朽。
$ ip netns exec ns ss -npti
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 192.168.0.1:1000 192.168.0.2:50916 users:(("socat",pid=692883,fd=6))
bbr_bili ...
1
如果我们仔细查看第 3 节的最后输出,我们可以看到 timer 确实在 ss 的输出中不复存在。
结束问题链
要修复此内核错误,我们只需在 tcp_abort 中删除 SOCK_DEAD 检查。此补丁已提交给内核并被接受,您可以在此处(https://patchwork.kernel.org/project/netdevbpf/patch/[email protected]/)找到更多详细信息。在开发补丁时,virtme-ng 是测试补丁的一个很好的工具,使用 virtme-ng 更快地进行内核测试(https://lwn.net/Articles/951313/)。
要点:
我们的 changeling 仍然可以用来更改 cc 算法或任何其他套接字选项,并且非常方便。
如果是没有打过补丁的内核,请不要在孤立套接字上使用 ss -K。
ss、bpftrace 和 virtme-ng 是调试内核问题的好工具。
感谢您的阅读;整个冒险从一个简单的 cc 交换工具开始,到内核错误修复结束。我希望您能学到一些可以玩的新工具。
附言:在此补丁被添加到最新的内核树之后,三星也在他们的测试中遇到了这个错误,并且是他们将该补丁向下移植到了 5.15 和 6.1。
This article is also available in English(https://github.com/kuroa-me/bilibili-blog).
-End-
作者丨Kuroame
开发者问答
Socket 还有什么不为人知的技巧吗?
欢迎在留言区分享你的见解~
转发本文至朋友圈并留言,即可参与下方抽奖⬇️
小编将抽取1位幸运的小伙伴获取小电视鼠标垫键盘垫
抽奖截止时间:6月27日12:00
如果喜欢本期内容的话,欢迎点个“在看”吧!
往期精彩指路
丨丨
丨丨
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...