WEBKT

eBPF流量整形实战-如何用eBPF限制特定IP/端口的带宽?

221 0 0 0

eBPF流量整形实战-如何用eBPF限制特定IP/端口的带宽?

作为一名网络工程师,你是否经常遇到这样的问题:某些用户或服务占用了过多的带宽,导致其他用户的网络体验变差?传统的流量整形方案往往配置复杂,性能损耗大。今天,我将带你使用eBPF来实现一个简单而高效的流量整形器,它可以限制特定IP地址或端口的带宽使用,有效防止恶意用户占用过多资源。

1. 为什么选择eBPF?

在深入代码之前,我们先来了解一下为什么eBPF是流量整形的一个好选择。

  • 高性能: eBPF程序运行在内核态,避免了用户态和内核态之间频繁的上下文切换,从而提高了性能。
  • 灵活性: eBPF程序可以动态加载和卸载,无需修改内核代码,方便部署和维护。
  • 安全性: eBPF程序在运行前会经过内核的验证,确保程序的安全性,防止恶意代码破坏系统。

2. 流量整形的基本原理

流量整形,顾名思义,就是对网络流量进行“整形”,使其符合一定的规则。常见的流量整形算法包括:

  • 令牌桶(Token Bucket): 令牌桶算法是一种常用的流量整形算法。它维护一个令牌桶,以一定的速率向桶中添加令牌。每个数据包需要消耗一个令牌才能通过。如果桶中没有足够的令牌,数据包将被延迟或丢弃。
  • 漏桶(Leaky Bucket): 漏桶算法以恒定的速率从桶中“漏”出数据包。如果数据包到达的速度超过了漏出的速度,数据包将被放入桶中等待。如果桶满了,数据包将被丢弃。

在本文中,我们将使用令牌桶算法来实现流量整形器。

3. eBPF流量整形器的设计

我们的eBPF流量整形器将实现以下功能:

  • 基于IP地址和端口的流量限制: 可以针对特定的IP地址或端口设置带宽限制。
  • 动态配置: 可以动态修改带宽限制,无需重启系统。
  • 可观测性: 可以监控流量整形器的运行状态,例如丢包率、延迟等。

3.1 数据结构

为了实现上述功能,我们需要定义以下数据结构:

// 定义一个结构体,用于存储IP地址和端口信息
struct ip_port_key_t {
    __u32 ip;
    __u16 port;
};

// 定义一个结构体,用于存储令牌桶信息
struct token_bucket_t {
    __u64 tokens; // 当前令牌数
    __u64 last_time; // 上次更新令牌的时间
    __u64 rate; // 令牌生成速率
    __u64 burst; // 令牌桶容量
};

// 定义一个BPF哈希表,用于存储IP地址/端口和令牌桶的映射关系
BPF_TABLE("hash", struct ip_port_key_t, struct token_bucket_t, ip_port_map, 65536);
  • ip_port_key_t:用于存储IP地址和端口信息,作为BPF哈希表的键。
  • token_bucket_t:用于存储令牌桶信息,包括当前令牌数、上次更新令牌的时间、令牌生成速率和令牌桶容量,作为BPF哈希表的值。
  • ip_port_map:BPF哈希表,用于存储IP地址/端口和令牌桶的映射关系。哈希表的大小设置为65536,可以存储大量的IP地址/端口信息。

3.2 eBPF程序

eBPF程序的主要流程如下:

  1. 获取数据包的源IP地址和端口。
  2. 在BPF哈希表中查找对应的令牌桶。
  3. 如果找到了令牌桶,则更新令牌数。
  4. 如果令牌数足够,则允许数据包通过,并消耗一个令牌。
  5. 如果令牌数不足,则丢弃数据包。

下面是eBPF程序的代码:

#include <uapi/linux/bpf.h>
#include <linux/in.h>
#include <linux/ip.h>
#include <linux/tcp.h>

#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

// 定义一个结构体,用于存储IP地址和端口信息
struct ip_port_key_t {
    __u32 ip;
    __u16 port;
};

// 定义一个结构体,用于存储令牌桶信息
struct token_bucket_t {
    __u64 tokens; // 当前令牌数
    __u64 last_time; // 上次更新令牌的时间
    __u64 rate; // 令牌生成速率
    __u64 burst; // 令牌桶容量
};

