[译]使用eBPF per-CPU Cgroup本地存储进行低功耗统计

译者注

背景

译者还在学习eBPF,在学习计划中,分了阅读、翻译、模仿、研发等几个阶段。这篇文章是译者学习过程中的第二篇eBPF文章翻译。在阅读过程中,动手练习,理解原文目的、思路、方法。加深对知识点的理解。
本文翻译自Using eBPF per-CPU Cgroup local storage for low overhead accounting,由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。

第一篇翻译文章为:使用 eBPF(并绕过 TCP/IP)加速云原生应用程序的经验教训
对于本文中涉及的代码,都放在GitHub:ebpf-demo仓库下的bpf-accounting下,请自取。

per-CPU

随着多CPU架构的成熟发展,BPF Map也引入了per-cpu类型,如BPF_MAP_TYPE_PERCPU_HASH、BPF_MAP_TYPE_PERCPU_ARRAY等,当你使用这种类型的BPF Map时,每个CPU都会存储并看到它自己的Map数据,从属于不同CPU之间的数据是互相隔离的,这样做的好处是,在进行查找和聚合操作时更加高效,性能更好,尤其是你的BPF程序主要是在做收集时间序列型数据,如流量数据或指标等。参考

原文

前言

Linux eBPF生态系统正在迅速增长,每个发布周期都会出现新功能。从本质上讲,eBPF是一种通过attach到各种per-defines hooks和导出函数的方式,来编写Linux内核脚本的安全方式。在eBPF中,可以使用各种“map”类型来实现kernel space和user space通信。

每隔几个内核版本,就会引入或改进新的“map”类型,并且在3年前BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE引入,现在可以在4.20以后的内核中使用,git提交日志:bpf: introduce per-cpu cgroup local storage

译者注:

BPF Compiler Collection (BCC)维护了eBPF新特性变化的日志,包含每个特性的增加时间、内核版本号、commit时间等,详情见:BPF Features by Linux Kernel Version

我对它很感兴趣,想通过一个简单的Cgroup网络吞吐量监控应用来了解它。

通常情况下,在资源受限的机器上运行多个工作负载时的实际用例。但事实证明,我很难理解如何快速有效地使用它。之后,我决定写一篇文章,希望可以帮助你快速学习per-CPU eBPF Cgroup local storage

示例演示

这篇文章将 在用户空间的应用使用Cilium 的ebpf库。当然,在C中使用libbpf在 Python 中使用BCC可以轻松实现类似的效果。内核侧eBPF无需更改,可以配合其他任何语言实现的eBPF数据读取应用。

为了监控目标Cgroup入口和出口吞吐量,演示程序将附加到cgroup_skb/ingresscgroup_skb/egress Cgroup eBPF 钩子。当然,将map类型的result发送到用户空间BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE

为了保持这篇文章简单并专注于eBPF部分,这篇文章将使用一个硬编码的cgroup地址。

示例代码

如下代码,这是eBPF内核态程序的概要。以一个伪注释开始,以便让go编译器不要编译它。然后是 linux内核以及BPF helper等各种头文件。

license可能是多余的,但eBPF程序与普通c内核模块遵循相同的规则。通常,某些符号导出受“仅GPL”约束(或兼容)。

C代码

// +build ignore

#include <stdbool.h>
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <netinet/ip.h>
#include <netinet/ip6.h>

// ---------------------------------------------
// -- The real program will be somewhere here --
// ---------------------------------------------

char __license[] __attribute__((section("license"), used)) = "MIT";

编译器clang(llvm)将这个C文件编译为eBPF的字节码文件:

clang -g -Wall -Werror -O2 -emit-llvm -c bpf-accounting.c -o - | llc -march=bpf -filetype=obj -o bpf-accounting.o

当然,需要先安装Clang,以及linux kernel对应的头文件,安装方法可以参考上篇译者的eBPF开发环境

go代码

这是现在的Go代码模版。它主要包含设置阶段和监控阶段两部分代码,监控阶段每秒运行一次,按“Ctrl+C”或TERM信号终止运行。

设定好TARGET_CGROUP_V2_PATH,确保关注的是Cgroup V2版本。在笔者开发环境的路径是/sys/fs/cgroup/unified前缀,因系统而异,这里是Ubuntu,但其Systemd配置仍使用v1的Cgroup。

