写点什么

[大厂实践] DoorDash 基于 eBPF 的监控实践

作者:俞凡
  • 2024-01-09
    上海
  • 本文字数:5681 字

    阅读完需:约 19 分钟

eBPF 是监控云原生应用的强大工具,本文介绍了 DoorDash 构建基于 eBPF 的监控系统的实践。原文: BPFAgent: eBPF for Monitoring at DoorDash


随着 DoorDash 在过去几年中经历了快速增长,我们开始看到传统监控方法的局限性。度量、日志和跟踪提供了服务生态系统的重要信息,但这些信号几乎完全依赖应用程序级别的检测,不同系统可能会互相冲突。因此我们决定寻找能够提供更完整、统一的网络拓扑的潜在解决方案。


其中一种解决方案是基于 eBPF 进行监控,该机制允许开发人员编写直接注入内核的程序,并能够跟踪内核操作。这些程序可以轻量级访问大多数内核组件,程序运行在内核沙箱内,并且在执行之前会进行安全性验证。DoorDash 对通过名为 kprobes(内核动态跟踪)和跟踪点(tracepoint)的钩子跟踪网络流量特别感兴趣,通过这些钩子,可以拦截和理解跨多个 Kubernetes 集群的 TCP、UDP 连接。


通过在内核构建基础设施级别的网络流量监控,让我们对 DoorDash 独立于服务业务流的后端生态系统有了新的认识。


为了运行这些 eBPF 探针,我们开发了一个名为 BPFAgent 的 Golang 应用程序,将其作为所有 Kubernetes 集群中的守护进程运行。本文将介绍如何构建 BPFAgent,构建和维护探针的过程,以及各个 DoorDash 团队如何使用收集到的数据。

构建 BPFAgent

我们用bcciovisor/gobpf库开发了第一版 BPFAgent,这个初始版本帮助我们了解了如何在 Kubernetes 环境中开发和部署 eBPF 探针。


虽然可以很快确认投资开发 BPFAgent 的价值,但我们也经历了糟糕的开发生命周期以及缓慢的启动时间等多个痛点。使用bcc意味着探针是在运行时编译的,这会大大增加部署新版本的启动时间,从而使得新版本的平滑升级变得困难,因为部署监控需要相当长时间。此外,探针对 Kubernetes 节点的 Linux 内核版本有很强的依赖性,所有内核版本都必须在 Docker 镜像中考虑。很多情况下,对 Kubernetes 节点底层操作系统的升级会导致 BPFagent 停止工作,直到更新到支持新的 Linux 版本为止。


我们很高兴的发现,社区已经开始通过 BPF CO-RE(一次编译,到处运行)来解决这些痛点。使用 CO-RE,我们从运行时的bcc编译,转变为在 BPFAgent Golang 应用程序的构建过程中使用 Clang 编译。这一更改依赖于 Clang 支持以 BPF 类型格式(BTF,BPF Type Format)编译的能力,这种能力通过利用libbpf和内存重定位信息创建可执行的探针版本,这些版本在很大程度上独立于内核版本。这个更改可以防止大多数操作系统和内核更新影响到 BPFAgent 应用或探针。有关 BPF 可移植性和 CO-RE 的更详细介绍,请参阅Andrii Nakryiko关于该主题的博客文章


Cilium 项目有一个特殊的cilium/ebpf Golang 库,可以编译 Golang 代码中的 eBPF 探针并与之交互。它提供了易于使用的go:generate集成,可以通过 Clang 将 eBPF C 代码编译成 BTF 格式,然后将 BTF 工件封装在易于使用的 go 包中以加载探针。


在切换到 CO-RE 和 cilium/ebpf 后,我们发现内存使用量减少了 40%,由于 oomkill 导致的容器重启减少了 98%,每个 Kubernetes 集群的部署时间减少了 80%。总的来说,单个 BPFAgent 实例保留的 CPU 内核和内存不到典型节点的 0.3%。

BPFAgent 内部组件

