eBPF DNS 解析记录采集

文摘   2024-07-14 08:46   江苏  

若隐若现的异常

开篇一起看一个 Spark 连接 HDFS 的错误,主要错误信息如下:

    Exception in thread "main" java.nio.channels.UnresolvedAddressException
        ...
    org.apache.spark.deploy.SparkSubmit.org$apache$spark$deploy$SparkSubmit$$runMain(SparkSubmit.scala:901)
        ...

该 issue 单流转到基础设施团队之前,已经由调度应用开发、大数据运维排查一遍,均未发现明显错误。最奇怪的这个 issue 不是所有环境都能复现,而且是特定环境特定 ai 任务才能复现,最要命的是该类型任务执行10次任务也只有1-2次失败。

基于以上的现状,既然任务错误日志直指域名解析,那最简单方法就是直接使用 tcpdump 节点上抓取下所有 DNS 解析记录以及解析结果,就可以定位是否为域名解析导致的问题,具体命令如下:

sudo tcpdump -i any port 53 -w dns_traffic.pcap

截稿前该 issue 目前还在处理过程中,当然根据前面的抓取结果是排除域名解析导致错误的可能。既然日常场景下有对于 DNS 解析结果的查询需求,那是不是可以用 eBPF 技术结合 cilium/ebpf 库,实现一个简单的工具用于实现 DNS 解析明细和结果的采集器呢?

技术调研

挂载点

kprobe/kretprobe

kprobe 挂载在 udp_recvmsg 或 udp_sendmsg 函数上,可以捕获发送和接收的 DNS 数据包。

  1. 1. udp_recvmsg

  • • 功能udp_recvmsg 函数用于从 UDP 套接字接收数据。它负责将接收到的数据包从内核缓冲区复制到用户空间缓冲区,并处理相关的协议细节。

  • • 参数:

    • • struct sock *sk:指向套接字结构的指针。

    • • struct msghdr *msg:包含接收数据的消息头。

    • • size_t len:要接收的数据长度。

    • • int flags:接收标志。

    • • int *addr_len:地址长度。

    • • struct sockaddr_in *sin:源地址。

  • • 实现:处理 UDP 特有的接收逻辑,检查数据包完整性,并处理多播、广播等特性。

  1. 1. udp_sendmsg

  • • 功能udp_sendmsg 函数用于通过 UDP 套接字发送数据。它负责将用户空间的数据复制到内核缓冲区,并将数据包发送到目标地址。

  • • 参数:

    • • struct sock *sk:指向套接字结构的指针。

    • • struct msghdr *msg:包含发送数据的消息头。

    • • size_t len:要发送的数据长度。

  • • 实现:处理 UDP 特有的发送逻辑,确保数据包正确构建,并处理多播、广播等特性。

kretprobe 可以在函数返回时捕获相关数据。

数据结构

msghdr

msghdr 结构体在 Linux 内核和用户空间之间进行消息传递时起到了关键作用。它定义了一个数据结构,用于描述消息及其关联的元数据。msghdr 结构体通常用于 sendmsg 和 recvmsg 系统调用。以下是 msghdr 结构体的详细介绍:

在用户空间,msghdr 结构体定义如下:

