WEBKT

告别DNS盲区?用eBPF为你的Kubernetes集群装上“透视眼”

148 0 0 0

作为一名SRE,我经常被Kubernetes集群中各种各样的网络问题搞得焦头烂额,尤其是DNS解析问题,简直就像黑盒一样,出了问题很难定位。传统的监控手段往往只能看到表面的延迟和错误率,根本无法深入了解内部机制。直到我接触了eBPF,才发现它简直是解决这类问题的神器!

什么是eBPF?为什么它能监控DNS?

eBPF(extended Berkeley Packet Filter)是一种强大的内核技术,允许你在内核中安全地运行自定义代码,而无需修改内核源码或加载内核模块。这听起来可能有点抽象,但你可以把它想象成一个“内核探针”,可以hook各种内核事件,并收集相关数据。

对于DNS监控来说,eBPF的优势在于:

  • 高性能:eBPF程序在内核中运行,避免了用户态和内核态之间频繁的切换,性能损耗极低。
  • 细粒度:可以hook到内核中socket层的DNS请求和响应,获取最原始的DNS数据。
  • 灵活性:可以根据需求自定义eBPF程序,监控特定的DNS事件和指标。
  • 安全性:eBPF程序需要经过内核验证器的严格检查,确保不会对系统造成安全风险。

实战:用eBPF监控Kubernetes DNS

接下来,我将分享一个使用eBPF监控Kubernetes集群中DNS查询的实际案例。我们将编写一个eBPF程序,用于捕获DNS查询数据,并将其导出到用户空间进行分析。

1. 环境准备

  • Kubernetes集群:确保你有一个正在运行的Kubernetes集群。
  • bcc工具:bcc (BPF Compiler Collection) 是一个用于创建 Linux BPF 程序的框架,它提供了一组高级工具和库,可以简化 eBPF 程序的开发。可以通过包管理器安装,例如 apt install bcc-toolsyum install bcc-tools
  • libbpf:libbpf 是一个 C 库,用于加载、验证和管理 BPF 程序。通常,它会与 bcc 一起安装。如果你的系统上没有 libbpf,你需要手动安装它。具体安装方法可以参考 libbpf 的官方文档。
  • bpftrace:bpftrace 是一种高级的 BPF 跟踪语言,可以让你用类似 awk 的语法编写 BPF 程序。虽然 bcc 提供了 Python 接口,但 bpftrace 在某些场景下更加方便快捷。可以通过包管理器安装,例如 apt install bpftrace

2. 编写eBPF程序 (使用bpftrace)

我们将使用bpftrace来编写eBPF程序,代码如下:

#!/usr/bin/bpftrace

#include <linux/socket.h>

// 定义 DNS 报文头的结构体,用于解析 DNS 响应
struct dns_header {
  unsigned short id;
  unsigned short flags;
  unsigned short qdcount;
  unsigned short ancount;
  unsigned short nscount;
  unsigned short arcount;
};

// kretprobe:inet_recvmsg - 跟踪 inet_recvmsg 函数的返回,该函数用于接收网络消息
kretprobe:inet_recvmsg
{
  // 获取 socket 结构体指针
  $sk = arg0->sk;
  // 获取网络家族(AF_INET 或 AF_INET6)
  $family = $sk->__sk_common.skc_family;

  // 只处理 IPv4 的 DNS 响应 (AF_INET = 2)
  if ($family == 2) {
    // 获取 msghdr 结构体指针,其中包含接收到的消息
    $msg = arg1;
    // 获取 iovec 数组指针,该数组包含接收到的数据缓冲区
    $iov = $msg->msg_iov;
    // 获取 iovec 数组中第一个元素的地址,即数据缓冲区的起始地址
    $iov_base = $iov->iov_base;
    // 获取接收到的数据长度
    $len = arg2;

    // 检查数据长度是否大于等于 DNS 报文头的长度 (12 字节)
    if ($len >= 12) {
      // 将数据缓冲区的起始地址转换为 dns_header 结构体指针
      $dns = (struct dns_header *) $iov_base;

      // 检查 DNS 响应标志,判断是否为响应报文 (QR 位为 1)
      if (($dns->flags & 0x8000) != 0) {
        // 从 socket 结构体中获取目标 IP 地址和端口
        $daddr = $sk->__sk_common.skc_daddr;
        $dport = $sk->__sk_common.skc_dport;

        // 打印 DNS 响应信息:
        // pid:进程 ID
        // comm:进程名
        // daddr:目标 IP 地址 (网络字节序)
        // dport:目标端口 (网络字节序)
        // id:DNS 报文 ID
        // flags:DNS 标志
        // ancount:DNS 响应中的 Answer 数量
        printf("%-6d %-16s %-16s %-5d id=%-5d flags=0x%-4x answers=%d\n",
               pid,
               comm,
               ntop($daddr),
               ntohs($dport),
               $dns->id,
               $dns->flags,
               $dns->ancount);
      }
    }
  }
}