如果bpf-accounting.c里的eBPF部分代码没有被调用,那么EBPF_PROG_ELF加载部分,就需要好好调整。

package main

import (
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/cilium/ebpf"
    "github.com/cilium/ebpf/link"
    "golang.org/x/sys/unix"
)

const (
    TARGET_CGROUP_V2_PATH = "/sys/fs/cgroup/unified/yadutaf" // <--- Change as needed for your system/use-case
    EBPF_PROG_ELF         = "./bpf-accounting.o"
)

func main() {
    log.Printf("Attaching eBPF monitoring programs to cgroup %s\n", TARGET_CGROUP_V2_PATH)

    // ------------------------------------------------------------
    // -- The real program initialization will be somewhere here --
    // ------------------------------------------------------------

    // Wait until signaled
    c := make(chan os.Signal, 1)
    signal.Notify(c, syscall.SIGINT)
    signal.Notify(c, syscall.SIGTERM)

    // Periodically check counters
    ticker := time.NewTicker(1 * time.Second)

out:
    for {
        select {
        case <-ticker.C:
            log.Println("-------------------------------------------------------------")

            // ------------------------------------------
            // -- And here will be the counters report --
            // ------------------------------------------

        case <-c:
            log.Println("Exiting...")
            break out
        }
    }
}

数据结构

现在介绍一下BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE类型。但请先思考一个问题,为什么不使用最典型、更容易使用的BPF_MAP_TYPE_HASHmap类型?

对于本文的用例,BPF_MAP_TYPE_HASH将会特别适合。但是,如果应用程序需要监视所有cgroup,并且在创建动态时attach,或在Cgroup终止时清除相关资源,BPF_MAP_TYPE_CGROUP_STORAGE类型则可能是更合适的选择。

但是,如果可以从多个CPU并发访问收集的数据,那么per-CPU对应的BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE类型是避免自旋锁的最佳选择。

从原理上看,BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE实际上是一个/virtual/映射,或更准确地说是叫Cgrouplocal storage本地存储的内核术语。在内部,Cgroup数据结构有一个专用于cgroup本地存储的成员。因此,指定类型的程序将为指定的Cgroup共享相同的存储区域。默认情况下,不同类型的程序在Cgroup中都会有一个固定的存储区域。然而,可以在指定的Cgroup的所有程序类型之间共享这个存储区域。

几个要点:

  1. 根据设计,map(虚拟)的key在概念上是一个(cgroup_id, program_type)元组。这不是固定的。比如,叶成员类型是灵活的。
  2. 如果多个相同类型的程序需要在同一个Cgroup中存储数据,那么必须配合进行叶子成员类型定义。
  3. 程序只能访问它们所连接的cgroup的存储区域。换句话说,即使由子Cgroup foo/bar中的事件触发,attach到Cgroup的程序foo也只会访问Cgroup的存储foo的值。如果需要专门跟踪子 Cgroup事件,则还需要将相同的代码逻辑attach到关注的Cgroup。

提醒一下BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE
由于per-CPU是map类型,因此group中的存储不是叶子成员类型,而是这种类型的per-CPU数组。这对程序的eBPF部分是完全透明的,只有它当前的cgroup+CPU视图。然而,它需要对Go部分进行特殊处理,来获取每个cgroup+CPU存储的完整数据视图。

eBPF逻辑实现

对于本文,有两种业务目标 (cgroup_skb/ingresscgroup_skb/egress)来匹配incomming和outgoing流量。由于目标是测量每个方向传输的字节总数,因此叶子成员类型可以是uint64

内核态C代码

在eBPF端,结构声明很简单:

struct bpf_map_def SEC("maps") cgroup_counters_map = {
    .type = BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE,
    .key_size = sizeof(struct bpf_cgroup_storage_key),
    .value_size = sizeof(__u64),
};

这定义了 eBPF map cgroup_counters_map类型BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE,其主键struct bpf_cgroup_storage_key的类型为bpf.h中定义的类型,为无符号uint64,用它作为map的member来保存字节大小。

用户态Go代码

用户态的逻辑代码方面涉及更多,但别担心。首先,程序需要增加最大“locked”内存。虽然这个例子来说不是必需的,这里只是普通的字节大小计数器,不会占用啥太大内存。但在使用eBPF的场景里,这里可能是最容易疏忽的陷阱。因此,需要实现为:

   // Increase max locked memory (for eBPF maps)
  // For a real program, make sure to adjust to actual needs
  unix.Setrlimit(unix.RLIMIT_MEMLOCK, &unix.Rlimit{
        Cur: unix.RLIM_INFINITY,
        Max: unix.RLIM_INFINITY,
    })

