cilium/pwru 源码分析-2 数据流

文摘   2024-07-31 18:30   美国  

挂载点

以下是包含各个 eBPF 挂载点功能的表格:

挂载点挂载点类型(自定义/内核)挂载点功能
kprobe/skb_by_stackid自定义
捕获与特定 stack ID 相关的 sk_buff 操作
kprobe/skb_lifetime_termination自定义kprobe/kfree_skbmem捕获 sk_buff 生命周期结束时的事件
fexit/skb_clone内核
在 skb_clone 函数退出时触发,监控 sk_buff 的克隆操作
fexit/skb_copy内核
在 skb_copy 函数退出时触发,监控 sk_buff 的复制操作
fentry/tc内核
在流量控制(Traffic Control)入口时触发
fentry/xdp内核
在 XDP(eXpress Data Path)入口时触发
kprobe/veth_convert_skb_to_xdp_buff内核kprobe/veth_convert_skb_to_xdp_buff捕获 veth_convert_skb_to_xdp_buff 函数的调用
kretprobe/veth_convert_skb_to_xdp_buff内核kretprobe/veth_convert_skb_to_xdp_buff捕获 veth_convert_skb_to_xdp_buff 函数的返回事件

详细功能描述

  • • kprobe/skb_by_stackid:用于跟踪和诊断网络数据包的路径,通过捕获 sk_buff 的创建、复制和销毁等操作,可以分析网络数据包在内核中的流动情况,有助于网络性能调优和故障排查。

  • • kprobe/skb_lifetime_termination:用于监控和分析 sk_buff 的生命周期,特别是在网络数据包被处理完成或丢弃时,可以帮助识别网络数据包的终点,分析数据包丢失或处理延迟的原因。

  • • fexit/skb_clone:用于监控和分析 sk_buff 的克隆操作,这在多播和数据包复制场景中非常重要,可以帮助了解数据包复制操作对系统性能的影响。

  • • fexit/skb_copy:用于监控 sk_buff 的复制操作,特别是在需要对数据包进行修改或缓存时,这个挂载点有助于分析数据包处理的性能开销。

  • • fentry/tc:用于流量控制和网络数据包调度的监控和管理,通过在 tc 入口处挂载,可以分析数据包进入流量控制系统的情况,有助于网络带宽管理和流量优化。

  • • fentry/xdp:用于监控和管理 XDP 程序,这些程序在网络数据包接收到网络设备后立即执行。通过在 XDP 入口挂载,可以分析和优化高性能网络数据包处理应用。

  • • kprobe/veth_convert_skb_to_xdp_buff:用于监控虚拟以太网设备(veth)中的 sk_buff 转换操作,这在容器网络和虚拟化环境中非常重要,可以帮助优化网络性能和数据包处理效率。

  • • kretprobe/veth_convert_skb_to_xdp_buff:用于分析 sk_buff 到 XDP 缓冲区转换的结果和性能,进一步优化虚拟网络设备的处理流程。

数据流

Map 存储

map 名称map 类型数据结构解释功能解释
skb_headsBPF_MAP_TYPE_HASHkey: __u64value: bool用于标记是否已处理过特定的 sk_buff,避免重复处理
veth_skbsBPF_MAP_TYPE_HASHkey: __u64value: __u64*存储从 veth 设备转换为 xdp_buff 的 skb 地址,用于跟踪处理过程
skb_stackidBPF_MAP_TYPE_HASHkey: struct sk_buff*value: __u64存储 skb 和其对应的 stack id,用于通过 stack id 跟踪 skb
stackid_skbBPF_MAP_TYPE_HASHkey: __u64value: struct skb*存储 stack id 和其对应的 skb,用于通过 stack id 查找 skb
print_stack_mapBPF_MAP_TYPE_STACK_TRACEkey: u32value: MAX_STACK_DEPTH * sizeof(u64)存储函数调用栈的信息,用于调试和跟踪
print_skb_mapBPF_MAP_TYPE_ARRAYkey: u32value: struct print_skb_value存储 skb 的打印信息,用于输出 skb 的详细信息
print_shinfo_mapBPF_MAP_TYPE_ARRAYkey: u32value: struct print_shinfo_value存储 skb 共享信息的打印信息,用于输出 skb 共享信息的详细内容
eventsBPF_MAP_TYPE_QUEUEvalue: struct event_t存储事件数据,用于在用户空间和内核空间之间传递信息

skb_heads

函数名称操作类型
handle_everything查询 (bpf_map_lookup_elem), 更新 (bpf_map_update_elem)
kprobe_skb_lifetime_termination删除 (bpf_map_delete_elem)
track_skb_clone查询 (bpf_map_lookup_elem), 更新 (bpf_map_update_elem)
fentry_xdp查询 (bpf_map_lookup_elem), 更新 (bpf_map_update_elem)
kprobe_veth_convert_skb_to_xdp_buff查询 (bpf_map_lookup_elem)
kretprobe_veth_convert_skb_to_xdp_buff更新 (bpf_map_update_elem), 删除 (bpf_map_delete_elem)

