eBPF入門
最近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 GuideやBPF Design Q&Aをご覧ください.(制限を通過するかを知る唯一の方法はプログラムをロードしてみることです.と書かれている...)
bcc(BPF Compiler Collection)
bccとはeBPFのためのPython/Lua用フレームワークです.
C言語で書いたコードをPython/Luaを用いてアタッチし,eBPFで得た情報をログやファイルに出力する部分をbccなどで行うことが多いです.ちなみにGo用のgobpfというフレームワークもあります.
以降はbccを前提とした解説を行います.
筆者の環境
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
などで実行し,別のターミナルでコマンドを実行すると,以下の画像のように出力されると思います.
順番にプログラムを見ていきましょう.
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アドレス・宛先ポート」が出力されると思います.
ではプログラムを見ていきましょう.
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_printk
とb.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)
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_PASS
がTC_ACT_OK
に,XDP_DROP
がTC_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_OK
やTC_ACT_SHOT
などでパケットの運命を決められることのようです.これがない場合は別のfilterをアタッチする必要があるんでしょうか?(よくわかってない)
これで無事tcを用いたBPFプログラムをアタッチできました.
返り値
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という本に詳しくまとめられています.
もっと様々なプログラム例を見たい方は以下のサイトを辿ると良さそうです
- https://github.com/iovisor/bcc/tree/master/examples
- https://github.com/torvalds/linux/tree/master/samples/bpf
- https://github.com/zoidbergwill/awesome-ebpf
Discussion