现在需要应用层调用内核eBPF接口加载eBPF字节码的eBPF ELF文件。这里是Cilium的ebpf库的亮点。解析ELF字节码文件并提取map定义和所有程序metadata “collection”:

   collec, err := ebpf.LoadCollection(EBPF_PROG_ELF)
    if err != nil {
        log.Fatal(err)
    }

从collection中获取map的句柄非常简单:

   // Get a handle on the statistics map
  cgroup_counters_map := collec.Maps["cgroup_counters_map"]

在访问map时,程序需要key和member类型定义。重点是,Cilium的库还没有内置定义。由于key format是由内核的ABI强加的,因此需要按照struct bpf_cgroup_storage_key内核文档:BPF_MAP_TYPE_CGROUP_STORAGE
完成结构体的解析,以及注意32位虚拟字段的对齐问题:

type BpfCgroupStorageKey struct {
    CgroupInodeId uint64
    AttachType    ebpf.AttachType
    _             uint32
}

go程序需要定义成员类型。由于程序使用per-CPU数据结构,这需要是一个slice:

type PerCPUCounters []uint64

由于我们只对传输的字节总数感兴趣,并不关注其他事情,那么直接for循环slice,进行相加即可。:

func sumPerCpuCounters(perCpuCounters PerCPUCounters) uint64 {
    sum := uint64(0)
    for _, counter := range perCpuCounters {
        sum += counter
    }
    return sum
}

最后一件事:要访问map的entry,只是定义map数据结构是不够的。其中需要有一些实际值。在AttachType已经讨论的,这是节目的类型(ingress VS egress)。最棘手的部分是CgroupInodeId。该字段是cgroup路径的Inode编号,需要提前读取。

由于这是一个简单的例子,那就先硬编码TARGET_CGROUP_V2_PATH cgroup的值。因此,可以间接写死Inode的ID:

   // Get cgroup folder inode number to use as a key in the per-cgroup map
  cgroupFileinfo, err := os.Stat(TARGET_CGROUP_V2_PATH)
    if err != nil {
        log.Fatal(err)
    }
    cgroupStat, ok := cgroupFileinfo.Sys().(*syscall.Stat_t)
    if !ok {
        log.Fatal("Not a syscall.Stat_t")
    }
    cgroupInodeId := cgroupStat.Ino

现在,我们来在结构中实际存储一些我们需要的数据。

ingress/egress大小统计

有几种情况需要考虑:

  • 传输的数据包可能位于ingressegress路径上。
  • 传输的数据包可以是IPv4或IPv6。

所有这些情况都可以在 eBPF内核侧的一个通用函数中简单地处理:

inline int handle_skb(struct __sk_buff *skb)
{
    __u16 bytes = 0;

    // Extract packet size from IPv4 / IPv6 header
    switch (skb->family)
    {
    case AF_INET:
        {
            struct iphdr iph;
            bpf_skb_load_bytes(skb, 0, &iph, sizeof(struct iphdr));
            bytes = ntohs(iph.tot_len);
            break;
        }
    case AF_INET6:
        {
            struct ip6_hdr ip6h;
            bpf_skb_load_bytes(skb, 0, &ip6h, sizeof(struct ip6_hdr));
            bytes = ntohs(ip6h.ip6_plen);
            break;
        }
    default:
        // This should never be the case as this eBPF hook is called in
        // netfilter context and thus not for AF_PACKET, AF_UNIX nor AF_NETLINK
        // for instance.
        return true;
    }

    // Update counters in the per-cgroup map
    __u64 *bytes_counter = bpf_get_local_storage(&cgroup_counters_map, 0);
    __sync_fetch_and_add(bytes_counter, bytes);

    // Let the packet pass
    return true;
}

实现起来特别简单。最大的难点来自IPv4与IPv6与错误处理。不过,我们关注的主要还是与map相关的那几行。

bpf_get_local_storage()封装实现了(AttachedCgroup, ProgramType, CPU)三元组加载到存储区的功能,直接调用即可。

