给流量(特指 TCP 流量)打上 TOS,可以在 cgroup bpf sockops 的这 3 个 HOOK 里调 bpf_setsockopt(skops, SOL_IP, IP_TOS, &tos, sizeof(tos)) 给 socket 设置 TOS。

  1. BPF_SOCK_OPS_TCP_CONNECT_CB
  2. BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB
  3. BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB

不过,同事最近遇到个问题:bpf_setsockopt() 返回了 -EINVAL 导致 TOS 没打上。

背景

同事需要按需给特定流量打上 TOS,从而更好地搞流量治理。

他分析发现,bpf_setsockopt() 失败的 socket 的 sk_familyAF_INET6,但 tcpdump 抓包却是 IPv4,觉得有点奇怪;经过一番分析,发现是内核协议栈的 IPv4 over IPv6 的特性,也就是 ipv4-mapped ipv6,内核代码里的 ipv6-mapped

分析

看下 bpf_setsockopt() helper 的源代码就知道了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// net/core/filter.c

static int sol_ip_sockopt(struct sock *sk, int optname,
                          char *optval, int *optlen,
                          bool getopt)
{
        if (sk->sk_family != AF_INET)
                return -EINVAL;

        switch (optname) {
        case IP_TOS:
                if (*optlen != sizeof(int))
                        return -EINVAL;
                break;
        default:
                return -EINVAL;
        }

        if (getopt)
                return do_ip_getsockopt(sk, SOL_IP, optname,
                                        KERNEL_SOCKPTR(optval),
                                        KERNEL_SOCKPTR(optlen));

        return do_ip_setsockopt(sk, SOL_IP, optname,
                                KERNEL_SOCKPTR(optval), *optlen);
}

static int __bpf_setsockopt(struct sock *sk, int level, int optname,
                            char *optval, int optlen)
{
        if (!sk_fullsock(sk))
                return -EINVAL;

        if (level == SOL_SOCKET)
                return sol_socket_sockopt(sk, optname, optval, &optlen, false);
        else if (IS_ENABLED(CONFIG_INET) && level == SOL_IP)
                return sol_ip_sockopt(sk, optname, optval, &optlen, false);
        else if (IS_ENABLED(CONFIG_IPV6) && level == SOL_IPV6)
                return sol_ipv6_sockopt(sk, optname, optval, &optlen, false);
        else if (IS_ENABLED(CONFIG_INET) && level == SOL_TCP)
                return sol_tcp_sockopt(sk, optname, optval, &optlen, false);

        return -EINVAL;
}

static int _bpf_setsockopt(struct sock *sk, int level, int optname,
                           char *optval, int optlen)
{
        if (sk_fullsock(sk))
                sock_owned_by_me(sk);
        return __bpf_setsockopt(sk, level, optname, optval, optlen);
}


BPF_CALL_5(bpf_sock_ops_setsockopt, struct bpf_sock_ops_kern *, bpf_sock,
           int, level, int, optname, char *, optval, int, optlen)
{
        // ...
        return _bpf_setsockopt(bpf_sock->sk, level, optname, optval, optlen);
}

sol_ip_sockopt() 里检查 sk->sk_family 是否为 AF_INET;对于 ipv4-mapped ipv6 socket,这里就会返回 -EINVAL

修复

修起来并不难;只需要在这里放开 AF_INET6 的检查就好:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
diff --git a/net/core/filter.c b/net/core/filter.c
index 9590877b0714..57b00c6cc8cc 100644
--- a/net/core/filter.c
+++ b/net/core/filter.c
@@ -5544,11 +5544,24 @@ static int sol_tcp_sockopt(struct sock *sk, int optname,
                                  KERNEL_SOCKPTR(optval), *optlen);
 }