BPFAgent 应用由三个主要组件组成。如图 1 所示,BPFAgent 首先通过 eBPF 探针检测内核,以捕获和生成事件。然后将这些事件发送给处理器,以根据进程和 Kubernetes 信息进行填充。最后,通过导出器将丰富的事件发送到数据存储。



让我们深入了解如何构建和维护探针。每个探针都是一个 Go 模块,包含三个主要组件: eBPF C 代码及其生成的工件、探针执行器和事件类型。


探针执行器遵循标准模式。在初始探针创建期间,通过生成的代码(下面代码片段中的loadBpfObjects函数)加载 BPF 代码,并为事件创建管道,这些事件将被发送给 bpfagent 的处理器和导出函数进行处理。


type Probe struct {  objs  bpfObjects  link  link.Link  rdr  *ringbuf.Reader  events chan Event}
func New(bufferLimit int) (*Probe, error) { var objs bpfObjects
if err := loadBpfObjects(&objs, nil); err != nil { return nil, err }
events := make(chan Event, bufferLimit)
return &Probe{ objs: objs, events: events, }, nil}
复制代码


然后,该对象作为 BPFagent Attach()过程的一部分被注入内核。探针被加载、附加并链接到所需的 Linux 系统调用(如skb_consume_udp)。成功后,将创建一个新的环形缓冲区读取器,并引用我们的 BPF 环形缓冲区。最后,启动程序来轮询要解析并发布到管道的新事件。


