skaiuijing

引言

经常发生的事情经常发生,不经常发生的事情不经常发生。

经常发生,指的是在过去未来的任意时间尺度都恒成立,即:过去经常发生,未来也大概率经常发生。未来经常发生,过去也大概率经常发生。

这就是时间局部性,不管对于程序还是万事万物,它都成立。

问题

笔者最近在使用ebpf开发Linux网络相关的程序,但是遇到了一个问题。

那就是,网络是分层的,那么如何将数据包与具体进程关联起来呢?

在每个eBPF的hook点,获取到的上下文都是不同的,不同层访问的信息都是有限制的。

在内核层,可以对数据包执行许多操作,例如丢弃、修改、重定向等等,但是在应用层,当数据包要被递交给具体进程时,就无法做到这些了,但是内核层能获取到的信息又非常有限,这该怎么办呢?

我们可以想到,tcp在传输层实际上已经与具体进程绑定了,那么在传输层进行具体操作不久可以了吗?

但是,越往上层,我们使用eBPF程序修改数据包的权限就小,性能也越差,但是底层又无法直接与进程相关联,这该怎么办?

例如,我们知道五元组:源ip,目的ip,源端口,目的端口,协议.可以代表一个链接,也可以直接代表一个进程。

但是在传输层虽然可以解析到协议及端口,但是ip层又相对变得透明了,难道我们还要进行逆向解析数据包吗?

逆向解析数据包是不推荐的,但是,Linux内核其实为了优化网络性能,实现了很多跨层加速的策略,例如early demux早期分流。

性能

我们都知道如果一件事情经常发生,那么它大概率在任何时间尺度下都经常发生。

所以,如果数据包经常交付给某个进程,那么接下来到达网络协议栈的数据包,也大概率是交付给这个进程。

如果我们能准确判断出一类数据包,那么对于这所有的一类数据包,我们只需要进行同一类的处理即可。

例如,如果我们在数据包刚到达网卡时就知道这个数据包是给某个进程的,而且协议栈只需要对这一类数据包进行简单的检查处理,并没有严格的约束。那么我们完全可以绕过协议栈,直接把包的内容丢给该进程。

从时间局部性我们可以得知,如果某个数据在某时刻被访问,那么它很可能在不久后再次被访问。这体现在:

  • TCP 连接是持久的:一旦建立连接,后续的数据包会频繁使用同一个 socket 和路由。
  • socket 和 dst 缓存在内核中:这些结构在连接期间保持不变,适合被提前复用。

所以:既然你刚刚用过这个 socket,那我就假设你还会继续用它。

内核基于这个原理,实现了early demux机制。

early demux机制

在传统路径中,IP 层处理完数据包后,才进入传输层查找 socket(如 TCP 的连接四元组匹配)并设置 skb->skskb->dst。这一步通常涉及:

  • 哈希查找 socket(可能是红黑树或哈希表)
  • 查找路由缓存或重新计算路由

而 early demux 的优化在于:

如果数据包属于一个已建立连接(如 TCP ESTABLISHED),那么我们可以在 IP 层就直接绑定 socket 和路由信息,跳过后续查找。

笔者在查看Linux内核源码时,注意到:

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

int tcp_v4_early_demux(struct sk_buff *skb)
{
struct net *net = dev_net(skb->dev);
const struct iphdr *iph;
const struct tcphdr *th;
struct sock *sk;

if (skb->pkt_type != PACKET_HOST)
return 0;

if (!pskb_may_pull(skb, skb_transport_offset(skb) + sizeof(struct tcphdr)))
return 0;

iph = ip_hdr(skb);
th = tcp_hdr(skb);

if (th->doff < sizeof(struct tcphdr) / 4)
return 0;

sk = __inet_lookup_established(net, net->ipv4.tcp_death_row.hashinfo,
iph->saddr, th->source,
iph->daddr, ntohs(th->dest),
skb->skb_iif, inet_sdif(skb));
if (sk) {
skb->sk = sk;
skb->destructor = sock_edemux;
if (sk_fullsock(sk)) {
struct dst_entry *dst = rcu_dereference(sk->sk_rx_dst);

if (dst)
dst = dst_check(dst, 0);
if (dst &&
sk->sk_rx_dst_ifindex == skb->skb_iif)
skb_dst_set_noref(skb, dst);
}
}
return 0;
}

skb获取相关代码:

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

static inline struct sock *__inet_lookup_skb(struct inet_hashinfo *hashinfo,
struct sk_buff *skb,
int doff,
const __be16 sport,
const __be16 dport,
const int sdif,
bool *refcounted)
{
struct net *net = dev_net(skb_dst(skb)->dev);
const struct iphdr *iph = ip_hdr(skb);
struct sock *sk;

sk = inet_steal_sock(net, skb, doff, iph->saddr, sport, iph->daddr, dport,
refcounted, inet_ehashfn);
if (IS_ERR(sk))
return NULL;
if (sk)
return sk;

return __inet_lookup(net, hashinfo, skb,
doff, iph->saddr, sport,
iph->daddr, dport, inet_iif(skb), sdif,
refcounted);
}

数据包进入ip层的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

static int ip_rcv_finish(struct net *net, struct sock *sk, struct sk_buff *skb)
{
struct net_device *dev = skb->dev;
int ret;

/* if ingress device is enslaved to an L3 master device pass the
* skb to its handler for processing
*/
skb = l3mdev_ip_rcv(skb);
if (!skb)
return NET_RX_SUCCESS;

ret = ip_rcv_finish_core(net, sk, skb, dev, NULL);
if (ret != NET_RX_DROP)
ret = dst_input(skb);
return ret;
}

