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

最近在哔哩哔哩,我们开发了一种改进的 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

                评论
                添加红包

                请填写红包祝福语或标题

                红包个数最小为10个

                红包金额最低5元

                当前余额3.43前往充值 >
                需支付:10.00
                成就一亿技术人!
                领取后你会自动成为博主和红包主的粉丝 规则
                hope_wisdom
                发出的红包
                实付
                使用余额支付
                点击重新获取
                扫码支付
                钱包余额 0

                抵扣说明:

                1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
                2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

                余额充值