最近学习了网络包在线分类算法 TupleMerge,惊讶于其高效的查询和更新效率,比基于 bitmap 的算法更加节省内存、查询效率更高。本文将介绍 TupleMerge 算法的原理和实现,以及如何在 XDP 中使用 TupleMerge 算法实现 ACL。
复习之前实现的 XDP ACL:
TupleMerge 算法
TupleMerge 算法来自如下论文:
不过,该论文晦涩难懂,还是推荐阅读下面的 (还没合入上游) kernel patch:
参考 kernel patch,TupleMerge 算法的原理如下:
1. 分表
按照一定规则,将 ACL rule 分成多个表,每个表有一个不重复的 ID,和该表对应的 rule 描述。
比如 kernel patch 里的分表规则:
 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
  | 
static int tm_table_compatible(const struct bpf_wildcard *wcard,
                   const struct tm_table *table,
                   const struct wildcard_key *key)
{
    u32 off = 0;
    u32 size, i;
    u32 prefix;
    for (i = 0; i < wcard->desc->n_rules; i++) {
        size = wcard->desc->rule_desc[i].size;
        switch (wcard->desc->rule_desc[i].type) {
        case BPF_WILDCARD_RULE_PREFIX:
            prefix = *(u32 *)(key->data + off + size);
            /* table only is compatible if its prefix is less than or equal rule prefix */
            if (table->mask->prefix[i] > prefix)
                return 0;
            off += size + sizeof(u32);
            break;
        case BPF_WILDCARD_RULE_RANGE:
            /* ignore this case, table is always compatible */
            off += 2 * size;
            break;
        case BPF_WILDCARD_RULE_MATCH:
            /* ignore this case, table is always compatible */
            off += size;
            break;
        }
    }
    return 1;
}
  | 
 
即,只适配类似 CIDR 的 prefix 规则项,其他的规则项忽略。

然后,就所有 rule 依赖的表保存到一个链表里。
2. 哈希
为了高效查询,哈希是必选项。不过,选择哈希,必定需要处理哈希冲突。TupleMerge 算法的处理方式是,将哈希冲突的 rule 保存到一个链表里。
不过,使用哪些字段进行哈希呢?
kernel patch 里的哈希规则如下:
 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
  | 
static u32 tm_hash_rule(const struct wildcard_desc *desc,
            const struct tm_table *table,
            const struct wildcard_key *key)
{
    u8 buf[BPF_WILDCARD_MAX_TOTAL_RULE_SIZE];
    const void *data = key->data;
    u32 type, size, i;
    u32 n = 0;
    for (i = 0; i < desc->n_rules; i++) {
        type = desc->rule_desc[i].type;
        size = desc->rule_desc[i].size;
        if (type == BPF_WILDCARD_RULE_RANGE ||
            (type == BPF_WILDCARD_RULE_PREFIX && !table->mask->prefix[i]))
            goto ignore;
        if (likely(type == BPF_WILDCARD_RULE_PREFIX))
            __tm_copy_masked_rule(buf+n, data, size, table->mask->prefix[i]);
        else if (type == BPF_WILDCARD_RULE_MATCH)
            memcpy(buf+n, data, size);
        n += size;
ignore:
        switch (desc->rule_desc[i].type) {
        case BPF_WILDCARD_RULE_PREFIX:
            data += size + sizeof(u32);
            break;
        case BPF_WILDCARD_RULE_RANGE:
            data += 2 * size;
            break;
        case BPF_WILDCARD_RULE_MATCH:
            data += size;
            break;
        }
    }
    return jhash(buf, n, table->id);
}
  | 
 
使用非 RANGE 类型的规则项,且对 PREFIX 类型的规则项进行 mask 处理,然后把分表 ID 当作随机因子、使用 jhash 算法进行哈希。
3. 分组
进行哈希后,简单计算后便可得到分组数组中的某一个分组,然后将 rule 保存到该分组的链表里;当然,在保存时,可根据优先级保存到链表的某个位置。

4. 匹配
匹配步骤如下:
- 遍历分表。
 
- 计算哈希。
 
- 查询分组。
 
- 遍历分组里的所有规则。
 
- 匹配规则。
 
kernel patch 里的匹配规则如下:
 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
  | 
