バニキPの気ままに

気ままにやったこととか紹介していきます

eBPFについて知ってみよう

この記事は,Akatsuki Advent Calendar 202014日目の記事です.

はじめに

後輩が研究で使っている?もしくは,それに関連しているとかでBPFという技術があるということを知りました. 後輩の研究に対して何かしらのコメントとか指摘とかしたくて軽く調べたことがきっかけで興味を持ちました. このコロナ下の恩恵で,eBPF Summitが開催されたので,英語はそこまで聞き取れなかったけれども,何となくこんなことが出来るのか...と理解した. そのことを軽く話していると,ネコさんが「SystemTapで昔ゲームを作ったことがあるから,BPFでも作れるはずだから作ってみたら」と. 調べてみるとBPFでテトリスを動かしている人がいた. これは,何か簡単なゲームなら作れるかもしれないと思い,Advent Calendarに参加した.(出来るとは言ってない) まずは,BPFについてあれこれ調べようということで調査してみた.

BPFとは何か

BPFとは,Berkeley Packet Filterの略で,1992年にUNIX上でパケットキャプチャ.フィルタリングを効率的にするために開発されたソフトウェアである. 特定のパケットのみをアプリケーション側でキャプチャする時は,NICが受診したパケットを全てキャプチャプログラムへ渡してフィルタリングする方法がある. しかし,カーネル空間とユーザ空間で大量のシステムコールとデータコピーが発生してしまう. その問題解消解消するために,BPFが提唱されている. BPFは,カーネル内でパケットフィルタリングを行うことで,データコピーの削減やカーネル空間とユーザ空間の切り替えのオーバーヘッド削減を可能とした. 1997年に,Linuxカーネルに移植され,LinuxでBPFがパケットフィルタリング以外で利用されるseccomp(システムコール制限機構)が生まれた. BPFは,カーネル内で動作する独自のレジスタマシンと命令セットを持った仮想マシンの1つで,カーネルモジュールと違い,使える機能やプログラムの文法に制限がある. 加えて,ユーザ空間で作ったプログラムがカーネルとクラッシュさせたり,破壊したりしないように検証する機構を持っている. 2014年にBPFをより汎用的な仮想マシンにするために拡張され,拡張されたBPFをeBPF(extended BPF)と呼び,従来のBPFをcBPF(classic BPF)と呼ぶ. この拡張によって,パケットフィルタリング以外の用途で使われるようになった. 具体的には,以下のような場面にBPFが利用可能になっている.

  • ネットワーキング
  • トレーシング
    • カーネルトレーシング
    • ユーザプログラムトレーシング
    • パーフォマンスカウンタモニタリング
  • セキュリティ

cBPFとeBPFの違い

eBPFになってどのような拡張が行われ,どのような違いがあるか示す.(以下に示す以外にも違いは存在する)

数値的な差

項目 cBPF eBPF
レジスタ 2 10
レジスタ幅(bit) 32 64
スタックサイズ(byte) 16 512
スタックアクセスサイズ(byte) 4 1,2,4,8
パケットアクセスサイズ(byte) 1,2,4 1,2,4,8

命令数の増加

拡張によって以下の命令が追加された. また,cBPFでは多くの命令がAレジスタに対する命令であったが,eBPFでは,src_reg(代入するレジスタ,メモリアドレスまたは数値を指定する)やdst_reg(値を受け取る記憶領域を指定する)フィールドにより使用するレジスタを柔軟に選択できるようになっている.

  • 事前に登録済みの外部関数(カーネル内関数)呼び出し命令
  • アトミック加算命令(BPF mapのデータ更新に利用する)
  • エンディアン変換命令
  • 64bit幅命令(64bit幅でのメモリ読み書き及び64bit)

eBPF mapの追加

