🐝

ebpf-goによるLinuxカーネルトレース入門

2023/12/30に公開

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のインストール手順、カーネル設定の詳細は下記を参照してください。

テスト環境は下記

  • 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などを使用する場合は、カーネルのメモリに直接アクセスできます。

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