用 eBPF 追踪 K8s 用户请求全链路,揪出性能瓶颈!
在云原生时代,Kubernetes (K8s) 已经成为容器编排的事实标准。然而,随着微服务架构的普及,K8s 集群内部的服务调用关系也变得越来越复杂。当用户请求出现性能问题时,如何快速定位瓶颈,成为了一个巨大的挑战。
传统的监控手段,例如 Metrics 和 Logging,虽然可以提供一些信息,但往往难以还原完整的请求链路。例如,一个请求经过 Ingress,Service,最终到达 Pod,中间可能经过多次转发和负载均衡。如果仅仅依靠 Metrics 和 Logging,很难确定是哪个环节出现了问题。
这个时候,eBPF (extended Berkeley Packet Filter) 就派上用场了。eBPF 是一种强大的内核技术,可以在内核中安全地运行用户自定义的代码。通过 eBPF,我们可以hook内核中的各种事件,例如网络包的收发,函数的调用等等。这使得我们可以追踪用户请求在 K8s 集群中的完整调用链,从而快速定位性能瓶颈。
eBPF 简介
eBPF 最初是为网络包过滤而设计的,后来逐渐发展成为一种通用的内核可编程技术。eBPF 程序运行在内核态,但用户无法直接编写内核代码,而是需要编写 eBPF 字节码。为了方便开发,通常会使用高级语言(例如 C)编写 eBPF 程序,然后使用 LLVM 等工具将其编译成 eBPF 字节码。
eBPF 程序运行在一个沙箱环境中,受到内核的严格安全检查。这保证了 eBPF 程序不会崩溃内核,也不会访问未经授权的资源。同时,eBPF 程序的执行效率非常高,几乎可以达到原生内核代码的水平。
如何使用 eBPF 追踪 K8s 请求链路
要使用 eBPF 追踪 K8s 请求链路,我们需要在 K8s 集群的各个节点上部署 eBPF 程序。这些 eBPF 程序负责hook内核中的关键事件,例如:
- Ingress 的入口和出口
- Service 的转发
- Pod 的请求处理
当一个用户请求到达 Ingress 时,eBPF 程序会记录下请求的元数据,例如:
- 请求 ID
- 用户 ID
- 时间戳
然后,当请求经过 Service 转发到 Pod 时,eBPF 程序会再次记录下这些元数据。通过将这些元数据关联起来,我们就可以还原出完整的请求链路。
下面是一个简单的示例,展示如何使用 eBPF 追踪 TCP 连接的建立:
#include <uapi/linux/ptrace.h>
#include <net/sock.h>
#include <net/inet_sock.h>
#include <net/net_namespace.h>
struct flow_key_t {
u32 pid;
u32 saddr;
u32 daddr;
u16 sport;
u16 dport;
u32 netns;
};
BPF_HASH(connect_timestamp, struct flow_key_t, u64);
int kprobe__tcp_v4_connect(struct pt_regs *ctx, struct sock *sk) {
struct inet_sock *inet = inet_sk(sk);
struct flow_key_t key = {
.pid = bpf_get_current_pid_tgid() >> 32,
.saddr = inet->inet_saddr,
.daddr = inet->inet_daddr,
.sport = inet->inet_sport,
.dport = sk->sk_dport,
.netns = sk->sk_net.net->ns.inum
};
u64 ts = bpf_ktime_get_ns();
connect_timestamp.update(&key, &ts);
return 0;
}
int kretprobe__tcp_v4_connect(struct pt_regs *ctx) {
struct sock *sk = (struct sock *)PT_REGS_RC(ctx);
struct inet_sock *inet = inet_sk(sk);
struct flow_key_t key = {
.pid = bpf_get_current_pid_tgid() >> 32,
.saddr = inet->inet_saddr,
.daddr = inet->inet_daddr,
.sport = inet->inet_sport,
.dport = sk->sk_dport,
.netns = sk->sk_net.net->ns.inum
};
u64 *ts = connect_timestamp.lookup(&key);
if (ts) {
bpf_trace_printk("PID %d, connect time = %lld ns\n", key.pid, bpf_ktime_get_ns() - *ts);
connect_timestamp.delete(&key);
}
return 0;
}
这个 eBPF 程序hook了 tcp_v4_connect 函数的入口和出口。在入口处,程序记录下连接的元数据和时间戳。在出口处,程序计算连接建立的时间,并将其打印到 trace log 中。
要运行这个 eBPF 程序,需要使用 BCC (BPF Compiler Collection) 工具。BCC 提供了一系列的 Python 脚本,可以方便地编译和加载 eBPF 程序。
from bcc import BPF
program = """
#include <uapi/linux/ptrace.h>
#include <net/sock.h>
#include <net/inet_sock.h>
#include <net/net_namespace.h>
struct flow_key_t {
u32 pid;
u32 saddr;
u32 daddr;
u16 sport;
u16 dport;
u32 netns;
};
BPF_HASH(connect_timestamp, struct flow_key_t, u64);
int kprobe__tcp_v4_connect(struct pt_regs *ctx, struct sock *sk) {
struct inet_sock *inet = inet_sk(sk);
struct flow_key_t key = {
.pid = bpf_get_current_pid_tgid() >> 32,
.saddr = inet->inet_saddr,
.daddr = inet->inet_daddr,
.sport = inet->inet_sport,
.dport = sk->sk_dport,
.netns = sk->sk_net.net->ns.inum
};
u64 ts = bpf_ktime_get_ns();
connect_timestamp.update(&key, &ts);
return 0;
}
int kretprobe__tcp_v4_connect(struct pt_regs *ctx) {
struct sock *sk = (struct sock *)PT_REGS_RC(ctx);
struct inet_sock *inet = inet_sk(sk);
struct flow_key_t key = {
.pid = bpf_get_current_pid_tgid() >> 32,
.saddr = inet->inet_saddr,
.daddr = inet->inet_daddr,
.sport = inet->inet_sport,
.dport = sk->sk_dport,
.netns = sk->sk_net.net->ns.inum
};
u64 *ts = connect_timestamp.lookup(&key);
if (ts) {
bpf_trace_printk("PID %d, connect time = %lld ns\n", key.pid, bpf_ktime_get_ns() - *ts);
connect_timestamp.delete(&key);
}
return 0;
}
""
b = BPF(text=program)
b.trace_print()
将上面的代码保存为 connect.py,然后运行 sudo python connect.py 即可。
eBPF 在 K8s 中的应用
除了追踪请求链路,eBPF 还可以用于很多其他的 K8s 场景,例如:
- 网络策略 enforcement
- 安全审计
- 性能监控
- 服务发现
eBPF 的挑战
虽然 eBPF 功能强大,但也存在一些挑战:
- 学习曲线陡峭
- 需要内核支持
- 存在安全风险
总结
eBPF 是一种强大的内核技术,可以用于追踪 K8s 集群中的用户请求链路,从而快速定位性能瓶颈。虽然 eBPF 存在一些挑战,但随着技术的不断发展,相信 eBPF 将会在 K8s 中发挥越来越重要的作用。