🏹

eBPF入門

25 min read

最近eBPFのコードを書いているのですが、体系的に学べる記事や書籍がまだない印象です。自分の知っている範囲でまとめようと思います。まだわからない箇所もあるので是非教えて下さい。

eBPF(extended Berkeley Packet Filter)とは

様々なタイミングでバイトコードを実行できるLinuxカーネルの機能です.カーネル内の関数が呼び出された時,パケットがNICに到達した時,ユーザー空間のアプリケーションの関数が呼び出された時だったりとかなり自由度高くフックポイントを設定できます.
バイトコードを書くのはしんどいので,一般的にはC言語を用いてeBPFプログラムを作成します.

NetflixのパフォーマンスエンジニアであるBrendan Greggはブログで

eBPFがLinuxへ行うことはJavaScriptがHTMLに行ったことと同じである( eBPF does to Linux what JavaScript does to HTML.)

という言葉を使っています.ワクワクしますね👀
ちなみに,歴史的にはeのつく前のBPFがあるのですが今はeBPFのことを「BPF」古いBPFのことを「cBPF」と呼ぶことが多いです.

特徴

  • イベント駆動なので無駄なく処理できる
  • 非常に高速なネットワーク処理が可能である
  • 自由度高く・詳細なメトリクス取得が可能である

採用プロジェクト

BPFを用いているプロジェクトは多くあります.

など...

2017年にはFacebookがレイヤ4ロードバランサをXDP(後述するeBPFの機能)ベースのものに置き換えているという話もありました.
このブログにパフォーマンス比較などが詳しくまとまっていました.

eBPFの制限

eBPFはカーネル内にコードを送り込むため,自由にさせすぎるのも問題になります.そのためeBPFにはいくつかの制限があり,その制限に合わないものは実行寺にエラーを吐きます.
eBPF verifierとして知られており.一番悩む制限は「ループが使用できない」ことだと思います.将来的にはこの制限をなくしたいようで,一部ではループも可能になっています.参考
そのほかの制限についてはBPF and XDP Reference GuideBPF Design Q&Aをご覧ください.(制限を通過するかを知る唯一の方法はプログラムをロードしてみることです.と書かれている...)

bcc(BPF Compiler Collection)

bccとはeBPFのためのPython/Lua用フレームワークです.
C言語で書いたコードをPython/Luaを用いてアタッチし,eBPFで得た情報をログやファイルに出力する部分をbccなどで行うことが多いです.ちなみにGo用のgobpfというフレームワークもあります.
以降はbccを前提とした解説を行います.

eBPF自体をPython/Luaでかけるという意味ではありません.おそらくそういったものはまだないはず...?
-> RustでeBPFをかけるものがあるらしいです https://medium.com/nttlabs/ebpf-bytecode-in-rust-7612c69c151d

筆者の環境

OS:Ubuntu 20.04.2 LTS
kernel: 5.10.0-1016-oem

インストール

bcc/INSTALL.mdを読んでもらいたいです.パッケージマネージャではなくソースからビルドしないと自分は動きませんでした.

カーネルに関するもの

カーネルの特定の場所でeBPFプログラムが実行されるように設定します.基本的にはカーネルの関数が呼び出された/実行され終わったタイミングだと思います.(それ以外でも可能なのか知りません)

Kprobe

カーネル内の関数が呼び出されたタイミングでeBPFプログラムを実行します.そのため,関数に渡された引数の情報にアクセスできます.では早速プログラムを見てみましょう.
cloneシステムコールを監視するため以下のようなプログラムを作成できます.iovisor/bcc/examples/tracing/hello_fields.py を参考にしています.

#!/usr/bin/python
from bcc import BPF
from bcc.utils import printb

# define BPF program
bpf_text= """
int hello(void *ctx) {
    bpf_trace_printk("Hello, World!\\n");
    return 0;
}
"""

