eBPF 概述

什么是 eBPF

eBPF(extended Berkeley Packet Filter)是一种运行在内核中的虚拟机,基于它可以在不修改内核代码、不加载额外的内核模块的前提下,安全、高效地扩展内核的功能。它能够运行 BPF 程序,用户可以按需注入 BPF 程序以在内核中运行。这些程序遵循 eBPF 提供的特定指令集,具有某些需要遵循的规则,并且只运行安全的程序。

eBPF 的使用正在兴盛,越来越多的 eBPF 程序被应用。例如,用 eBPF 代替 iptables 规则可以将应用发出的包直接转发到对端的 socket 来有效地处理数据包,通过缩短数据路径来加速数据平面。

eBPF 的核心原理

eBPF 的架构图如下:

eBPF 分为两部分,分别是运行在用户空间的程序和运行在内核空间的程序。用户空间程序负责把 BPF 字节码加载到内核空间的 eBPF 虚拟机中,并在需要的时候读取内核返回的各种事件信息、统计信息;而内核中的 BPF 虚拟机负责执行内核中的特定事件,如果需要传递数据,就将执行结果通过 BPF map 或 perf 缓冲区中的 perf-events 发送至用户空间。整个流程如下:

  1. 编写好的 BPF 程序会被 Clang、LLVM 等工具编译成 BPF 的字节码(因为 BPF 程序并不是普通的 ELF 程序,而是要运行在虚拟机中的字节码)。eBPF 程序中还会包含配置的事件源,所谓事件源其实就是一些需要 hook 的挂载点。

  2. 加载器会在程序运行前通过 eBPF 系统调用加载到内核,这时候验证器会验证字节码的安全性,比如校验循环次数必须在有限时间内结束等。当校验通过后,一旦挂载的事件发生,就会在 eBPF 虚拟机中执行字节码的逻辑。

  3. (可选)逐个事件输出,或通过 BPF map 返回统计数据、调用栈数据,传递至用户空间。

eBPF 支持静态追踪 socket、tracepoint、USDT,动态追踪 kprobe、uprobe 等几大类探针。

动态追踪

eBPF 提供了:

  • 面向内核的 kprobe/kretprobe,k = kernel
  • 面向应用的 uprobe/uretprobe,u = user land

分别用于探测函数入口处和函数返回(ret)处的信息。

kprobe/kretprobe 可以探测内核大部分函数,出于安全考虑,有部分内核函数不允许安装探针,有可能会导致跟踪失败。

uprobe/uretprobe 用来实现用户态程序动态追踪的机制。与 kprobe/kretprobe 类似,区别在于跟踪的函数是用户程序中的函数而已。

动态追踪技术依赖内核和应用的符号表,对于那些 inline 或者 static 函数则无法直接安装探针,需要自行通过 offset 实现。可以借助 nm 或者 strings 指令查看应用的符号表。

动态追踪技术的原理与 GDB 类似,当对某段代码安装探针,内核会将目标位置指令复制一份,并替换为 int3 中断, 执行流跳转到用户指定的探针 handler,再执行备份的指令,如果此时也指定了 ret 探针,也会被执行,最后再跳转回原来的指令序列。

接下来看看如何进行动态追踪。首先编写一段 main.go 测试代码:

1
2
3
4
5
6
7
8
9
package main

func main() {
println(sum(3, 3))
}

func sum(a, b int) int {
return a + b
}

接下来,关闭内联优化编译代码,执行 go build -gcflags="-l" ./main.go 命令。如果开启内联优化的话,很可能 Go 的编译器会在编译期消除函数调用,这样 eBPF 就会找不到函数对应的探针了。

下一步,编写 bpftrace 脚本 main.pt

1
2
3
4
5
6
7
8
BEGIN{
printf("Hello!\n");
}
uprobe:./main:main.sum {printf("a: %d b: %d\n", reg("ax"), reg("bx"))}
uretprobe:./main:main.sum {printf("retval: %d\n", retval)}
END{
printf("Bye!\n");
}

最后执行 bpftrace 监控这个函数调用,运行 bpftrace main.pt 命令,然后按下 Ctl+C 退出,得到下面的输出:

1
2
3
4
Hello!
a: 3 b: 3
retval: 6
^CBye!

静态追踪