veth_skbs

函数名称操作类型
kprobe_veth_convert_skb_to_xdp_buff更新 (bpf_map_update_elem)
kretprobe_veth_convert_skb_to_xdp_buff更新 (bpf_map_update_elem), 删除 (bpf_map_delete_elem)

skb_stackid

函数名称操作类型
handle_everything更新 (bpf_map_update_elem), 查询 (bpf_map_lookup_elem), 删除 (bpf_map_delete_elem)
kprobe_skb_lifetime_termination删除 (bpf_map_delete_elem)

stackid_skb

函数名称操作类型
handle_everything更新 (bpf_map_update_elem), 查询 (bpf_map_lookup_elem), 删除 (bpf_map_delete_elem)
kprobe_skb_lifetime_termination删除 (bpf_map_delete_elem)

events

函数名称操作类型
kprobe_skb插入 (bpf_map_push_elem)
fentry_tc插入 (bpf_map_push_elem)
fentry_xdp插入 (bpf_map_push_elem)

输入

kprobe_skb

前面在介绍如何追踪所有 skb 网络包的时候,给所有函数中入参带有 skb_buff 挂载采集 kprobe_skb 函数,我们一起来看看该函数的主要逻辑。kprobe_skb 函数的主要作用是处理传入的 sk_buff 结构体,并将处理后的事件数据推送到 events map 中。以下是该函数的详细步骤:

  1. 1. 初始化事件结构体:创建一个 event_t 类型的结构体 event,用于存储事件相关的数据。

  2. 2. 处理数据:调用 handle_everything 函数,传入 skb(网络包)、ctx(上下文)、event(事件结构体)和可选的 _stackid(栈ID)。这个函数负责填充 event 结构体的各个字段,包括网络包的元数据、元组信息、栈信息等。如果配置了跟踪 skb 或根据栈ID跟踪 skb,还会更新相应的 map。

  3. 3. 设置事件类型和地址:根据是否使用 kprobe.multi 和是否能获取函数的 IP 地址,设置事件的类型(kprobefentryfexit 等)和地址(函数地址或指令指针地址)。

  4. 4. 推送事件到 events map:使用 bpf_map_push_elem 函数将填充好的 event 结构体推送到 events map 中。这样,用户空间程序就可以从这个 map 中检索和处理这些事件数据了。

以下是 kprobe_skb 函数的代码:

static __always_inline int kprobe_skb(struct sk_buff *skb, struct pt_regs *ctx, bool has_get_func_ip, u64 *_stackid) {
    struct event_t event = {};

    // 处理 skb,填充 event 结构体
    if (!handle_everything(skb, ctx, &event, _stackid))
        return BPF_OK;

    // 设置事件的 skb 头部地址、函数地址、第二个参数和调用者地址
    event.skb_head = (u64) BPF_CORE_READ(skb, head);
    event.addr = has_get_func_ip ? bpf_get_func_ip(ctx) : PT_REGS_IP(ctx);
    event.param_second = PT_REGS_PARM2(ctx);
    if (CFG.output_caller)
        bpf_probe_read_kernel(&event.caller_addr, sizeof(event.caller_addr), (void *)PT_REGS_SP(ctx));

    // 将事件推送到 events map
    bpf_map_push_elem(&events, &event, BPF_EXIST);

    return BPF_OK;
}

这个函数通过调用 handle_everything 函数来处理 skb,并将处理后的事件数据推送到 events map 中。

handle_everything