func (p *Probe) Attach() (<-chan *Event, error) {  l, err := link.Kprobe("skb_consume_udp", p.objs.KprobeSkbConsumeUdp, nil)
// ...
rdr, err := ringbuf.NewReader(p.objs.Events) // ...
p.link = l p.rdr = rdr
go p.run()
return p.events, nil}
func (p *Probe) run() { for { record, err := p.rdr.Read()
// ...
var event Event
if err = event.Unmarshal(record.RawSample, binary.LittleEndian); err != nil { // ... }
select { case p.events <- event: continue default: // ... } }
...}
复制代码


事件本身很简单。例如,DNS 探测是一个仅包含网络命名空间 id (netns)、进程 id (pid)和原始数据包数据的事件。我们通过一个解析函数,将内核中的原始字节转换为我们的数据结构。


type Event struct {  Netns uint64  Pid  uint32  Pkt  [4084]uint8}
func (e *Event) Unmarshal(buf []byte, order binary.ByteOrder) error { if len(buf) < 4096 { return fmt.Errorf("expected input too small, len([]byte) = %d", len(buf)) }
e.Netns = order.Uint64(buf[0:8]) e.Pid = order.Uint32(buf[8:12]) copy(e.Pkt[:], buf[12:4096])
return nil}
复制代码


我们一开始使用编码/二进制来解码。然而通过 profiling,不出所料的发现大量 CPU 时间用于解码。这促使我们创建一个自定义的数据解码过程来代替基于反射的数据解码。基准测试改进验证了这一决定,并帮助我们保持 BPFAgent 的轻量。


pkg: github.com/doordash/bpfagent/pkg/tracing/dnscpu: Intel(R) Core(TM) i9-8950HK CPU @ 2.90GHzBenchmarkEventUnmarshal-12          8289015       127.0 ns/op         0 B/op     0 allocs/opBenchmarkEventUnmarshalReflect-12     33640       35379 ns/op      8240 B/op     3 allocs/op
复制代码


接下我们来讨论 eBPF 探针本身。探针大多数是 kprobe,提供了跟踪 Linux 系统调用的优化访问。使用 kprobe,我们可以拦截特定系统调用并检索提供的参数和执行上下文。在此之前,我们使用的是 fentry 版本的探针,但由于我们用的是基于 ARM 的 Kubernetes 节点,而当前的 Linux 内核版本不支持基于 ARM 架构优化的入口探测,所以改用 kprobe。


对于网络监控,探针可以捕获以下事件:


  • DNS

  • kprobe/skb_consume_udp

  • TCP

  • kprobe/tcp_connect

  • kprobe/tcp_close

  • Exit

  • tracepoint/sched/sched_process_exit


为了捕获 DNS 查询和响应,由于大多数 DNS 流量都是通过 UDP 传输的,因此可以通过skb_consume_udp探针拦截 UDP 数据包。


struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx);struct sk_buff *skb = (struct sk_buff *)PT_REGS_PARM2(ctx);  // ...
evt->netns = BPF_CORE_READ(sk, __sk_common.skc_net.net, ns.inum);
unsigned char *data = BPF_CORE_READ(skb, data);size_t buflen = BPF_CORE_READ(skb, len);
if (buflen > MAX_PKT) { buflen = MAX_PKT;}
bpf_core_read(&evt->pkt, buflen, data);
复制代码


如上所示,skb_consume_udp可以访问套接字和套接字缓冲区,然后可以使用BPF_CORE_READ等辅助函数从结构中读取所需数据。这些帮助程序特别重要,因为它们支持跨多个 Linux 版本使用相同的编译探针,并且可以处理跨内核版本内存中的任何数据重定位。


对于 TCP,我们使用两个探针来跟踪连接何时启动和关闭。为了创建连接,我们探测tcp_connect,它同时处理 TCPv4 和 TCPv6 连接。该探针主要用于隐藏对套接字的引用,以获取有关连接源的基本上下文信息。


struct source {  u64 ts;  u32 pid;  u64 netns;  u8 task[16];};
struct { __uint(type, BPF_MAP_TYPE_LRU_HASH); __uint(max_entries, 1 << 16); __type(key, u64); __type(value, struct source);} socks SEC(".maps");
复制代码


为了获取 TCP 连接事件,我们等待与tcp_connect相关联的tcp_close调用。我们用struct sock *作为键查询bpf_map_lookup_elem。这么做的目的是因为来自bpf_get_current_comm()等 bpf 帮助程序的上下文信息在tcp_close探测中并不总是准确。


struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx);
if (!sk) { return 0;}
u64 key = (u64)sk;
struct source *src;
src = bpf_map_lookup_elem(&socks, &key);
复制代码


在捕获连接关闭事件时,我们需要获取连接发送和接收的字节数。为此,我们根据套接字的网络族将套接字转换为tcp_sock (TCPv4)或tcp6_sock (TCPv6)。这些结构包含RFC 4898中描述的扩展 TCP 统计信息,因此有可能让我们获取到需要的统计数据。


u16 family = BPF_CORE_READ(sk, __sk_common.skc_family);
if (family == AF_INET) { BPF_CORE_READ_INTO(&evt->saddr_v4, sk, __sk_common.skc_rcv_saddr); BPF_CORE_READ_INTO(&evt->daddr_v4, sk, __sk_common.skc_daddr);
struct tcp_sock *tsk = (struct tcp_sock *)(sk);
evt->sent_bytes = BPF_CORE_READ(tsk, bytes_sent); evt->recv_bytes = BPF_CORE_READ(tsk, bytes_received);} else { BPF_CORE_READ_INTO(&evt->saddr_v6, sk, __sk_common.skc_v6_rcv_saddr.in6_u.u6_addr32); BPF_CORE_READ_INTO(&evt->daddr_v6, sk, __sk_common.skc_v6_daddr.in6_u.u6_addr32);
struct tcp6_sock *tsk = (struct tcp6_sock *)(sk);
evt->sent_bytes = BPF_CORE_READ(tsk, tcp.bytes_sent); evt->recv_bytes = BPF_CORE_READ(tsk, tcp.bytes_received);}
复制代码


最后,我们用 tracepoint 探针跟踪进程何时退出。tracepoint 由内核开发人员添加,用于 hook 内核中发生的特定事件。因为不需要绑定到特定系统调用,因此其设计比 kprobe 更稳定。该探针的事件用于从内存缓存中取出数据。


所有探针都在 CI 流水线中基于cilium/ebpf并用 clang 编译。


所有原始事件都必须添加有用的识别信息。由于 BPFAgent 是部署在节点进程 ID 命名空间中的 Kubernetes 守护进程,因此可以直接从/proc/:id/cgroup中读取进程 cgroup。因为节点上运行的大多数进程都是 Kubernetes pod,所以大多数 cgroup 标识符看起来像这样:


/kubepods.slice/kubepods-pod8c1087f5_5bc3_42f9_b214_fff490864b44.slice/cri-containerd-cedaf026bf376abf6d5c4200bfe3c4591f5eb3316af3d874653b0569f5208e2b.scope. 
复制代码


基于约定,我们可以提取 pod 的 UID(在/kubepods-pod.slice之间)以及容器 ID(在cri-containerd-.scope之间)。


有了这两个 id,我们就可以检查 Kubernetes pod 信息的内存缓存,找到绑定连接的 pod 和容器。每个事件都用容器、pod 和命名空间名称进行注释。


最后,使用google/gopacket库对前面提到的 DNS 事件进行解码。通过解码数据包,可以导出事件,其中包括 DNS 查询类型、查询问题和响应代码。在此处理过程中,我们使用 DNS 数据创建(netns, ip)到(hostname)的内存缓存映射。此缓存用于使用与连接关联的可能主机名进一步丰富 TCP 事件中的目标 IP。简单的 IP 到主机名查找是不实际的,因为单个 IP 可能由多个主机名共享。


BPFAgent 导出的数据被发送到可观测 Kafka 集群,在那里每个数据类型被分配一个 topic。然后,这些大批量的数据被储存到 ClickHouse 集群中。团队可以通过 Grafana 仪表板与数据进行交互。

使用 BPFAgent 的好处

可以看到,到目前为止,上面所介绍的数据是有帮助的,eBPF 数据在提供独立于所部署的应用程序的见解方面确实表现出色。以下是 DoorDash 团队如何使用 BPFAgent 数据的一些示例:


  1. 在我们向单一服务所有权推进的过程中,我们的存储团队使用这些数据来调查共享数据库。可以根据常见的数据库端口(如 PostgreSQL 的 5432)进行 TCP 连接过滤,然后根据目标主机名和 Kubernetes 命名空间进行聚合,以检测多个命名空间使用的数据库。这些数据可以使他们避免将不同服务的指标混淆起来,因为指标可能有一样的命名约定。

  2. 我们的流量团队使用这些数据来检测发夹(hairpin)流量,即在从公共互联网重新进入虚拟私有云之前退出的内部流量,这会产生额外的成本和延迟。BPF 数据使我们能够快速找到针对面向外部主机名(如 api.doordash.com)的内部流量,一旦能够消除这种流量,团队就能自信的建立流量策略,禁止未来的发夹流量。

  3. 我们的计算团队用 DNS 数据来更好的理解 DNS 流量的峰值。虽然以前也有节点级的 DNS 流量指标,但并没有基于特定的 DNS 问题或源 pod 分解。有了 BPF 数据,就能够找到行为不良的 pod,并与团队一起优化 DNS 流量。

  4. 产品工程团队使用这些数据来支持向市场分片 Kubernetes 集群的迁移。这种迁移需要服务的所有依赖项都采用基于Consul的服务发现。BPF 数据是一个重要的事实来源,可以突出显示任何意外交互,并验证所有客户端都已转移到新的服务发现方法。

结论

实现 BPFAgent 使我们能够理解网络层的服务依赖关系,并更好的控制微服务和基础设施。我们对新的见解感到兴奋,这促使我们扩展 BPFAgent,以支持网络流量监视之外的其他用例。首先要做的是构建探针以从共享配置卷中捕获对文件系统的读取,从而在所有应用程序中推动最佳实践。


我们期待加入更多用例,并推动平台在未来支持性能分析和按需探测。我们还希望探索新的探测类型以及 Linux 内核团队创建的任何新钩子,以帮助开发人员更深入了解他们的系统。




你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

发布于: 刚刚阅读数: 4
用户头像

俞凡

关注

公众号:DeepNoMind 2017-10-18 加入

俞凡,Mavenir Systems研发总监,关注高可用架构、高性能服务、5G、人工智能、区块链、DevOps、Agile等。公众号:DeepNoMind

评论

发布
暂无评论
[大厂实践] DoorDash基于eBPF的监控实践_云原生_俞凡_InfoQ写作社区