代码解释:

  • kretprobe:inet_recvmsg:hook的是inet_recvmsg函数的返回,这个函数负责接收网络数据包。
  • $sk = arg0->sk:获取socket结构体,里面包含了连接的信息,比如源IP、目的IP、端口等。
  • $family = $sk->__sk_common.skc_family:获取协议族,只处理IPv4的DNS请求。
  • $msg = arg1; $iov = $msg->msg_iov; $iov_base = $iov->iov_base;:从消息结构体中获取数据缓冲区的起始地址。
  • $len = arg2:获取接收到的数据长度。
  • $dns = (struct dns_header *) $iov_base:将数据缓冲区转换为DNS报文头的结构体,方便解析。
  • ($dns->flags & 0x8000) != 0:判断是否是DNS响应报文,通过检查flags字段的QR位。
  • printf(...):打印关键信息,包括进程ID、进程名、目标IP、目标端口、DNS报文ID、flags和Answer数量。

3. 运行eBPF程序

将上面的代码保存为dns_monitor.bt,然后使用bpftrace运行:

sudo bpftrace dns_monitor.bt

运行后,你将会看到类似下面的输出:

495 systemd-resolve 192.168.1.11 53 id=51247 flags=0x8180 answers=1
495 systemd-resolve 192.168.1.11 53 id=51248 flags=0x8180 answers=1
495 systemd-resolve 192.168.1.11 53 id=51249 flags=0x8180 answers=1

这些数据告诉你,进程ID为495(systemd-resolve)的进程向192.168.1.11(你的DNS服务器)的53端口发送了DNS查询,并且收到了包含1个Answer的响应。

4. 进阶:分析DNS性能

有了这些原始数据,我们就可以进行更深入的DNS性能分析了。例如,我们可以统计每个域名解析的次数、解析时间等。下面是一个更复杂的bpftrace脚本,可以统计DNS解析延迟:

#!/usr/bin/bpftrace

#include <linux/socket.h>

struct dns_header {
  unsigned short id;
  unsigned short flags;
  unsigned short qdcount;
  unsigned short ancount;
  unsigned short nscount;
  unsigned short arcount;
};

// 定义一个 map,用于存储 DNS 请求的发送时间
@start = {};

// kprobe:sendto - 跟踪 sendto 函数,该函数用于发送网络消息
kprobe:sendto
{
  // 获取 socket 结构体指针
  $sk = arg0;
  // 获取网络家族 (AF_INET 或 AF_INET6)
  $family = $sk->__sk_common.skc_family;

  // 只处理 IPv4 的 DNS 请求 (AF_INET = 2)
  if ($family == 2) {
    // 获取目标 IP 地址和端口
    $daddr = $sk->__sk_common.skc_daddr;
    $dport = $sk->__sk_common.skc_dport;

    // 检查目标端口是否为 DNS 端口 (53)
    if (ntohs($dport) == 53) {
      // 获取 msghdr 结构体指针,其中包含要发送的消息
      $msg = arg2;
      // 获取 iovec 数组指针,该数组包含要发送的数据缓冲区
      $iov = $msg->msg_iov;
      // 获取 iovec 数组中第一个元素的地址,即数据缓冲区的起始地址
      $iov_base = $iov->iov_base;

      // 将数据缓冲区的起始地址转换为 dns_header 结构体指针
      $dns = (struct dns_header *) $iov_base;

      // 记录 DNS 报文 ID 和发送时间到 @start map 中
      @start[pid, $dns->id] = nsecs;
    }
  }
}