传输层的early demux相关代码,我们可以看见,其实在ip层,内核就已经获取了传输层相关信息:

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121

int tcp_v4_early_demux(struct sk_buff *skb);
int udp_v4_early_demux(struct sk_buff *skb);
static int ip_rcv_finish_core(struct net *net, struct sock *sk,
struct sk_buff *skb, struct net_device *dev,
const struct sk_buff *hint)
{
const struct iphdr *iph = ip_hdr(skb);
int err, drop_reason;
struct rtable *rt;

drop_reason = SKB_DROP_REASON_NOT_SPECIFIED;

if (ip_can_use_hint(skb, iph, hint)) {
err = ip_route_use_hint(skb, iph->daddr, iph->saddr, iph->tos,
dev, hint);
if (unlikely(err))
goto drop_error;
}

if (READ_ONCE(net->ipv4.sysctl_ip_early_demux) &&
!skb_dst(skb) &&
!skb->sk &&
!ip_is_fragment(iph)) {
switch (iph->protocol) {
case IPPROTO_TCP:
if (READ_ONCE(net->ipv4.sysctl_tcp_early_demux)) {
tcp_v4_early_demux(skb);

/* must reload iph, skb->head might have changed */
iph = ip_hdr(skb);
}
break;
case IPPROTO_UDP:
if (READ_ONCE(net->ipv4.sysctl_udp_early_demux)) {
err = udp_v4_early_demux(skb);
if (unlikely(err))
goto drop_error;

/* must reload iph, skb->head might have changed */
iph = ip_hdr(skb);
}
break;
}
}

/*
* Initialise the virtual path cache for the packet. It describes
* how the packet travels inside Linux networking.
*/
if (!skb_valid_dst(skb)) {
err = ip_route_input_noref(skb, iph->daddr, iph->saddr,
iph->tos, dev);
if (unlikely(err))
goto drop_error;
} else {
struct in_device *in_dev = __in_dev_get_rcu(dev);

if (in_dev && IN_DEV_ORCONF(in_dev, NOPOLICY))
IPCB(skb)->flags |= IPSKB_NOPOLICY;
}

#ifdef CONFIG_IP_ROUTE_CLASSID
if (unlikely(skb_dst(skb)->tclassid)) {
struct ip_rt_acct *st = this_cpu_ptr(ip_rt_acct);
u32 idx = skb_dst(skb)->tclassid;
st[idx&0xFF].o_packets++;
st[idx&0xFF].o_bytes += skb->len;
st[(idx>>16)&0xFF].i_packets++;
st[(idx>>16)&0xFF].i_bytes += skb->len;
}
#endif

if (iph->ihl > 5 && ip_rcv_options(skb, dev))
goto drop;

rt = skb_rtable(skb);
if (rt->rt_type == RTN_MULTICAST) {
__IP_UPD_PO_STATS(net, IPSTATS_MIB_INMCAST, skb->len);
} else if (rt->rt_type == RTN_BROADCAST) {
__IP_UPD_PO_STATS(net, IPSTATS_MIB_INBCAST, skb->len);
} else if (skb->pkt_type == PACKET_BROADCAST ||
skb->pkt_type == PACKET_MULTICAST) {
struct in_device *in_dev = __in_dev_get_rcu(dev);

/* RFC 1122 3.3.6:
*
* When a host sends a datagram to a link-layer broadcast
* address, the IP destination address MUST be a legal IP
* broadcast or IP multicast address.
*
* A host SHOULD silently discard a datagram that is received
* via a link-layer broadcast (see Section 2.4) but does not
* specify an IP multicast or broadcast destination address.
*
* This doesn't explicitly say L2 *broadcast*, but broadcast is
* in a way a form of multicast and the most common use case for
* this is 802.11 protecting against cross-station spoofing (the
* so-called "hole-196" attack) so do it for both.
*/
if (in_dev &&
IN_DEV_ORCONF(in_dev, DROP_UNICAST_IN_L2_MULTICAST)) {
drop_reason = SKB_DROP_REASON_UNICAST_IN_L2_MULTICAST;
goto drop;
}
}

return NET_RX_SUCCESS;

drop:
kfree_skb_reason(skb, drop_reason);
return NET_RX_DROP;

drop_error:
if (err == -EXDEV) {
drop_reason = SKB_DROP_REASON_IP_RPFILTER;
__NET_INC_STATS(net, LINUX_MIB_IPRPFILTER);
}
goto drop;
}

eBPF程序编写

既然知道了这一点,那么我们完全可以建立数据包与进程信息的映射:

1
2
3
4
5
6
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);
__type(key, struct sock *);
__type(value, struct ProcInfo);
__uint(max_entries, 1 >> 16);
} sock_map SEC(".maps");

通过map,在应用层,我们建立sock与进程信息的map结构,在ip层,就能直接通过sock查找到数据包关联的进程信息。

但是还有一个问题,tcp是面向连接的协议,这样做确实可行,但是UDP这样做真的可以吗?

答案是:这种做法确实只对tcp有效,udp在大部分情况下确实是失效的,不过现在大部分应用都是基于tcp,其实已经完全够用了。

至于udp,笔者推荐采用五元组手动建立映射。