Linuxにおける利用が急速に増えている「Berkeley Packet Filter(BPF)」について、基礎から応用まで幅広く紹介する連載。今回は、最近のBPFの発展に欠かすことのできない重要機能「BPF Type Format(BTF)」について。
Linuxにおける利用が急速に増えている「Berkeley Packet Filter(BPF)」について、基礎から応用まで幅広く紹介する連載「Berkeley Packet Filter(BPF)入門」。今回は、最近のBPFの発展に欠かすことのできない重要機能「BPF Type Format(BTF)」を紹介します。
BPFプログラムは主にC言語で記述することが多いですが、カーネルにロードされるのはコンパイルされたバイナリデータです。「カーネルにどのようなBPFプログラムをロードしているか」ということは後から確認できますが、ただのバイナリデータだけではそのBPFプログラムが何をするのか理解するのは困難です。
一般のプログラムでは、コンパイルしたバイナリデータにそのプログラムのソースの情報やデータ構造の情報を「デバッグ情報」という形で持たせることができます。代表的なデバッグ情報のフォーマットに「DWARF」があります。DWARFを利用することで、バイナリコードに対応したソースコードの位置や関数の引数の型情報などが得られます。
BPF Type Format(BTF)は、DWARFのように、BPFプログラムのソース情報やデータ構造を保持するためのデータフォーマットです。BPFプログラムを補助するためのメタデータだと思えば分かりやすいでしょう。BTFはBPFプログラムのデバッグのみではなく、BPFのさまざまな機能実現のために利用されています。
BTFの具体的な仕様は、カーネルのドキュメントを参照してください。DWARFのデバッグ情報はサイズが大きいこともあり、一般にプログラムのデバッグをするときだけその機能を有効にすることが多いです。一方でBTFは、BPFを利用する場合は常にあること(あるいは、常にあっても問題ないこと)を想定しています。このため、BTFはBPFプログラムに特化した設計になっており、保持する情報を絞ることでそのサイズを小さくしています。
BTFの使用用途は大きく2つに分けられます。1つ目は、BPFプログラムのデバッグ情報としての利用です。そして2つ目は、Linuxカーネルのデータ構造情報を取得するための利用です。以降、それぞれについて説明します。
ClangにはBTFのサポートが含まれています。デバッグ情報ありでBPFプログラムをコンパイルすると、BTFが生成されます。生成されたBTFは特定のELFセクション(.BTFおよび.BTF.ext)に格納されます。例えば、下記のようにしてC言語のファイルからBTFが生成できます。
% clang -target bpf -g -c a.c
% readelf -S a.o
There are 16 section headers, starting at offset 0x6c0:
 
Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
...
  [ 5] .BTF              PROGBITS         0000000000000000  000001cb
       00000000000000de  0000000000000000           0     0     1
  [ 6] .BTF.ext          PROGBITS         0000000000000000  000002a9
       0000000000000070  0000000000000000           0     0     1
...
また、「pahole」というツールを利用して、DWARFの情報をBTFに変換することも可能です。
こうして得られるBTFのデータをBPFプログラムのロード時やマップ作成時に指定することで、特定のBPFプログラムやBPFマップにBTFの情報をひも付けることができます。BTFの情報はBPFプログラムやマップと一緒にカーネル内に保持され、後からその情報を参照することができます。
連載第6回で紹介した、BPFプログラム作成のためのライブラリ「BPF Compiler Collection(BCC)」には、BTFのサポートが含まれています。BTFを利用することで、コンパイルしたプログラムとソースコード情報の対応付けや、作成したマップのデータ構造の取得などが可能です。
例として、下記のBCCプログラムを考えます。
#!/usr/bin/env python
import bcc
 
text = r"""
#include <linux/ptrace.h>
struct data_t {
  u32 a;
  u32 b;
};
 
BPF_HASH(hash, int, struct data_t);
 
int func(volatile struct pt_regs *ctx) {
  struct data_t data = { .a = 1, .b = 2 };
  int key = 0;
  hash.update(&key, &data);
  return 0;
}
"""
 
b = bcc.BPF(text=text, debug=0x8)
b.attach_perf_event(ev_type=bcc.PerfType.SOFTWARE,
                    ev_config=bcc.PerfSWConfig.CPU_CLOCK, fn_name="func",
                    sample_freq=1, cpu=0)
