哔哩哔哩技术 07月21日 01:17
从拥塞控制算法热交换到内核错误修复
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文记录了哔哩哔哩团队在测试改进的BBR拥塞控制算法时遇到的挑战及解决方案。文章详细介绍了如何通过内核模块热交换、利用`pidfd_getfd`进行套接字窃取以修改拥塞控制算法,以及如何处理因TCP窗口机制导致的孤立套接字问题。最终,通过分析内核代码和利用`bpftrace`等工具,定位并修复了一个导致孤立套接字无法被终止的内核错误,并分享了使用`virtme-ng`进行内核测试的经验,为开发者提供了宝贵的实战技巧。

🚀 **内核模块热交换与套接字控制**:在迭代开发过程中,直接加载新版本内核模块时遇到同名冲突。通过`setsockopt`配合`pidfd_getfd`系统调用,实现了“套接字窃取”,允许在不重新编译或等待套接字关闭的情况下,动态修改正在使用的套接字的拥塞控制算法,提高了开发效率。

⚠️ **孤立套接字问题与`ss -K`的局限性**:文章详细阐述了因TCP窗口机制(如零窗口)可能导致进程退出后产生无法正常清理的孤立套接字。虽然`ss -K`命令可用于终止套接字,但在特定内核错误下,即使`ss -K`报告成功,孤立套接字仍可能残留,因为它可能已被标记为`SOCK_DEAD`,且`tcp_write_queue_purge`清除了`packets_out`计数器,阻止了后续的超时机制。

💡 **内核错误定位与修复**:通过`bpftrace`追踪`tcp_abort`函数,发现`SOCK_DEAD`标志导致套接字未被完全关闭。分析内核代码后,确认问题根源在于`tcp_abort`中未正确处理`SOCK_DEAD`状态下的套接字清理。最终通过移除`SOCK_DEAD`检查的补丁解决了此问题,该补丁已被内核社区接受并向后移植。

🛠️ **调试工具的有效运用**:文章强调了`ss`(特别是`-K`选项)、`bpftrace`和`virtme-ng`在调试网络内核问题中的重要作用。`ss`用于查看和管理套接字状态,`bpftrace`用于动态追踪内核函数执行和变量状态,而`virtme-ng`则提供了快速的内核测试环境,极大地提升了问题排查和验证的效率。

原创 通用工程 2025-06-24 12:03 上海

最近在哔哩哔哩,我们开发了一种改进的 BBR 拥塞控制算法,需要在真实环境中进行测试。

最近在哔哩哔哩,我们开发了一种改进的 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 的套接字上调用它,然后就可以收工了。

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


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: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):


# 终端 1 - 服务器

socat -u \                # 使用单向模式。第一个地址仅用于读取,第二个地址仅用于写入。

  - \                      # 第一个地址,即 STDIO (-)。

  "TCP-LISTEN:65432,fork" # 第二个地址,我们的侦听服务器。


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


# 终端 2 - 客户端

$ ip netns exec ns socat \

  "/dev/zero" \

  "TCP:192.168.0.2:65432"

# 等待几秒钟后使用 Ctrl+C 终止客户端

^C


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


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 (https://sysctl-explorer.net/net/ipv4/tcp_orphan_retries/)控制。如果我们不等待那么长时间怎么办?或者如果套接字是一个不会超时的近零窗口套接字怎么办?

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


# 在此处添加过滤器以确保。

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 后它仍然存在?


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 实际是如何终止套接字的。代码位于 https://github.com/iproute2/iproute2/blob/main/misc/ss.c


staticintkill_inet_sock(struct nlmsghdr *h, void *arg, struct sockstat *s)

{

  ...

  DIAG_REQUEST(req, struct inet_diag_req_v2 r);

  req.nlh.nlmsg_type = SOCK_DESTROY;

  ...

  return rtnl_talk(rth, &req.nlh, NULL);

}


staticintshow_one_inet_sock(struct nlmsghdr *h, void *arg)

{

  ...

    if (diag_arg->f->kill && kill_inet_sock(h, arg, &s) != 0) {

    if (errno == EOPNOTSUPP || errno == ENOENT) {

      /* Socket can't be closed, or is already closed. */

      return0;

    } else {

      perror("SOCK_DESTROY answers");

      return-1;

    }

  }

  ...

  err = inet_show_sock(h, &s);

  if (err < 0)

    return err;

  

  return0;

}


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


// 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 是一个重要的标志,它决定了代码的流向。要找出它在正在运行的机器中的值,我们可以使用 bpftracehttps://bpftrace.org/) 来打印 sock_flag 的值。


// 完整代码在 github 上

kprobe:tcp_abort{

  printf("aborting: %x\n", ((struct sock *)arg0)->sk_flags);

}

# 附加 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 文件。


// net/ipv4/tcp_timer.c

staticvoidtcp_probe_timer(struct sock *sk)

{

  ...

  if (tp->packets_out || !skb) {

    icsk->icsk_probes_out = 0;

    return;

  }

  ...

  if (icsk->icsk_probes_out >= max_probes) {

    // tcp_write_err() - 关闭套接字并保存错误信息

abort:    tcp_write_err(sk);

  } else {

    /* 仅当我们没有关闭连接时才发送另一个探测。*/

    tcp_send_probe0(sk);

  }

}


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


// 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 检查。此补丁已提交给内核并被接受,您可以在此处https://patchwork.kernel.org/project/netdevbpf/patch/20240812105315.440718-1-kuro@kuroa.me/)找到更多详细信息。在开发补丁时,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 Englishhttps://github.com/kuroa-me/bilibili-blog).


-End-

作者丨Kuroame


开发者问答

Socket 还有什么不为人知的技巧吗?

欢迎在留言区分享你的见解~

转发本文至朋友圈并留言,即可参与下方抽奖⬇️

小编将抽取1位幸运的小伙伴获取小电视鼠标垫键盘垫

抽奖截止时间:6月27日12:00

如果喜欢本期内容的话,欢迎点个“在看”吧!



往期精彩指路

全链路压测改造之全链自动化测试实践

哔哩哔哩⼤数据建设之路—实时DQC篇

Apache Kyuubi 在B站大数据场景下的应用实践


通用工程大前端业务线

大数据AI多媒体

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

BBR Linux 内核 拥塞控制 bpftrace 套接字
相关文章