从拥塞控制算法热交换到内核错误修复

最近在哔哩哔哩,我们开发了一种改进的 BBR 拥塞控制算法,需要在真实环境中进行测试。该算法本身以内核模块的形式存在,因此将其安装到服务器上不是问题。然而,在快节奏的迭代过程中,我们遇到了一系列问题,最终发现了一个内核错误。本文将带您了解我们解决问题的整个过程,从拥塞控制算法热交换到内核错误修复。下方列出了本文所处的实验环境,可以帮助您复现实验。

实验环境

我们使用的 Linux 版本是 5.10。为了隔离测试环境,我们使用 ip netns 创建一个名为 ns 的网络命名空间,并创建一对 veth ve_o 和 ve_i 来运行 TCP 连接。

bash 复制代码
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 很简单:

shell 复制代码
# 加载模块
$ insmod tcp_bbr_bili.ko
# 使其成为默认的拥塞控制算法
$ sysctl -w net/ipv4/tcp_congestion_control=bbr_bili

借助 ss 的强大功能,我们可以看到拥塞控制算法的实际效果:

css 复制代码
$ 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。

现在假设我们有了一个修复了一些错误的新版本算法,我们来加载它:

arduino 复制代码
$ insmod tcp_bbr_bili.ko
insmod: ERROR: could not insert module tcp_bbr_bili.ko: File exists

糟糕,我们无法加载更新后的模块,因为它与旧模块同名。为了迭代算法,我们需要卸载旧模块并加载新模块。

vbnet 复制代码
$ rmmod tcp_bbr_bili
rmmod: ERROR: Module tcp_bbr_bili is in use

这是有道理的;某个进程正在使用该模块,所以我们无法卸载它。lsmod 也证实了该模块正在使用中:

perl 复制代码
$ lsmod | grep bili
tcp_bbr_bili           20480  2

在这种情况下,我们可以将拥塞控制算法更改为 cubic 或 bbr,等待使用 bbr_bili 的套接字关闭,然后卸载模块。或者我们可以用不同的名称重新编译模块,但这会很麻烦。由于我们迭代算法的速度比较快,等待套接字关闭不是一个好选择;重新编译模块会在内核中产生大量垃圾。我想知道是否有更好的方法可以在不等待或重新编译的情况下卸载模块? 有的兄弟,有的。

第二个问题:算法热交换和套接字窃取

有一种方法可以在不等待套接字关闭的情况下释放模块。我们可以使用 setsockopt 直接更改套接字的拥塞控制算法。

arduino 复制代码
setsockopt(sockfd, IPPROTO_TCP, TCP_CONGESTION, "bbr_bili", strlen("bbr_bili"));

然而,这需要我们拥有该套接字才能执行 setsockopt 系统调用,而且我们无法修改每个使用该算法的程序来添加此代码。因此,我们需要一种方法从使用它的进程中"窃取"套接字。这就是 pidfd_getfd 发挥作用的地方。

