聚合国内IT技术精华文章,分享IT技术精华,帮助IT从业人士成长

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

2021-11-10 00:01 浏览: 3176308 次 我要评论(0 条) 字号:

译者注

背景

译者还在学习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 %sn", 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: %dn", 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...

就酱! 
<br>
</p>
<script>

var contentimgs=document.getElementById(


网友评论已有0条评论, 我也要评论

发表评论

*

* (保密)

Ctrl+Enter 快捷回复