“静态”是指探针的位置、名称都是在代码中硬编码的,编译时就确定了。静态追踪的实现原理类似 callback,当被激活时执行,关闭时不执行,性能比动态追踪高一些。其中:

  • tracepoint 是内核中的
  • USDT = Userland Statically Defined Tracing,是应用中的

静态追踪已经在内核和应用中包含了探针参数信息,可以直接通过 args->参数名 访问函数参数。tracepoint 的 参数信息可以通过 bpftrace -lv 查看,例如:

1
2
3
4
5
6
7
8
bpftrace -lv tracepoint:syscalls:sys_enter_openat
# 输出:
# tracepoint:syscalls:sys_enter_openat
# int __syscall_nr;
# int dfd;
# const char * filename;
# int flags;
# umode_t mode;

静态追踪通过 args->filename 访问 sys_enter_openat 的 filename 参数:

1
2
3
4
5
6
7
8
9
bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }'
# 输出:
# Attaching 1 probe...
# uwsgi /proc/self/stat
# uwsgi /proc/self/fd
# uwsgi /proc/self/statm
# uwsgi /proc/loadavg
# uwsgi /proc/self/io
# ...

其中 comm 表示父进程的名字。

k8s 三种 Service

本文简单介绍 k8s 的三种 Service: ClusterIP、NodePort、LoadBalancer。

ClusterIP

ClusterIP 通过集群内部 IP 地址暴露服务,但该地址仅在集群内部可见,无法被集群外部的客户端访问。ClusterIP 是 Service 的默认类型,内部 IP 建议由 k8s 动态指定一个,也支持手动指定。示例:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Service
metadata:
name: nginx-pod-service
spec:
type: ClusterIP
ports:
- port: 8080
targetPort: 80
selector:
app: nginx

创建 ClusterIP 后,查看内部 IP,并进行访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
root@cloud:~# kubectl get svc -o wide
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 6d2h <none>
nginx-pod-service ClusterIP 10.100.23.74 <none> 8080/TCP 6s app=nginx
root@cloud:~# curl 10.100.23.74:8080
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

NodePort

NodePort 是 ClusterIP 的增强类型,它在 ClusterIP 的基础之上,在每个节点上使用一个相同的端口号将外部流量引入到该 Service 上来。示例:

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: Service
metadata:
name: nginx-pod-service
spec:
type: NodePort
ports:
- port: 8080
targetPort: 80
nodePort: 30080
selector:
app: nginx

创建后,在集群外可以通过节点IP:30080访问该服务。

LoadBalancer

LoadBalancer 是 NodePort 的增强类型,为节点上的 NodePort 提供一个外部负载均衡器,需要公有云支持。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
name: nginx-pod-service
spec:
type: LoadBalancer
ports:
- port: 8080
targetPort: 80
nodePort: 30080
loadBalancerIP: 1.2.3.4
selector:
app: nginx

创建后,在集群外可以通过1.2.3.4:30080访问该服务。

k8s 集群中的 port

本文介绍 k8s 集群外访问集群内部服务不同方式下的 port。

hostPort

出现在 Deployment、Pod 等资源对象描述文件中的容器部分,类似于 docker run -p <containerPort>:<hostPort>。containerPort 为容器暴露的端口;hostPort 为容器暴露的端口直接映射到的主机端口。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: apps/v1
kind: Deployment
...
spec:
...
template:
...
spec:
nodeName: node1
containers:
- name: nginx
image: nginx
ports:
- containerPort: 80 # containerPort是pod内部容器的端口
hostPort: 30080

集群外访问方式:node1的IP:30080

nodePort

出现在 Service 描述文件中,Service 为 NodePort 类型时。port 为在k8s集群内服务访问端口;targetPort 为关联 pod 对外开放端口,与上述 containerPort 保持一致;nodePort 为集群外访问端口,端口范围为 30000-32767。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Service
metadata:
name: nginx-pod-service
labels:
app: nginx
spec:
type: NodePort
ports:
- port: 8080 # port是k8s集群内部访问Service的端口
targetPort: 80 # targetPort是pod的端口,从port和nodePort来的流量经过kube-proxy流入到pod的targetPort上
nodePort: 30080
selector:
app: nginx

集群外访问方式:节点IP:30080