由于eBPF程序不应该被中断并且数据是per-CPU的,所以__sync_fetch_and_add是个递增原子计数器。在当前代码例子中可能不需要。但上游的运行是支持eBPF抢占的。

这个通用函数可以同时处理ingressegress两种流量:

// Ingress hook - handle incoming packets
SEC("cgroup_skb/ingress") int ingress(struct __sk_buff *skb)
{
    return handle_skb(skb);
}

// Egress hook - handle outgoing packets
SEC("cgroup_skb/egress") int egress(struct __sk_buff *skb)
{
    return handle_skb(skb);
}

eBPF内核态部分现在已经完成。下一步是加载这些程序并将其attach到关注的Cgroup上,并对收集的数据做计算。

attach程序并收集数据

为了监控ingressegress流量,需要为两个目标cgroup分别attach一次。同样,生成的数据需要在两个方向上进行查询。

为了保持代码简洁,声明一个简单的helper结构体:

type BPFCgroupNetworkDirection struct {
    Name       string
    AttachType ebpf.AttachType
}

var BPFCgroupNetworkDirections = []BPFCgroupNetworkDirection{
    {
        Name:       "ingress",
        AttachType: ebpf.AttachCGroupInetIngress,
    },
    {
        Name:       "egress",
        AttachType: ebpf.AttachCGroupInetEgress,
    },
}

如上代码为每个方向分别定义了attach的类型。

接下来,使用简单的循环将程序从加载的目标cgroup对应collection中读取数据:

// Attach program to monitored cgroup
for _, direction := range BPFCgroupNetworkDirections {
    link, err := link.AttachCgroup(link.CgroupOptions{
        Path:    TARGET_CGROUP_V2_PATH,
        Attach:  direction.AttachType,
        Program: collec.Programs[direction.Name],
    })
    if err != nil {
        log.Fatal(err)
    }
    defer link.Close()
}

然后,用map查询结果:

for _, direction := range BPFCgroupNetworkDirections {
    var perCPUCounters PerCPUCounters

    mapKey := BpfCgroupStorageKey{
        CgroupInodeId: cgroupInodeId,
        AttachType:    direction.AttachType,
    }

    if err := cgroup_counters_map.Lookup(mapKey, &perCPUCounters); err != nil {
        log.Printf("%s: error reading map (%v)", direction.Name, err)
    } else {
        log.Printf("%s: %d\n", direction.Name, sumPerCpuCounters(perCPUCounters))
    }
}

同样,此代码段在预定义的流量卡点上一直循环。然后不停的查询目标方向的流量产生的数据。

查询功能实现在很大程度上依赖于Cilium的eBPF库,用于内核态和Go用户态之间的数据通讯。其他部分代码的都不重要。

测试

现在一切就绪,假设目标cgroup和文件名保持不变,现在可以编译和运行程序。

编译:

clang -g -Wall -Werror -O2 -emit-llvm -c bpf-accounting.c -o - | llc -march=bpf -filetype=obj -o bpf-accounting.o
llvm-strip -g bpf-accounting.o
go build .

要设置测试环境,最简单的方法是打开终端并运行:

# Create the Cgroup
sudo mkdir -p /sys/fs/cgroup/unified/yadutaf

# Register the current shell PID in the cgroup
echo $$ | sudo tee /sys/fs/cgroup/unified/yadutaf/cgroup.procs

# Start an advanced networking command
ping -n 2001:4860:4860::8888
# Or, for IPv4: ping -n 8.8.8.8
# 或者 wget下载几个文件之类的。

最后,再开一个shell

sudo ./bpf-accounting

译者提醒

这里需要提醒一下,上面cgroup的设定是针对执行命令的shell进程的,如果在其他shell进程里执行网络访问行为,那么这个程序就无法收集到流量了。就是说/sys/fs/cgroup/unified/yadutaf/cgroup.procs里的shell进程id是被cgroup监控的,产生网络流量的行为,必须也在这个进程里才行。

运行结果

应该打印如下内容:

2021/08/22 17:20:50 Attaching eBPF monitoring programs to cgroup /sys/fs/cgroup/unified/yadutaf
2021/08/22 17:20:51 -------------------------------------------------------------
2021/08/22 17:20:51 ingress: 64
2021/08/22 17:20:51 egress: 64
2021/08/22 17:20:52 -------------------------------------------------------------
2021/08/22 17:20:52 ingress: 128
2021/08/22 17:20:52 egress: 128
2021/08/22 17:20:53 -------------------------------------------------------------
2021/08/22 17:20:53 ingress: 192
2021/08/22 17:20:53 egress: 192
2021/08/22 17:20:54 -------------------------------------------------------------
2021/08/22 17:20:54 ingress: 256
2021/08/22 17:20:54 egress: 256
^C2021/08/22 17:20:54 Exiting...

