eBPF tail call 使い方メモ

eBPFにはtail callと呼ばれるプログラム呼び出し方法が存在する。 これは、ある実行中のBPFプログラムから別のBPFプログラムにジャンプするものである。あくまでジャンプであって、通常の関数呼び出しと違って呼び出し元に処理は戻らない。ユースケースとしては例えば、役割が違う複数のプログラムの独立性を保ったまま連携させたい場合に役立つ。

bpf: introduce bpf_tail_call() helper [LWN.net]

また、tail callの面白い点として、ジャンプ先のルックアップは BPF_MAP_TYPE_PROG_ARRAY という種類のBPF mapを使う点が挙げられる。このmapは通常のBPF map同様、プログラム実行中にユーザ空間側から動的に書き換えることができる。したがって、ジャンプ先のプログラムを動的に入れ替えたり、あるいはジャンプを取りやめるといったことが容易に実現できる。

通常の使用例

tail callを使うには、BPF_MAP_TYPE_PROG_ARRAY のBPF mapを用意した上で bpf_tail_call() helperを使えばよい。カーネルsamples/bpf/sockex3_kern.c が参考になる。

linux/sockex3_kern.c at master · torvalds/linux · GitHub

該当部分を抜粋する*1:

#define PARSE_VLAN 1
#define PARSE_MPLS 2
#define PARSE_IP 3
#define PARSE_IPV6 4

static inline void parse_eth_proto(struct __sk_buff *skb, u32 proto)
{
    switch (proto) {
    case ETH_P_8021Q:
    case ETH_P_8021AD:
        bpf_tail_call(skb, &jmp_table, PARSE_VLAN);
        break;
    case ETH_P_MPLS_UC:
    case ETH_P_MPLS_MC:
        bpf_tail_call(skb, &jmp_table, PARSE_MPLS);
        break;
    case ETH_P_IP:
        bpf_tail_call(skb, &jmp_table, PARSE_IP);
        break;
    case ETH_P_IPV6:
        bpf_tail_call(skb, &jmp_table, PARSE_IPV6);
        break;
    }
}

jmp_table がprogram arrayである。protoETH_P_8021Q であれば jmp_table[PARSE_VLAN] が示すファイルディスクリプタのBPFプログラムにジャンプする。なお、もしここでjmp_table[n] が有効なBPFプログラムでない場合、そのまま処理が続行(fall-through)される点に注意。逆にこの特性を利用すれば、トラブルシューティングの時だけデバッグ用のBPFプログラムを登録する、といったことが可能になる。

bccでの使用例

ここではiovisor/bccを使った場合の動作を試してみることにする。bccにはprog_array.call()という構文のsyntax sugarが用意されており、bpf_tail_call()と等価な処理を容易に実現できる。

ここでは例として、通常時は全てのパケットを通過するが、コマンドによって全てのパケットをドロップするXDPプログラムを考える*2

XDP program

カーネル空間側で実行されるXDPのコードは下記の通り。通常時はprog_entryを実行するが、パケットをドロップさせたい時はprog_dropにジャンプさせる。jmp_tableがprogram arrayである。

なお、events.perf_submit() はログをカーネル空間からユーザ空間に送るのに使用する。これもbccのsyntax sugarで、bpf_perf_event_output()と等価である。

// xdp_chain.c
BPF_TABLE("prog", int, int, jmp_table, 8);
BPF_PERF_OUTPUT(events);

struct event_t {
    u64 timestamp;
    char message[128];
};

int prog_drop(struct xdp_md *ctx)
{
    struct event_t e = {
        .timestamp = bpf_ktime_get_ns(),
        .message = "Enter prog_drop\0",
    };
    events.perf_submit(ctx, &e, sizeof(e));

    return XDP_DROP;
}


int prog_entry(struct xdp_md *ctx)
{
    struct event_t e_enter = {
        .timestamp = bpf_ktime_get_ns(),
        .message = "Enter prog_entry\0",
    };
    events.perf_submit(ctx, &e_enter, sizeof(e_enter));

    jmp_table.call(ctx, 0);

    struct event_t e_exit = {
        .timestamp = bpf_ktime_get_ns(),
        .message = "Exit prog_entry\0",
    };
    events.perf_submit(ctx, &e_exit, sizeof(e_exit));

    return XDP_PASS;
}

動作を観察する

ここでは簡単のため、ユーザ空間側の処理はipython経由で逐次実行する。

まずは下準備としてプログラムのロードまでを実施する。ここでevents.open_perf_buffer()は前述したログを読み出すためのもので、bpf.kprobe_poll()を呼び出してログが到着するとprint_event()がコールバックされる。

$ sudo ipython
In [1]: %cpaste
Pasting code; enter '--' alone on the line to stop or use Ctrl-D.
:import ctypes as ct
:from bcc import BPF
:
:class Event(ct.Structure):
:    _fields_ = [
:        ("timestamp", ct.c_uint64),
:        ("message", ct.c_char * 128),
:    ]
:
:def print_event(cpu, data, size):
:    event = ct.cast(data, ct.POINTER(Event)).contents
:    print event.timestamp, event.message
:
:
:bpf = BPF(src_file="./xdp_chain.c")
:
:events = bpf["events"]
:events.open_perf_buffer(print_event)
:
:prog_entry = bpf.load_func("prog_entry", BPF.XDP)
:prog_drop = bpf.load_func("prog_drop", BPF.XDP)
:<EOF>

prog_entryをインタフェースens9にアタッチ。XDPでのパケット処理が開始される。

In [2]: bpf.attach_xdp("ens9", prog_entry)

ログは以下のようになっている。jmp_table に何も登録されていないためfall-throughされていることが確認できる。

In [3]: bpf.kprobe_poll()
87485224130884 Enter prog_entry
87485224144293 Exit prog_entry
87487208201839 Enter prog_entry
87487208220583 Exit prog_entry

この状態で、jmp_table[0]prog_drop を登録してみる。

In [4]: jmp_table = bpf["jmp_table"]
In [5]: jmp_table[0] = prog_drop.fd 

外部からens9宛のpingが通らなくなり、パケットがドロップされるようになったことが確認できる。ログを確認すると、prog_dropが実行され、かつprog_entryの末尾部分に処理が戻っていないことも確認できる。

In [6]: bpf.kprobe_poll()
87669224146031 Enter prog_entry
87669224160730 Enter prog_drop
87671208094900 Enter prog_entry
87671208110055 Enter prog_drop

jmp_table[0]を削除すれば、再び最初の状態に戻る。

In [7]: del jmp_table[0]
In [8]: bpf.kprobe_poll()
87875240128813 Enter prog_entry
87875240144164 Enter prog_drop
87877224181681 Enter prog_entry
87877224199698 Enter prog_drop
87879208128601 Enter prog_entry
87879208145440 Exit prog_entry

*1:ライセンスは抜粋元のGPLに従う

*2:もちろん実用上は全てのパケットをドロップするのではなく何らかの条件に従ってドロップすることになる