若隐若现的异常
开篇一起看一个 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.
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.
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. 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. 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. 检查目的端口: 程序首先检查
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. 提取进程信息: 提取当前进程的 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. 读取
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. 读取域名: 从数据缓冲区中读取域名信息,确保读取的长度不超过
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. 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.
ParseDNS
函数
这个函数是整个解析过程的入口。它接收一个 []int8
类型的数组作为输入,并返回一个格式化的 DNS 域名字符串。
func ParseDNS(bs []int8) string {
raw := convertInt8ToBytes(bs)
return parseDNSDomain(raw)
}
1.
convertInt8ToBytes
函数
这个函数将 []int8
转换为 []byte
。DNS 数据通常以字节数组([]byte
)形式处理,而不是 []int8
。
func convertInt8ToBytes(bs []int8) []byte {
ba := make([]byte, 0, len(bs))
for _, b := range bs {
ba = append(ba, byte(b))
}
return ba
}
这个转换的目的是为了简化后续处理,因为 []byte
是处理二进制数据的标准类型。
1.
parseDNSDomain
函数
这个函数负责实际的 DNS 域名解析。它接收一个 []byte
类型的 DNS 查询数据,并返回一个格式化的 DNS 域名字符串。
func parseDNSDomain(query []byte) string {
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. 初始化:创建一个
strings.Builder
用于构建最终的域名字符串。2. 解析标签:
• 读取第一个字节,表示标签的长度。
• 读取该长度的字节,转换为字符串,并追加一个点(
.
)。• 更新
query
数组,跳过已读取的部分。
3. 处理结束:如果遇到长度为 0
的标签,或剩余数据不足以表示标签,则停止解析。
4. 移除末尾的点:最终返回去除末尾点的域名字符串。
示例
假设输入为 []int8{3, 'w', 'w', 'w', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0}
:
1. 转换为
[]byte
:[]byte{3, 'w', 'w', 'w', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0}
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
文件,避免编译错误。