近期我们容器 SRE 团队基于 eBPF 技术建设网络连接异常感知能力,灰度上线过程中发现了生产环境 10+ 以上的应用配置错误、程序 Bug 等问题。在和应用负责同学同步风险过程中,大家都挺好奇我们如何实现在对应用无侵入的情况下发现服务连接异常的。本篇文档将尝试从技术角度和大家聊聊我们是如何通过内核超能力 eBPF 为以摄像头式的实现应用连接异常感知和检测。
背景
在云上生产环境中,相信大家一定遇到过应用偶发访问中间件或下游服务异常的场景,这些异常究竟是云厂商虚拟网络环境的抖动还是应用自身连接设置不当,亦或网络策略变更所导致,我们总是不能快速便捷地做出初步判断,此类问题的排查多半需要具有丰富网络经验的 SRE 同学在主机上使用 tcpdump 抓包分析;但与此同时,网络异常问题多半具有偶发性,我们不仅需要在捉摸不定的场景下捕获到异常信息,同样也需要能够基于应用信息进行历史异常数据追溯,偶发性和可追溯性叠加,这无疑进一步增加了此类问题排查难度。
业内有句万金油的大法:一切问题都皆可归结于网络偶发抖动导致,至于到底是否是网络抖动导致的,云商一般也会是惜字如金“指标正常,没有异常”,这类问题往往最后被时间所淹没。
那么,是否可参考城市道路上架设摄像头的模式,即可实现网络异常实时查看又可进行历史异常追溯呢?经过一番调研,我们注意结合 Linux 内核观察和业内流行的 eBPF 技术可实现网络质量摄像头的能力,本文尝试以网络连接异常场景建设网络摄像头能力进行展开,关键技术给与介绍和实践验证。
方案调研
经过业内相关技术调研,我们发现 Linux 内核的跟踪机制配合 eBPF 技术可实现灵活的网络事件采集和过滤能力,并且可实现多个网络事件的联合分析和汇聚,这个章节我将分别给与介绍。为简单起见,Linux 内核跟踪机制我仅以跟踪点机制进行介绍(实际场景中内核中还有动态跟踪机制 kprobe 更加灵活,但跨内核移植性略差)。
可观测基石: Linux 内核网络跟踪点
内核跟踪点(Tracepoint) 是内核开发人员提前在代码中预定义的探测点,开发者在这些跟踪点上注入自己定义的处理函数,以便在事件发生时进行数据采集。内核跟踪点的优势在于是内核开发者预先定义好的,位置和语义明确,使用时不需要修改内核代码。通过Linux 内核中的 tracefs
文件机制,使用者可以方便地启用或禁用特定的跟踪点,按需采集自己感兴趣的数据。Linux 内核跟踪点可在目录 /sys/kernel/debug/tracing/events/
中按照类别查看
网络跟踪点介绍
网络相关的跟踪点主要位于在 /sys/kernel/debug/tracing/events/
目录下的 net
/sock
/ tcp
等子目录,其中 tcp
目录下跟踪主要和 TCP 网络协议相关,sock 目录下为网络资源对象 socket
相关的跟踪点。如果本地安装了性能分析工具 perf
,则可以使用 perf list 'tcp:*' 'sock:inet*'
命令查看当前内核相关的 跟踪点(此处为 5.10 内核版本,不同内核版本跟踪点数量略有不同):
sock:inet_sock_set_state [Tracepoint event]
tcp:tcp_retransmit_skb [Tracepoint event]
tcp:tcp_retransmit_synack [Tracepoint event]
tcp:tcp_rcv_space_adjust [Tracepoint event]
tcp:tcp_probe [Tracepoint event]
tcp:tcp_send_reset [Tracepoint event]
tcp:tcp_receive_reset [Tracepoint event]
tcp:tcp_destroy_sock [Tracepoint event]
这里我们对常用的网络相关跟踪点功能给与简单介绍,结合这些跟踪点可实现不同场景下的网络质量监控和诊断:
网络跟踪点实践
Linux 内核中可基于 tracefs
机制直接控制跟踪点的启用和停止,方便我们可以快速进行测试和数据搜集,这里以 sock/inet_sock_set_state
跟踪点观察 Socket 对象状态变化为例演示:
echo "1" > /sys/kernel/debug/tracing/events/sock/inet_sock_set_state/enable
cat /sys/kernel/debug/tracing
etcd-3825 [006] ..s2. 2198.949362: inet_sock_set_state: family=AF_INET protocol=IPPROTO_TCP sport=46142 dport=32766 saddr=127.0.0.1 daddr=127.0.0.1 saddrv6=::ffff:127.0.0.1 daddrv6=::ffff:127.0.0.1 oldstate=TCP_ESTABLISHED newstate=TCP_CLOSE_WAIT
....
echo "0" > /sys/kernel/debug/tracing/events/sock/inet_sock_set_state/enable
上述样例输出中,我们可以看到进程 etcd 进程中的某个连接状态从 TCP_ESTABLISHED
到 TCP_CLOSE_WAIT
的变化。该跟踪点打印数据的格式,可在文件/sys/kernel/debug/tracing/events/sock/inet_sock_set_state/format
中查看。
内核可编程:Linux 内核超能力 eBPF
尽管我们在上述场景进行了验证,可以获取到文本数据,但是仍然存在较多难以解决的问题,比如我们需要基于特定条件过滤和多个跟踪点场景进行关联分析,基于 tracefs
的方式可能会面临采集信息低效,而且无法实现多更重点场景数据关联合并聚合分析场景。此时,我们就需要赋予内核编程能力的超能力 eBPF 技术来进行高效和灵活的采集和聚合分析能力。
eBPF 超能力
eBPF 是一项革命性的技术,起源于 Linux 内核,它可以在特权上下文中(如操作系统内核)运行沙盒程序,其可用于安全有效地扩展内核的功能,而无需通过更改内核源代码或加载内核模块的方式来实现。
eBPF 自 2014 年引入到内核以后,经历了快速的发展和完善,当前已经成为顶级的内核模块。应用场景也从最初的网络数据包过滤扩展到内核可观测、安全、网络和调度等多个领域。eBPF 可实现动态地编程内核以实现高效的网络、可观测性、追踪和安全性。eBPF 相对于内核,犹如 JavaScript 与 HTML 本身,eBPF 技术赋予了内核的可编程能力,带来了众多的创新可能。
就 eBPF 技术而言,其具有以下特性:
-
高性能:eBPF 通过 JIT 编译以及运行在内核空间中,可以大幅提高内核中数据处理能力;
-
安全:eBPF 程序经过严格的验证,不会导致内核崩溃,并且只能由特权用户进行修改;
-
灵活:功能和用例的修改、增加即时加载到内核并生效,而不需要重启或者打补丁。
eBPF 技术原理
eBPF 技术原理简单而言就是在内核中基于事件运行用户自定义的程序,并且能通过提供的 map/perf 等机制提供用户空间与内核空间的双向数据交换。工作原理的核心流程如下图所示:
-
编译: 用户可基于场景编写特定用途的 eBPF 程序,借助于 LLVM(clang)套件将其编译为通用的 eBPF 字节码(有点类似 Java 字节码);
-
加载: 通过系统调用将编译后的字节码通过系统调用
bpf()
加载到内核对应的事件 Hook 点; -
验证:内核会针对加载的 eBPF 字节码进行各种安全检查,确保程序运行时的安全性,避免对内核造成灾难;验证通过后,将 eBPF 字节码通过 JIT 优化为二进制指令(默认启用),并与特定的事件相关联;
-
触发运行:在内核对应的事件触发时,就会运行对应的 eBPF 程序,基于上下文数据进行处理或将处理后的数据发送至用户空间的程序进行进一步处理分析。
如果你想进一步了解 eBPF 技术,推荐进一步阅读BPF 技术概览文章。
使用 eBPF 注意事项
用于 eBPF 是在内核事件发生时触发的运行的程序,其需要保障安全和高效:
-
不能访问内核任务函数,不同的场景下只能访问特定场景下的上下文信息,比如网络类型的 eBPF 程序只能访问到网络相关上下文信息;
-
eBPF 程序运行的栈只有 512 个字节,能够运行的指令数量也最大只能为 100 万条指令。
上述限制都是为了保障在内核事件触发后不会影响内核安全和内核运行效率。在介绍完内核网络跟踪点和 eBPF 基础技术后,我们进入到整体方案设计阶段。
架构设计和实现
方案架构设计
在整体架构上,我们可将程序部署在 K8s 容器集群或单机 ECS 上。当 K8s 调度到单机或部署到单机上,需要运行用户空间程序 net-collector
和内核事件处理程序 bpf_collector
,核心流程如下:
-
用户空间程序
net-collector
用于加载 BPF 程序至内核中; -
当内核中有网络连接异常信息时,内核中的
bpf_collector
程序就会被触发; -
bpf_collector
程序将采集到数据发送到用户空间程序net-collector
; -
用户空间程序
net-collector
在进行数据过滤和聚合后将数据写入到本地日志记录文件中; -
单机部署的日志采集 agent 将数据采集到日志平台;
-
最后,我们可通过可视化界面完成日志平台保存数据的消费处理和架构化展示,以应用维度提供异常展示。
部署在单机 eBPF 程序,就像核心路口的摄像头一样,可时时刻刻按照约定规则采集信息,我们既可以实时查看采集的信息,还可以通过日志平台进行回溯查看。
方案实现
以网络连接超时为例,我们尝试采集环境中所有新建连接的网络重传事件,这里有两个关键点:
-
连接初始阶段,这时 TCP 状态位于 SYN_SENT 状态;
-
在连接状态出现了数据包的重传,基于上述网络跟踪点章节介绍,我们适合选取跟踪点
tcp:tcp_retransmit_skb
附着编写的 eBPF 程序即可。
单机采集端的实现,我们需要编写两部分程序:
-
用户空间程序
net-colloctor
:用于将 eBPF 程序加载到内核,并接受在内核事件触发 eBPF 程序传递的数据,进行采集、聚合和分析,并写入到日志文件记录中; -
内核中运行的 eBPF 程序
bpf-collector
:内核程序附着与tcp:tcp_retransmit_skb
跟踪点,在网络连接重传事件发生时候,采集网络上下文信息,并将其发送至用户空间程序net-colloctor
进行分析。
内核空间 eBPF 程序
内核中的 eBPF 程序给与简单说明,核心包括 3 个步骤:
SEC("tracepoint/tcp/tcp_retransmit_skb") // 与跟踪点对应的声明
int handle_tcp_retransmit_skb(struct trace_event_raw_tcp_event_sk_skb *ctx)
{
struct event event = {};
const struct sock *sk = ctx->skaddr;
// 步骤 ①
char state = 0;
state = BPF_CORE_READ(sk, __sk_common.skc_state);
if (state != TCP_SYN_SENT)
{
return 0;
}
// 步骤 ②
event.family = BPF_CORE_READ(sk, __sk_common.skc_family);
event.ts_us = bpf_ktime_get_ns() / 1000;
event.type = RESTRANSMIT;
event.sport = BPF_CORE_READ(ctx, sport);
event.dport = BPF_CORE_READ(ctx, dport);
switch (event.family)
{
case AF_INET:
bpf_probe_read_kernel(&event.saddr, sizeof(ctx->saddr), BPF_CORE_READ(ctx, saddr));
bpf_probe_read_kernel(&event.daddr, sizeof(ctx->daddr), BPF_CORE_READ(ctx, daddr));
break;
case AF_INET6:
bpf_probe_read_kernel(&event.saddr, sizeof(ctx->saddr_v6), BPF_CORE_READ(ctx, saddr_v6));
bpf_probe_read_kernel(&event.daddr, sizeof(ctx->daddr_v6), BPF_CORE_READ(ctx, daddr_v6));
break;
default:
}
// 步骤 ③
bpf_perf_event_output(ctx, &dw_net_col_events, BPF_F_CURRENT_CPU, &event, sizeof(event));
return 0;
}
上述代码中的核心步骤介绍如下:
步骤 1:获取到触发重传函数上下文中的 TCP Sock 对象的状态信息,这里我们仅采集 TCP_SYN_SENT 状态的,否则就直接返回;
步骤 2:基于内核跟踪点的 Famliy 信息来区分是 IPv4 或 IPv6,获取到必要的信息并填充到 event 对象,event 对象是用户空间程序与内核 BPF 程序公用的数据结构;
步骤 3:将内核中封装好的数据发送到用户空间。
这里需要注意的是,不同内核版本的跟踪点结构体可能会发生变化。为了同时适配多个内核版本,我们使用可以跨多个内核版本移植方案 CO-RE。CO-RE 全称为 Compile Once – Run Everywhere
,中文翻译为一次编译、到处运行,当前社区已经提供在低版本支持 CO-RE 的实现,我们在 4.19 内核中由于没有默认支持 CO-RE,这需要进行一些特定的处理步骤。关于 CO-RE 详细介绍可参考这篇文档。
用户空间程序
用户空间的程序主要用于加载 BPF 程序到内核,并读取 BPF 程序运行过程中上报的数据。程序使用 go 开发,基于库 cilium/ebpf
。简化代码如下所示:
func main() {
flag.Parse()
var btfSpec *btf.Spec
var err error
// 步骤 ①
// Load pre-compiled programs and maps into the kernel.
var opts ebpf.CollectionOptions
opts.Programs.KernelTypes = btfSpec
objs := bpfObjects{}
if err := loadBpfObjects(&objs, &opts); err != nil {
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()
// 步骤 ②
tpRestranSkb, err := link.Tracepoint("tcp", "tcp_retransmit_skb", objs.HandleTcpRetransmitSkb, nil)
if err != nil {
log.Fatalf("opening tracepoint tcp/tcp_retransmit_skb %s", err)
}
defer tpRestranSkb.Close()
// 步骤 ③
rd, err := perf.NewReader(objs.DwNetColEvents, os.Getpagesize()*10)
if err != nil {
log.Fatalf("creating perf event reader: %s", err)
}
defer rd.Close()
// 步骤 ④
var event bpfEvent
for {
record, err := rd.Read()
if err := binary.Read(bytes.NewBuffer(record.RawSample),
binary.LittleEndian, &event); err != nil {
continue
}
eventHandler.Handle(&event)
}
}
上述代码已经经过简化,上述核心步骤介绍如下:
步骤 1:加载内核运行的 eBPF 程序到内存对象中,完成内存初始化并加载到内核;
步骤 2:将加载到内核的 eBPF 程序与我们定义的跟踪点关联,此处关联后当事件触发时就可运行 eBPF 程序;
步骤 3:打开与 eBPF 程序约定的通信 perf 通道,后续可以持续读取数据;
步骤 4:循环读取 perf 通道中的数据,然后调用 eventHandler.Handle(&event)
函数进行后续的各种分析和处理,这里主要是数据过滤、聚合分析和缓存等各种我们特定的业务处理。
容器集群部署
在单机上运行 BPF 程序需要特权模式 privileged: true
和挂载宿主机相关目录 /lib/modules
和/sys/kernel/debug
。同时考虑在版本升级和变更的灰度,我们还需要将部署策略 updateStrategy
设置为type: OnDelete
。 部署文件主要设置内容如下所示:
template:
spec:
containers:
- name: main
image: repoin.shizhuang-inc.net/component/net_conn_collector:0.1
imagePullPolicy: IfNotPresent
securityContext:
privileged: true
capabilities:
add:
- SYS_ADMIN
volumeMounts:
- name: modules-host
mountPath: /lib/modules
readOnly: false
- name: debug-fs-host
mountPath: /sys/kernel/debug
readOnly: false
volumes:
- name: modules-host
hostPath:
path: /lib/modules
- name: debug-fs-host
hostPath:
path: /sys/kernel/debug
updateStrategy:
type: OnDelete
效果展示
当在部分 K8s 集群灰度上线后,我们可直观针对主机上网络连接异常情况基于主机和上下游分析。基于原始数据,我们与 CMDB 数据整合打通,通过图形化的方式展示异常链路,可快速定位到应用上下游,大幅提升了问题定位效率。