ebpf-goによるLinuxカーネルトレース入門
ebpf-goによるLinuxカーネルトレース入門
ebpf-goを使用したLinuxカーネルトレース(fentry)について紹介します。
ebpf-goは、eBPFのGo向けライブラリです。このライブラリは、libbpfに依存せず(cgoを使用せず)にeBPFプログラムとデータのやり取りが可能であるため、ポータビリティが高くGo言語に適したeBPFライブラリとなります。
fentryは、カーネル関数のエントリポイントにプログラムをアタッチするためのBPFプログラムタイプです。簡潔に言うと、カーネル関数にフック処理を行うことができます。
本記事ではebpf-goのexamples内のtcprttを基に、ebpf-goの開発方法についても説明します。
前提条件として、Linuxカーネル設定でBPFとBTF(BPF Type Format)を有効にする必要があります。また、bccのインストール手順、カーネル設定の詳細は下記を参照してください。
- https://github.com/iovisor/bcc/blob/master/INSTALL.md
- https://github.com/iovisor/bcc/blob/master/docs/kernel_config.md
テスト環境は下記
- OS: Ubuntu 22.04 (arm64)
- Kernel: 6.5.0-14
- go: 1.21.3
- bcc: 0.29.1 (ビルド要)
- cilium/ebpf: 0.12.3
- clang: 14.0.0 (aptでインストール)
注意事項として、環境構築でビルドが失敗する可能性があります。ubuntu 23.10とbccの組み合わせではビルドに失敗しました。また、ubuntu 22.10と最新のbccの組み合わせでもビルドに失敗しました。ビルドに成功したのは、ubuntu 22.10とbcc 0.29.1(git checkout v0.29.1)の組み合わせです。
tcprttの全体構成およびビルド方法
tcprttのコード構成は以下の通りです。
$ tree .
.
├── bpf_bpfeb.go # [bpf2goコマンドで自動生成] ビッグエンディアン向けのeBPF操作やデータのやり取りを行うコード。
├── bpf_bpfeb.o # [bpf2goコマンドで自動生成] ビッグエンディアン向けのeBPFバイトコード。clang -triple bpfbl tcprtt.c
├── bpf_bpfel.go # [bpf2goコマンドで自動生成] リトルエンディアン向けのeBPF操作やデータのやり取りを行うコード。
├── bpf_bpfel.o # [bpf2goコマンドで自動生成] リトルエンディアン向けのeBPFバイトコード。clang -triple bpfel tcprtt.c
├── main.go # アプリケーションのメイン部分。
└── tcprtt.c # eBPFコード。このコードからbpf_bpfe[bl].o、bpf_bpfe[bl].goが生成されます。
# eBPFコードをビルドするときにC言語のヘッダが必要
$ ls ../headers
bpf_endian.h bpf_helper_defs.h bpf_helpers.h bpf_tracing.h common.h LICENSE.BSD-2-Clause update.sh
ebpf-goの特徴として、バイナリ内にバイトコードを取り込みシングルバイナリを実現しています。アプリケーションのメイン部分が実行されたとき、bpf(2)をコールしカーネルにバイトコードをロードする仕組みになっています。
bpf_bpfel.go
// Do not access this directly.
//
//go:embed bpf_bpfel.o
var _BpfBytes []byte
コード生成にはgo:generate構文を利用しています。
main.go
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -type event bpf tcprtt.c -- -I../headers
図で表すと以下の通りです。
tcprttを単体でビルドするためには、以下のパッチを適用してください。ebpf-goプロジェクト全体でビルドする場合は不要です。
diff --git a/examples/tcprtt/main.go b/examples/tcprtt/main.go
index 3c55cc6..7a22969 100644
--- a/examples/tcprtt/main.go
+++ b/examples/tcprtt/main.go
@@ -24,7 +24,6 @@ import (
"os/signal"
"syscall"
- "github.com/cilium/ebpf/internal"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/rlimit"
@@ -90,7 +89,7 @@ func readLoop(rd *ringbuf.Reader) {
}
// Parse the ringbuf event entry into a bpfEvent structure.
- if err := binary.Read(bytes.NewBuffer(record.RawSample), internal.NativeEndian, &event); err != nil {
+ if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.NativeEndian, &event); err != nil {
log.Printf("parsing ringbuf event: %s", err)
continue
}
@@ -108,6 +107,6 @@ func readLoop(rd *ringbuf.Reader) {
// intToIP converts IPv4 number to net.IP
func intToIP(ipNum uint32) net.IP {
ip := make(net.IP, 4)
- internal.NativeEndian.PutUint32(ip, ipNum)
+ binary.NativeEndian.PutUint32(ip, ipNum)
return ip
}
ビルドおよび実行方法
$ echo "module tcprtt" > go.mod
$ go mod tidy
go: finding module for package github.com/cilium/ebpf/link
go: finding module for package github.com/cilium/ebpf/rlimit
go: finding module for package github.com/cilium/ebpf/ringbuf
go: finding module for package github.com/cilium/ebpf
go: found github.com/cilium/ebpf in github.com/cilium/ebpf v0.12.3
go: found github.com/cilium/ebpf/link in github.com/cilium/ebpf v0.12.3
go: found github.com/cilium/ebpf/ringbuf in github.com/cilium/ebpf v0.12.3
go: found github.com/cilium/ebpf/rlimit in github.com/cilium/ebpf v0.12.3
$ go generate main.go
Compiled /home/yusuke/GolandProjects/ebpf/examples/tcprtt/bpf_bpfel.o
Stripped /home/yusuke/GolandProjects/ebpf/examples/tcprtt/bpf_bpfel.o
Wrote /home/yusuke/GolandProjects/ebpf/examples/tcprtt/bpf_bpfel.go
Compiled /home/yusuke/GolandProjects/ebpf/examples/tcprtt/bpf_bpfeb.o
Stripped /home/yusuke/GolandProjects/ebpf/examples/tcprtt/bpf_bpfeb.o
Wrote /home/yusuke/GolandProjects/ebpf/examples/tcprtt/bpf_bpfeb.go
$ go build -o tcprtt main.go bpf_bpfel.go
$ sudo ./$ sudo ./tcprtt
2023/12/30 11:03:53 Src addr Port -> Dest addr Port RTT
2023/12/30 11:04:08 192.168.10.19 49610 -> 192.168.10.17 22 0
Tips: Makefileを利用すると、開発がより効率的になります。eBPFコード(tcprtt.c)が修正された場合のみ、コードの生成を行います。
Makefile
TARGET=tcprtt
.PHONY: all
all: bpf_bpfel.go
go build -o ${TARGET} main.go bpf_bpfel.go
bpf_bpfel.go: tcprtt.c
go generate main.go
.PHONY: run
run: all
sudo ./${TARGET}
eBPFコード(tcprtt.c)
下記のコードは、TCP接続が閉じられるときに実行されます。具体的には、tcp_closeカーネル関数のfentry(関数エントリ)ポイントにアタッチされます。
TCP接続が閉じられるたびに実行され、接続に関する情報をリングバッファに送信します。
このリングバッファの詳細な説明は省略します。カーネル空間とユーザ空間でデータをやり取りする手段として、マップやリングバッファなどが存在する、という認識で問題ありません。
注記
- fentryは、Linux kernel 5.5でサポートが開始され、それ以前はkprobeのBPFタイプを使用してカーネル関数にアタッチしていました。
- BPF_PROGを使用しfentry, fexitなどを使用する場合は、カーネルのメモリに直接アクセスできます。
- kprobeではカーネルメモリへのアクセス、例えばtcprtt.cの場合ではskへのアクセスを行う際には、bpf_probe_read*()を使用してアクセスする必要があります。
- https://man7.org/linux/man-pages/man7/bpf-helpers.7.html
tcprtt.c抜粋
SEC("fentry/tcp_close")
int BPF_PROG(tcp_close, struct sock *sk) {
if (sk->__sk_common.skc_family != AF_INET) {
return 0;
}
// The input struct sock is actually a tcp_sock, so we can type-cast
struct tcp_sock *ts = bpf_skc_to_tcp_sock(sk);
if (!ts) {
return 0;
}
struct event *tcp_info;
tcp_info = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);
if (!tcp_info) {
return 0;
}
tcp_info->saddr = sk->__sk_common.skc_rcv_saddr;
tcp_info->daddr = sk->__sk_common.skc_daddr;
tcp_info->dport = bpf_ntohs(sk->__sk_common.skc_dport);
tcp_info->sport = sk->__sk_common.skc_num;
tcp_info->srtt = ts->srtt_us >> 3;
tcp_info->srtt /= 1000;
bpf_ringbuf_submit(tcp_info, 0);
return 0;
}
下記にLinuxカーネルヘッダの部分的な定義があります。この定義は、tcprtt.cで使用しているフィールドのみを記載しています。
__attribute__((preserve_access_index))
の属性をつけて構造体を定義すると、実行時にカーネルが保持するBTF(BPF Type Format)から構造体のフィールドのアクセス先(オフセット)を適切に計算し、アクセスが可能になります。
sock.hの定義を比較すると、フィールドが欠けていることが確認できると思います。
tcprtt.c抜粋
struct sock_common {
union {
struct {
// skc_daddr is destination IP address
__be32 skc_daddr;
// skc_rcv_saddr is the source IP address
__be32 skc_rcv_saddr;
};
};
union {
struct {
// skc_dport is the destination TCP/UDP port
__be16 skc_dport;
// skc_num is the source TCP/UDP port
__u16 skc_num;
};
};
// skc_family is the network address family (2 for IPV4)
short unsigned int skc_family;
} __attribute__((preserve_access_index));
sock.h抜粋 (linux kernel)
struct sock_common {
union {
__addrpair skc_addrpair;
struct {
__be32 skc_daddr;
__be32 skc_rcv_saddr;
};
};
union {
unsigned int skc_hash;
__u16 skc_u16hashes[2];
};
/* skc_dport && skc_num must be grouped as well */
union {
__portpair skc_portpair;
struct {
__be16 skc_dport;
__u16 skc_num;
};
};
// 略
アプリケーションコード
以下のコードは典型的なパターンで構成されており、入門レベルでは深く理解する必要はないと思います。
- rlimit.RemoveMemlock()
- カーネル 5.11以降では何も行いません。5.111以前はメモリロック制限の解除に使用していました。互換性のために存在します。
- loadBpfObjects()
- バイトコードをカーネルに送信し、マップやリングバッファにアクセスできるようにします。
- link.AttachTracing()
- tcp_close()にアタッチします。
- ringbuf.NewReader()
- リングバッファのリーダーを作成します。
注意
- コードを修正し、新たなカーネル関数にフックする場合は、link.AttachTracingに登録する必要があります。
- 複数のカーネル関数を登録する場合は、link.AttachTracingをそれぞれ実行する必要があります。
main.goから抜粋
func main() {
stopper := make(chan os.Signal, 1)
signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)
// Allow the current process to lock memory for eBPF resources.
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal(err)
}
// Load pre-compiled programs and maps into the kernel.
objs := bpfObjects{}
if err := loadBpfObjects(&objs, nil); err != nil {
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()
link, err := link.AttachTracing(link.TracingOptions{
Program: objs.bpfPrograms.TcpClose,
})
if err != nil {
log.Fatal(err)
}
defer link.Close()
rd, err := ringbuf.NewReader(objs.bpfMaps.Events)
if err != nil {
log.Fatalf("opening ringbuf reader: %s", err)
}
defer rd.Close()
log.Printf("%-15s %-6s -> %-15s %-6s %-6s",
"Src addr",
"Port",
"Dest addr",
"Port",
"RTT",
)
go readLoop(rd)
// Wait
<-stopper
}
これはeBPFからのリングバッファのデータを受け取る処理です。
カーネルからデータが送られてくるのを待ち、データが到着したら読み込む無限ループとなっています。非常にシンプルなコードです。
カーネルのトレースを採取する場合、多くの場合このパターンになると思われます。
main.goから抜粋
func readLoop(rd *ringbuf.Reader) {
// bpfEvent is generated by bpf2go.
var event bpfEvent
for {
record, err := rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrClosed) {
log.Println("received signal, exiting..")
return
}
log.Printf("reading from reader: %s", err)
continue
}
// Parse the ringbuf event entry into a bpfEvent structure.
if err := binary.Read(bytes.NewBuffer(record.RawSample), internal.NativeEndian, &event); err != nil {
log.Printf("parsing ringbuf event: %s", err)
continue
}
log.Printf("%-15s %-6d -> %-15s %-6d %-6d",
intToIP(event.Saddr),
event.Sport,
intToIP(event.Daddr),
event.Dport,
event.Srtt,
)
}
}
より理解を深めるためには、ebpf-goのexamplesのUprobe、Tracepoint、XDPなどを参照すると良いでしょう。
補足(BPF Type Format無効化版でのカーネルトレース採取方法)
研究用途や個人利用ではBTFを有効化することが可能ですが、商用ではビルドツールやカーネルバージョンが指定されるため、現状ではBTFを有効化できない開発者も多いと考えられます。
BTFが有効化できず、vmlinux.hが作成できない場合、構造体の定義を手動で作成する必要があります。この構造体定義が非常に至難です。
私が調査した限りでは、sock.hのプリプロセッサ適用版のヘッダを簡単に作成することはできません。CONFIG_IKHEADERS=yに設定してカーネルヘッダを作成しても、この定義ではビルドができませんでした。
eBPFの思想からは外れますが、解決策の一つとしてカーネルを修正しました。最小限のsock構造体を定義し、eBPFプログラムから容易にフックできるようにすることで、BTF無効化版でもカーネルトレースが可能となります。
下記はtcp_closeの例です。
struct tiny_sock {
union {
struct {
__be32 skc_daddr;
__be32 skc_rcv_saddr;
};
};
union {
struct {
__be16 skc_dport;
__u16 skc_num;
};
};
short unsigned int skc_family;
};
void __attribute__((used)) tcp_close_hook(struct tiny_sock *tsk)
{
}
EXPORT_SYMBOL(tcp_close_hook);
void tcp_close(struct sock *sk, long timeout)
{
struct tiny_sock tsk
tsk.skc_daddr = sk->__sk_common.skc_daddr;
tsk.skc_rcv_saddr = sk->__sk_common.skc_rcv_saddr;
tsk.skc_dport = sk->__sk_common.skc_dport;
tsk.skc_num = sk->__sk_common.skc_num;
tsk.skc_family = sk->__sk_common.skc_family;
tcp_close_hook(&tsk);
lock_sock(sk);
__tcp_close(sk, timeout);
release_sock(sk);
sock_put(sk);
}
EXPORT_SYMBOL(tcp_close);
参考
ネットワークログの採取を目的として作成したサンプルプロジェクトです。これはtcprttの発展形です。
Discussion