static u32 tm_hash(const struct wildcard_desc *desc,
           const struct tm_table *table,
           const struct wildcard_key *key)
{
    u8 buf[BPF_WILDCARD_MAX_TOTAL_RULE_SIZE];
    const void *data = key->data;
    u32 type, size, i;
    u32 n = 0;
    for (i = 0; i < desc->n_rules; i++) {
        type = desc->rule_desc[i].type;
        size = desc->rule_desc[i].size;
        if (type == BPF_WILDCARD_RULE_RANGE ||
            (type == BPF_WILDCARD_RULE_PREFIX && !table->mask->prefix[i]))
            goto ignore;
        if (likely(type == BPF_WILDCARD_RULE_PREFIX))
            __tm_copy_masked_elem(buf+n, data, size, table->mask->prefix[i]);
        else if (type == BPF_WILDCARD_RULE_MATCH)
            memcpy(buf+n, data, size);
        n += size;
ignore:
        data += size;
    }
    return jhash(buf, n, table->id);
}
static void *tm_match(const struct bpf_wildcard *wcard,
              const struct wildcard_key *key)
{
    struct tm_bucket *bucket;
    struct tm_table *table;
    struct wcard_elem *l;
    u32 hash;
    list_for_each_entry_rcu(table, &wcard->tables_list_head, list) {    // 遍历分表
        hash = tm_hash(wcard->desc, table, key);                        // 计算哈希
        bucket = &wcard->buckets[hash & (wcard->n_buckets - 1)];        // 查询分组
        hlist_for_each_entry_rcu(l, &bucket->head, node) {              // 遍历分组里的所有规则
            if (l->hash != hash)
                continue;
            if (l->table_id != table->id)
                continue;
            if (__match(wcard->desc, (void *)l->key, key))              // 匹配规则
                return l;
        }
    }
    return NULL;
}
  | 
 
已习得 TupleMerge 算法的精髓,那么有没有办法使用纯 bpf 来实现呢?
pure-bpf TupleMerge demo
既已明白 TupleMerge 算法是怎么回事,那么就使用纯 bpf 来实现一个匹配 IPv4 五元组规则的 demo 吧。
规则描述如下:
1
2
3
4
5
6
7
8
9
  | 
{
    "saddr": "10.11.12.0/24",
    "daddr": "192.168.1.0/24",
    "proto": "tcp",
    "dport": "22",
    "sport": "1024-65535",
    "action": "allow",
    "priority": 2
}
  | 
 
对应到 XDP 里,规则描述如下:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  | 
struct acl_rule {
    __u32 table_id;
    __u32 hash;
    __u8 protocol;
    __be32 saddr;
    __be32 smask;
    __be32 daddr;
    __be32 dmask;
    __be16 sport_start;
    __be16 sport_end;
    __be16 dport_start;
    __be16 dport_end;
    __u8 action;
    __u16 _pad;
} __attribute__((packed));
  | 
 
不过,不同于 kernel patch,eBPF 里要到 6.2 版本才支持链表。所以,这里使用数组来保存分表和规则。也就是,分表保存到一个 ARRAY bpf map 里,规则保存到一些 ARRAY bpf map 里;而分组则保存到一个 ARRAY_OF_MAPS bpf map 里。

因此,需要用户态空间的 Go 代码来管理这些 bpf map,并且在处理 rule 时使用同一套哈希算法。
分表处理:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  | 
func (t *aclTables) findCompatibleTable(smaskBits, dmaskBits int) *aclTable {
    for _, table := range *t {
        // xbits <= ybits means ybits is a subset of xbits
        if table.smaskBits <= smaskBits && table.dmaskBits <= dmaskBits {
            return table
        }
    }
    tblID := randID()
    for t.isTableIDUsed(tblID) {
        tblID = randID()
    }
    tbl := &aclTable{
        ID:        tblID,
        smaskBits: smaskBits,
        dmaskBits: dmaskBits,
    }
    *t = append(*t, tbl)
    return tbl
}
  | 
 
只需要看 saddr 和 daddr 是否合适即可。
哈希:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  | 
func (r *rule) doHash() {
    var hashKey struct {
        saddr    [4]byte
        daddr    [4]byte
        protocol uint8
    }
    hashKey.saddr = r.saddr
    hashKey.daddr = r.daddr
    hashKey.protocol = r.protocol
    b := (*[9]byte)(unsafe.Pointer(&hashKey))[:]
    r.hash = jhash(b, r.tableID)
}
  | 
 