就酱! 😊

结论

本文通过一个简单的per-cgroup的BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE eBPF类型,对网络吞吐量进行监控,展示了local storage的eBPF虚拟map使用。

总结重点

  • 在性能敏感的上下文中存储per-cgroup测量或状态时使用这个存储方式。
  • 如果性能不是问题,请考虑使用BPF_MAP_TYPE_CGROUP_STORAGE以避免增加管理per-cpu数据的复杂性。
  • attach到指定Cgroup的所有给定类型的eBPF cgroup将共享相同的数据存储空间。
  • 将eBPF程序attach到每个目标cgroup:此存储与特定附件相关联。

此示例可用作监控应用程序的基础。例如,可以监视Cgroup创建并动态地attach程序。还可以通过 OpenMetrics(Prometheus)等进行数据收集,集成到更大的系统中。

最后,我要感谢Cilium的ebpf社区在我写这篇文章时帮助我修复了库中一个微小但必不可少的错误。

提醒

对于本文中创建的bpf map,可以使用bpftool进行查询,安装方式见编译bpftool工具部分。

root@vmubuntu:/home/cfc4n# bpftool prog show
3: cgroup_skb  tag 6deef7357e7b4530  gpl
    loaded_at 2021-11-09T13:03:31+0000  uid 0
    xlated 64B  jited 54B  memlock 4096B
    pids systemd(1)
4: cgroup_skb  tag 6deef7357e7b4530  gpl
    loaded_at 2021-11-09T13:03:31+0000  uid 0
    xlated 64B  jited 54B  memlock 4096B
    pids systemd(1)
5: cgroup_skb  tag 6deef7357e7b4530  gpl
    loaded_at 2021-11-09T13:03:31+0000  uid 0
    xlated 64B  jited 54B  memlock 4096B
    pids systemd(1)
6: cgroup_skb  tag 6deef7357e7b4530  gpl
    loaded_at 2021-11-09T13:03:31+0000  uid 0
    xlated 64B  jited 54B  memlock 4096B
    pids systemd(1)
7: cgroup_skb  tag 6deef7357e7b4530  gpl
    loaded_at 2021-11-09T13:03:36+0000  uid 0
    xlated 64B  jited 54B  memlock 4096B
    pids systemd(1)
8: cgroup_skb  tag 6deef7357e7b4530  gpl
    loaded_at 2021-11-09T13:03:36+0000  uid 0
    xlated 64B  jited 54B  memlock 4096B
    pids systemd(1)
12: cgroup_skb  name ingress  tag e0be5f67a85710f4
    loaded_at 2021-11-09T13:04:27+0000  uid 0
    xlated 200B  jited 127B  memlock 4096B  map_ids 6
    btf_id 127
    pids bpf-accounting(1480)
13: cgroup_skb  name egress  tag e0be5f67a85710f4
    loaded_at 2021-11-09T13:04:27+0000  uid 0
    xlated 200B  jited 127B  memlock 4096B  map_ids 6
    btf_id 127
    pids bpf-accounting(1480)
root@vmubuntu:/home/cfc4n# bpftool -p map show id 6
{
    "id": 6,
    "type": "percpu_cgroup_storage",
    "name": "cgroup_counters",
    "flags": 0,
    "bytes_key": 16,
    "bytes_value": 8,
    "max_entries": 0,
    "bytes_memlock": 0,
    "frozen": 0,
    "pids": [{
            "pid": 1480,
            "comm": "bpf-accounting"
        }
    ]
}

cgroup目录清理:

apt install cgroup-tools/hirsute
cgdelete unified/yadutaf

或重启系统。

知识共享许可协议CFC4N的博客CFC4N 创作,采用 知识共享 署名-非商业性使用-相同方式共享(3.0未本地化版本)许可协议进行许可。基于https://www.cnxct.com上的作品创作。转载请注明转自:[译]使用eBPF per-CPU Cgroup本地存储进行低功耗统计

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据