handle_everything 函数的主要作用是处理传入的 sk_buff 结构体,并根据配置和过滤条件决定是否跟踪该 skb,以及是否将事件数据推送到 events map 中。以下是该函数的详细步骤:

  1. 1. 初始化变量

  • • tracked_by:用于记录跟踪的方式。

  • • skb_head:读取 skb 的头部地址。

  • • stackid:如果配置了 track_skb_by_stackid,则获取栈ID。

  • 2. 检查配置

    • • 如果配置了 track_skb_by_stackid,则获取栈ID。

    • • 如果配置了 is_set,则继续处理。

  • 3. 检查是否跟踪 skb

    • • 如果配置了 track_skb 并且 skb_heads map 中存在 skb_head,则设置 tracked_by 为 TRACKED_BY_SKB 或 TRACKED_BY_STACKID,跳过下面的逻辑跳转到输出部分。

    • • 如果配置了 track_skb_by_stackid 并且 stackid_skb map 中存在 stackid,则设置 tracked_by 为 TRACKED_BY_STACKID,跳过下面的逻辑跳转到输出部分。

    • • 如果通过过滤条件,则设置 tracked_by 为 TRACKED_BY_FILTER,跳过下面的逻辑跳转到输出部分。

    • • 如果以上三种条件均不匹配,则返回 false 退出当前函数。

  • 4. 设置输出

    • • 如果配置了 track_skb 并且 tracked_by 为 TRACKED_BY_FILTER,则更新 skb_heads map。

      • • 条件:cfg->track_skb 为真且 tracked_by 等于 TRACKED_BY_FILTER。

      • • 操作:将 skb_head 作为键,TRUE 作为值,更新到 skb_heads map 中。

      • • 目的:如果 skb 是通过过滤条件跟踪的,则将其头部地址记录在 skb_heads map 中,以便后续跟踪

    • • 如果配置了 track_skb_by_stackid 并且 tracked_by 不为 TRACKED_BY_STACKID,则更新 stackid_skb 和 skb_stackid map。

      • • 条件:cfg->track_skb_by_stackid 为真且 tracked_by 不等于 TRACKED_BY_STACKID。

      • • 操作:

        • • 查找 skb_stackid map 中是否存在当前 skb 的旧栈ID。

        • • 如果存在且旧栈ID不等于当前栈ID,则从 stackid_skb map 中删除旧栈ID对应的条目。

        • • 将新的 stackid 和 skb 更新到 stackid_skb 和 skb_stackid map 中。

      • • 目的:确保 skb 的栈ID与 skb 的映射关系是最新的,并且删除旧的无效映射。

    • • 设置事件的元数据、元组信息、skb 信息、共享信息和栈信息。

  • 5. 设置事件的其他字段

    • • 设置事件的进程ID、时间戳和CPU ID。

  • 6. 返回

    • • 返回 true 表示处理成功,返回 false 表示处理失败。

    filter

    filter_pcap

    filter_pcap 函数结合了多个子函数来实现对 sk_buff 结构体的过滤策略。以下是 filter_pcap 及其相关子函数的总结:

    1. 1. MAC 层长度检查

    • • filter_pcap 首先检查 skb 的 mac_len 字段。

    • • 如果 mac_len 为 0,表示数据包没有 MAC 层头部,调用 filter_pcap_l3 进行 L3 层过滤。

    • • 否则,调用 filter_pcap_l2 进行 L2 层过滤。

  • 2. L3 层过滤

    • • filter_pcap_l3 读取 skb 的头部地址。

    • • 计算网络层头部的起始地址和数据结束地址。

    • • 调用 filter_pcap_ebpf_l3 函数进行实际的 L3 层过滤。

    • • filter_pcap_ebpf_l3 检查数据包头部和数据结束地址是否一致。

  • 3. L2 层过滤

    • • filter_pcap_l2 读取 skb 的头部地址。

    • • 计算 MAC 层头部的起始地址和数据结束地址。

    • • 调用 filter_pcap_ebpf_l2 函数进行实际的 L2 层过滤。

    • • filter_pcap_ebpf_l2 检查数据包头部和数据结束地址是否一致。

  • 4. 确保数据包头部和数据结束地址一致的检查是通过 filter_pcap_ebpf_l3 和 filter_pcap_ebpf_l2 函数实现的。这两个函数的实现如下:

    static __noinline bool
    filter_pcap_ebpf_l3(void *_skb, void *__skb, void *___skb, void *data, void* data_end) {
        return data != data_end && _skb == __skb && __skb == ___skb;
    }

    static __noinline bool
    filter_pcap_ebpf_l2(void *_skb, void *__skb, void *___skb, void *data, void* data_end) {
        return data != data_end && _skb == __skb && __skb == ___skb;
    }

    这两个函数通过以下方式检查数据包头部和数据结束地址的一致性:传递三次 skb 是为了确保在 eBPF 程序中进行指针一致性检查。通过传递三个相同的指针并在函数内部进行比较,可以确保这些指针在传递过程中没有被修改。这种方法可以增加代码的健壮性和安全性。

    1. 1. 检查数据包头部和数据结束地址是否一致

    • • data != data_end:确保数据包头部地址 data 和数据结束地址 data_end 不相同。

  • 2. 检查 skb 指针的一致性

    • • _skb == __skb && __skb == ___skb:确保传入的 skb 指针 _skb__skb 和 ___skb 是相同的。

    filter_meta

    filter_meta 函数用于解析并过滤 sk_buff 结构体中的元数据。具体来说,它根据配置 (cfg) 中的网络命名空间 (netns)、标记 (mark) 和接口索引 (ifindex) 来过滤数据包。如果数据包的这些元数据与配置不匹配,则函数返回 false,表示过滤掉该数据包;否则返回 true

    static __always_inline bool
    filter_meta(struct sk_buff *skb) {
        if (cfg->netns && get_netns(skb) != cfg->netns) {
            return false;
        }
        if (cfg->mark && BPF_CORE_READ(skb, mark) != cfg->mark) {
            return false;
        }
        if (cfg->ifindex != 0 && BPF_CORE_READ(skb, dev, ifindex) != cfg->ifindex) {
            return false;
        }
        return true;
    }

    输出

    set_output 主要功能是根据配置 (cfg) 设置 event 结构体的各个字段。它会调用一系列以 set_ 开头的子函数来填充 event 结构体中的元数据、元组、skb 和 shinfo 信息,以及堆栈信息。

    • • 根据配置 (cfg) 调用不同的 set_ 开头的子函数来填充 event 结构体的字段。

    1. 1. set_meta

    • • 功能:设置 skb_meta 结构体中的元数据字段。

    • • 主要操作:读取 sk_buff 结构体中的网络命名空间、标记、长度、协议、接口索引和 MTU 等字段。

  • 2. set_tuple

    • • 功能:设置 tuple 结构体中的源地址、目的地址、源端口、目的端口、L3 协议和 L4 协议字段。

    • • 主要操作:根据 sk_buff 结构体中的网络头部和 MAC 头部信息,读取 IP 和传输层协议的相关字段。

  • 3. set_skb_btf

    • • 功能:设置 event 结构体中的 print_skb_id 字段,并将 sk_buff 结构体的信息写入 print_skb_map 映射中。

    • • 主要操作:使用 bpf_snprintf_btf 函数将 sk_buff 结构体的信息格式化为字符串并存储在 print_skb_map 映射中。

  • 4. set_shinfo_btf

    • • 功能:设置 event 结构体中的 print_shinfo_id 字段,并将 skb_shared_info 结构体的信息写入 print_shinfo_map 映射中。

    • • 主要操作:使用 bpf_snprintf_btf 函数将 skb_shared_info 结构体的信息格式化为字符串并存储在 print_shinfo_map 映射中。

  • 5. set_xdp_meta

    • • 功能:设置 skb_meta 结构体中的元数据字段(适用于 xdp_buff)。

    • • 主要操作:读取 xdp_buff 结构体中的网络命名空间、接口索引和 MTU 等字段。

  • 6. set_xdp_tuple

    • • 功能:设置 tuple 结构体中的源地址、目的地址、源端口、目的端口、L3 协议和 L4 协议字段(适用于 xdp_buff)。

    • • 主要操作:根据 xdp_buff 结构体中的数据头部信息,读取 IP 和传输层协议的相关字段。

  • 7. set_xdp_output

    • • 功能:根据配置 (cfg) 调用不同的 set_xdp_ 开头的子函数来填充 event 结构体的字段(适用于 xdp_buff)。

    • • 主要操作:调用 set_xdp_metaset_xdp_tuple 和堆栈信息设置函数。

    Meta

    SKB                CPU PROCESS          NETNS      MARK/x        IFACE       PROTO  MTU   LEN   FUNC
    0xffff888d92d8f400 0   ~r/bin/ping:3432 4026531840 0               0         0x0000 1500  84    __ip_local_out
    0xffff888d92d8f400 0   ~r/bin/ping:3432 4026531840 0               0         0x0800 1500  84    ip_output
    0xffff888d92d8f400 0   ~r/bin/ping:3432 4026531840 0            enp0s5:2     0x0800 1500  84    nf_hook_slow

    set_meta 函数主要设置 skb_meta 结构体中的元数据字段。以下是 skb_meta 结构体中各个字段的含义:

    struct skb_meta {
        u32 netns;     // 网络命名空间标识符
        u32 mark;      // 数据包的标记
        u32 ifindex;   // 接收数据包的网络接口索引
        u32 len;       // 数据包的长度
        u32 mtu;       // 最大传输单元
        u16 protocol;  // 数据包的协议类型
        u16 pad;       // 填充字段,用于对齐
    } __attribute__((packed));

    字段解释:

    • • netns: 网络命名空间标识符,用于标识数据包所属的网络命名空间。

    • • mark: 数据包的标记,通常用于流量控制和分类。

    • • ifindex: 接收数据包的网络接口索引,标识数据包是通过哪个网络接口接收的。

    • • len: 数据包的长度,以字节为单位。

    • • mtu: 最大传输单元,表示网络接口能够传输的最大数据包大小。

    • • protocol: 数据包的协议类型,例如 IPv4、IPv6 等。

    • • pad: 填充字段,用于对齐结构体。

    netns

    netns 是指网络命名空间(network namespace)的标识符。它是一个唯一的 ID,用于标识数据包所属的网络命名空间。网络命名空间允许多个网络堆栈在同一主机上共存,每个网络命名空间都有自己的网络设备、IP 地址、路由表等。

    在代码中,netns 通常通过读取网络设备或套接字的 ns.inum 字段来获取。例如:

    u32 netns = BPF_CORE_READ(skb, dev, nd_net.net, ns.inum);

    如果 skb->dev 并没有完成初始化,则尝试从 skb->sk->__sk_common.skc_net.net->ns.inum 中获取 netns,具体实现如下:

    if (netns == 0) {
            struct sock *sk = BPF_CORE_READ(skb, sk);
            if (sk != NULL) {
                netns = BPF_CORE_READ(sk, __sk_common.skc_net.net, ns.inum);
            }
        }

    要在操作系统中查找对应的网络命名空间(network namespace)ID,可以使用以下命令:

    # lsns -t net
            NS TYPE NPROCS     PID USER       NETNSID NSFS                           COMMAND
    4026531888 net     410       1 root    unassigned /run/docker/netns/default      /usr/lib/systemd/systemd --switched-root --system --deserialize 18
    4026533120 net       2    6925 root             8 /run/docker/netns/4cdb6c37b059 /pause
    4026533207 net      17    2121 10000            1 /run/docker/netns/6c0353b4f2cb nginx: master process nginx -g daemon off;
    4026533249 net       1    2122 unbound          0 /run/docker/netns/542b5bdf4251 redis-server *:6379         
    4026533407 net       1    2139 10000            2 /run/docker/netns/fd1784876cc1 /harbor/harbor_core
    4026533582 net       6    7019 root             9 /run/docker/netns/f38fd9c6a931 /pause

    mark

    sk_buff 结构体中的 mark 字段用于存储数据包的标记值。这个标记值可以由内核或用户空间程序设置,并且可以用于多种用途,例如流量控制、路由决策和防火墙规则等。

    主要用途

    1. 1. 流量控制mark 可以用于区分不同类型的流量,从而应用不同的流量控制策略。

    2. 2. 路由决策:在路由过程中,mark 可以用于选择不同的路由表或策略路由。

    3. 3. 防火墙规则:防火墙可以根据 mark 的值来应用不同的规则,从而实现更细粒度的流量过滤。

    设置 mark

    mark 可以通过多种方式设置,例如:

    • • iptables:使用 iptables 的 MARK 目标来设置数据包的 mark 值。

    • • tc:使用 tc(Traffic Control)命令来设置数据包的 mark 值。

    # 使用 iptables 设置 mark
    iptables -t mangle -A PREROUTING -s 192.168.1.1 -j MARK --set-mark 1

    # 使用 tc 设置 mark
    tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 match ip src 192.168.1.1 flowid 1:1 action skbedit mark 1

    读取 mark

    在内核代码中,可以使用 skb->mark 来读取数据包的 mark 值。例如,在 eBPF 程序中:

    u32 mark = BPF_CORE_READ(skb, mark);

    在云原生场景下,skb 中 mark 字段还是被大部分 CNI 组件用来做各种黑魔法,可以查看《Packet mark in a Cloud Native world[1]》这篇文章。

    MTU

    pwru 在获取数据包 MTU 信息时,先直接从 skb->mtu 中获取,如果 skb 中包含一条 dst_entry (目标路由条目),则 skb->_skb_refdst->dst_entry->_metrics + RTAX_MTU - 1 计算出准确的 mtu 信息,如果还是获取不到 mtu ,作为兜底策略从 dst->dev->mtu获取mtu

        meta->mtu = BPF_CORE_READ(skb, dev, mtu);
        struct dst_entry *dst = __SKB_DST_PTR(BPF_CORE_READ(skb, _skb_refdst));
        if (dst) {
            u32 *metrics = __DST_METRICS_PTR(BPF_CORE_READ(dst, _metrics));
            bpf_probe_read_kernel(&meta->mtu, sizeof(meta->mtu), metrics + RTAX_MTU - 1);
            if (!meta->mtu)
                meta->mtu = BPF_CORE_READ(dst, dev, mtu);
        }

    metrics 是一个指向 dst_entry 结构体中 _metrics 字段的指针。dst_entry 结构体中的 _metrics 字段通常是一个包含各种路由度量(如 MTU、RTT 等)的数组。通过 __DST_METRICS_PTR 宏,metrics 指针被转换为一个 u32 类型的指针,以便访问这些度量值。

    metrics + RTAX_MTU - 1 这种算法的原理是通过指针运算来访问 metrics 数组中的特定元素。具体来说,metrics 是一个指向 u32 类型的指针,而 RTAX_MTU 是一个宏,通常定义为 2。通过这种方式,可以访问 metrics 数组中的第二个元素(即 RTAX_MTU 对应的 MTU 值)。

    1. 1. metrics 是一个指向 u32 类型的指针,表示一个数组的起始地址。

    2. 2. RTAX_MTU 是一个宏,定义为 2,表示 MTU 在 metrics 数组中的索引。

    3. 3. metrics + RTAX_MTU 将指针移动到 metrics 数组的第二个元素。

    4. 4. metrics + RTAX_MTU - 1 将指针移动到 metrics 数组的第一个元素。

    Tuple

    pwru 在获取 tuple 信息时候,先获取 IP 头部的版本字段,再根据 v4 或者 v6 不同版本获取 源地址、目标地址、协议(TCP/UDP),最后根据传输协议获取 源端口、目标端口。

    SKB                CPU PROCESS          TUPLE FUNC
    0xffff888d910c5a00 0   ~r/bin/ping:1830 10.**.***.114:0->1.1.1.1:0(icmp) __ip_local_out
    0xffff888d910c5a00 0   ~r/bin/ping:1830 10.**.***.114:0->1.1.1.1:0(icmp) ip_output
    0xffff888d910c5a00 0   ~r/bin/ping:1830 10.**.***.114:0->1.1.1.1:0(icmp) nf_hook_slow

    如何判断 IP 版本

    1. 1. 读取 sk_buff 结构体中的 head 和 network_header 字段。

    2. 2. 根据 network_header 偏移量获取 IP 头部。l3_off 是指网络层(Layer 3)的偏移量。这里的 l3 代表 OSI 模型中的第三层,即网络层。网络层负责数据包的路由和转发,常见的协议包括 IPv4 和 IPv6。在网络编程中,l3_off 通常用于表示从数据缓冲区起始位置到网络层头部(例如 IP 头部)的偏移量。通过这个偏移量,可以定位到数据包的网络层头部,从而读取和处理 IP 头部信息。

    3. 3. 读取 IP 头部的版本字段,判断是 IPv4 还是 IPv6。

    void *skb_head = BPF_CORE_READ(skb, head);
    u16 l3_off = BPF_CORE_READ(skb, network_header);

    struct iphdr *l3_hdr = (struct iphdr *) (skb_head + l3_off);
    u8 ip_vsn = BPF_CORE_READ_BITFIELD_PROBED(l3_hdr, version);

    // 跳过处理非 v4 或者 v6 的连接
    if (ip_vsn !=4 && ip_vsn != 6)
      return;

    bool is_ipv4 = ip_vsn == 4;

    如何获取连接信息

    1. 1. 通过 is_ipv4 判断是否为 IP v4,反之为 IP v6。

    2. 2. 设置 l3_proto

    • • ETH_P_IP 和 ETH_P_IPV6 是以太网协议类型字段的值,用于标识以太网帧中封装的上层协议类型。这些值是由 IEEE 802.3 标准定义的。

    • • ETH_P_IP (0x0800) 表示以太网帧中封装的是 IPv4 协议。

    • • ETH_P_IPV6 (0x86DD) 表示以太网帧中封装的是 IPv6 协议。

  • 3. 获取 源、目标地址,协议

    • • 对于 IPv4,读取 struct iphdr 中的 saddr 和 daddr 字段获取源地址和目标地址,读取 protocol 字段获取 l4_proto 协议。

    • • 对于 IPv6,读取 struct ipv6hdr 中的 saddr 和 daddr 字段获取源地址和目标地址,读取 nexthdr 字段获取 l4_proto 协议。

  • 4. 获取 l4_off

    • • l4_off是指向第 4 层协议头部(如 TCP 或 UDP)的偏移量。它是通过在第 3 层协议头部(如 IPv4 或 IPv6)偏移量的基础上加上第 3 层头部的长度来计算的。

    • • IPv4 头部的长度是可变的,由 ihl(Internet Header Length)字段指定。ihl 字段的单位是 32 位字(4 字节)。因此,l4_off 的计算方式是:l3_off + ihl * 4

    • • IPv6 头部的长度是固定的,通常为 40 字节。但是在代码中,使用了 ipv6_hdrlen(ip6) 函数来计算 IPv6 头部的长度。这是因为 IPv6 头部可能包含扩展头部,扩展头部的长度也需要考虑在内。

  • 5. 获取 源、目标端口

    • • 检查 tpl->l4_proto 是否为 IPPROTO_TCP 或 IPPROTO_UDP

    • • 如果是 IPPROTO_TCP,读取 tcphdr 结构体中的 source 和 dest 字段,分别获取源端口和目标端口。

    • • 如果是 IPPROTO_UDP,读取 udphdr 结构体中的 source 和 dest 字段,分别获取源端口和目标端口。

    static __always_inline void
    __set_tuple(struct tuple *tpl, void *data, u16 l3_off, bool is_ipv4) {
        u16 l4_off;

      // 如果是 IPV4
        if (is_ipv4) {
        // 获取 IPV4 头指针
            struct iphdr *ip4 = (struct iphdr *) (data + l3_off);
        // 获取 源、目标地址
            BPF_CORE_READ_INTO(&tpl->saddr, ip4, saddr);
            BPF_CORE_READ_INTO(&tpl->daddr, ip4, daddr);
        // 获取协议类型
            tpl->l4_proto = BPF_CORE_READ(ip4, protocol);
        // 设置 IP 版本
            tpl->l3_proto = ETH_P_IP;
        // 获取 L4 偏移量,用于获取端口信息
            l4_off = l3_off + BPF_CORE_READ_BITFIELD_PROBED(ip4, ihl) * 4;

        } else {
            struct ipv6hdr *ip6 = (struct ipv6hdr *) (data + l3_off);
            BPF_CORE_READ_INTO(&tpl->saddr, ip6, saddr);
            BPF_CORE_READ_INTO(&tpl->daddr, ip6, daddr);
            tpl->l4_proto = BPF_CORE_READ(ip6, nexthdr); // TODO: ipv6 l4 protocol
            tpl->l3_proto = ETH_P_IPV6;
            l4_off = l3_off + ipv6_hdrlen(ip6);
        }

        if (tpl->l4_proto == IPPROTO_TCP) {
        // 获取 TCP 协议头部的指针
            struct tcphdr *tcp = (struct tcphdr *) (data + l4_off);
            tpl->sport= BPF_CORE_READ(tcp, source);
            tpl->dport= BPF_CORE_READ(tcp, dest);
        } else if (tpl->l4_proto == IPPROTO_UDP) {
        // 获取 UDP 协议头部的指针
            struct udphdr *udp = (struct udphdr *) (data + l4_off);
            tpl->sport= BPF_CORE_READ(udp, source);
            tpl->dport= BPF_CORE_READ(udp, dest);
        }
    }

    BTF

    set_skb_btf 和 set_shinfo_btf 两个函数的作用如下:

    • • set_skb_btf:

      • • 作用:将 struct sk_buff 结构体的内容格式化为 BTF(BPF Type Format)字符串,并将其存储在 print_skb_map BPF 映射中。

      • • 主要步骤:

    1. 1. 初始化 BTF 指针,设置类型 ID 和指针地址。

    2. 2. 获取并递增 print_skb_id,确保每个事件有唯一的 ID。

    3. 3. 在 print_skb_map 中查找对应 ID 的条目。

    4. 4. 使用 bpf_snprintf_btf 函数将 struct sk_buff 的内容格式化为字符串。

    5. 5. 将格式化后的字符串长度和内容存储在映射中。

    # ./pwru --output-skb 'host 1.1.1.1'
    SKB                CPU PROCESS          FUNC
    0xffff888d8b791a00 1   ~r/bin/ping:4317 __ip_local_out
    (struct sk_buff){
     (union){
      .sk = (struct sock *)0x00000000a4c1108e,
      .ip_defrag_offset = (int)-2065085440,
     },
     ...
    }

    set_shinfo_btf:

    • • 作用:将 struct skb_shared_info 结构体的内容格式化为 BTF 字符串,并将其存储在 print_shinfo_map BPF 映射中。

    • • 主要步骤:

    1. 1. 获取 struct sk_buff 末尾的 struct skb_shared_info 结构体。

    2. 2. 初始化 BTF 指针,设置类型 ID 和指针地址。

    3. 3. 获取并递增 print_shinfo_id,确保每个事件有唯一的 ID。

    4. 4. 在 print_shinfo_map 中查找对应 ID 的条目。

    5. 5. 使用 bpf_snprintf_btf 函数将 struct skb_shared_info 的内容格式化为字符串。

    6. 6. 将格式化后的字符串长度和内容存储在映射中。

    # ./pwru --output-skb-shared-info 'host 1.1.1.1'
    SKB                CPU PROCESS          FUNC
    0xffff888d8c7efa00 0   ~r/bin/ping:4486 __ip_local_out
    (struct skb_shared_info){
     .dataref = (atomic_t){
      .counter = (int)1,
     },
    }

    Stack

    # ./pwru --output-stack 'host 1.1.1.1'
    SKB                CPU PROCESS          FUNC
    0xffff888d90f6e200 0   ~r/bin/ping:4680 __ip_local_out
    __ip_local_out
    ping_v4_sendmsg
    inet_sendmsg
    __sock_sendmsg
    __sys_sendto
    __x64_sys_sendto
    x64_sys_call
    do_syscall_64
    entry_SYSCALL_64_after_hwframe

    内核侧堆栈的写入过程

    1. 1. 定义 BPF 映射:在 eBPF 程序中定义一个 BPF_MAP_TYPE_STACK_TRACE 类型的映射,用于存储堆栈信息。

      struct {
          __uint(type, BPF_MAP_TYPE_STACK_TRACE);
          __uint(max_entries, 256);
          __uint(key_size, sizeof(u32));
          __uint(value_size, MAX_STACK_DEPTH * sizeof(u64));
      } print_stack_map SEC(".maps");
    2. 2. 捕获堆栈 ID:在 eBPF 程序中,通过 bpf_get_stackid 函数获取堆栈 ID,并将其存储在事件结构中。

      static __always_inline void
      set_output(void *ctx, struct sk_buff *skb, struct event_t *event) {
          if (cfg->output_stack) {
              event->print_stack_id = bpf_get_stackid(ctx, &print_stack_map, BPF_F_FAST_STACK_CMP);
          }
      }

    用户态侧获取堆栈信息

    func getStackData(event *Event, o *output) (stackData string) {
        var stack StackData
        id := uint32(event.PrintStackId)
      // 根据 stack id 获取调用链
        if err := o.printStackMap.Lookup(&id, &stack); err == nil {
            for _, ip := range stack.IPs {
                if ip > 0 {
            // 将函数 IP 转换为函数名
                    stackData += fmt.Sprintf("\n%s", o.addr2name.findNearestSym(ip))
                }
            }
        }
      // 清理已经处理的 stack id
        _ = o.printStackMap.Delete(&id)
        return stackData
    }

    用户态程序中获取堆栈信息的关键函数是 getStackData。以下是该函数的详细总结:

    1. 1. 定义堆栈数据结构:定义一个 StackData 结构体实例,用于存储堆栈信息。

    2. 2. 查找堆栈信息:通过 printStackMap.Lookup 函数,根据事件中的 PrintStackId 从 BPF 映射中查找堆栈信息。

    3. 3. 解析堆栈信息:遍历堆栈中的 IP 地址,并使用 addr2name.findNearestSym 函数将 IP 地址解析为符号名称。

    4. 4. 删除堆栈条目:从 printStackMap 中删除已处理的堆栈条目。

    5. 5. 返回堆栈数据:将解析后的堆栈信息拼接成字符串并返回。

    在 getStackData 函数中,stack 结构体中的 IPs 是一个存储堆栈中每个函数调用地址的数组。每个 ip 是一个程序计数器(PC)地址,表示堆栈中的一个函数调用位置。

    这些 ip 地址是通过 eBPF 程序捕获的,当 eBPF 程序调用 bpf_get_stackid 函数时,内核会记录当前的调用堆栈,并将这些地址存储在 BPF_MAP_TYPE_STACK_TRACE 类型的映射中。用户态程序通过查找这些地址并使用符号解析工具(如 addr2name.findNearestSym)将这些地址转换为可读的函数名称。

    func (a *Addr2Name) findNearestSym(ip uint64string {
        total := len(a.Addr2NameSlice)
        i, j := 0, total
      // 二分法查找最接近 ip 
        for i < j {
        // 中间位置
            h := int(uint(i+j) >> 1)
        // 如果查找 ip 在 Addr2NameSlice 右侧
            if a.Addr2NameSlice[h].addr <= ip {
          // 确保 h+1 不会超出数组的边界
          // 如果 a.Addr2NameSlice[h].addr 小于等于 ip,并且 a.Addr2NameSlice[h+1].addr 大于 ip,则 a.Addr2NameSlice[h] 是最接近 ip 的符号地址。这样可以确保返回的符号名称是最接近 ip 的符号名称。
                if h+1 < total && a.Addr2NameSlice[h+1].addr > ip {
                    return strings.Replace(a.Addr2NameSlice[h].name, "\t"""-1)
                }
          // 当 Addr2NameSlice[h].addr 小于等于 ip 时,说明目标 ip 在 Addr2NameSlice 的右半部分。因此,需要将左边界 i 更新为 h + 1,以缩小查找范围到右半部分。这样可以继续在右半部分进行查找,直到找到最接近 ip 的符号地址。
                i = h + 1
            } else {
          // 在二分查找法中,i 和 j 分别表示查找范围的左右边界。h 是当前查找范围的中间位置。  如果 Addr2NameSlice[h].addr 大于 ip 说明目标 ip 在 Addr2NameSlice 的左半部分。因此,需要将右边界 j 更新为 h,以缩小查找范围到左半部分。这样可以继续在左半部分进行查找,直到找到最接近 ip 的符号地址。
                j = h
            }
        }
      // 在二分查找结束后,如果没有在循环中找到确切的匹配项,i 会指向第一个大于 ip 的元素位置。因此,i-1 就是小于等于 ip 的最大元素位置。由于 Addr2NameSlice 是按地址排序的,所以 i-1 位置的元素是最接近且小于等于 ip 的符号地址。
        return strings.Replace(a.Addr2NameSlice[i-1].name, "\t"""-1)
    }

    findNearestSym 函数从 Addr2NameSlice 获取对应关系。Addr2NameSlice 是一个按地址排序的 ksym 结构体指针数组,存储了地址和符号名称的对应关系。

    这些对应关系是在 ParseKallsyms 函数中从 /proc/kallsyms 文件中解析并填充到 Addr2NameSlice 中的。ParseKallsyms 函数读取 /proc/kallsyms 文件的每一行,将地址和符号名称解析出来,并存储在 Addr2NameMap 和 Addr2NameSlice 中。

    引用链接

    [1] Packet mark in a Cloud Native world: https://lpc.events/event/7/contributions/683/attachments/554/979/lpc20-pkt-mark-slides.pdf


    朱慧君
    大龄yaml工程师逼逼叨