// kretprobe:inet_recvmsg - 跟踪 inet_recvmsg 函数的返回,该函数用于接收网络消息
kretprobe:inet_recvmsg
{
  // 获取 socket 结构体指针
  $sk = arg0->sk;
  // 获取网络家族 (AF_INET 或 AF_INET6)
  $family = $sk->__sk_common.skc_family;

  // 只处理 IPv4 的 DNS 响应 (AF_INET = 2)
  if ($family == 2) {
    // 获取 msghdr 结构体指针,其中包含接收到的消息
    $msg = arg1;
    // 获取 iovec 数组指针,该数组包含接收到的数据缓冲区
    $iov = $msg->msg_iov;
    // 获取 iovec 数组中第一个元素的地址,即数据缓冲区的起始地址
    $iov_base = $iov->iov_base;
    // 获取接收到的数据长度
    $len = arg2;

    // 检查数据长度是否大于等于 DNS 报文头的长度 (12 字节)
    if ($len >= 12) {
      // 将数据缓冲区的起始地址转换为 dns_header 结构体指针
      $dns = (struct dns_header *) $iov_base;

      // 检查 DNS 响应标志,判断是否为响应报文 (QR 位为 1)
      if (($dns->flags & 0x8000) != 0) {
        // 从 @start map 中获取 DNS 请求的发送时间
        $start_time = @start[pid, $dns->id];

        // 如果找到了发送时间
        if ($start_time) {
          // 计算 DNS 解析延迟 (纳秒)
          $latency_ns = nsecs - $start_time;
          // 将延迟转换为毫秒
          $latency_ms = $latency_ns / 1000000;

          // 打印 DNS 响应信息:
          // pid:进程 ID
          // comm:进程名
          // id:DNS 报文 ID
          // latency_ms:DNS 解析延迟 (毫秒)
          printf("%-6d %-16s id=%-5d latency=%.2f ms\n", pid, comm, $dns->id, $latency_ms);

          // 从 @start map 中删除该 DNS 请求的记录
          delete(@start[pid, $dns->id]);
        }
      }
    }
  }
}

代码解释:

  • @start = {}:定义一个map,用于存储DNS请求的发送时间,key是进程ID和DNS报文ID,value是发送时间。
  • kprobe:sendto:hook的是sendto函数,这个函数负责发送网络数据包。
  • sendto hook中,我们判断目标端口是否为53(DNS端口),如果是,则记录下发送时间和DNS报文ID。
  • kretprobe:inet_recvmsg hook中,我们根据进程ID和DNS报文ID从@start map中取出发送时间,计算出延迟,并打印出来。
  • delete(@start[pid, $dns->id]):删除map中的记录,防止内存泄漏。

运行这个脚本,你将会看到类似下面的输出:

495 systemd-resolve id=51247 latency=1.23 ms
495 systemd-resolve id=51248 latency=0.98 ms
495 systemd-resolve id=51249 latency=1.11 ms

这些数据告诉你每个DNS查询的延迟,你可以根据这些数据来判断DNS服务器是否健康,是否存在性能瓶颈。

5. 集成到监控系统

仅仅在命令行查看数据是不够的,我们需要将这些数据集成到现有的监控系统中,例如Prometheus。我们可以编写一个exporter,将eBPF程序收集到的数据转换为Prometheus可以识别的格式。

这部分内容比较复杂,涉及到Prometheus exporter的开发,这里就不展开讲解了。但是,我可以提供一些思路:

  • 使用bcc的Python接口来编写exporter。
  • 使用共享内存或perf event ring buffer来将eBPF程序收集到的数据传递给exporter。
  • 将exporter部署到Kubernetes集群中,并使用ServiceMonitor来自动发现。

eBPF在Kubernetes DNS监控中的更多应用场景

除了上面介绍的DNS查询延迟监控,eBPF还可以用于:

  • DNS错误分析:可以捕获DNS服务器返回的错误码,例如NXDOMAIN、SERVFAIL等,帮助你快速定位DNS错误的原因。
  • DNS缓存分析:可以监控DNS缓存的命中率和过期时间,优化DNS缓存配置。
  • 恶意DNS请求检测:可以检测异常的DNS请求,例如DGA域名、DNS隧道等,提高安全性。
  • Service DNS监控:可以监控Kubernetes Service的DNS解析情况,确保Service能够正常访问。

总结

eBPF为Kubernetes DNS监控带来了革命性的变化,它提供了高性能、细粒度、灵活和安全的监控能力,帮助我们深入了解DNS内部机制,快速定位和解决DNS问题。虽然eBPF的学习曲线比较陡峭,但是一旦掌握了它,你将会发现它是一个非常强大的工具,可以解决很多其他的网络和性能问题。

希望这篇文章能够帮助你入门eBPF,并将其应用到Kubernetes DNS监控中。 告别DNS盲区,让你的Kubernetes集群拥有“透视眼”!

Tips:

  • eBPF程序需要root权限才能运行。
  • 在生产环境中使用eBPF程序时,需要 тщательно 测试,确保不会对系统造成影响。
  • 可以使用bpftool命令来管理和调试eBPF程序。
  • 可以参考cilium、falco等开源项目,学习它们是如何使用eBPF的。

作为一名热衷于探索新技术的SRE,我将继续深入研究eBPF,并将其应用到更多的场景中,为Kubernetes集群的稳定性和性能保驾护航。

Kernel探针侠 eBPFKubernetesDNS监控

评论点评

打赏赞助
sponsor

感谢您的支持让我们更好的前行

分享

QRcode

https://www.webkt.com/article/9396