単なるデバッグ情報だけではない「BPF Type Format」(BTF)の使い道:Berkeley Packet Filter(BPF)入門(10)
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 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カーネルのデータ構造情報を取得するための利用です。以降、それぞれについて説明します。
BPFプログラムのデバッグ情報としてのBTF
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プログラムやマップと一緒にカーネル内に保持され、後からその情報を参照することができます。
BCCでのBTFの利用例
連載第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」の合計サイズ)の値がダンプされることになります。
カーネルのデータ構造を取得するためのBTF
ここまで、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プログラムの例ですが、下記のような問題があります。
- 関数の第1引数が「struct sk_buff*」であることを想定しているが、そうではないかもしれない
- アタッチする関数を間違えた場合(プログラミングエラー)
- カーネルの更新により、関数の引数が変わった場合
- 「skb->dev」や「skb->ifindex」でデータにアクセスしているが、「struct sk_buff」の構造はカーネルの更新で変わるかもしれない
- 古いカーネルでコンパイルしたBPFプログラムを、新しいカーネルで動かした場合、予期しないデータにアクセスする可能性がある
従来のBPFの検証器はソースコードレベルで検証しないため、上記のような問題は検出できません。BPFを利用する側が注意する必要がありました。この問題は、今動作しているカーネルのデータ構造を取得すれば、解決できます。このカーネルのデータ構造情報のためにもBTFは利用されます。
カーネルの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の確認
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」などと同様に、現在実行中のカーネルの情報を保持する信頼できるデータとして扱われます。
BTFの応用例:カーネルデータ構造の取得
前回紹介した「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
「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の詳細に関しては以前紹介した記事があるので、そちらも参照してください。
KRSI(BPFによるLSMフック)
Linux 5.7から、「Kernel Runtime Security Instrumentation(KRSI)」と呼ばれる、LSM(Linux Security Module)フックにBPFプログラムをアタッチする機能が追加されました。これにより、BPFプログラムによってLSMの実行可否を決定したり、ロギングを行えたりします。仕組み的にはBPF Trampolineを応用したもので、BTFデータによってアタッチする関数の引数情報を利用しています。
BPF CO-RE
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のネットワークに関する応用について
今回は、BPFのメタ情報、BTFを紹介しました。BTFはBPFプログラムのデバッグのみならず、今では高度な処理をBPFで実現するために欠かせない機能となっています。BTFが利用できる環境はまだ少ないですが、今後増えていくものと予想されます。
次回は、BPFのネットワークに関する応用について解説します。
参考文献
- BPF Type Format(BTF)
- LSM BPF Programs
- Andrii Nakryiko, BPF Portability and CO-RE,2020
- Andrii Nakryiko, Bringing BPF dev experience to the next level,2019
- Andrii Nakryiko, Encing the Linux kernel with BTF type information,2018
筆者紹介
味曽野 雅史(みその まさのり)
東京大学 大学院 情報理工学系研究科 博士課程
オペレーティングシステムや仮想化技術の研究に従事。
- メール:misono(at)os.ecc.u-tokyo.ac.jp
- ブログ:http://mmi.hatenablog.com/
Copyright © ITmedia, Inc. All Rights Reserved.
関連記事
- 2017年版Linuxカーネル開発レポート公開――支援している企業トップ10とは?
The Linux Foundationは2017年版Linuxカーネル開発レポートを公開した。Linuxカーネル4.8から4.13までの開発に焦点を当て、カーネル開発に携わった開発者や変更数などについて言及した。 - Linuxカーネルのソースコードを読んで、システムコールを探る
C言語の「Hello World!」プログラムで使われる、「printf()」「main()」関数の中身を、デバッガによる解析と逆アセンブル、ソースコード読解などのさまざまな側面から探る連載。前回まで、printf()内の中身をさまざまな方法で探り、write()やint $0x80の呼び出しまでたどり着いた。今回は、さらにその先にあるLinuxカーネル側のシステムコールを見ていく。 - SystemTapで真犯人を捕まえろ!