eBPF mapと呼ばれるデータ構造が利用可能になり,BPFプログラムから外部関数呼び出しを使うもしくは,ユーザ空間からシステムコールを利用して,アクセスすることが出来る. この機能によって,BPFプログラムとのやり取りが可能となった. mapには,BPF_TABLE,BPF_HASH,BPF_ARRAYなど16種類のデータ構造を利用することが可能である.

他のプログラムへのジャンプ

Tail Callと呼ばれるBPFプログラムへの遷移を行う機能で,遷移後に遷移元戻ることはない. 遷移先BPFプログラムとはスタックフレームを共有している. 一定時間で終了を保証するため,最大のTail Call回数は32回に制限されている.

eBPFの全体像

BPFの全体像を,以下に示す.

f:id:shivashin495:20201213235925p:plain

1.まずBPFプログラムをC言語で記述し,LLVM/Clangでコンパイルする.
2.bpf(2)システムコールを利用してカーネルにロードします.
3.カーネル内で検証器にてBPFプログラムの安全性を確認し,BPFプログラムのロードが完了する.
4.BPFプログラムとのデータのやり取りが必要な場合,bpf(2)システムコール経由でBPF mapが作成され,BPFプログラムとmapがやり取りを行う.
4'.ユーザ空間でBPF mapにアクセスすることでBPFプログラムから情報を取得することが出来る.
5.sockets,trace_points,kprobes,uprobesのイベントソースを使ってBPFをプログラムをアタッチする.
アタッチに対応するイベントが発生すると,BPFプログラムが呼び出され,処理が実行される.

補足

trace_pointsは,ソースに埋め込んで,イベントに処理を追加する仕組みのこと.
kprobesは,任意のアドレスに対して実行中のカーネルに処理をはさむための仕組みで,kretprobesという名前のものも存在し,違いとしては,関数の実行後に処理をはさむ点で異なる.
uprobesは,任意のアドレスに対してユーザ空間で動作シリアプリケーションに処理をはさむための仕組みで,uretprobesという名前のものも存在し,違いとしては,関数の実行後に処理をはさむ点で異なる.

安全性の確保

全体像で説明した通り,カーネルはBPFプログラムをアタッチする前に,実行しても安全であるか検証器を用いて検証する. 検証器で確認する前にも,bpf(2)システムコールの段階でBPFプログラムが一定サイズ以下(v5.9.14にて4096)であることを確認している. 検証器でのチェック項目は,以下のようなものがある

  • ループがないこと
  • 未初期化のレジスタを利用しないこと
  • コンテキストの許可範囲のみアクセしていること
  • 境界を超えたメモリアクセスをしないこと
  • メモリアクセスのアラインメントが正しいこと

また,検証器は必要に応じて一部の命令の書き換えを行う. 例えば,ネットワーク関連でinclude/uapi/linux/bpf.hで定義されているstruct __sk_buffを使う場合は,実際のカーネル内のデータ構造で利用されるsk_buffの対応したフィールドに対応するように変換する. オプションでJITコンパイルを行って,ROP攻撃対策やSpectre Variant 2対策を行うことが出来る. 他にも,/proc/sys/kernel/unprivileged_bpf_disabledを1にすることで,CAP_SYS_ADMINを持ってないユーザはbpf(2)システムコールが利用できないようにすることが可能である.

環境構築

BPFを利用するには,LinuxUNIX環境でないとカーネルに組み込まれていない. また,いろいろ試すにはいろいろ面倒なことが多かった(体験談). そこで,誰でも同じ環境が構築できるようにという目的と勉強も兼ねて,仮想環境の設定ファイルとAnsibleの設定ファイルを作成した.

必要なツール

以下のツールが必要になる. 確かめてはないが,基本安定最新版をインストールすれば,問題ないと思われる.(多分大丈夫なはず...)

インストール手順