# load BPF program
b = BPF(text=bpf_text)
b.attach_kprobe(event=b.get_syscall_fnname("clone"), fn_name="hello")
b.trace_print()
sudo -E python3 hello_fields.py

などで実行し,別のターミナルでコマンドを実行すると,以下の画像のように出力されると思います.
hello_fields_output
順番にプログラムを見ていきましょう.

bpf_text= """
int hello(void *ctx) {
    bpf_trace_printk("Hello, World!\\n");
    return 0;
}
"""

となっているのがeBPFプログラム本体です.pt_regsという型のポインタであるctxという引数をとっていますが,これにはカーネルが現在処理中のものに関する情報が入っています.しかし,その中身はアーキテクチャや対象となるフックポイントなどによっても違うらしいです.PT_REGS_PARM1などのマクロでアクセスできるそうですが,これについてはよく理解できていません.(とりあえず無視しても大抵のことは実現できる気がします)

bpf_trace_printk("Hello, World!\\n");

はデバッグ用の機能で,タイムスタンプなどの情報と共に,引数に渡したものを/sys/kernel/debug/tracing/trace_pipeへ送ります.送ったものを

b.trace_print()

で読み取っています.ちなみに,bpf_trace_printk("%d",1)`のようにテンプレートを使用できますが,引数は3つまで,文字列は1つまでという制限があります.

では肝心のeBPFプログラムをカーネルにアタッチしている部分をみましょう.

# load BPF program
b = BPF(text=bpf_text)
b.attach_kprobe(event=b.get_syscall_fnname("clone"), fn_name="hello")

まずbpf_textを読み込んだ後,attach_kprobeという関数を用いています.その名の通りkprobeを使ってアタッチします.eventという引数にはアタッチする場所を指定します.今回はcloneシステムコールにアタッチするのですが,その関数名はバージョンなどによって変わる可能性があります.get_syscall_fnnameという関数を用いることでbccがうまくやってくれます.fn_nameにはeBPFプログラムの中でアタッチする関数名を指定します.今回はhelloという名前です.

以上のように,かなり簡単にeBPFプログラムを実行することができます.

Kretprobe

Kretprobeはカーネル内の関数が実行し終わったタイミングでeBPFプログラムを実行します.そのため,関数の返り値などにアクセスできます.必要性が低いと思われるかもしれませんが,Kprobesと組み合わせることで,関数の中で処理された内容にアクセスできることがあります.例えば,IPv4での通信に使われるtcp_v4_connectというカーネル関数を監視してみましょう.この関数の中で引数として渡されたソケットにアドレスなどの情報を付与しているはずです(多分).プログラムはiovisor/bcc/examples/tracing/tcpv4connect.pyを参考にしています.

#!/usr/bin/python3

from bcc import BPF
from bcc.utils import printb
bpf_text = """
#include <uapi/linux/ptrace.h>
#include <net/sock.h>
#include <linux/sched.h>
#include <linux/utsname.h>
#include <linux/pid_namespace.h>
struct data_t{
    u32 pid;
    char comm[TASK_COMM_LEN];
    u32 saddr;
    u32 daddr;
    u16 dport;
};

// create map
BPF_HASH(socklist, u32, struct sock *);
BPF_PERF_OUTPUT(events);

// kprobe function
int tcp_connect(struct pt_regs *ctx, struct sock *sock){
    u32 pid = bpf_get_current_pid_tgid();
    socklist.update(&pid, &sock);
    return 0;
}

// kretprobe function
int tcp_connect_ret(struct pt_regs *ctx){
    u32 pid = bpf_get_current_pid_tgid();
    struct sock **sock, *sockp;
    struct data_t data = {};
    sock = socklist.lookup(&pid);
    if(sock == 0){
        return 0;
    }
    sockp = *sock;
    data.pid = pid;
    bpf_get_current_comm(&data.comm, sizeof(data.comm));
    data.saddr = sockp->__sk_common.skc_rcv_saddr;
    data.daddr = sockp->__sk_common.skc_daddr;
    u16 dport = sockp->__sk_common.skc_dport;
    data.dport = ntohs(dport);
    events.perf_submit(ctx, &data, sizeof(data));
    socklist.delete(&pid);
    return 0;
}
"""
# u32で送られてくるのを`0.0.0.0`みたいな読みやすいものにする
def ntoa(addr):
    ipaddr = b''
    for n in range(0, 4):
        ipaddr = ipaddr + str(addr & 0xff).encode()
        if (n != 3):
            ipaddr = ipaddr + b'.'
        addr = addr >> 8
    return ipaddr

# 出力用の関数
def get_print_event(b: BPF):
    def print_event(cpu, data, size):
        event = b["events"].event(data)
        printb(b"%-6d %-16s %-16s %-16s %-16d" % (
            event.pid, event.comm, ntoa(event.saddr), ntoa(event.daddr), event.dport))

    return print_event


b = BPF(text=bpf_text)
# プログラムのアタッチ
b.attach_kprobe(event='tcp_v4_connect', fn_name="tcp_connect")
b.attach_kretprobe(event='tcp_v4_connect', fn_name="tcp_connect_ret")


b["events"].open_perf_buffer(get_print_event(b))

print("%-6s %-16s %-16s %-16s %-16s" % (
        "PID","COMMAND", "S-IPADDR", "D-IPADDR", "DPORT"))
while 1:
   try:
      b.perf_buffer_poll()
   except KeyboardInterrupt:
      exit()

上記プログラムを

sudo -E python3 tcp-v4-connect.py

などで実行した状態で別のターミナルから

curl -4 example.com

などを実行すると以下の画像のように「Pid・コマンド名・送信元IPアドレス・宛先IPアドレス・宛先ポート」が出力されると思います.
tcp-v4-connect
ではプログラムを見ていきましょう.

b.attach_kprobe(event='tcp_v4_connect', fn_name="tcp_connect")
b.attach_kretprobe(event='tcp_v4_connect', fn_name="tcp_connect_ret")

の部分でKprobeとKretprobeのプログラムをtcp_v4_connectという関数へアタッチしています.
アタッチしているプログラムを見ていくとKprobe(関数の開始したときに呼ばれる)プログラムでは,現在のpidを取得し,それと引数で得たソケットへのポインタをsocklistへ登録しています.

BPF_HASH(socklist, u32, struct sock *);
// kprobe function
int tcp_connect(struct pt_regs *ctx, struct sock *sock){
    u32 pid = bpf_get_current_pid_tgid();
    socklist.update(&pid, &sock);
    return 0;
}

Linuxのソースコードを確認するとtcp_v4_connectは以下のように定義されています.

int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)

この引数をBPFプログラムでも使うため,pt_regsの後の引数で同じように定義しています.(必要でない分は省略して構いません)

int tcp_connect(struct pt_regs *ctx, struct sock *sock){

socklistという名前のmapを用いていますが,eBPFではeBPFプログラム同士やユーザー空間のプロセスと情報をやり取りするために様々なMapを使用することができます.詳しくはbcc/docs/reference_guide.mdを参照してください.
今回はいわゆるハッシュマップとしてsocklistを定義しています.socklistを用いてKretprobeのeBPFプログラムとやりとりをします.
ではKretprobe側のプログラムを見ましょう.

BPF_PERF_OUTPUT(events);
// kretprobe function
int tcp_connect_ret(struct pt_regs *ctx){
    u32 pid = bpf_get_current_pid_tgid();
    struct sock **sock, *sockp;
    struct data_t data = {};
    sock = socklist.lookup(&pid);
    if(sock == 0){
        return 0;
    }
    sockp = *sock;
    data.pid = pid;
    bpf_get_current_comm(&data.comm, sizeof(data.comm));
    data.saddr = sockp->__sk_common.skc_rcv_saddr;
    data.daddr = sockp->__sk_common.skc_daddr;
    data.dport = sockp->__sk_common.skc_dport;
    events.perf_submit(ctx, &data, sizeof(data));
    socklist.delete(&pid);
    return 0;
}

Kretprobeでも同じようにpidを取得し,それをキーとしてKprobeで保存しておいたソケットへのポインタを取得します.そのポインタから送信元アドレス・宛先アドレス・宛先ポートの情報を取得します.

ネットワーク関係のものを扱う場合はエンディアンに気をつけてください.

また,bpf_get_current_comm(&data.comm, sizeof(data.comm));を用いてtcp_v4_connect関数を呼び出したプロセス名も取得しています.
得た情報を事前にBPF_PERF_OUTPUT(events);で定義したバッファへevents.perf_submit(ctx, &data, sizeof(data));で送信しています.それをユーザースペース側では
b["events"].open_perf_buffer(get_print_event(b))b.perf_buffer_poll()で取得しています.

このように,Kprobeだけでは取得できない情報もKretprobeと組み合わせることで取得することができました.

プログラムを書く流れ

kprobeとkretprobeはLinuxの関数にeBPFプログラムをアタッチすることができます.しかし,Linuxの関数を覚えている人は少ないでしょうからb.get_syscall_fnnameを用いることでシステムコールから関数名へbccが変更してくれます.システムコールがどんな時に呼ばれるか知りたい場合はstraceというコマンドが便利です.

strace -ff -e trace=network curl example.com

と実行するとcurlによって呼び出されたシステムコールのうちネットワークに関するものだけが表示されます.
また,BPFプログラムの引数には

int 関数名(struct pt_regs *ctx, カーネル関数に渡される引数)

を書く必要がありますが,カーネル関数に渡される引数はLinuxのソースコードをみても良いですし,ググって出てくるサイトで調べても良いと思います.bootlinというものがよく出てきますが,何かはあまりわかっていません.とりあえず検索できて便利です.
eBPFプログラム内で使用する型なども同様にソースコードを読んでいます.解説記事があればラッキーという感じですね.

tracepoint

tracepointはカーネルに用意されているトレース用のフックポイントです.Kprobeとの違いとして,tracepointはカーネルバージョンによる違いが少ないです(基本的にはないはず?).その代わり全ての機能に対してtracepointが用意されているわけではありません.基本的にはtracepointを用いて,求めているものが存在しない場合はKprobeを使うのが良いと思います.
それでは具体的なプログラムを見てみましょう.cloneシステムコールのtracepointを監視してみます.

#!/usr/bin/python
from bcc import BPF
from bcc.utils import printb

# define BPF program
bpf_text= """
TRACEPOINT_PROBE(syscalls,sys_enter_clone) {
        bpf_trace_printk("%d",args->parent_tidptr);
        return 0;
}
"""

b = BPF(text=bpf_text)
b.trace_print()

このプログラムを今までと同じように

sudo -E python3 trace_clone.py

のように実行すると以下のように出力されると思います.

<...>-1240197 [006] d... 2425484.642151: bpf_trace_printk: 1899305424
<...>-1240197 [006] d... 2425484.642670: bpf_trace_printk: 1899305424
<...>-1240197 [006] d... 2425484.647232: bpf_trace_printk: 1910752064

ではプログラムの中身を見ていきましょう.
今までとは違う書き方をしており,BPFプログラムの方でTRACEPOINT_PROBE(カテゴリ名,イベント名)というマクロを使用しています.これを使うことでpython側でアタッチ処理をしなくて良くなります.もちろん,これを使わずにb.attach_tracepoint("syscalls:sys_enter_clone",関数名)とすることもできます.(何故か僕の環境では動きませんでした...)
システムコールに渡された引数にはargs->引数名でアクセスできます.今回はargs->parent_tidptrにアクセスしています.それをKprobeの例と同じようにbpf_trace_printkb.trace_print()で出力しています.

プログラムを書く流れ

tracepointの一覧は/sys/kernel/debug/tracing/eventsにあります.それぞれのディレクトリの中にはformatというファイルがあり,引数などの情報が書かれています.例えば /sys/kernel/debug/tracing/events/syscalls/sys_enter_clone/formatを見てみると

name: sys_enter_clone
ID: 122
format:
        field:unsigned short common_type;       offset:0;       size:2; signed:0;
        field:unsigned char common_flags;       offset:2;       size:1; signed:0;
        field:unsigned char common_preempt_count;       offset:3;       size:1; signed:0;
        field:int common_pid;   offset:4;       size:4; signed:1;

        field:int __syscall_nr; offset:8;       size:4; signed:1;
        field:unsigned long clone_flags;        offset:16;      size:8; signed:0;
        field:unsigned long newsp;      offset:24;      size:8; signed:0;
        field:int * parent_tidptr;      offset:32;      size:8; signed:0;
        field:int * child_tidptr;       offset:40;      size:8; signed:0;
        field:unsigned long tls;        offset:48;      size:8; signed:0;

print fmt: "clone_flags: 0x%08lx, newsp: 0x%08lx, parent_tidptr: 0x%08lx, child_tidptr: 0x%08lx, tls: 0x%08lx", ((unsigned long)(REC->clone_flags)), ((unsigned long)(REC->newsp)), ((unsigned long)(REC->parent_tidptr)), ((unsigned long)(REC->child_tidptr)), ((unsigned long)(REC->tls))

と記載されています.これについての詳しい説明を見つけることができなかったのですが,The Linux Kernel documentationにはcommon_から始まるものは共通で,それ以外のものが特有のもの(引数?)だと書かれています.
これを見て自分の必要とするトレースポイントがあるか,引数は何かを確認してプログラムを書いています.

ネットワークに関するもの

既存の技術などとの関連がよく纏まっている画像をwikipediaから引用します.(Author: Jan Engelhardt)
Netfilter-packet-flow

dockerなどに関する仮想デバイスに用いる場合は,受信と送信の方向に気をつけてください.一般的にはホストを経由してパケットが渡されるため反対になっているはずです.

XDP

XDPとは受信した通信に対して制御できるもので,eBPFの中でも特に注目されている機能だと思います.先ほどの画像を見てもらうとわかりますが.startの次にXDPで処理する部分があります.XDPはパケット処理の中でも非常に早い段階なので,パケット処理のためのメモリの確保が必要なく,低コストで早く処理できます.そのため,DDoS対策やLoad Balancerとしての活躍が期待されています.
尚,本来のXDP(native XDPと呼ばれるもの)には物理的なデバイス自体が対応している必要があり,リストに対応デバイスがまとめられています.しかし,パフォーマンスは低下すると思いますが仮想的にXDPが動くようにうまくやってくれます(generic XDPというもの).そのため,プログラムを動かすだけだとデバイスのことを意識する必要はあまりありません.具体的にどの程度違いがあるかはわかりません...

早速プログラムを見てみましょう. eBPFの紹介「おいしくてつよくなる」eBPFのはじめかた/Learn eBPFのXDPプログラムを参考にさせていただきます🙇‍♀️

#!/usr/bin/python3

from bcc import BPF
import sys
import time

bpf_text = """
#include <uapi/linux/bpf.h>
#include <linux/ip.h>

BPF_HASH(dropcnt, u32, u32);

int xdp_drop_icmp(struct xdp_md *ctx) {
  void* data_end = (void*)(long)ctx->data_end;
  void* data = (void*)(long)ctx->data;
  struct ethhdr *eth = data;
  u64 nh_off = sizeof(*eth);

  if (data + nh_off > data_end)
    return XDP_PASS;

  if (eth->h_proto == htons(ETH_P_IP)) {
    struct iphdr *iph = data + nh_off;
    if ((void*)&iph[1] > data_end)
      return XDP_PASS;
    u32 protocol;
    protocol = iph->protocol;
    if (protocol == 1) {
      u32 value = 0, *vp;
      vp = dropcnt.lookup_or_init(&protocol, &value);
      *vp += 1;
      return XDP_DROP;
    }
  }

  return XDP_PASS;
}
"""

b = BPF(text=bpf_text)

device = sys.argv[1]
b.attach_xdp(device, fn = b.load_func("xdp_drop_icmp", BPF.XDP))
dropcnt = b.get_table("dropcnt")
while True:
  try:
    dropcnt.clear()
    time.sleep(1)
    for k, v in dropcnt.items():
      print("{} {}: {} pkt/s".format(time.strftime("%H:%M:%S"), k.value, v.value))
  except KeyboardInterrupt:
    break

b.remove_xdp(device)

以下のようにNIC名を引数として渡すと実行できます.NIC名はipコマンドなどで確認できます.

sudo -E python3 xdp.py NIC名

別の端末やターミナルからpingを送ると通信ができないと思います.XDPプログラムを確認すると1秒毎にdropしたICMPパケットの数が表示されているはずです.

05:45:10 1: 1 pkt/s
05:45:11 1: 1 pkt/s
05:45:12 1: 1 pkt/s
05:45:13 1: 1 pkt/s

ではプログラムを見ていきましょう.XDPをアタッチするときはb.attach_xdp(device名, fn = b.load_func(関数名, BPF.XDP))を使います.これに関しては僕がbccのドキュメントを書いたので見てください(ぇ)
eBPFプログラムの方を見ると引数の型がxdp_mdになっています.Linuxのソースコードを見ると

struct xdp_md {
	__u32 data;
	__u32 data_end;
	__u32 data_meta;
	/* Below access go through struct xdp_rxq_info */
	__u32 ingress_ifindex; /* rxq->dev->ifindex */
	__u32 rx_queue_index;  /* rxq->queue_index  */

	__u32 egress_ifindex;  /* txq->dev->ifindex */
};

という定義になっています.dataにはEthernet headerの情報が入っているためxdp_mdはL2ヘッダへのポインタという理解をしています.(正しいかはわかりません)
XDPの時はこれが引数に渡されるので,ここから必要な情報を取得していきます.

  void* data_end = (void*)(long)ctx->data_end;
  void* data = (void*)(long)ctx->data;
  struct ethhdr *eth = data;
  u64 nh_off = sizeof(*eth);

  if (data + nh_off > data_end)
    return XDP_PASS;

の部分では,L2ヘッダの長さを取得しnh_offとしたあと,境界値チェックを行なっています.

if (eth->h_proto == htons(ETH_P_IP)) {
    struct iphdr *iph = data + nh_off;
    if ((void*)&iph[1] > data_end)
      return XDP_PASS;
    u32 protocol;
    protocol = iph->protocol;
    if (protocol == 1) {
      u32 value = 0, *vp;
      vp = dropcnt.lookup_or_init(&protocol, &value);
      *vp += 1;
      return XDP_DROP;
    }
  }

では,プロトコルがIPであればL2ヘッダ(nh_off)分ポインタを移動させIPヘッダの内容にアクセスします.そしてiph->protocolでプロトコルを取得し,それが1(ICMP)であればマップの値を増加させXDP_DROPでパケットを破棄します.プロトコル番号はwikipediaなどで確認できます.

返り値

既に見たようにXDPではreturn XDP_DROPでパケットをdropできます.wikipediaを参考に簡単にまとめておくと

  • XDP_PASS
    パケットを通過させ,通常のネットワークスタック処理に渡されます.
  • XDP_DROP
    パケットを破棄します.
  • XDP_TX
    パケットを同じNICへフォワーディングします.パケットの中身を書き換えた時などに使われるようですが詳しくはわかりません.bcc/examples/networking/xdp/xdp_macswap_count.pyが参考になると思います.
  • XDP_REDIRECT
    TXと似ていますが,違うNICなどにもフォワーディングできるそうです.詳しくはわかりません...
  • XDP_ABORTED
    結果的にはパケットが破棄されますが,エラー用のものです.通常使われることはないはずです.

Traffic Control

XDPでは受信したパケットに対してしか処理できませんでしが,Traffic Control(以下 tc)では受信送信も処理できます.Traffic ControlというのはBPFとは関係なくLinuxに存在する機能ですが,そこにBPFプログラムをアタッチできる感じです.
XDPと同じくICMPパケットを破棄する例を見ましょう.

#!/usr/bin/python3

from bcc import BPF
from pyroute2 import IPRoute
import sys
import time
ipr = IPRoute()

bpf_text = """
#include <uapi/linux/bpf.h>
#include <linux/pkt_cls.h>
#include <linux/ip.h>

BPF_HASH(dropcnt, u32, u32);

int tc_drop_icmp(struct __sk_buff *skb) {
  void* data_end = (void*)(long)skb->data_end;
  void* data = (void*)(long)skb->data;
  struct ethhdr *eth = data;
  u64 nh_off = sizeof(*eth);

  if (data + nh_off > data_end)
    return TC_ACT_OK;

  if (eth->h_proto == htons(ETH_P_IP)) {
    struct iphdr *iph = data + nh_off;
    if ((void*)&iph[1] > data_end)
      return TC_ACT_OK;
    u32 protocol;
    protocol = iph->protocol;
    if (protocol == 1) {
      u32 value = 0, *vp;
      vp = dropcnt.lookup_or_init(&protocol, &value);
      *vp += 1;
      return TC_ACT_SHOT;
    }
  }

  return TC_ACT_OK;
}
"""
device = sys.argv[1]

INGRESS="ffff:ffff2"
EGRESS="ffff:ffff3"

try:
    b = BPF(text=bpf_text, debug=0)
    fn = b.load_func("tc_drop_icmp", BPF.SCHED_CLS)
    idx = ipr.link_lookup(ifname=device)[0]

    ipr.tc("add", "clsact", idx)
    ipr.tc("add-filter", "bpf", idx, ":1", fd=fn.fd, name=fn.name, parent=INGRESS, classid=1,direct_action=True)

    dropcnt = b.get_table("dropcnt")
    while True:
      try:
        dropcnt.clear()
        time.sleep(1)
        for k, v in dropcnt.items():
          print("{} {}: {} pkt/s".format(time.strftime("%H:%M:%S"), k.value, v.value))
      except KeyboardInterrupt:
        break
finally:
    if "idx" in locals(): 
      ipr.tc("del", "clsact", idx)

XDPの例と同じように

sudo -E python3 tc.py NIC名

と実行した上で,pingを送ると通信ができず,1秒毎にdropしたICMPパケットの数が表示されているはずです.
それではプログラムを見ていきますが,実はeBPFプログラムに大きな違いはありません.引数がxdp_mdから__sk_buffになり,XDP_PASSTC_ACT_OKに,XDP_DROPTC_ACT_SHOTになったくらいです.__sk_buffについてはLinuxのソースコードを見ればどんなフィールドを持っているかわかります.tcの方がxdpよりもネットワーク処理の後の段階で実行されるので,アクセスできるフィールドが増えています.
大きく違うのはPython側のeBPFプログラムをアタッチする部分です.

b = BPF(text=bpf_text, debug=0)
fn = b.load_func("tc_drop_icmp", BPF.SCHED_CLS)
idx = ipr.link_lookup(ifname=device)[0]

ipr.tc("add", "clsact", idx)
ipr.tc("add-filter", "bpf", idx, ":1", fd=fn.fd, name=fn.name, parent=INGRESS, classid=1,direct_action=True)

今までのようにattach_*という関数が用意されていません.上の3行目は割と直感的で,関数を読み込んだ後,NICのindex番号を取得しています.

ipr.tc("add", "clsact", idx)

の部分では,clsactというqdiscを指定したNICに作成しています.

qdiscとはQueueing Disciplineの略で,NICに入るパケットをQueueで制御する仕組みです.デフォルトではpfifo_fastとなっており,その名の通りFIFO(先入先出し)によって処理されます.dockerで使用されるdocker0や仮想NICのvethなどではデフォルトがnoqueueとなっておりキューによる処理がされず,届いたら即座にNICへ送られます.

clsactというのはqdiscの種類で(多分),ingress(入力)側にもegress(出力)側にもBPFプログラムをアタッチできるものです.

作成したqdiscにBPFを用いたfilterをアタッチします.

INGRESS="ffff:ffff2"
EGRESS="ffff:ffff3"
ipr.tc("add-filter", "bpf", idx, ":1", fd=fn.fd, name=fn.name, parent=INGRESS, classid=1,direct_action=True)

:1はよくわかりません,単に1番目くらいの意味だと思っています.parent=INGRESSの部分で入力側に対する処理だということを明示しています.ffff:ffff2がingressだというのがどこに明文化されているのかは不明です.IPRouteのドキュメントの使い方を見て真似しました.

direct_actionというのは直接TC_ACT_OKTC_ACT_SHOTなどでパケットの運命を決められることのようです.これがない場合は別のfilterをアタッチする必要があるんでしょうか?(よくわかってない)

これで無事tcを用いたBPFプログラムをアタッチできました.

Goでtcプログラムを書く場合は直接Cライブラリを使ってよしなにするか,tcコマンドを実行することになると思います.詳しくは説明しませんがgobpfを使った例を載せておきます.

 m := bpf.NewModule(bpf_text, []string{})
 fn, err := m.Load(関数名, SCHED_CLS, 0, 0)
 err = elf.PinObjectGlobal(fn, "my-namespace", "tc-hello")
> tc qdisc add dev NIC名 clsact
> tc filter add dev NIC名 [ingress/egress] bpf da object-pinned /sys/fs/bpf/my-namespace/globals/tc-hello

返り値

XDPと似ていますが,TCの返り値も簡単にまとめておきます.tc-bpf(8)を参考にしています.

  • TC_ACT_OK
    パケットを通過させ,通常のネットワークスタック処理に渡されます.
  • TC_ACT_SHOT
    パケットを破棄します.
  • TC_ACT_UNSPEC
    デフォルトの動作を行います
  • TC_ACT_PIPE
    次の動作へ渡します.qdiscには複数のfilterをアタッチできるので次のfilterへ渡すという意味だと思います.
  • TC_ACT_RECLASSIFY
    最初から分類し直す.(どういう用途があるのかはわかりません)

Raw Socket

NICにBPFプログラムをアタッチできるattach_raw_socketというものもあるのですが,おそらく受信側にしか対応していないと思います.受信側であればXDPを使えば良いと思うので現在Raw Socketを使う用途があまり思い浮かんでいません.もし違いをご存知なら教えてください

最後に

自分の知っている限りの知識をまとめさせていただきました.ユーザー空間のeBPFプログラムについては書いたことがないので誰か詳しい方お願いします:pray:
正確性に乏しい箇所もあるかもしれませんがeBPFに取り組もうと思っている方の手助けになれば幸いです.
eBPFに関する日本語書籍を知らないのですが,Linux Observability with BPFという英語の書籍に詳しくまとまっており,とても良かったです.

eBPFを用いて作られたツールはたくさんあり,自分でコードを書かずともその恩恵を受けることができます.それについては BPF Performance Toolsという本に詳しくまとめられています.

もっと様々なプログラム例を見たい方は以下のサイトを辿ると良さそうです

参考文献

この記事に贈られたバッジ

Discussion

ログインするとコメントできます