Linuxにおける利用が急速に増えている「Berkeley Packet Filter(BPF)」について、基礎から応用まで幅広く紹介する連載。今回は、Linux 5.5で導入されたBPFに関する主な新機能について。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
Linuxにおける利用が急速に増えている「Berkeley Packet Filter(BPF)」について、基礎から応用まで幅広く紹介する連載「Berkeley Packet Filter(BPF)入門」。
2020年1月26日にLinux 5.5がリリースされたので、今回は通常の連載内容を中断して、Linux 5.5で導入されたBPFに関する主な新機能を紹介します。
BPF TrampolineはカーネルからBPFコードをほぼオーバーヘッドなしで呼び出すための仕組みです。動的にJIT(Just-In-Time)コンパイルしたBPFプログラムへのジャンプコードを生成し、それをBPFプログラム呼び出しに利用します。
従来であればBPFプログラムを呼び出すときには、引数の準備やBPFプログラム関数のルックアップ処理などのオーバーヘッドがありますが、BPF Trampolineによって、その部分を最小限に抑えることができます。
またBPF Trampolineを利用すると、BPFプログラム呼び出し時に関数ポインタを利用する必要がなくなります。結果としてSpectre対策の「Retpoline」のオーバーヘッドもなくなります。現時点ではまだ対応していませんが、将来的には特にパフォーマンスクリティカルなXDP(eXpress Data Path)におけるBPFプログラム呼び出しの高速化が期待されています。
このBPF Trampolineを利用して、カーネル関数の呼び出し/戻りをトレースする機能が追加されました。BPFプログラムは「BPF_PROG_TYPE_TRACING」というプログラムタイプになります。このとき、従来ある「ftrace」の機能を応用し、BPF Trampolineで生成したBPF呼び出しのコードを実行します。
ftraceはLinuxのトレース機構です。ftraceにはさまざまな機能がありますが、主要となる機能の一つが関数の呼び出し/戻りのトレースです。関数の呼び出しをトレースするには単純には関数の先頭でトレース用の関数を呼び出せばよいでしょう。しかし、単純に実装してしまうとトレースしていないときでもオーバーヘッドが発生してしまいます。そこでftraceは関数先頭に「nop」を埋め込みます(カーネルコンパイル時に処理します)。トレースを有効にした際にその箇所をパッチしてトレース処理を行います。BPF_PROG_TYPE_TRACINGでは、このnopの部分を生成したBPF Trampolineへのcall命令に書き換えてBPFプログラムを呼び出します。
BPF Trampolineの具体例として、ソースコードのコメントから「__be16 eth_type_trans(struct sk_buff *skb, struct net_device *dev);」関数の例を引用します。
まず、ftraceを有効にしてコンパイルしたvmlinuxでこの「eth_type_trans」関数をディスアセンブルすると、以下のようになっています。
% gdb vmlinux --ex 'disassemble eth_type_trans' [...] 0xffffffff830465f0 <+0>: callq 0xffffffff83601960 <__fentry__> 0xffffffff830465f5 <+5>: push %rbp 0xffffffff830465f6 <+6>: mov %rsp,%rbp 0xffffffff830465f9 <+9>: push %r15 0xffffffff830465fb <+11>: push %r14 [...]
ここで、関数先頭にある「__fentry__」がftraceのエントリポイントです。この部分は、通常時はnopに置き換えられています。「BPF_PROG_TYPE_TRACING」で関数呼び出しをトレースする場合、この「__fentry__」の部分が作成したBPF Trampolineコードへの呼び出し命令になります。BPF Trampolineのコードの実体は以下のようになります。
push rbp mov rbp, rsp sub rsp, 16 // space for skb and dev push rbx // temp regs to pass start time mov qword ptr [rbp - 16], rdi // save skb pointer to stack mov qword ptr [rbp - 8], rsi // save dev pointer to stack call __bpf_prog_enter // rcu_read_lock and preempt_disable mov rbx, rax // remember start time in bpf stats are enabled lea rdi, [rbp - 16] // R1==ctx of bpf prog call addr_of_jited_FENTRY_prog movabsq rdi, 64bit_addr_of_struct_bpf_prog // unused if bpf stats are off mov rsi, rbx // prog start time call __bpf_prog_exit // rcu_read_unlock, preempt_enable and stats math mov rdi, qword ptr [rbp - 16] // restore skb pointer from stack mov rsi, qword ptr [rbp - 8] // restore dev pointer from stack pop rbx leave ret
上の「addr_of_jited_FENTRY_prog」がBPFプログラムの呼び出し処理です。必要最小限のコードでBPFプログラムが呼び出されていることが確認できます。
さて、BPF Trampolineは単純にBPFプログラムにジャンプするだけではありません。もう一つ重要な処理としてBPFプログラムの引数の構成があります。BPF_PROG_TYPE_TRACINGのBPFプログラムの場合、BPFプログラムからトレース対象となる関数の引数にアクセスできます。これを実現するために、BPF Trampolineのコードはトレース対象となる関数の引数をスタックに積み、そのスタックのアドレスをBPFプログラムの引数として渡します。BPFプログラム側からはこのアドレスを利用して適切に値をロードすることで、目的の引数にアクセスします。
それでは、BPF Trampolineはどうやって関数の引数の箇所を求めるのでしょうか? 関数の引数の構成方法はABI(Application Binary Interface)により定められています。例えば「SytemV AMD64 ABI」の場合、RDI(デスティネーションインデックス)レジスタが第1引数、RSI(ソースインデックス)レジスタが第2引数、……というように決まっています。従って、それに応じてスタックに値を積めばよいことになります。しかし、ここでさらにもう一つ問題があります。それは、トレース対象となる関数の引数の数をどうやって知るかという問題です。ただのバイナリのコードからでは関数の引数の情報は分かりません。
この問題の救世主がBTF(BPF Type Format)です。BTFは特にBPFによる利用を想定したデバッグ情報のフォーマットです。デバッグ情報といえば「DWARF」が有名ですが、DWARFは情報が多い分、サイズが大きくなります。特にLinuxの場合、数MBの「vmlinux」ファイルがDWARFでは数百MBになります。BTFは持つ情報を関数の引数や、構造体の情報などに限定することで、デバッグ情報のサイズを小さくし、vmlinuxでもデバッグ情報を数MB程度に抑えます。
Linuxは4.18からBTFに対応し、コンパイル時にBTFのデバッグ情報を生成することができます。BPF_PROG_TYPE_TRACINGのBPFプログラムに関するBPF Trampolineを生成する際は、このBTFの情報を参照し、適切にトレース対象の関数の引数をスタックに積むコードを生成します。
ユーザーから見るとBPF_PROG_TYPE_TRACINGの動作は、大まかには関数先頭に「k(ret)probe」コマンドでBPFプログラムをアタッチした場合と同じですが。こちらの方が前述の通り、呼び出し時のオーバーヘッドが少なくなります。また、関数の戻り時に関数の引数にアクセスすることができます。これは現在のkretprobeにアタッチしたBPFプログラムからは行えません。現状kretprobeから関数の引数にアクセスしたい場合は、kprobeにアタッチしたBPFプログラムでBPFマップに必要な情報を格納し、それを後から参照する必要があります。また、kprobeは関数の任意のオフセットにアタッチできますが、BPF_PROG_TYPE_TRACINGではそれはできないという違いもあります。
LinuxカーネルはBPFプログラムをロードする際に、検証器によりプログラムの安全性を検査します。このときの動作の概要については連載第3回で紹介しました。簡単にいうと、検証器はBPFのアセンブラ命令レベルで各レジスタの型(「SCALAR_VALUE」「PTR_TO_CTX」「PTR_TO_STACK」……)を追跡し、それにより命令実行の可否を判断します。
しかし、このレベルの解析ではポインタの型が何なのかは分かりません。例としてコミットメッセージのプログラムを引用します。
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); }
これはkprobeにアタッチすることを想定したBPFプログラムです。このBPFプログラムには引数として「struct pt_regs」が与えられ、BPFプログラムはこのBPFプログラムを呼び出したときのタスクのレジスタ値にアクセスできます。このプログラムでは「ctx->di」(System V AMD64 ABIにおいて関数の第1引数)に「sk_buff」のポインタが入っていると想定してその領域にアクセスしています。しかし、従来の検証器ではそこまでの保証はできません。もしctx->diにsk_buffのポインタが入っていない場合どうなるでしょうか? 「bpf_probe_read()」関数は「page fault」が発生するような領域には0を返すので、カーネルがクラッシュするといったことはありませんが、プログラム作成者が意図した動作にはならないでしょう。
この問題を解決するためにLinux 5.5では、先ほど紹介したBTFを利用してC言語的な意味での型を意識した解析を実現する処理が追加されました。具体的には、BPFプログラムをロードする際に、そのBPFプログラムをアタッチする関数のBTF情報も一緒に与えます。検証器はこの情報から関数の引数の型が分かるため、例えば上記の例では「ctx->diが本当にsk_buffかどうか」が判断できます。
この機能はひとまず「RAW TRACEPOINT」のBPFプログラムに関して利用可能です。具体的な応用例として、「skb_output()」というsk_buffのデータを「perf event buffer」にダンプするヘルパー関数が新規に導入されています。ここで説明したBPFの型検査により、検証器はこのヘルパー関数に与えた引数が必ずsk_buffのポインタであることを保証できます。
「BPF map」には、ユーザースペースからは「bpf(2)」システムコール(「BPF_MAP_LOOKUP_ELEM」「BPF_MAP_UPDATE_ELEM」)でアクセスできますが、大量のアクセスには適していません。そこで「non-per CPU」の「BPF array」(連続したメモリ領域に配置されていることが保証されている)に関して「mmap」のアクセスがサポートされました。BPF mapには「BPF_MAP_FREEZE」という属性を付与することで更新禁止状態にできますが、この場合mmapは「read only」のみ許可されます。
なお、BPF mapのアクセスに関しては検索や更新、削除のバッチ処理が提案され、既にmaster branchにコードが入っています。Linux 5.6に含まれるはずです。
Linux 5.5から以下のヘルパー関数が追加されました。
BPFプログラムから、そのBPFプログラムが実行されたコンテキストに属するスレッドにシグナルを送信する関数です。トレースを行うBPFプログラムから利用できます。
Linux 5.3で導入された「bpf_send_singnal()」ヘルパー関数により、特定のpidを持つプロセスにシグナルを送信することができますが、マルチスレッドプログラムの場合、任意のスレッドがsignalを受信する可能性があります。bpf_send_signal_thread()であれば、必ずそのBPFプログラムを実行したスレッドにsignalが送信されます。
ユースケースとしては、BPFプログラムからスタックトレースを取得した後にそのスレッドに対して通知をするといったことがあるようです。
トレーシングを行うBPFプログラムは「bpf_probe_read()」ヘルパー関数でメモリ内容を読み出すことができます。この関数は「kernel_probe_read()」関数のラッパーですが、常に「KERNEL_DS」状態で動作するため、aarch64やsparcなどのアーキテクチャではユーザースペースのメモリ空間に正しくアクセスできない場合がありました。そこで、カーネルのメモリ領域とユーザーのメモリ領域にアクセスするために別々のヘルパー関数が導入されました。probe_read_user()関数はUSER_DSでアクセスが行われます。
今後は、利用可能であれば、こちらの関数を使用しましょう。なお、「xxx_str()」はNULLで終端された文字列を読み出す(NULL文字以降はデータをコピーしない)関数です。
TCPのACK(肯定応答/確認応答)を送信する関数です。現在BPFプログラムでTCPの輻輳(ふくそう)制御を実現しようという話が進められており、それの前段階としてアルゴリズム実装に必要なこのヘルパー関数が導入されました。
「64bit jiffies」を取得するための関数です。このヘルパー関数を呼び出す命令は、検証器によって直接「jiffer」のメモリからロードする命令に書き換えられます。この関数も前述の輻輳制御実装のために導入されました。
今回はLinux 5.5に導入された主なBPFの新機能を紹介しました。続々と新しい機能が追加されているので、いろいろと試してみてください。
次回は通常の連載内容に戻り、bpftraceによるトレーシング手法を紹介する予定です。
東京大学 大学院 情報理工学系研究科 博士課程
オペレーティングシステムや仮想化技術の研究に従事。
Copyright © ITmedia, Inc. All Rights Reserved.