b.trace_print()
ここで、「bcc.BPF(text=text, debug=0x8)」と、BCCでコンパイルする際に引数の「debug」に0x8を渡すと、BCCはBTFの情報をダンプします。下記に出力例を示します。
% sudo ./example.py
Disassembly of section .bpf.fn.func:
func:
; int func(volatile struct pt_regs *ctx) { // Line  22
   0:   18 01 00 00 01 00 00 00 00 00 00 00 02 00 00 00 r1 = 8589934593 ll
; struct data_t data = { .a = 1, .b = 2 }; // Line  24
   2:   7b 1a f8 ff 00 00 00 00 *(u64 *)(r10 - 8) = r1
   3:   b7 01 00 00 00 00 00 00 r1 = 0
; int key = 0; // Line  25
   4:   63 1a f4 ff 00 00 00 00 *(u32 *)(r10 - 12) = r1
; bpf_map_update_elem((void *)bpf_pseudo_fd(1, -1), &key, &data, BPF_ANY); // Line  26
   5:   18 11 00 00 ff ff ff ff 00 00 00 00 00 00 00 00 ld_pseudo       r1, 1, 4294967295
   7:   bf a2 00 00 00 00 00 00 r2 = r10
   8:   07 02 00 00 f4 ff ff ff r2 += -12
   9:   bf a3 00 00 00 00 00 00 r3 = r10
  10:   07 03 00 00 f8 ff ff ff r3 += -8
  11:   b7 04 00 00 00 00 00 00 r4 = 0
  12:   85 00 00 00 02 00 00 00 call 2
; return 0; // Line  27
  13:   b7 00 00 00 00 00 00 00 r0 = 0
  14:   95 00 00 00 00 00 00 00 exit
このように、BTFのデータに基づき、BPFのバイナリとソースコードの対応が分かります。なお、上記のソースコードとBTFによる出力によるソースコードが一部違うのは、BCCがプログラムをロードする際にソースコードの一部を書き換えたためです。
これだけでは少しありがたみが分かりにくいかもしれません。別の例として、下記のようにプログラムを一部書き換えて実行しています。
int func(volatile struct pt_regs *ctx) {
  struct data_t data = { .a = 1, .b = 2 };
  int key = 0;
  hash.update(&ctx->ax, &data); // &key から &ctx->ax に変更
  return 0;
}
すると、下記のようなエラーが得られます。
% sudo ./example.py
[...]
 
bpf: Failed to load program: Permission denied
Unrecognized arg#0 type PTR
; int func(volatile struct pt_regs *ctx) {
0: (bf) r2 = r1
1: (18) r1 = 0x200000001
; struct data_t data = { .a = 1, .b = 2 };
3: (7b) *(u64 *)(r10 -8) = r1
; bpf_map_update_elem((void *)bpf_pseudo_fd(1, -1), &ctx->ax, &data, BPF_ANY);
4: (18) r1 = 0xffff9e28933be800
; bpf_map_update_elem((void *)bpf_pseudo_fd(1, -1), &ctx->ax, &data, BPF_ANY);
6: (07) r2 += 80
7: (bf) r3 = r10
;
8: (07) r3 += -8
; bpf_map_update_elem((void *)bpf_pseudo_fd(1, -1), &ctx->ax, &data, BPF_ANY);
9: (b7) r4 = 0
10: (85) call bpf_map_update_elem#2
R2 type=ctx expected=fp
processed 9 insns (limit 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0
 
[...]
ここでは、「bpf_map_update_elem()」のkeyの引数はスタック上の変数でなければいけないため、エラーになっています。BPFの検証器のエラーメッセージは、そのままでは分かりにくいものが多いですが、今回のように「どのソースの箇所が問題か」が分かるだけで、デバッグしやすくなります。
bpftoolを利用することで、カーネルにロードしたBPFの情報を取得することができます。上述のBCCのプログラムを実行した状態で、以下のようにbpftoolでロードしたプログラムを確認できます。
% sudo bpftool prog
[...]
1518: perf_event  name func  tag f54b2f831fe8b42d  gpl
        loaded_at 2020-07-20T13:52:58+0900  uid 0
        xlated 120B  jited 87B  memlock 4096B  map_ids 1404
        btf_id 17
以下のようにして、ロードしたプログラムをダンプすることができます。BCCがBTF付きでプログラムをロードしたため、ソースの情報も確認できます。
% sudo bpftool prog dump xlated id 1518
int func(volatile struct pt_regs * ctx):
; int func(volatile struct pt_regs *ctx) {
   0: (18) r1 = 0x200000001
; struct data_t data = { .a = 1, .b = 2 };
   2: (7b) *(u64 *)(r10 -8) = r1
   3: (b7) r1 = 0
; int key = 0;
   4: (63) *(u32 *)(r10 -12) = r1
; bpf_map_update_elem((void *)bpf_pseudo_fd(1, -1), &key, &data, BPF_ANY);
   5: (18) r1 = map[id:1404]
   7: (bf) r2 = r10
;
   8: (07) r2 += -12
   9: (bf) r3 = r10
  10: (07) r3 += -8
; bpf_map_update_elem((void *)bpf_pseudo_fd(1, -1), &key, &data, BPF_ANY);
  11: (b7) r4 = 0
  12: (85) call htab_map_update_elem#123424
; return 0;
  13: (b7) r0 = 0
  14: (95) exit
また、以下のようにBPFマップもダンプすることができます。
% sudo bpftool map
[...]
1404: hash  name hash  flags 0x0
        key 4B  value 8B  max_entries 10240  memlock 921600B
        btf_id 17
% sudo bpftool map dump id 1404
[{
        "key": 0,
        "value": {
            "a": 1,
            "b": 2
        }
    }
]
ここで、ダンプした際にそのフィールド名(「a」および「b」)とその値が表示されるのはBTFの型情報のおかげです。もしもBTFがなかったら、ただの8バイト(フィールド「a」と「b」の合計サイズ)の値がダンプされることになります。
ここまで、BPFプログラムのデバッグ情報としてのBTFを紹介しました。BTFのもう一つ重要な応用先として、カーネルのデータ構造の取得があります。
BPFプログラムはカーネル内で動作します。そのため多くの場合はカーネルのデータ構造を参照することになります。例えば、下記のkprobeで利用することを想定したBPFプログラムを考えます(参考:bpf: revolutionize bpf tracing)。
int bpf_prog(struct pt_regs *ctx)
{
    struct net_device *dev;
    struct sk_buff *skb;
    int ifindex;
    skb = (struct sk_buff *) ctx->di;
    bpf_probe_read(&dev, sizeof(dev), &skb->dev);
    bpf_probe_read(&ifindex, sizeof(ifindex), &dev->ifindex);
}
このプログラムは「struct sk_buff*」を第1引数に取る関数にアタッチすることを想定して書かれています。これはよくあるBPFプログラムの例ですが、下記のような問題があります。
従来のBPFの検証器はソースコードレベルで検証しないため、上記のような問題は検出できません。BPFを利用する側が注意する必要がありました。この問題は、今動作しているカーネルのデータ構造を取得すれば、解決できます。このカーネルのデータ構造情報のためにもBTFは利用されます。
カーネルコンパイル時に「CONFIG_DEBUG_INFO_BTF」オプションを有効にすることで、LinuxカーネルのBTFデータが生成されます。これには、Linuxカーネルで利用されるデータ構造(構造体、共用体)の他に、カーネル関数の引数の情報が含まれます。内部的にはpaholeを利用してDWARFからBTFが生成されます。生成にはpahole 1.17以上が必要です。BTFデータはvmlinuxに含まれ、そのサイズは数MBです。カーネルのDWARFを生成すると数百MBになることが珍しくないので、それと比較してBTFが軽量であると分かります。
現時点でBTFデータを含めたカーネルを配布しているディストリビューションとして「Fedora 32」「Arch Linux」があります。
なお、現時点でカーネルモジュールに関するBTFのサポートはありません。カーネルモジュールに関してBTFを利用したい場合は、それをカーネルに含めてコンパイルする必要があります。また、static関数の情報も、ソースコード中に「#define」で定義された値もBTFには含まれていません。
BTFが含まれたカーネルを起動すると、「/sys/kernel/btf/vmlinux」にそのBTFの存在を確認できます。先述のbpftoolを利用してBTFの中身をダンプすることができます。
% sudo bpftool btf dump file /sys/kernel/btf/vmlinux format raw | head [1] INT '(anon)' size=4 bits_offset=0 nr_bits=32 encoding=(none) [2] INT 'long unsigned int' size=8 bits_offset=0 nr_bits=64 encoding=(none) [3] CONST '(anon)' type_id=2 [4] VOLATILE '(anon)' type_id=2 [5] ARRAY '(anon)' type_id=2 index_type_id=1 nr_elems=2 [6] PTR '(anon)' type_id=9 [7] CONST '(anon)' type_id=6 [8] INT 'char' size=1 bits_offset=0 nr_bits=8 encoding=(none) [9] CONST '(anon)' type_id=8 [10] INT 'unsigned int' size=4 bits_offset=0 nr_bits=32 encoding=(none) ...
このBTFの情報は、「/proc/kallsys」などと同様に、現在実行中のカーネルの情報を保持する信頼できるデータとして扱われます。
前回紹介した「bpftrace」には、BTFを利用してデータ構造を取得する機能があります。例えば、BTFがない場合、下記のように「vfs_open()」の引数「struct path」にアクセスするためには明示的に「struct path」を定義しているヘッダをインクルードする必要がありました。
#include <linux/path.h>
#include <linux/dcache.h>
kprobe:vfs_open
{
    printf("open path: %s\n", str(((struct path *)arg0)->dentry->d_name.name));
}
もしBTFのデータが利用可能な場合、以下のようにヘッダをインクルードせずにプログラムが作成できます。
% bpftrace -e 'kprobe:vfs_open { printf("open path: %s\n", \
                                 str(((struct path *)arg0)->dentry->d_name.name)); }'
bpftraceはBTFデータから「struct path」の定義を探し、それを利用します。
「BPF Trampoline」は「ftrace」を応用してBPFによるトレーシングプログラムを低オーバーヘッドで呼び出すための仕組みです。BPF Trampolineでは(レジスタ内に存在する)関数の引数をBPFプログラムがアクセスできるようにスタックに配置しますが、関数の引数の個数を知るためにBTFの情報を利用します。さらに、検証器では「どのBPFレジスタに、どの引数ロードされたか」という情報が伝わるため、特定のヘルパー関数呼び出し時に型チェックを行えます。例えば、「bpf_skb_output()」というヘルパー関数の第1引数は「struct sk_buff*」である必要あり、これはBTFで静的にチェックされます。
bpftraceでは、「kfunc」という名称でこの機能が利用可能です。上記の「vfs_open()」に関するプログラムは、kfuncでは下記のように書けます。
% bpftrace -e 'kfunc:vfs_open { printf("open path: %s\n", \
                                str(args->path->dentry->d_name.name)); }'
ここでポイントは、kprobeと比較して、kfuncでは明示的に引数をキャストする必要がない点と、「args->path」という名称で関数の引数にアクセスできる点です。もしカーネルの更新によって「args->path」という名称が変わったり、変数がなくなったりした場合は、BTFによりそのことが分かるため、実行時にエラーになります。
BPF Trampolineの詳細に関しては以前紹介した記事があるので、そちらも参照してください。
Linux 5.7から、「Kernel Runtime Security Instrumentation(KRSI)」と呼ばれる、LSM(Linux Security Module)フックにBPFプログラムをアタッチする機能が追加されました。これにより、BPFプログラムによってLSMの実行可否を決定したり、ロギングを行えたりします。仕組み的にはBPF Trampolineを応用したもので、BTFデータによってアタッチする関数の引数情報を利用しています。
Linuxカーネルの開発は活発であり、カーネルが利用するデータ構造はよく変更されます。従って、一般にカーネルのデータ構造にアクセスするトレーシングプログラムはカーネルのバージョンをまたいで動作する保証がありません。BCCやbpftraceでは、実行時に逐一BPFプログラムをコンパイルするという方法で、この問題を避けてきました。しかし、これには実行環境に巨大なコンパイラ(LLVMやClang)が必要で、かつ、コンパイルに伴う実行時のオーバーヘッドがあるという問題があります。
「BPF CO-RE」はこれを解決するために提案された仕組みです。BPF CO-REでは事前にコンパイルしたBPFプログラムを利用することで、実行時コンパイルに伴う問題を避けます。さらに、BTFデータを利用することで「BPFプログラムがアクセスしようとするデータが存在するかどうか」「データにアクセスする際のオフセットが正しいかどうか」といったことをBPFプログラムロード前にチェックします。もしデータ構造の変化によりオフセットが変わっていた場合は、オフセットを調整(リロケーション)します。これにより、1つのバイナリで異なるカーネルでの動作を実現します。なお、BPF CO-REの機能はユーザースペースで実現しています。
BPF CO-REでは、リロケーションを実現するために、下記のようにコンパイラのビルトイン機能を使って、アクセスするデータの情報をアノテーションします。
pid_t pid = __builtin_preserve_access_index(({ task->pid; }));
これにはClang/LLVM 10が必要です。この情報はELFバイナリの特定セクションに格納されます。BPFプログラムローダはこの情報を基に「BPFプログラムが、どの変数にアクセスしようとしているか」を求め、さらにBTF情報を利用してリロケーションを行います。このように、BPF CO-REを利用するためには、それ用にプログラムを作成する必要があります。
BPF CO-REのサポートは最新版のBCCに含まれています。具体的なBPF CO-REプログラムの作成方法に関しては、こちらのドキュメントが参考になります。また、BPF CO-REを利用したツールはこちらから確認できます。BPF CO-REに対応したツールはまだ少ないですが、定期的に追加されています。
今回は、BPFのメタ情報、BTFを紹介しました。BTFはBPFプログラムのデバッグのみならず、今では高度な処理をBPFで実現するために欠かせない機能となっています。BTFが利用できる環境はまだ少ないですが、今後増えていくものと予想されます。
次回は、BPFのネットワークに関する応用について解説します。
東京大学 大学院 情報理工学系研究科 博士課程
オペレーティングシステムや仮想化技術の研究に従事。
 2017年版Linuxカーネル開発レポート公開――支援している企業トップ10とは?
2017年版Linuxカーネル開発レポート公開――支援している企業トップ10とは? Linuxカーネルのソースコードを読んで、システムコールを探る
Linuxカーネルのソースコードを読んで、システムコールを探る SystemTapで真犯人を捕まえろ!
SystemTapで真犯人を捕まえろ!Copyright © ITmedia, Inc. All Rights Reserved.