+static bool sk_allows_sol_ip_sockopt(struct sock *sk)
+{
+       switch (sk->sk_family) {
+       case AF_INET:
+               return true;
+       case AF_INET6:
+               /* Allow getting/setting sockopt for possible ipv4-mapped ipv6 socket. */
+               return sk->sk_type != SOCK_RAW && !ipv6_only_sock(sk);
+       default:
+               return false;
+       }
+}
+
 static int sol_ip_sockopt(struct sock *sk, int optname,
                           char *optval, int *optlen,
                           bool getopt)
 {
-       if (sk->sk_family != AF_INET)
+       if (!sk_allows_sol_ip_sockopt(sk))
                 return -EINVAL;

         switch (optname) {

这里并不能简单地放行 AF_INET6

  • SOCK_RAW: 参考 ipv6_setsockopt() 的做法:
1
2
          if (level == SOL_IP && sk->sk_type != SOCK_RAW)
                return udp_prot.setsockopt(sk, level, optname, optval, optlen);
  • ipv6_only_sock(): tcp_v6_connect() 里处理 ipv4-mapped ipv6 case 的逻辑:
1
2
3
4
5
6
        if (addr_type & IPV6_ADDR_MAPPED) {
                //...
                if (ipv6_only_sock(sk))
                        return -ENETUNREACH;
                //...
                WRITE_ONCE(icsk->icsk_af_ops, &ipv6_mapped);

翻看了一下 tcp_ipv6.c::tcp_v6_connect()ipv6_sockglue.c::ipv6_setsockopt(),就确定了要这么改。

有趣的是,字节的 Feng Zhou 大佬在 2 年前就尝试修复这个问题;我在 tg 频道上发了个碎碎念,他看到后就聊了起来。

绕过

内核里修复起来并不复杂;可是,受到影响的线上机器有好多,全都要升级内核就不合适了。合适的做法是:热补丁、或者绕过。

其实,维护热补丁还挺麻烦的,就研究了一下绕过该问题的办法:如何给 ipv4-mapped ipv6 socket 设置 TOS?

用 AI 分析一下,它说通过用户态可以设置 TOS,并且给另一个进程的 socket 设置 TOS 也是可行的。

1
2
3
4
5
6
// net/ipv6/ip_sockglue.c

ip_setsockopt()
|-->do_ip_setsockopt()
    |-->ip_sock_set_tos()
        |-->__ip_sock_set_tos()

在这条路径上,确实没有 sk->sk_family != AF_INET 的检查。

给另一个进程设置 TOS 的核心逻辑:

  • int syscall(SYS_pidfd_open, pid_t pid, unsigned int flags);
  • int syscall(SYS_pidfd_getfd, int pidfd, int targetfd, unsigned int flags);
  • int setsockopt(int sockfd, int level, int optname, const void optval[.optlen], socklen_t optlen);

其中,pidfd 信息可以通过 fexit __sys_socket 拿到:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
SEC("fexit/__sys_socket")
int BPF_PROG(handle_socket, int family, int type, int protocol, int ret)
{
        if (family != AF_INET && family != AF_INET6)
                return 0;
        if (ret < 0)
                return 0;
        struct event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
        e->pid = bpf_get_current_pid_tgid() >> 32; /* tgid: fd table is per-process */
        e->fd  = ret;
        bpf_ringbuf_submit(e, 0);
        return 0;
}

使用 2 个 netns 来验证一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# ./tos-marker -netns /var/run/netns/cli -tos 0x1c
2026/06/19 21:25:12 marked pid=186808 fd=3 IP_TOS=0x1c

# ./run_case.sh tos 2
[cli] set IP_TOS      = 0x02
[cli] connect ::ffff:10.0.0.2:5201  (v4-mapped)
[cli] peer=::ffff:10.0.0.2  readback IPV6_TCLASS=0x00 IP_TOS=0x1c
[cli] ^ wire ToS is driven by IP_TOS (inet->tos) for mapped sockets
===== wire ToS seen (opt=tos val=2) =====
      2 tos 0x0
      4 tos 0x1c

# ip netns exec srv tcpdump -i any -c 6 -nv
21:25:12.989722 IP (tos 0x0, ttl 64, id 4837, offset 0, flags [DF], proto TCP (6), length 60)
    10.0.0.1.46376 > 10.0.0.2.5201: Flags [S], cksum 0x1431 (incorrect -> 0xb166), seq 2008149648, win 64240, options [mss 1460,sackOK,TS val 2508833376 ecr 0,nop,wscale 7], length 0
21:25:12.989753 IP (tos 0x0, ttl 64, id 4838, offset 0, flags [DF], proto TCP (6), length 52)
    10.0.0.1.46376 > 10.0.0.2.5201: Flags [.], cksum 0x1429 (incorrect -> 0xc236), ack 1718662282, win 502, options [nop,nop,TS val 2508833376 ecr 183300351], length 0
21:25:12.989827 IP (tos 0x1c, ttl 64, id 4839, offset 0, flags [DF], proto TCP (6), length 56)
    10.0.0.1.46376 > 10.0.0.2.5201: Flags [P.], cksum 0x142d (incorrect -> 0xe359), seq 0:4, ack 1, win 502, options [nop,nop,TS val 2508833376 ecr 183300351], length 4
21:25:12.989896 IP (tos 0x1c, ttl 64, id 4840, offset 0, flags [DF], proto TCP (6), length 52)
    10.0.0.1.46376 > 10.0.0.2.5201: Flags [.], cksum 0x1429 (incorrect -> 0xc22e), ack 5, win 502, options [nop,nop,TS val 2508833376 ecr 183300351], length 0
21:25:13.289987 IP (tos 0x1c, ttl 64, id 4841, offset 0, flags [DF], proto TCP (6), length 56)
    10.0.0.1.46376 > 10.0.0.2.5201: Flags [P.], cksum 0x142d (incorrect -> 0xe225), seq 4:8, ack 5, win 502, options [nop,nop,TS val 2508833676 ecr 183300351], length 4
21:25:13.290025 IP (tos 0x1c, ttl 64, id 4842, offset 0, flags [DF], proto TCP (6), length 52)
    10.0.0.1.46376 > 10.0.0.2.5201: Flags [.], cksum 0x1429 (incorrect -> 0xbfce), ack 9, win 502, options [nop,nop,TS val 2508833676 ecr 183300651], length 0

通过 tcpdump 抓包确认,tos 确实会被更新为指定的值;不过,前 2 个包没有设置成功。

该办法的缺陷:因为是异步设置 TOS 的,所以会导致新创的 socket 前几个包还没来得及设置 TOS。

该办法的 PoC 代码:tos-marker

小结

使用 tc-bpf 来补上 TOS?不可接受的原因是 tc-bpf 会处理每个经过 tc 的包;而基于 cgroup 的 sockops 是 socket 事件驱动的,性能损耗更低。

对于内核问题,能绕过则绕过;绕不过,优先考虑热补丁;最后才是升级内核。