此处的 r.saddr 和 r.daddr 是已经 mask 处理过的,所以直接提供给 jhash 即可。
而 jhash 则是参考 kernel 的实现,直接移植过来的。
分组:
 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
  | 
func (a *xdpAcl) saveRulesToMap(m *ebpf.Map, save func([]*rule) (*ebpf.Map, error)) error {
    maxEntries := m.MaxEntries()
    if !isPowOf2(maxEntries) {                                      // 必须是 2 的幂
        return fmt.Errorf("map max entries must be power of 2")
    }
    var index []uint32
    arr := make([][]*rule, maxEntries)
    for _, r := range a.rules {
        idx := r.hash & (maxEntries - 1)                            // 计算所在分组的索引
        arr[idx] = append(arr[idx], r)                              // 保存到分组里
        index = append(index, idx)
    }
    for _, idx := range index {
        innerMap, err := save(arr[idx])                             // 将规则保存到分组里
        if err != nil {
            return fmt.Errorf("failed to save rules: %w", err)
        }
        defer innerMap.Close()
        if err := m.Update(idx, innerMap, ebpf.UpdateAny); err != nil { // 将分组保存到 ARRAY_OF_MAPS 里
            return fmt.Errorf("failed to update acl buckets map: %w", err)
        }
    }
    return nil
}
  | 
 
匹配:
 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
  | 
struct rule_matching {
    struct packet pkt;
    __u32 table_id;
    __u32 hash;
    __u8 matched;
    __u8 action;
    __u8 pad;
} __attribute__((aligned(4)));
static __always_inline bool __match_rule(
    struct acl_rule *rule, struct packet *pkt)
{
    return ((rule->protocol == pkt->protocol) &&
        (rule->saddr == (pkt->saddr & rule->smask)) &&
        (rule->daddr == (pkt->daddr & rule->dmask)) &&
        (pkt->protocol == IPPROTO_ICMP || // tcp/udp
            (((rule->sport_start <= pkt->sport) &&
                 (pkt->sport <= rule->sport_end)) &&
                ((rule->dport_start <= pkt->dport) &&
                    (pkt->dport <= rule->dport_end)))));
}
static int __iterate_rules(struct bpf_map *map, const __u32 *key,
    struct acl_rule *value, struct rule_matching *match)
{
    if (match->table_id != value->table_id)
        return 0;
    if (match->hash != value->hash)
        return 0;
    if (__match_rule(value, &match->pkt)) {                             // 匹配规则
        match->matched = 1;
        match->action = value->action;
        return 1;
    }
    return 0;
}
static int __iterate_tables(struct bpf_map *map, const __u32 *key,
    struct acl_rule_table *value, struct rule_matching *match)
{
    struct acl_rule_hash_key hash_key = {
        .saddr = match->pkt.saddr & value->smask,
        .daddr = match->pkt.daddr & value->dmask,
        .protocol = match->pkt.protocol,
    };
    match->table_id = value->id;
    match->hash = jhash(&hash_key, 9, value->id);                       // 计算哈希值
    __u32 index = match->hash & (RULE_BUCKETS_NUM - 1);                 // 计算所在分组的索引
    void *inner_map = bpf_map_lookup_elem(&acl_rule_buckets, &index);   // 从 ARRAY_OF_MAPS 里取出分组对应的 inner bpf map
    if (!inner_map)
        return 0;
    bpf_for_each_map_elem(inner_map, __iterate_rules, match, 0);        // 遍历分组里的所有规则
    return match->matched ? 1 : 0;
}
static __always_inline int match_acl_rules(struct xdp_md *ctx)
{
    struct rule_matching match = { 0 };
    // ...
    match.pkt.saddr = iph->saddr;
    match.pkt.daddr = iph->daddr;
    match.pkt.protocol = iph->protocol;
    bpf_for_each_map_elem(&acl_tables, __iterate_tables, &match, 0);    // 遍历所有的分表
    return match.matched ? match.action : XDP_PASS;
}
  | 
 
其中,使用的 bpf_for_each_map_elem() 是 5.15 kernel 新增的 BPF helper,用于遍历 map。
最终,通过 Go 和 eBPF 的巧妙组合,便可实现一个简化版的 TupleMerge 算法。
小结
本文介绍了 TupleMerge 算法的原理,以及如何使用 Go 和 eBPF 实现一个简化版的 TupleMerge 算法。