// 定义一个BPF哈希表,用于存储IP地址/端口和令牌桶的映射关系
BPF_TABLE("hash", struct ip_port_key_t, struct token_bucket_t, ip_port_map, 65536);

int packet_filter(struct xdp_md *ctx) {
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;

    // 解析IP头部
    struct iphdr *iph = data;
    if (iph + 1 > data_end)
        return XDP_PASS;
    if (iph->version != 4 || iph->ihl < 5)
        return XDP_PASS;

    // 只处理TCP协议
    if (iph->protocol != IPPROTO_TCP)
        return XDP_PASS;

    // 解析TCP头部
    struct tcphdr *tcph = (void *)iph + iph->ihl * 4;
    if (tcph + 1 > data_end)
        return XDP_PASS;

    // 获取源IP地址和端口
    struct ip_port_key_t key = {
        .ip = bpf_ntohl(iph->saddr),
        .port = bpf_ntohs(tcph->source)
    };

    // 在BPF哈希表中查找对应的令牌桶
    struct token_bucket_t *bucket = ip_port_map.lookup(&key);
    if (!bucket)
        return XDP_PASS;

    // 获取当前时间
    __u64 now = bpf_ktime_get_ns();

    // 计算自上次更新令牌以来经过的时间
    __u64 delta = now - bucket->last_time;

    // 更新令牌数
    bucket->tokens += delta * bucket->rate / 1000000000; // 纳秒转换为秒
    if (bucket->tokens > bucket->burst)
        bucket->tokens = bucket->burst;

    // 更新上次更新令牌的时间
    bucket->last_time = now;

    // 如果令牌数足够,则允许数据包通过,并消耗一个令牌
    if (bucket->tokens >= (__u64)bpf_ntohs(iph->tot_len)) {
        bucket->tokens -= (__u64)bpf_ntohs(iph->tot_len);
        ip_port_map.update(&key, bucket);
        return XDP_PASS;
    }

    // 如果令牌数不足,则丢弃数据包
    return XDP_DROP;
}

代码解释:

  • 首先,我们包含了必要的头文件,包括bpf.hin.hip.htcp.h。这些头文件定义了eBPF程序所需的各种数据结构和函数。
  • packet_filter函数是eBPF程序的入口点。它接收一个xdp_md结构体作为参数,该结构体包含了数据包的信息。
  • 在函数内部,我们首先解析IP头部和TCP头部,获取源IP地址和端口。注意,我们需要使用bpf_ntohlbpf_ntohs函数将网络字节序转换为本机字节序。
  • 然后,我们在BPF哈希表中查找对应的令牌桶。如果找到了令牌桶,则更新令牌数。更新令牌数的公式为:tokens = tokens + delta * rate,其中delta为自上次更新令牌以来经过的时间,rate为令牌生成速率。为了防止令牌数超过令牌桶的容量,我们需要将令牌数限制在burst值之内。
  • 最后,我们判断令牌数是否足够。如果令牌数大于数据包的长度,则允许数据包通过,并消耗一个令牌。否则,丢弃数据包。

3.3 用户态程序

用户态程序的主要功能如下:

  • 加载eBPF程序到内核。
  • 创建和管理BPF哈希表。
  • 提供API,用于动态配置带宽限制。
  • 监控流量整形器的运行状态。

下面是用户态程序的代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <stdint.h>
#include <arpa/inet.h>

#include <bpf/bpf.h>
#include <bpf/libbpf.h>

// 定义一个结构体,用于存储IP地址和端口信息
struct ip_port_key_t {
    __u32 ip;
    __u16 port;
};

// 定义一个结构体,用于存储令牌桶信息
struct token_bucket_t {
    __u64 tokens; // 当前令牌数
    __u64 last_time; // 上次更新令牌的时间
    __u64 rate; // 令牌生成速率
    __u64 burst; // 令牌桶容量
};