Gitページに拙い英語で手順を書いているが,以下に示す. cloneしたディレクトリに入った後,vagrant upコマンドで仮想環境を立ち上げます. ansible-playbook playbooks/upgrade-kernel.yml(カーネルのバージョンアップ)とvagrant reload(仮想環境の再起動)は,やらないくても動作します. これは,BPFプログラムの命令数上限がLinux5.3で4096命令制限が特権ユーザなら最大100万命令までロードできるようになった(このコミット). 上限を引き上げる必要がある場合に実行することが必要になる. ansible-playbook playbooks/setup.ymlで実際に必要なツールなどをインストールしている.

git clone https://github.com/shivashin/bpf-test-environment.git
cd /path/bpf-test-environment
vagrant up
ansible-playbook playbooks/upgrade-kernel.yml
vagrant reload
ansible-playbook playbooks/setup.yml

インストールしているもの

  • BCC
  • python3-bcc
  • bpftrace
  • サンプルプログラム
  • その他必要なツール群

実際に何か動かしてみよう

先ほどの章で説明した環境構築で,手元に仮想環境は構築されている(はず...). vagrant sshで仮想環境にsshして,実際にeBPFのプログラムを動かしてみようと思う. 今回はpython3-bccの導入をしているので,pythonを用いる. 詳しい構文などについての情報はここにある.

コード

今回実行するコードを以下に示す. progの変数に格納されているコードがBPFのプログラムになる. 今回は,「Hello. World!」を出力するだけの簡単なプログラムである. bpf_trace_printkのように見慣れない関数がいるが,これは,ユーザ空間にメッセージを渡す関数である. BPF(text=prog)の部分でカーネルにロードする. このロード時に検証器にて,安全性の確認が行われるので,途中で説明したようなチェックでエラーになるとロードされない. b.attach_kprobe(event=b.get_syscall_fnname("mmap"), fn_name="hello")では,指定するイベントにアタッチしている. 今回の動作では,cloneシステムコールにアタッチしている. get_syscall_fnnameを使って,カーネル内の関数名を取得して,kprobeを用いて関数にアタッチしている. これ以降のコードは出力に関する設定をしている. このプログラムを実行することで,mmapシステムコールは発行された際に,「Hello, World!」を出力するプログラムが呼び出される.

from bcc import BPF

# define BPF program
prog = """
int hello(void *ctx) {
    bpf_trace_printk("Hello, World!\\n");
    return 0;
}
"""

# load BPF program
b = BPF(text=prog)
b.attach_kprobe(event=b.get_syscall_fnname("mmap"), fn_name="hello")

# header
print("%-18s %-16s %-6s %s" % ("TIME(s)", "COMM", "PID", "MESSAGE"))

# format output
while 1:
    try:
        (task, pid, cpu, flags, ts, msg) = b.trace_fields()
    except ValueError:
        continue
    print("%-18.9f %-16s %-6d %s" % (ts, task, pid, msg))

実行してみた

このプログラムを実行して本当に動作しているか確認する. 確認方法は,lsコマンドを実行して確認する. 用意した環境にtmuxを導入しているので,別セッションを開くことで確認を行う. まず,lsコマンドが何回のmmapシステムコールを発行しているか確認するため,straceコマンドを用いる.

f:id:shivashin495:20201214001157p:plain

この結果より,mmapシステムコールは,17回発行されていることがわかる. eBPFプログラム側で17回分実行を検知しているか確認する.

f:id:shivashin495:20201213235959p:plain

eBPF側で17回lsコマンドでmmapが発行されていることを検知して「Hello, World!」を出力することができた.

さいごに

今回は,eBPFについて知ってみようということでこの記事を書きました. 本当は,簡単なゲームでも作ろうとしてましたが,Ansibleの作成やエラー処理,及び執筆に時間を使ったために実現しませんでした. その分,出来るだけ詳しめにBPF周りの情報をまとめたりしました. eBPFを触るきっかけにでもなればと思います.(出来るだけ調べたつもりですが,何か間違いがあったら申し訳ないです.) P.S. 時間はかかってもゲームは作ってみようと思います.(今年中は厳しいかな...,もう少しプログラムを書いてみないと分からないことが多すぎ.)