不久前在浏览 Cloudflare 博客时,我遇到了一种称为"套接字窃取"的技术,它使用 pidfd_getfd 系统调用从另一个进程复制套接字。我将从演讲 (*www.usenix.org/system/file... eBPF 的魔力。

如幻灯片所示,为了从另一个进程"窃取"(复制)套接字,我们需要目标进程的 PID 和套接字的文件描述符。幸运的是,我们可以从 ss 的 Process 列中获取所有这些信息:

css 复制代码
$ 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 复制套接字。结合这些步骤,代码如下所示:

scss 复制代码
// 获取目标进程的 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 <congestion_algorithm> 作为参数,并更改目标套接字的拥塞控制算法。代码可在 Github(*github.com/kuroa-me/bi... 上找到。让我们看看它的实际效果:

ruby 复制代码
$ ./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 的套接字上调用它,然后就可以收工了。

等等,那是什么?一个没有进程的套接字?

css 复制代码
$ ip netns exec ns ss -np
Netid       State            Recv-Q       Send-Q              Local Address:Port                Peer Address:Port        Process
tcp         FIN-WAIT-1       0            20481                 192.168.0.1:58732                192.168.0.2:65432

第三个问题:孤立套接字

孤立套接字是"由系统持有但未附加到任何用户文件句柄的套接字"(LARTC:*lartc.org/howto/lartc... 1,从而阻止我们卸载模块。

系统中的罪魁祸首是 TCP 窗口,它导致一些孤立套接字存活时间过长而成为问题。让我们一起看看这个问题,参考下面的 TCP 有限状态机 (*www.tcpipguide.com/free/t_TCPO...

ESTABLISHED 状态下,用户进程可以调用 close() 来关闭套接字。然后内核会将一个 FIN 附加到套接字的发送队列,并将状态更改为 FIN-WAIT-1 。然后内核将等待对等方 ACK 该 FIN。但是由于 FIN 位于发送队列的末尾,如果 TCP 窗口非常小或为零,则需要很长时间才能发送 FIN,从而阻止对等方 ACK 它,并使套接字停滞在 FIN-WAIT-1 状态。

上一节中的示例是通过使用 2 个 socat 命令模拟零窗口场景创建的。一个是"坏坏"服务器,在接受连接后不会从套接字读取任何数据。引自 socat 手册页 (*www.dest-unreach.org/socat/doc/s...

ruby 复制代码
# 终端 1 - 服务器
$ socat -u \                # 使用单向模式。第一个地址仅用于读取,第二个地址仅用于写入。  
  - \                      # 第一个地址,即 STDIO (-)。  
  "TCP-LISTEN:65432,fork" # 第二个地址,我们的侦听服务器。

另一个是客户端,它只是连接到服务器并不断从 /dev/zero 向服务器转储 0。

bash 复制代码
# 终端 2 - 客户端
$ ip netns exec ns socat \  
  "/dev/zero" \ 
  "TCP:192.168.0.2:65432"
# 等待几秒钟后使用 Ctrl+C 终止客户端
^C

由于服务器没有在套接字上调用接收,因此接收队列 (Recv-Q) 没有被清空,从而阻止发送队列 (Send-Q) 清空,有效地模拟了零窗口 TCP 连接。几秒钟后,我们可以手动终止客户端进程,剩下的将是一个孤立的类零窗口套接字。

ruby 复制代码
$ ip netns exec ns ss -n4tpe
State              Recv-Q         Send-Q                 Local Address:Port                  Peer Address:Port         Process
FIN-WAIT-1         0              883585                   192.168.0.1:60820                  192.168.0.2:65432         timer:(persist,1min50sec,0) ...
$ ss -n4tpe '( sport = :65432 )'
State         Recv-Q         Send-Q                   Local Address:Port                    Peer Address:Port          Process
ESTAB         124032         0                          192.168.0.2:65432                    192.168.0.1:60820          users:(("socat",pid=1509536,fd=6)) ...

幸运的是,内核最终会超时并清理孤立套接字。(请注意上面输出中的 timer:(persist,1min9sec,0))。这主要由 tcp_orphan_retries sysctl (sysctl-explorer.net/net/ipv4/tc... )控制。如果我们不等待那么长时间怎么办?或者如果套接字是一个不会超时的近零窗口套接字怎么办?

ss 是一个不断带来惊喜的宝库。它有一个 -K 选项可用于终止套接字。

ruby 复制代码
# 在此处添加过滤器以确保。
$ ip netns exec ns ss -n4tpe -K '( dport = :65432 )'
State              Recv-Q         Send-Q                 Local Address:Port                  Peer Address:Port         Process
FIN-WAIT-1         0              883585                   192.168.0.1:60820                  192.168.0.2:65432

ss 向我们显示了它找到并成功终止的套接字。现在我们可以修改我们最初的脚本,在调用 changeling 之后对每个孤立套接字调用 ss -K,太棒了!

等等,为什么孤立套接字仍然存在?为什么在多次调用 ss -K 后它仍然存在?

ruby 复制代码
$ ip netns exec ns ss -n4tpe -K '( dport = :65432 )'
State              Recv-Q         Send-Q                 Local Address:Port                  Peer Address:Port         Process
FIN-WAIT-1         0              883585                   192.168.0.1:60820                  192.168.0.2:65432         ino:0 sk:531a ---
$ ip netns exec ns ss -n4tpe -K '( dport = :65432 )'
State              Recv-Q         Send-Q                 Local Address:Port                  Peer Address:Port         Process
FIN-WAIT-1         0              883585                   192.168.0.1:60820                  192.168.0.2:65432         ino:0 sk:531a ---
$ ip netns exec ns ss -n4tpe -K '( dport = :65432 )'
State              Recv-Q         Send-Q                 Local Address:Port                  Peer Address:Port         Process
FIN-WAIT-1         0              883585                   192.168.0.1:60820                  192.168.0.2:65432         ino:0 sk:531a ---

第四个问题:"套接字已死,套接字万岁!"

无法终止套接字是一个问题,但我必须专注于手头的任务,所以我决定给它一天时间让它超时。第二天,我回到办公室,发现套接字仍然存在。惊恐之下,我开始调查到底发生了什么。

起初,我以为这是 ss 中的一个 bug,并检查了 ss 实际是如何终止套接字的。代码位于 github.com/iproute2/ip...

从代码中我们可以看到 ss 正在使用 Netlink 公开的 SOCK_DIAG 基础结构。当调用 show_one_inet_sock 时,它将尝试通过发送带有 SOCK_DESTROY (kill_inet_sock) 的 nlmsg 来终止套接字。成功后,它将始终打印已终止套接字的信息,这与我们在上一节中看到的最后输出相匹配。也就是说,内核向 ss 确认它已经终止了套接字。现在我们需要查看内核代码以了解发生了什么。下面的函数按我跟踪整个过程的方式排序;更有经验的开发人员可能有更好的方法来执行此操作。(主要查看 IPv4 TCP 代码)。

scss 复制代码
// net/ipv4/inet_diag.c
staticintinet_diag_cmd_exact(){  
  err = handler->destroy(in_skb, req);
}
// net/ipv4/tcp_diag.c
staticconststructinet_diag_handlertcp_diag_handler = { 
  .destroy    = tcp_diag_destroy,
};
// net/ipv4/tcp_diag.c
staticinttcp_diag_destroy(struct sk_buff *in_skb,  
          const struct inet_diag_req_v2 *req) { 
  err = sock_diag_destroy(sk, ECONNABORTED);
}
// net/core/sock_diag.c
intsock_diag_destroy(struct sock *sk, int err){  
  return sk->sk_prot->diag_destroy(sk, err);
}
// net/ipv4/tcp_ipv4.c
structprototcp_prot = { 
  .diag_destroy    = tcp_abort,
};
// net/ipv4/tcp.c
inttcp_abort(struct sock *sk, int err)
{
 ... 
 if (!sock_flag(sk, SOCK_DEAD)) { 
   ...    
   if (tcp_need_reset(sk->sk_state)) 
     tcp_send_active_reset(sk, GFP_ATOMIC);    
   tcp_done(sk);  
 }
 ...
 tcp_write_queue_purge(sk);  
 release_sock(sk); 
 return0;
}
EXPORT_SYMBOL_GPL(tcp_abort);
// net/ipv4/tcp.c
voidtcp_done(struct sock *sk)
{
 ... 
 if (!sock_flag(sk, SOCK_DEAD)) 
   sk->sk_state_change(sk); 
 else   
   inet_csk_destroy_sock(sk);
}
EXPORT_SYMBOL_GPL(tcp_done);

这里的关键角色是 tcp_abort 和 tcp_done。它们负责在 TCP 的不同状态下关闭套接字;为简洁起见,我省略了不相关的代码。SOCK_DEAD 是一个重要的标志,它决定了代码的流向。要找出它在正在运行的机器中的值,我们可以使用 bpftracebpftrace.org/ ) 来打印 sock_flag 的值。

arduino 复制代码
// 完整代码在 github 上
kprobe:tcp_abort{  
  printf("aborting: %x\n", ((struct sock *)arg0)->sk_flags);
}
makefile 复制代码
# 附加 bpftrace 后尝试终止孤立套接字
$ bpftrace tcp_abort.bt
Attaching 1 probe...
aborting: 0x301

内核将 SOCK_DEAD 放在 enum sock_flags 的最低有效位,因此 0x301 表示设置了 SOCK_DEAD。我们可以尝试相应地遵循代码路径。 在 tcp_abort 中,由于设置了 SOCK_DEAD,它只会使用 tcp_write_queue_purge 清除队列,而不会通过调用 tcp_done 实际关闭套接字。这就解释了为什么在多次成功调用 ss -K 后套接字仍然存在。但是为什么套接字不会超时呢?

答案在于 tcp_timer.c 文件。

在这里,如果 packets_out 为 0,tcp_probe_timer 将提前返回,而不会检查计数器以决定是使套接字超时还是发送另一个探测。而我们的 tcp_write_queue_purge 恰好清除了 packets_out 计数器。因此,在当前计时器到期后,套接字将不会获得另一个计时器或超时,从而变得不朽。

scss 复制代码
// net/ipv4/tcp.c
voidtcp_write_queue_purge(struct sock *sk)
{
 ... 
 tcp_sk(sk)->packets_out = 0; 
 inet_csk(sk)->icsk_backoff = 0;
}

如果我们仔细查看第 3 节的最后输出,我们可以看到 timer 确实在 ss 的输出中不复存在。

结束问题链

要修复此内核错误,我们只需在 tcp_abort 中删除 SOCK_DEAD 检查。此补丁已提交给内核并被接受,您可以在此处patchwork.kernel.org/project/net... )找到更多详细信息。在开发补丁时,virtme-ng 是测试补丁的一个很好的工具,使用 virtme-ng 更快地进行内核测试lwn.net/Articles/95... )。

要点:

我们的 changeling 仍然可以用来更改 cc 算法或任何其他套接字选项,并且非常方便。

如果是没有打过补丁的内核,请不要在孤立套接字上使用 ss -K。

ss、bpftrace 和 virtme-ng 是调试内核问题的好工具。

感谢您的阅读;整个冒险从一个简单的 cc 交换工具开始,到内核错误修复结束。我希望您能学到一些可以玩的新工具。

附言:在此补丁被添加到最新的内核树之后,三星也在他们的测试中遇到了这个错误,并且是他们将该补丁向下移植到了 5.15 和 6.1。

This article is also available in English (*github.com/kuroa-me/bi....

-End-

作者丨Kuroame

相关推荐
CoovallyAIHub4 分钟前
YOLOv13都来了,目标检测还卷得动吗?别急,还有这些新方向!
深度学习·算法·计算机视觉
转转技术团队37 分钟前
边学边做:图片识别技术的学习与应用
后端·算法
一块plus1 小时前
2025 年值得一玩的最佳 Web3 游戏
算法·设计模式·程序员
前端拿破轮1 小时前
不是吧不是吧,leetcode第一题我就做不出来?😭😭😭
后端·算法·leetcode
一块plus1 小时前
什么是去中心化 AI?区块链驱动智能的初学者指南
人工智能·后端·算法
Mr_Xuhhh1 小时前
网络基础(1)
c语言·开发语言·网络·c++·qt·算法
前端拿破轮1 小时前
😭😭😭看到这个快乐数10s,我就知道快乐不属于我了🤪
算法·leetcode·typescript
lyx 弈心1 小时前
I/O 进程 7.2
linux·算法·io
静心问道1 小时前
APE:大语言模型具有人类水平的提示工程能力
人工智能·算法·语言模型·大模型
醇醛酸醚酮酯2 小时前
std::promise和std::future的使用示例——单线程多链接、多线程单链接
网络·c++·算法