我们可以看到 eBPF 在云原生环境中进行黑盒调试的威力。不过,如何让 eBPF 程序在云原生平台上更好地被使用呢?要从两个维度来考虑,一个是纵向的,另一个是横向的。
1、纵向的深入
简单来说,就是需要不断地加深对内核的理解。
在编写 eBPF 代码的过程中,你需要运用到 Linux 内核的基本实现原理。
比如,如果我们需要追踪一个内核函数,就会发现有好几种 eBPF program 的类型可以选择,每一种 eBPF program 的输入参数还都是不一样的。如果我们需要追踪内核函数 __set_task_comm() ,想看看哪个进程的进程名字被修改了,可以用 kprobe 的方式来追踪它,也可以用 tracepoint 的方式,还能通过 raw_tracepoint 的方式来追踪。
在使用 kprobe eBPF program 的时候,它的输入参数是 struct pt_regs;在使用 tracepoint eBPF prgoram 的时候,它的输入参数是一个自定义的结构 struct task_rename;而到使用 raw tracepoint 方式的时候,输入参数又变成了 struct bpf_raw_tracepoint_args。不知道你在使用 eBPF program 的时候,有没有想过这个问题:为什么这些参数是不一样的?
void __set_task_comm(struct task_struct *tsk, const char *buf, bool exec) { task_lock(tsk); trace_task_rename(tsk, buf); strlcpy(tsk->comm, buf, sizeof(tsk->comm)); task_unlock(tsk); perf_event_comm(tsk, exec); }
SEC("kprobe/__set_task_comm") int prog(struct pt_regs *ctx) { return 0; } /* from /sys/kernel/debug/tracing/events/task/task_rename/format */ struct task_rename { __u64 pad; __u32 pid; char oldcomm[16]; char newcomm[16]; __u16 oom_score_adj; }; SEC("tracepoint/task/task_rename") int prog(struct task_rename *ctx) { return 0; } SEC("raw_tracepoint/task_rename") int prog(struct bpf_raw_tracepoint_args *ctx) { return 0; }
这个问题的答案,其实还是要从内核中 kprobe、tracepoint 等内核追踪技术里得到。比如 kprobe ,是通过把当前地址上的指令替换成 int3 中断指令来实现的。而 Linux 内核会在中断发生的时候,把当前各个寄存器的内容写入到栈内存中,而结构 pt_regs 就是用来描述这块栈内存里的内容的。因此,kprobe eBPF program 的输出参数就是 struct pt_regs,根据 Linux ABI,我们就可以知道,被追踪函数的第一个参数的值可以从 pt_regs->di 里得到了。
对于每一个 tracepoint,它都是内核中的一个特别的函数。比如上面例子中的函数 trace_task_rename(), 函数的实现是通过 tracepoint 的一个固定模板(宏)定义的。通过 bpf tracepoint 的 commit,我们看到,在 tracepoint 被调用的时候,TP_STRUCT__entry 里的变量会被赋值,tracepoint eBPF program 的输入参数就是每个 tracepoint 中 TP_STRUCT__entry 的内容。tracepoint 宏定义如下所示:
TRACE_EVENT(task_rename, TP_PROTO(struct task_struct *task, const char *comm), TP_ARGS(task, comm), TP_STRUCT__entry( __field( pid_t, pid) __array( char, oldcomm, TASK_COMM_LEN) __array( char, newcomm, TASK_COMM_LEN) __field( short, oom_score_adj) ), TP_fast_assign( __entry->pid = task->pid; memcpy(entry->oldcomm, task->comm, TASK_COMM_LEN); strlcpy(entry->newcomm, comm, TASK_COMM_LEN); __entry->oom_score_adj = task->signal->oom_score_adj; ), TP_printk("pid=%d oldcomm=%s newcomm=%s oom_score_adj=%hd", __entry->pid, __entry->oldcomm, __entry->newcomm, __entry->oom_score_adj) );
最后看 raw tracepoint。这是 eBPF 引入的 raw tracepoint program ,其目的就是访问 tracepoint 定义里的 TP_PROTO 参数。比如在我们当前的这个例子里,用了 raw tracepoint 就可以访问到 task_struct *task 参数了,可以得到 task_struct 里的所有信息,而不是 TP_STRUCT__entry 里的那几个变量了。
上面的几个例子说明,编写 eBPF 程序首先需要理解 Linux 内核的追踪机制的实现。而在使用 eBPF 定位具体内核问题的时候,就更需要理解内核对应部分的代码了。比如,如果我们要诊断网络问题,很自然地,我们需要知道一个网络数据包从网卡驱动进入到内核网络协议栈的主要函数,然后对这些函数进行追踪。下面是内核网络协议栈数据包的接收和发送主要函数图:
2、横向的应用
纵向的问题是在一个节点上的深入,而横向的问题是在云原生平台上,如何对几千几万台机器使用 eBPF。在生产环境的云原生平台上使用 eBPF,一般有两种场景:
- 第一种是针对生产环境中的特定问题做诊断。Client 收到异常 TCP RST 的问题,就是属于这种场景。
- 第二种是通过 eBPF 来采集内核相关的参数,作为云平台常规监控数据的一部分,进行长期的收集。
对于第一种场景,在几千几万台机器中使用 eBPF 来定位问题,和只在一台机器上运行 eBPF 程序还是有很大不同的,它需要考虑到下面两个问题。
第一个问题是权限的问题。在生产环境的云平台上,有 root 权限登录宿主机的同学很少,用户的 pod/container 绝大多数都是 non-privileged,而运行 eBPF 程序又需要有 privileged 的权限。这样就产生了一个很大的矛盾: 如果用户的 pod 出现网络问题,希望使用 eBPF 程序来做深入的诊断,而用户自己根本就没有权限,这样一旦发生问题,只能依靠有 root 权限的同学登录到宿主机上去执行 eBPF 程序。而有 root 权限的同学寥寥无几,这也会成为瓶颈,这样就把 eBPF 在云平台上的使用门槛不必要地拉高了。
第二个问题是多节点操作的问题。在生产环境的云平台上,要诊断一个问题,往往需要同时在多个节点上执行程序或者收集数据。比如要解决网络延时的问题,就需要在 client pod 宿主机、software LB 宿主机、server pod 宿主机上同时对一个流的数据包进行追踪。类似的多节点诊断操作在生产环境的云平台上常常会发生,如果每次都是手动到各个节点上去执行 eBPF 程序,然后汇总数据,那么工作效率也是很低下的。
要解决上面的两个问题,我们需要在云平台上建立一个运行 eBPF 诊断程序的框架。这个框架至少要包含三个部分:
- 第一部分是在每个宿主机上的 agent。这个 agent 可以用来运行各种 eBPF 诊断程序;
- 第二部分是一个 controller。这个 controller 用于接收到用户诊断的命令,然后协调相关宿主机里的 agent 来运行 eBPF 程序并且汇总结果;
- 第三部分是一个用户界面,包含用户认证、权限控制,让用户输入诊断指令并且输出结果。
在云平台上有了 eBPF 诊断的框架之后,它不仅可以帮助解决我们前面说的第一种场景中碰到的问题,其实也可以为第二种场景,也就是监控方面提供服务。在每个宿主机上运行的 agent 不但可以接收指令来执行特定的 eBPF 程序,也可以不断输出 metrics 与现有的监控平台相结合。比如,可以使用 eBPF_exporter 把一些内核相关的 metrics 不断地输出。开源项目 pixie 也提供了在云原生平台上的一个比较完整的 observability 框架和工具,底层的 agent 也是基于 eBPF。