int main(int argc, char **argv) {
    // 加载eBPF程序
    struct bpf_object *obj = bpf_object__open("packet_filter.o");
    if (!obj) {
        fprintf(stderr, "Failed to open BPF object\n");
        return 1;
    }

    int err = bpf_object__load(obj);
    if (err) {
        fprintf(stderr, "Failed to load BPF object\n");
        return 1;
    }

    // 获取BPF哈希表的fd
    int map_fd = bpf_object__find_map_fd_by_name(obj, "ip_port_map");
    if (map_fd < 0) {
        fprintf(stderr, "Failed to find map\n");
        return 1;
    }

    // 设置IP地址和端口的带宽限制
    struct ip_port_key_t key = {
        .ip = inet_addr("192.168.1.100"),
        .port = htons(8080)
    };

    struct token_bucket_t bucket = {
        .tokens = 1000000, // 初始令牌数
        .last_time = bpf_ktime_get_ns(), // 上次更新令牌的时间
        .rate = 1000000, // 令牌生成速率,单位:字节/秒
        .burst = 10000000 // 令牌桶容量,单位:字节
    };

    err = bpf_map_update_elem(map_fd, &key, &bucket, BPF_ANY);
    if (err) {
        fprintf(stderr, "Failed to update map: %s\n", strerror(errno));
        return 1;
    }

    printf("Traffic shaping configured for 192.168.1.100:8080\n");

    // 等待一段时间
    sleep(60);

    // 卸载eBPF程序
    bpf_object__close(obj);

    return 0;
}

代码解释:

  • 首先,我们使用bpf_object__open函数打开eBPF程序的目标文件(packet_filter.o)。
  • 然后,使用bpf_object__load函数将eBPF程序加载到内核。
  • 接下来,使用bpf_object__find_map_fd_by_name函数获取BPF哈希表的fd。map_fd是后续操作BPF哈希表的关键。
  • 然后,我们创建一个ip_port_key_t结构体,用于指定要限制带宽的IP地址和端口。注意,我们需要使用inet_addrhtons函数将IP地址和端口转换为网络字节序。
  • 接下来,我们创建一个token_bucket_t结构体,用于设置令牌桶的参数,包括初始令牌数、上次更新令牌的时间、令牌生成速率和令牌桶容量。
  • 最后,我们使用bpf_map_update_elem函数将IP地址/端口和令牌桶的映射关系添加到BPF哈希表中。BPF_ANY标志表示如果该键不存在,则创建一个新的键值对;如果该键已经存在,则更新该键对应的值。

4. 编译和运行

4.1 编译eBPF程序

首先,我们需要安装必要的工具链,包括clangllvm。然后,使用以下命令编译eBPF程序:

clang -O2 -target bpf -c packet_filter.c -o packet_filter.o

4.2 编译用户态程序

接下来,我们需要安装libbpf库。然后,使用以下命令编译用户态程序:

gcc -Wall user_program.c -o user_program -lbpf

4.3 运行程序

首先,我们需要将eBPF程序加载到XDP(eXpress Data Path)。XDP是一种高性能的网络数据包处理框架,它可以将eBPF程序附加到网络接口上,从而实现对数据包的快速处理。可以使用ip link命令将eBPF程序附加到网络接口上:

ip link set dev eth0 xdp obj packet_filter.o

其中,eth0为网络接口的名称,packet_filter.o为eBPF程序的目标文件。

然后,我们可以运行用户态程序:

sudo ./user_program

运行结果如下:

Traffic shaping configured for 192.168.1.100:8080

这表示我们已经成功地为IP地址192.168.1.100的端口8080设置了带宽限制。

5. 验证流量整形效果

为了验证流量整形效果,我们可以使用iperf3工具来测试带宽。首先,在一台机器上运行iperf3服务器:

iperf3 -s

然后,在另一台机器上运行iperf3客户端,连接到服务器的8080端口:

iperf3 -c 192.168.1.100 -p 8080

如果流量整形器工作正常,iperf3客户端的带宽应该被限制在我们设置的速率之内。

6. 总结与展望

本文介绍了如何使用eBPF来实现一个简单的流量整形器,它可以限制特定IP地址或端口的带宽使用。通过使用eBPF,我们可以实现高性能、灵活和安全的流量整形方案。

当然,本文介绍的流量整形器还比较简单,只实现了基本的流量限制功能。在实际应用中,我们还可以添加更多的功能,例如:

  • 支持更多的流量整形算法: 除了令牌桶算法,我们还可以支持漏桶算法、PQ算法等。
  • 支持更细粒度的流量控制: 可以根据不同的服务类型、用户优先级等进行流量控制。
  • 支持动态调整带宽限制: 可以根据网络状况动态调整带宽限制。
  • 提供更丰富的监控指标: 可以监控丢包率、延迟、队列长度等指标。

希望本文能够帮助你了解eBPF在流量整形方面的应用,并启发你使用eBPF来解决实际问题。

NetBoy eBPF流量整形网络限速

评论点评