Kubernetes 观测:基于 eBPF 的云原生深度可观测性实践
随着云原生技术栈的迅速发展,系统复杂性逐渐下沉到服务网格、网关、通用 sidecar、serverless 运行时、内核等基础设施层面,诚然这大大减轻了业务开发同学的心智负担,让其可以更专注于业务本身,但却给可观测性带来了巨大的挑战:
盲点多
基础设施逐渐“黑盒”化,应用往往仅仅是较薄的一层,如果这个时候继续沿用传统 APM 观测方案,会存在大量的盲点,在问题发生时可能只能看到应用层的问题表象,而无法快速定位根因。
传统的容器网络观测方案通常只关注自身维度,缺乏上下游视角,且维度信息非常有限,在日益复杂的网络环境下,难以回答诸如“究竟是谁访问我发生了故障”“我究竟影响了下游哪些实例”“是什么原因导致发生了丢包” 等问题。
埋点困难
传统 APM 方案需要依赖 SDK/Javaagent 的方式来进行插桩埋点,这给在多协议、多语言场景下统一所有业务线的接入造成了极大挑战。同时,传统 APM 方案也无法对基础设施实施插桩。
传统基于 cadvisor 的容器观测方案只能看到 Kernel 主动暴露的数据,而 Kernel 对于微服务层面的隔离和可观测性还不太够,如果需要深入内核进行插桩,传统的方式可能会需要重新编译内核,成本和风险极高。
数据孤岛,缺少全栈视角的串联分析
相关调查数据显示,超过 65% 的企业组织拥有超过 10 种监控工具,而这些工具通常作为独立解决方案单独运行,以支持不同团队的特定需求。可观测性并非简单的数据堆砌,更重要的是将数据通过一定的关联纽带有机串联起来,而不同监控工具可能都有各自的元数据语义化标准,难以实现对齐统一。
各个观测数据之间也缺乏必要的因果关系,在根因定位的时候难以实现有效关联。
从应用层到内核,自顶向下,需要能够尽可能全面地进行覆盖;
接入成本需要尽可能低;
需要能够有统一标准的语义化标签和因果关系,来帮助我们关联分析各个离散的可观测数据。
监控:需要我们回答各个组件的运行状态。这并不陌生,也很容易实现,我们只需要监控组件单个特定状态,如果超出阈值则触发告警即可;
可观测性:要求我们回答组件为何不工作。其更多的是对组件内部可见性的一个要求,我们通常可以引入日志和传统 APM 工具,来帮我们提高组件系统内部的可见性。
问题在整个堆栈中是如何传播的?
问题根因究竟在哪?
问题开始的时候堆栈是什么样子的?
问题发生,哪些组件会受到影响?
海量的观测数据及告警应该如何关联?
这些问题,也正是真正困扰技术团队的问题。根据可观测性模型理论,要能够回答这些问题,核心要实现的 2 个必要维度便是:拓扑和时间。
拓扑可视化让工程师得以在全栈活动的上下文中查看来自网络、基础设施、应用程序和其他领域的遥测数据;它还提供了重要的背景信息,方便工程师了解发生故障时业务会受到怎样的影响。
来源:《可观测性成熟度模型》
当然,仅仅一个静态拓扑也无法应对日益频繁变化的微服务部署架构,我们还需要结合时间维度来绘制一个动态拓扑,并且让这个动态拓扑能够和其他可观测数据(例如日志、指标、事件、trace)有机地关联起来。
一个可以纵向关联各种可观测性数据,横向可以追溯任意时序状态的动态拓扑,可以向我们展示跨不同层、数据孤岛、团队和技术的任何更改或故障的原因和影响。这将显著缩短我们解决问题的时间,也同时让我们具备开始自动化根本原因分析、业务影响分析和警报关联的基础。
eBPF 是一种数据包过滤技术,从 BPF (Berkeley Packet Filter) 技术扩展而来,它起源于 Linux 内核,可以在操作系统内核中运行沙盒程序。eBPF 被用于安全有效地扩展内核的功能,而无需更改内核源代码或加载内核模块,同时 eBPF 程序在加载的时候有严格的 Verifier 进行校验,可以确保代码的正确性,避免死循环或者非法内存访问等问题,这大大提高了内核拓展的安全性。
来源:eBPF 社区
eBPF 借助 JIT 机制将字节码转换为机器特定指令集,这使得 eBPF 程序的运行效率与本机内核代码几乎一样高效,并且整个插桩过程对应用程序来说都是无感知、无侵入的。优秀的性能和无侵入的接入方式,很好地回答了前文提到的接入成本的问题。
来源:eBPF 社区
除了提供了很多预定义的 Hook 之外,eBPF 还允许我们创建内核探针 (kprobe) 或用户探针 (uprobe) 来将 eBPF 程序附加到内核或用户应用程序中的几乎任何位置。
如下图所示,工程师几乎可以在任何内核子模块、系统库、应用程序中进行插桩,实现观测能力覆盖。这大大提高了技术团队对内核的可编程能力,以解锁更多深度观测能力,也回答了刚刚的可观测性覆盖度问题。
Microscope Agent 支持 Collection 插件拓展机制,以插件化的方式拓展采集能力,自顶向下,全面覆盖用户态框架及系统库、网络层、存储层、运行时等。
●Linux 数据包收发流程
发送数据包之前得先建立连接,建连起始于用户空间的 socket 框架函数,再来到内核态 L4 层,经过关键函数 tcp_v4_connect ,最后建立连接; 连接建立之后,后续的数据包也是先从用户空间出发,在 L4 层,会经过关键的 tcp_sendmsg 函数,层层调用之后来到 tcp_transmit_skb 函数完成 TCP 协议处理,封闭 TCP 包头,调用 ip 层的 ip_queue_xmit 进入后续流程。
tcp_v4_connect/tcp_v6_connect:获取连接建立相关数据; tcp_sendmsg:获取 tcp 流量相关数据。
● L4 网络拓扑
● L7 应用拓扑
只有 L4 拓扑,在覆盖度方面还是不太够。比如基于 L4 拓扑,我们只能感知到一些网络层的异常情况,当需要观测应用层具体错误码或者哪个接口异常的场景,就无从入手了。因此,我们还需要额外实现 L7 的拓扑能力。
L7 协议流量追踪会比 L4 复杂度更高,需要额外关注应用层协议内容。实现的方案也比较多,既可以和传统 APM 的 SDK/Javaagent 一样,利用 Uprobe 去追踪框架稳定的函数,也可以追踪 socket 相关 Syscall 函数。具体选取哪种 hook 方式,需要具体场景具体分析:
HTTP/1.1 场景:我们可以在 socket 层拿到每次完整的 buf 数据,那就可以考虑去 hook socket 相关 syscall,比如通用的 read、write 函数。不过这种方式我们会监听到所有的socket 读写流量,比如磁盘io读写。因此我们需要先在内核进行协议推断,过滤掉不需要关心的数据,然后在用户态进行协议解析才能完成整个流程;
HTTPS 场景:由于加密的原因,我们在 socket 层只能拿到加密后的 buf 数据,因此只能考虑在加密之前进行 hook;
HTTP/2 场景:由于头部压缩算法 HPACK,我们在 socket 层也是拿不到完整的数据,因此需要在压缩之前就进行 hook。
○ 深入 Syscall
buf:原始报文数据,这边提取是为了用于后续的协议解析,识别具体 L7 协议内容;
sockaddr:访问的远端地址,这边提取是为了构建访问关系。
下面以 HTTP/1.1 为例,我们可以先看下从 syscall 里 socket 相关函数可以拿到什么参数。
值得注意的是,单纯 hook read/write 这些系统调用,我们只能拿到 buf 数据,无法拿到 sockaddr,而传统的方式,通常会去 hook socket syscall 的 connect 函数来获取 sockaddr,如下所示:
但是这种方案有一个问题:在长连接场景,我们的 eBPF 程序启动之前,connect 和 accept 可能就已经发生了,此时就会遗漏这些事件,还需要额外在用户态通过 netlink 去补偿获取,这种方式会让整个链路变得非常冗长。
如上图所示,我们可以直接通过 bpf_get_current_task 来读取到 sock,顺序大致为 task_struct → files_struct → fd 数组 → file 结构 → sock 指针 → socket 信息:
通过 bpf_get_current_task() 系统调用可以获取到当前进程的 task_struct 指针;
task_struct 中有一个 files 字段,类型是 files_struct *,它指向当前进程的文件描述符表 files_struct;
files_struct 中维护了当前进程打开文件的文件描述符表,其中包括 socket 在内的所有文件描述符。可以通过遍历这个表,根据 socket 的 fd ,获取对应文件的 file 结构;
每个文件描述符都对应一个 file 结构,socket 的 file 中包含了一个 sock 字段,其类型是 socket *,即这个 socket 的内核对象指针。
有了 sock 指针后,就可以通过 sock 访问 socket 的所有内核信息,如我们最需要的 sockaddr 等。
○ 完整流程
基于稳定的 tracepoint 追踪 socket syscall 相关的函数(如 read/write/sendto/recvfrom/close 等);
提取相应的 buf 参数,并从 task_struct 提取 socket 元信息,构建原始 event;
在内核态进行相应的协议推断,判断是否是我们支持的协议,不支持的或者未开启采集的协议数据可以直接从内核态丢弃,减少 perf_buffer 的压力;
将需要进一步处理的 socket buf event 通过 perf_buffer/ringbuffer(5.8 以上内核使用 ringbuffer)推送给用户态程序;
用户态进行数据分帧、协议解析、请求&响应匹配聚合、构建 Flow 数据;
将 Flow 数据转换成对应 Metrics/Traces/Logs 数据并输出。
●元数据关联
无论是 L4 还是 L7,我们基于 eBPF 拿到的原始数据都是 socket 五元组(自身 IP 和 Port、对端 IP 和 port、L4 协议类型)、PID、netns 等原始信息 ,在与 K8s 的资源关联起来前,它们还需要经过元数据关联加工。
首先是节点上可以拿到的元数据关联。基于 /proc 和 CRI 来构建 PID、 Netns、Container 元数据的本地缓存,然后根据 eBPF 拿到的原始 netns、pid 等信息进行反查即可。
其次是远端 IP 的关联。当远端 IP、Netns 在节点上找不到的情况,我们就会 fallback 到基于 Kubernetes APIServer的方案来获取。而为了避免每个节点都直连 Kubernetes APIServer,造成大规模场景下 APIServer 的性能压力,我们会将需要进一步加工的数据吐给集群维度的 Opentelemetry Collector,并通过拓展 processor 的方式实现了 K8s 相关的 metadata 关联加工能力。
可观测性需要帮助用户很好地反馈上下文,设计上需要以指标、链路、日志为基本,尽可能将信息放在同一个页面中:依托指标发现故障,依托链路判断影响面并分析问题所在,通过日志定位根因,有效组织信息,降低理解成本,进而加速问题排查。典型的排查路径为:
通过 RED 指标和 L4 网络指标的观测和告警,及时发现发生故障的资源;
查看资源的接口、上下游调用的应用性能指标,结合网络性能指标和服务访问关系,定位问题根因所在的服务;
向下钻取,关联基础指标资源、日志/时间和内核指标,进行根因分析,排除故障并进行针对性改进。
例:告警中心触发了 rating-v1 的响应时间长的告警,响应时间已超过 2 秒
例:查看 rating-v1 服务端视角的接口调用响应时间,发现服务自身的响应时间正常
例:查看从上游客户端访问 rating-v1 的响应时间,发现响应时间较长,超过 2 秒,可以说明问题并不在应用层接口逻辑本身,而是可能发生在客户端侧的网络层面,我们可以继续往下排查
例:进一步查看应用指标和网络指标,发现 TCP 重传和建连耗时指标异常,定位到是网络问题引发的服务响应慢
告警中心触发 DNS 的响应时间长的告警; 在拓扑图中查看到 coredns 的访问,展开详情发现很多 serverFailed 的错误,进一步将故障定位在 DNS 的服务端; 之后可以进一步查看 coredns 的监控看板和 coredns 和上级 DNS 服务端之间的 DNS 指标做进一步排查。
VKO 的 “因果可观测性” 核心思路就是:通过使用 eBPF 无侵入的采集多语言、多网络协议的性能指标(包括应用性能的 RED 指标和网络性能指标),结合 Kubenretes 对象的各类上下文和元数据,将应用观测、网络观测、Kubernetes 资源观测、日志事件、内核事件等关联起来,整合显示在同一个资源的观测页面中,实现从应用性能视角出发,自上而下地处理和分析服务响应异常、网络性能故障等场景的观测排障流程,进而实现了 Kubernetes 的一站式可观测。
目前,VKO 已经开始面向企业用户提供服务。未来,火山引擎云原生团队也将持续推进 VKO 的长期迭代和功能完善,主要围绕以下几个方面:
下方核心功能正在陆续上线中,以更好支撑企业可观测需求:
日志、事件中心
观测能力市场,补齐更多的预置告警模板
支持更多应用层协议
补齐更丰富的网络层、资源层指标
网络异常事件分析
eBPF AutoTracing
CPU/GPU Profiling
欢迎感兴趣的用户扫码咨询、使用!
扫码咨询
火山引擎云原生团队主要负责火山引擎公有云及私有化场景中 PaaS 类产品体系的构建,结合字节跳动多年的云原生技术栈经验和最佳实践沉淀,帮助企业加速数字化转型和创新。产品包括容器服务、镜像仓库、分布式云原生平台、函数服务、服务网格、持续交付、可观测服务等。