DoorDash 基于 eBPF 的监控实践

简介: DoorDash 基于 eBPF 的监控实践

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/dns
cpu: Intel(R) Core(TM) i9-8950HK CPU @ 2.90GHz
BenchmarkEventUnmarshal-12          8289015       127.0 ns/op         0 B/op     0 allocs/op
BenchmarkEventUnmarshalReflect-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",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

相关实践学习
深入解析Docker容器化技术
Docker是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的Linux机器上,也可以实现虚拟化,容器是完全使用沙箱机制,相互之间不会有任何接口。Docker是世界领先的软件容器平台。开发人员利用Docker可以消除协作编码时“在我的机器上可正常工作”的问题。运维人员利用Docker可以在隔离容器中并行运行和管理应用,获得更好的计算密度。企业利用Docker可以构建敏捷的软件交付管道,以更快的速度、更高的安全性和可靠的信誉为Linux和Windows Server应用发布新功能。 在本套课程中,我们将全面的讲解Docker技术栈,从环境安装到容器、镜像操作以及生产环境如何部署开发的微服务应用。本课程由黑马程序员提供。 &nbsp; &nbsp; 相关的阿里云产品:容器服务 ACK 容器服务 Kubernetes 版(简称 ACK)提供高性能可伸缩的容器应用管理能力,支持企业级容器化应用的全生命周期管理。整合阿里云虚拟化、存储、网络和安全能力,打造云端最佳容器化应用运行环境。 了解产品详情: https://www.aliyun.com/product/kubernetes
目录
相关文章
|
存储 安全 Linux
探索eBPF:Linux内核的黑科技(上)
探索eBPF:Linux内核的黑科技
|
人工智能 安全 机器人
OpenClaw(原 Clawdbot)钉钉对接保姆级教程 手把手教你打造自己的 AI 助手
OpenClaw(原Clawdbot)是一款开源本地AI助手,支持钉钉、飞书等多平台接入。本教程手把手指导Linux下部署与钉钉机器人对接,涵盖环境配置、模型选择(如Qwen)、权限设置及调试,助你快速打造私有、安全、高权限的专属AI助理。(239字)
38266 184
|
10月前
|
Web App开发 算法 安全
《拆解WebRTC:NAT穿透的探测逻辑与中继方案》
本文深入解析了WebRTC应对NAT穿透的技术体系。NAT因类型多样(完全锥形、受限锥形、端口受限锥形、对称NAT)给端到端通信带来挑战,而WebRTC通过STUN服务器探测公网地址与NAT类型,借助ICE协议规划多路径(本地地址、公网反射地址、中继地址)并验证连接,TURN服务器则作为中继保障通信。文章还探讨了多层NAT、运营商级NAT等复杂场景的应对策略,揭示WebRTC通过探测、协商与中继实现可靠通信的核心逻辑,展现其在网络边界中寻找连接路径的技术智慧。
509 7
|
8月前
|
消息中间件 缓存 监控
中间件架构设计与实践:构建高性能分布式系统的核心基石
摘要 本文系统探讨了中间件技术及其在分布式系统中的核心价值。作者首先定义了中间件作为连接系统组件的&quot;神经网络&quot;,强调其在数据传输、系统稳定性和扩展性中的关键作用。随后详细分类了中间件体系,包括通信中间件(如RabbitMQ/Kafka)、数据中间件(如Redis/MyCAT)等类型。文章重点剖析了消息中间件的实现机制,通过Spring Boot代码示例展示了消息生产者的完整实现,涵盖消息ID生成、持久化、批量发送及重试机制等关键技术点。最后,作者指出中间件架构设计对系统性能的决定性影响,
|
人工智能 Kubernetes API
应用网关的演进历程和分类
唯一不变的是变化,在现代复杂的商业环境中,企业的业务形态与规模往往处于不断变化和扩大之中。这种动态发展对企业的信息系统提出了更高的要求,特别是在软件架构方面。为了应对不断变化的市场需求和业务扩展,软件架构必须进行相应的演进和优化。网关作为互联网流量的入口,其形态也在跟随软件架构持续演进迭代中。我们下面就聊一聊网关的演进历程以及在时下火热的 AI 浪潮下,网关又会迸发怎样新的形态。
1026 175
|
JavaScript
升级echarts v5.0以后vue项目报错“export ‘default‘ (imported as ‘echarts‘) was not found in ‘echarts‘
升级echarts v5.0以后vue项目报错“export ‘default‘ (imported as ‘echarts‘) was not found in ‘echarts‘
|
jenkins 测试技术 持续交付
Docker最佳实践:构建高效的CI/CD流水线
【10月更文挑战第17天】在现代软件开发实践中,持续集成(Continuous Integration, CI)和持续部署(Continuous Deployment, CD)已成为提高开发效率和软件质量的重要手段。Docker作为一种容器技术,为构建一致且隔离的开发环境提供了强有力的支撑。本文将探讨如何利用Docker来优化CI/CD流程,包括构建环境的标准化、镜像管理以及与CI/CD工具(如Jenkins、GitLab CI)的集成。
920 5
|
存储 监控 数据可视化
在Linux中,有哪些日志管理和分析工具?
在Linux中,有哪些日志管理和分析工具?
|
NoSQL Linux 开发工具
【core analyzer】core analyzer的介绍和安装详情
【core analyzer】core analyzer的介绍和安装详情
709 5
|
Ubuntu Linux 网络安全
Proxmox安装
Proxmox安装
2418 0