struct msghdr {
    void         *msg_name;       // 源IP地址和端口号
    socklen_t    msg_namelen;     // msg_name缓冲区的长度
    struct iovec *msg_iov;        // 指向数据缓冲区数组的指针
    size_t       msg_iovlen;      // 数据缓冲区数组的元素个数
    void         *msg_control;    // 指向辅助数据的指针
    size_t       msg_controllen;  // 辅助数据的长度
    int          msg_flags;       // 消息的标志
};
  1. 1. msg_name

  • • 指向目的地址的指针,可以是 sockaddr_in 或 sockaddr_in6 等。

  • • 如果消息是从某个地址发送的,则该指针指向发送方的地址信息。

  • 2. msg_namelen

    • • 目的地址的长度。

    • • 指定 msg_name 所指向地址结构的大小。

  • 3. msg_iov

    • • 指向 iovec 结构体数组的指针。

    • • iovec 结构体数组描述了数据缓冲区及其长度。

  • 4. msg_iovlen

    • • 数据缓冲区数组的元素个数。

    • • 指定 msg_iov 数组中元素的数量。

  • 5. msg_control

    • • 指向辅助数据(控制消息)的指针。

    • • 用于传递额外的控制信息,如文件描述符或特定协议的选项。

  • 6. msg_controllen

    • • 辅助数据的长度。

    • • 指定 msg_control 所指向数据的大小。

  • 7. msg_flags

    • • 消息的标志。

    • • 用于指定或获取消息的状态和行为,如 MSG_OOB(带外数据)、MSG_DONTROUTE(不使用路由)等。

    msg_iov

    msg_iov 是 msghdr 结构体中的一个重要字段,它指向一个 iovec 结构体数组。iovec 结构体数组用于描述数据缓冲区及其长度,在发送和接收数据时起到了关键作用。

    iovec 结构体定义如下:

    struct iovec {
        void  *iov_base;  // 数据缓冲区的起始地址
        size_t iov_len;   // 数据缓冲区的长度
    };
    1. 1. iov_base

    • • 发送数据:当使用 sendmsg 函数发送数据时,iov_base 指向要发送的数据缓冲区。

    • • 接收数据:当使用 recvmsg 函数接收数据时,iov_base 指向用于存储接收数据的缓冲区。

  • 2. iov_len

    • • 数据缓冲区的长度,以字节为单位。

    • • 指定从 iov_base 开始的缓冲区的大小。

    msg_iov 字段指向一个 iovec 结构体数组,这个数组可以包含多个 iovec 结构体。每个 iovec 结构体描述一个单独的缓冲区。这种设计允许在一次系统调用中处理多个数据缓冲区,从而提高效率。

    实现

    eBPF 工程

    eBPF 程序需要通过 kprobe 挂载在 udp_sendmsg 函数上,捕获并解析发送到 DNS 端口的数据包,提取出进程 ID、进程名称和 DNS 查询域名,并将这些信息推送到用户空间。程序使用了 BPF_MAP_TYPE_PERF_EVENT_ARRAY 类型的 map 来传递事件,并确保在数据读取和处理时的安全性和正确性。

    Map 类型

    eBPF 程序使用了 BPF_MAP_TYPE_PERF_EVENT_ARRAY 类型的 map,用于将捕获到的事件推送到用户空间。

    struct {
        __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    } events SEC(".maps");

    BPF_MAP_TYPE_PERF_EVENT_ARRAY 类型优势如下:

    高效的事件传递机制: BPF_MAP_TYPE_PERF_EVENT_ARRAY 提供了一种高效的机制,用于从 eBPF 程序向用户空间传递事件数据。它使用 Linux 内核的性能监控框架(perf)进行数据传输,确保事件在低延迟下被及时传递。

    适用于异步事件传递: 这种 map 类型非常适合需要异步事件通知的场景,例如网络包捕获、系统调用跟踪等。它允许 eBPF 程序在捕获到感兴趣的事件时立即将数据推送给用户空间的监听进程,而无需等待用户空间的轮询。

    大数据量处理能力: BPF_MAP_TYPE_PERF_EVENT_ARRAY 可以处理较大的数据量,适用于传递较大结构体(如 DNS 查询数据)和高频率事件。它可以高效地管理和传递这些数据,确保不会丢失重要信息。

    多 CPU 支持: 这种 map 类型支持多 CPU 环境,每个 CPU 都有自己的事件缓冲区,确保在高并发情况下不会发生竞争条件。这对于性能至关重要,因为它避免了不同 CPU 间的锁争用。

    数据结构

    定义了一个 event 结构体,用于存储捕获到的 DNS 请求的相关信息。

    struct event {
        __u32 pid;          // 进程 ID
        u32 len;            // 数据长度
        char common[64];    // 进程名称
        char domain[100];   // 域名
    };
    struct event *unused_event __attribute__((unused));

    数据解析

    eBPF 程序挂载在 udp_sendmsg 函数上,通过 kprobe 捕获发送到 DNS 端口的数据包。以下是主要的数据解析步骤:

    1. 1. 检查目的端口: 程序首先检查 udp_sendmsg 函数中的套接字的目的端口是否为 53(DNS 端口)。

      struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx);
      __u16 dport = BPF_CORE_READ(sk, __sk_common.skc_dport);

      if (dport != bpf_htons(DNS_PORT)) {
          return 0;
      }
    2. 2. 提取进程信息: 提取当前进程的 PID 和进程名称。

      struct event query = {};
      __u64 pid_tgid = bpf_get_current_pid_tgid();
      bpf_get_current_comm(query.common, 64);
      query.pid = pid_tgid >> 32;
    3. 3. 读取 msghdr 和 iovec 数据: 从 udp_sendmsg 函数的参数中读取 msghdr 和 iovec 结构体,解析出数据缓冲区的地址和长度。

      struct msghdr *msg = (struct msghdr *)PT_REGS_PARM2(ctx);
      if (!msg) {
          return 0;
      }

      const struct iovec *iov = BPF_CORE_READ(msg, msg_iter.iov);
      if (!iov) {
          return 0;
      }

      void __user *iov_base;
      bpf_probe_read(&iov_base, sizeof(iov_base), &iov->iov_base);

      u32 iov_len;
      bpf_probe_read(&iov_len, sizeof(iov_len), &iov->iov_len);

      query.len = iov_len;
    4. 4. 读取域名: 从数据缓冲区中读取域名信息,确保读取的长度不超过 query.domain 的大小。

      u32 read_len = iov_len < sizeof(query.domain) ? iov_len : sizeof(query.domain);
      int ret = bpf_probe_read(&query.domain, read_len, iov_base + 12);
      if (ret != 0) {
          return 0;
      }

    解析域名

    eBPF 代码中,有一行代码从 iov_base 开始偏移 12 字节读取数据到 query.domain 中:

    int ret = bpf_probe_read(&query.domain, read_len, iov_base + 12);

    为了理解为什么会有 iov_base + 12 的偏移,需要考虑 DNS 协议的具体结构和数据包格式。DNS 请求和响应的格式相对固定,通常包含一些标准头部字段。

    DNS 请求报文结构

    典型的 DNS 请求报文格式如下:

    1. 1. DNS Header (12 字节)

    • • 包含标识字段、标志字段、问题数、回答数、授权数和额外信息数。

  • 2. Question Section

    • • 包含查询的域名、查询类型(如 A、AAAA、MX 等)和查询类(通常为 IN)。

    在这段代码中,假设 iov_base 指向的是 DNS 数据包的起始位置:

    • • 偏移 12 字节:偏移 12 字节是因为 DNS 头部(DNS Header)的长度正好是 12 字节。DNS 头部之后就是实际的 DNS 查询名(domain name)。通过偏移 12 字节,可以直接访问和读取 DNS 查询名,而不需要解析和跳过头部字段。

    DNS Header 结构

    DNS Header 的结构可以详细描述如下:

    • • Transaction ID (2 字节),标识符,用于匹配请求和响应。客户端生成一个随机值,服务器在响应中复制该值。

    • • Flags (2 字节),控制标志,包含多个字段,用于指示报文的类型和状态:

      • • QR(1 位):查询/响应标志,0 表示查询,1 表示响应。

      • • Opcode(4 位):操作码,通常为 0(标准查询)。

      • • AA(1 位):授权回答标志,1 表示服务器是权威服务器。

      • • TC(1 位):截断标志,1 表示报文超过 512 字节并被截断。

      • • RD(1 位):期望递归标志,1 表示客户端希望进行递归查询。

      • • RA(1 位):递归可用标志,1 表示服务器支持递归查询。

      • • Z(3 位):保留字段,必须为 0。

      • • RCode(4 位):返回码,指示响应的状态(如无错误、格式错误等)。

    • • Questions (2 字节),问题数,指示报文中包含的查询问题数量。

    • • Answer RRs (2 字节),回答资源记录数,指示响应中包含的回答记录数量。

    • • Authority RRs (2 字节),授权资源记录数,指示响应中包含的授权记录数量。

    • • Additional RRs (2 字节),额外资源记录数,指示响应中包含的额外记录数量。

    总计 12 字节。因此,偏移 12 字节后,iov_base + 12 指向的是 DNS 查询部分的起始位置。

    事件推送

    最后,程序使用 bpf_perf_event_output 将解析出的 event 结构体推送到用户空间。

    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &query, sizeof(query));

    GO 工程

    格式化域名

    1. 1. ParseDNS 函数

    这个函数是整个解析过程的入口。它接收一个 []int8 类型的数组作为输入,并返回一个格式化的 DNS 域名字符串。

    func ParseDNS(bs []int8string {
        raw := convertInt8ToBytes(bs)
        return parseDNSDomain(raw)
    }
    1. 1. convertInt8ToBytes 函数

    这个函数将 []int8 转换为 []byte。DNS 数据通常以字节数组([]byte)形式处理,而不是 []int8

    func convertInt8ToBytes(bs []int8) []byte {
        ba := make([]byte0len(bs))
        for _, b := range bs {
            ba = append(ba, byte(b))
        }
        return ba
    }

    这个转换的目的是为了简化后续处理,因为 []byte 是处理二进制数据的标准类型。

    1. 1. parseDNSDomain 函数

    这个函数负责实际的 DNS 域名解析。它接收一个 []byte 类型的 DNS 查询数据,并返回一个格式化的 DNS 域名字符串。

    func parseDNSDomain(query []bytestring {
        var domain strings.Builder
        for len(query) > 0 {
            length := int(query[0])   // 获取当前标签的长度
            if length == 0 {
                break  // 遇到长度为 0 的标签,表示域名结束
            }
            if len(query) < length+1 {
                break  // 如果剩余数据不足以表示当前标签,则退出
            }
            domain.WriteString(string(query[1:length+1]) + ".")
            query = query[length+1:]  // 更新 query 指针,指向下一个标签
        }
        // 移除末尾的点
        return strings.TrimSuffix(domain.String(), ".")
    }

    DNS 域名格式

    DNS 域名由一系列标签(label)组成,每个标签前有一个字节表示其长度。例如,www.example.com 的字节表示如下:

    3www7example3com0
    • • 3 表示 www 的长度

    • • 7 表示 example 的长度

    • • 3 表示 com 的长度

    • • 0 表示域名的结束

    parseDNSDomain 函数解析流程

    1. 1. 初始化:创建一个 strings.Builder 用于构建最终的域名字符串。

    2. 2. 解析标签:

    • • 读取第一个字节,表示标签的长度。

    • • 读取该长度的字节,转换为字符串,并追加一个点(.)。

    • • 更新 query 数组,跳过已读取的部分。

  • 3. 处理结束:如果遇到长度为 0 的标签,或剩余数据不足以表示标签,则停止解析。

  • 4. 移除末尾的点:最终返回去除末尾点的域名字符串。

  • 示例

    假设输入为 []int8{3, 'w', 'w', 'w', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0}

    1. 1. 转换为 []byte[]byte{3, 'w', 'w', 'w', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0}

    2. 2. 解析为:www.example.com.,去除末尾点后得到 www.example.com

    构建

    错误的 target

    /ebpf/examples/dns/dns_failure.c:29:35: error: The eBPF is using target specific macros, please provide -target that is not bpf, bpfel or bpfeb
       29 |         struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx);

    解决方案:手动设置编译标志,-target amd64,arm64 该配置说明需要构建 x86 、arm64 架构动态库。

    //go:generate go run github.com/cilium/ebpf/cmd/bpf2go -type event -target amd64,arm64 bpf dns_failure.c -- -I../headers_fake

    参考:https://github.com/cilium/ebpf/issues/1283#issuecomment-1875424917

    原因:核心问题是使用 clang 编译 eBPF 程序时添加了不必要( bpfel/bpfeb)的编译标志。建议是使用 bpf2go 工具自动传递所需的目标标志和定义给 clang,避免手动添加 cflags。示例命令为:

    go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-12 -target arm64 -cflags "-g -O2 -Wall" ...

    多架构支持

    Compiled /ebpf/examples/dns/bpf_x86_bpfel.o
    Stripped /ebpf/examples/dns/bpf_x86_bpfel.o
    Wrote /ebpf/examples/dns/bpf_x86_bpfel.go
    /ebpf/examples/dns/dns_failure.c:29:35: error: incomplete definition of type 'struct user_pt_regs'
       29 |         struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx);
          |                                          ^~~~~~~~~~~~~~~~~~

    编译日志显示 x86 架构已经编译完成,是在编译 arm64 架构的时候报错了,通过错误信息找到对应 issue(https://lore.kernel.org/bpf/6bf1e9cb-77c8-7bb8-c55d-bf85a09819cd@huawei.com/t/),在邮件中,提到了在 x86_64 主机上交叉编译 arm64 bpf 自测程序时遇到的 struct user_pt_regs 定义不完整问题。解决方法包括在 Makefile 中添加架构特定的 UAPI 头文件目录:

    ARCH_APIDIR := $(abspath ../../../../arch/$(SRCARCH)/include/uapi)
    BPF_CFLAGS = -g -D__TARGET_ARCH_$(SRCARCH) $(MENDIAN) -I$(INCLUDE_DIR) -I$(CURDIR) -I$(APIDIR) -I$(abspath $(OUTPUT)/../usr/include) -I$(ARCH_APIDIR)

    这个解决方案确保了在交叉编译过程中正确包含目标架构的 UAPI 头文件,避免了定义不完整的问题。

    当前项目在构建时引入的头文件目录如下:

    .
    ├── bpf_core_read.h   # 提供安全读取核心内核数据结构和字段的宏。
    ├── bpf_endian.h      # 包含字节序转换函数,确保跨架构的一致性。
    ├── bpf_helper_defs.h # 定义 eBPF 程序中使用的 BPF 辅助函数原型。
    ├── bpf_helpers.h     # 包含常用的辅助宏和内联函数,简化 eBPF 程序开发。
    ├── bpf_tracing.h     # 支持通过宏将 BPF 程序附加到 tracepoints 和 kprobes 上进行跟踪。
    ├── common.h          # 包含多个 eBPF 程序共享的通用定义和宏。
    ├── update.sh         # 用于自动更新或编译相关代码和头文件的脚本。
    └── vmlinux.h         # 定义 BPF 程序访问内核内存和数据的内核数据结构。

    头文件中仅包含了 x86 vmlinux.h 文件,是不是 arm64 vmlinux.h 头文件的缺失导致编译错误?如果头文件目录中如何同时存在多架构的 vmlinux.h 文件呢?

    为了同时支持多架构,可以在头文件目录中包含多个架构的 vmlinux.h 文件,并根据目标架构选择相应的文件进行包含。

    具体实现参考 https://github.com/cilium/pwru/blob/main/bpf/headers/vmlinux.h ,以下是一个示例目录结构和包含方法:

    目录结构

    .
    ├── bpf_core_read.h
    ├── bpf_endian.h
    ├── bpf_helper_defs.h
    ├── bpf_helpers.h
    ├── bpf_tracing.h
    ├── common.h
    ├── update.sh
    ├── vmlinux-x86.h
    └── vmlinux-arm64.h

    头文件包含方法

    在源文件中根据目标架构选择适当的 vmlinux.h 文件:

    #if defined(__TARGET_ARCH_x86)
    #include "vmlinux-x86.h"
    #elif defined(__TARGET_ARCH_arm64)
    #include "vmlinux-arm64.h"
    #else
    #error "Unknown architecture"
    #endif

    确保在构建系统中定义正确的目标架构,例如在 Makefile 或编译命令中:

    CFLAGS := -D__TARGET_ARCH_$(ARCH)

    或直接在编译命令中:

    clang -D__TARGET_ARCH_arm64 -I/path/to/headers -c your_ebpf_program.c

    这样,在编译时会根据定义的架构选择相应的 vmlinux.h 文件,避免编译错误。


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