eBPFプログラムの実装について学習した内容をアウトプットする記事となります。
私はC言語、eBPFプログラムを初めて触る初心者のため、記事の内容も初学者向けのものとなっています。
eBPFの概要は公式ドキュメントを参照して下さい。
目次
eBPFプログラムの概要
GoogleCloudのGCEインスタンスで、カーネルのtcp_sendmsg
, tcp_cleanup_rbuf
関数をフックするeBPFプログラムを実装します。
eBPFプログラム(agent.o)は、送信元・宛先のIPアドレスの組み合わせ毎に通信量(TX, RX)を加算して、eBPFマップに保存します。
ユーザー空間プログラム(agent_checker)は定期的にeBPFマップを読み込み、内容を標準出力します。
環境情報
以下、実行環境のサーバー情報です。
コンポーネント | バージョン |
---|---|
OS | Ubuntu 24.04.3 LTS |
Kernel | 6.14.0-1012-gcp |
Clang | 17.0.6 |
bpftool | v7.6.0 |
libbpf | v1.6 |
環境構築
TerraformでGCEインスタンスを構築します。
main.tf
variable "GC_CREDENTIALS_PATH" {
type = string
description = "Path for GoogleCloud credentials."
}
variable "GC_PROJECT" {
type = string
description = "GoogleCloud project name."
}
locals {
project = var.GC_PROJECT
region = "asia-northeast1"
zone = "asia-northeast1-c"
servername = "dev-server"
}
terraform {
required_version = "~> 1.14.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.48"
}
}
backend "gcs" {
bucket = "tmatsuno-gke-test-tfstate"
prefix = "ebpf-hook-tcp-test"
}
}
provider "google" {
credentials = file(var.GC_CREDENTIALS_PATH)
project = local.project
region = local.region
zone = local.zone
}
output "dev_server_ssh_command" {
value = "gcloud compute ssh <SSHユーザー名>@${google_compute_instance.dev_server.name} --project=${local.project} --zone=${local.zone}"
}
resource "google_compute_instance" "dev_server" {
name = local.servername
machine_type = "e2-small"
boot_disk {
auto_delete = true
source = google_compute_disk.default.self_link
}
network_interface {
network = "default"
access_config {}
}
metadata_startup_script = <<EOF
#!/bin/bash
sudo su
apt-get update
apt-get install -y apt-transport-https gnupg wget
wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | apt-key add -
echo "deb http://apt.llvm.org/bullseye/ llvm-toolchain-bullseye-17 main" > /etc/apt/sources.list.d/llvm.list
apt-get update
apt-get -y install \
git \
pkg-config \
iproute2 \
strace \
build-essential \
gdb \
clang-17 \
lld-17 \
llvm \
libelf-dev \
zlib1g-dev \
libbpf-dev \
linux-headers-$(uname -r)
apt-get -y upgrade libbpf-dev
# bpftrace, bpfcc を利用したい場合はコメントイン
# apt-get -y install bpftrace bpfcc-tools libbpfcc libbpfcc-dev
ln -s /usr/bin/clang-17 /usr/bin/clang
# eBPFプログラムがカーネルのトレースポイントを利用できるようにするために設定
echo "kernel.perf_event_paranoid = 1" | tee -a /etc/sysctl.conf > /dev/null
sysctl -p
git clone https://github.com/libbpf/libbpf.git /tmp/libbpf
git clone --recurse-submodules https://github.com/libbpf/bpftool.git /tmp/bpftool
cd /tmp/bpftool/src
make install
cd /tmp/libbpf/src
make install
EOF
}
resource "google_compute_disk" "default" {
name = "${local.servername}-disk"
image = "ubuntu-os-cloud/ubuntu-2404-lts-amd64"
size = 20
}
eBPFプログラムの実装
以下のファイルを作成します。
.
├── Makefile # Cプログラムをコンパイルするための設定ファイル
├── bpf_common.h # 共通利用する型定義ファイル
├── agent.c # TCP通信のbyte数合計をeBPFマップに書き込む、eBPFプログラム
└── agent_checker.c # 定期的にeBPFマップを読み込み標準出力する、ユーザー空間プログラム
bpf_common.h
eBPFマップのキー・バリューとして使用する構造体を定義します。
IPアドレスはIPv4-mapped (IPv6) addressという形式で配列に格納します。
カーネルは内部的に、IPv4ソケットの通信もIPv6ソケットの通信も統一的に扱えるよう、IPv4アドレスを::ffff:192.0.2.1
のような形式にマッピングして保持することがあります。
#ifndef BPF_COMMON_H
#define BPF_COMMON_H
// TCP通信を一意に識別するために、送信元(source)と宛先(destination)のIPアドレスを保持して、この組み合わせをキー値とします
struct ip_key_t {
// IPアドレスを格納する配列
// 16バイト(128ビット)のサイズを確保することで、IPv4とIPv6の両方に対応できます
__u8 saddr[16]; // 送信元IPアドレス (source address)
__u8 daddr[16]; // 宛先IPアドレス (destination address)
};
// eBPFマップのバリュー(値)として使用する構造体。送信または受信した合計バイト数を格納します
struct val_t {
__u64 bytes;
};
#endif // BPF_COMMON_H
agent.c
カーネル内で発生するTCP通信を捕捉し、送信元・宛先IPアドレスに関連付けてネットワークの送受信バイト数を計測するプログラムを実装します。
eBPFマップの定義
IPアドレスをキーにして、送受信バイト数を格納するマップを2つ作成します。
// 送信(TX: Transmit)バイト数をIPアドレスペアごとに格納するためのeBPFハッシュマップ
struct {
__uint(type, BPF_MAP_TYPE_HASH); // マップの種類。ハッシュマップは高速な検索が可能です
__uint(max_entries, 10240); // マップが保持できる最大のキー・バリューペアの数
__type(key, struct ip_key_t); // キーの型を指定
__type(value, struct val_t); // バリューの型を指定
} tx_stats SEC(".maps");
// 受信(RX: Receive)バイト数をIPアドレスペアごとに格納するためのeBPFハッシュマップ
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, struct ip_key_t);
__type(value, struct val_t);
} rx_stats SEC(".maps");
tcp_sendmsg をフックする関数の定義
tcp_sendmsg は、TCPソケットを通じてデータを送信する際に呼ばれる関数です。tcp_sendmsg 関数をフックして、外部に送信されるデータ量(TX)を計測します。
SEC("kprobe/tcp_sendmsg")
int BPF_KPROBE(trace_tcp_sendmsg, struct sock *sk, struct msghdr *msg, size_t size) {
// 送信サイズが0の場合は、統計に影響しないため、ここで処理を終了します
if (size == 0) {
return 0;
}
struct ip_key_t key = {};
struct val_t *valp, zero = {};
// sk (struct sock) は、カーネルがTCP接続などのソケット情報を管理するための構造体です
// bpf_probe_read_kernelヘルパー関数を使い、カーネルメモリからIPアドレスを読み出します
bpf_probe_read_kernel(&key.saddr, sizeof(key.saddr), &sk->__sk_common.skc_v6_rcv_saddr);
bpf_probe_read_kernel(&key.daddr, sizeof(key.daddr), &sk->__sk_common.skc_v6_daddr);
// IPアドレスペアをキーとして、送信統計マップ(tx_stats)を検索します
valp = bpf_map_lookup_elem(&tx_stats, &key);
if (!valp) {
// マップにこのIPペアのエントリがまだ存在しない場合、新しいエントリをゼロ値で初期化してマップに追加します
bpf_map_update_elem(&tx_stats, &key, &zero, BPF_NOEXIST);
valp = bpf_map_lookup_elem(&tx_stats, &key);
if (!valp) {
return 0; // 再度検索しても見つからない場合は、エラーとして処理を中断します
}
}
// 取得した送信バイト数(size)をマップの値に加算します
__sync_fetch_and_add(&valp->bytes, size);
// 常に0を返して、カーネルの元の処理を妨げないようにします
return 0;
}
tcp_cleanup_rbuf をフックする関数の定義
tcp_cleanup_rbuf は、TCPの受信バッファからデータが読み出され、受信が完了した後に呼び出される関数です。tcp_cleanup_rbuf 関数をフックして、受信したデータ量(RX)を計測します。
SEC("kprobe/tcp_cleanup_rbuf")
int BPF_KPROBE(trace_tcp_cleanup_rbuf, struct sock *sk, int copied) {
// コピーされたバイト数が0以下の場合は、統計に影響しないため無視します
if (copied <= 0) {
return 0;
}
struct ip_key_t key = {};
struct val_t *valp, zero = {};
// 送信時とは逆に、受信時は daddr が自分、saddr が相手のアドレスになる
bpf_probe_read_kernel(&key.saddr, sizeof(key.saddr), &sk->__sk_common.skc_v6_daddr);
bpf_probe_read_kernel(&key.daddr, sizeof(key.daddr), &sk->__sk_common.skc_v6_rcv_saddr);
// IPアドレスペアをキーとして、受信統計マップ(rx_stats)を検索・更新します
valp = bpf_map_lookup_elem(&rx_stats, &key);
if (!valp) {
bpf_map_update_elem(&rx_stats, &key, &zero, BPF_NOEXIST);
valp = bpf_map_lookup_elem(&rx_stats, &key);
if (!valp) {
return 0;
}
}
// 受信バイト数(copied)を加算します
__sync_fetch_and_add(&valp->bytes, copied);
return 0;
}
全体のコードは以下となります。
agent.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include "bpf_common.h"
// 送信(TX: Transmit)バイト数をIPアドレスペアごとに格納するためのeBPFハッシュマップ
struct {
__uint(type, BPF_MAP_TYPE_HASH); // マップの種類。ハッシュマップは高速な検索が可能です
__uint(max_entries, 10240); // マップが保持できる最大のキー・バリューペアの数
__type(key, struct ip_key_t); // キーの型を指定
__type(value, struct val_t); // バリューの型を指定
} tx_stats SEC(".maps");
// 受信(RX: Receive)バイト数をIPアドレスペアごとに格納するためのeBPFハッシュマップ
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, struct ip_key_t);
__type(value, struct val_t);
} rx_stats SEC(".maps");
SEC("kprobe/tcp_sendmsg")
int BPF_KPROBE(trace_tcp_sendmsg, struct sock *sk, struct msghdr *msg, size_t size) {
// 送信サイズが0の場合は、統計に影響しないため、ここで処理を終了します
if (size == 0) {
return 0;
}
struct ip_key_t key = {};
struct val_t *valp, zero = {};
// sk (struct sock) は、カーネルがTCP接続などのソケット情報を管理するための構造体です
// bpf_probe_read_kernelヘルパー関数を使い、カーネルメモリからIPアドレスを読み出します
bpf_probe_read_kernel(&key.saddr, sizeof(key.saddr), &sk->__sk_common.skc_v6_rcv_saddr);
bpf_probe_read_kernel(&key.daddr, sizeof(key.daddr), &sk->__sk_common.skc_v6_daddr);
// IPアドレスペアをキーとして、送信統計マップ(tx_stats)を検索します
valp = bpf_map_lookup_elem(&tx_stats, &key);
if (!valp) {
// マップにこのIPペアのエントリがまだ存在しない場合、新しいエントリをゼロ値で初期化してマップに追加します
bpf_map_update_elem(&tx_stats, &key, &zero, BPF_NOEXIST);
valp = bpf_map_lookup_elem(&tx_stats, &key);
if (!valp) {
return 0; // 再度検索しても見つからない場合は、エラーとして処理を中断します
}
}
// 取得した送信バイト数(size)をマップの値に加算します
__sync_fetch_and_add(&valp->bytes, size);
// 常に0を返して、カーネルの元の処理を妨げないようにします
return 0;
}
SEC("kprobe/tcp_cleanup_rbuf")
int BPF_KPROBE(trace_tcp_cleanup_rbuf, struct sock *sk, int copied) {
// コピーされたバイト数が0以下の場合は、統計に影響しないため無視します
if (copied <= 0) {
return 0;
}
struct ip_key_t key = {};
struct val_t *valp, zero = {};
// 送信時とは逆に、受信時は daddr が自分、saddr が相手のアドレスになります
bpf_probe_read_kernel(&key.saddr, sizeof(key.saddr), &sk->__sk_common.skc_v6_daddr);
bpf_probe_read_kernel(&key.daddr, sizeof(key.daddr), &sk->__sk_common.skc_v6_rcv_saddr);
// IPアドレスペアをキーとして、受信統計マップ(rx_stats)を検索・更新します
valp = bpf_map_lookup_elem(&rx_stats, &key);
if (!valp) {
bpf_map_update_elem(&rx_stats, &key, &zero, BPF_NOEXIST);
valp = bpf_map_lookup_elem(&rx_stats, &key);
if (!valp) {
return 0;
}
}
// 受信バイト数(copied)を加算します
__sync_fetch_and_add(&valp->bytes, copied);
return 0;
}
// Linuxカーネル全体は GPL (GNU General Public License) というライセンスで公開されているため、
// eBPFプログラムがカーネルのヘルパー関数などを利用する場合に、GPLライセンスであることが要求されます
char LICENSE[] SEC("license") = "GPL";
agent_checker.c
eBPFマップの定期的に読み込み、内容を標準出力するユーザー空間プログラムを実装します。詳細はコメントを参考にして下さい。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>
#include <arpa/inet.h>
#include <bpf/libbpf.h>
#include "bpf_common.h"
static volatile bool exiting = false;
static struct bpf_object *bpf_obj = NULL;
// Ctrl+Cが押されたときに呼ばれるシグナルハンドラ
static void sig_handler(int sig) {
exiting = true;
}
// IPアドレスのバイト配列を人間が読める文字列に変換するヘルパー関数
static const char *addr_to_str(const __u8 addr[16]) {
static char str[INET6_ADDRSTRLEN];
// IPv4射影アドレスのプレフィックス (最初の12バイト)
static const __u8 v4_mapped_prefix[12] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff};
// 最初の12バイトがプレフィックスと一致するかどうかを比較
if (memcmp(addr, v4_mapped_prefix, sizeof(v4_mapped_prefix)) == 0) {
// 一致すれば、IPv4アドレスとしてフォーマット
inet_ntop(AF_INET, addr + 12, str, sizeof(str));
} else {
// 一致しなければ、IPv6アドレスとしてフォーマット
inet_ntop(AF_INET6, addr, str, sizeof(str));
}
return str;
}
// eBPFマップの内容を読み出して表示する関数
static void print_stats(struct bpf_map *tx_map, struct bpf_map *rx_map) {
struct ip_key_t key = {}, prev_key = {};
struct val_t val;
char saddr_str[INET6_ADDRSTRLEN];
char daddr_str[INET6_ADDRSTRLEN];
printf("\n--- TX Stats ---\n");
memset(&prev_key, 0, sizeof(prev_key));
// bpf_map__get_next_keyを使ってマップの全エントリをイテレート
while (bpf_map__get_next_key(tx_map, &prev_key, &key, sizeof(key)) == 0) {
if (bpf_map__lookup_elem(tx_map, &key, sizeof(key), &val, sizeof(val), 0) == 0) {
strcpy(saddr_str, addr_to_str(key.saddr));
strcpy(daddr_str, addr_to_str(key.daddr));
printf(" %s -> %s : %llu bytes\n",
saddr_str, daddr_str, val.bytes);
}
memcpy(&prev_key, &key, sizeof(key));
}
printf("--- RX Stats ---\n");
memset(&prev_key, 0, sizeof(prev_key)); // prev_keyをリセット
while (bpf_map__get_next_key(rx_map, &prev_key, &key, sizeof(key)) == 0) {
if (bpf_map__lookup_elem(rx_map, &key, sizeof(key), &val, sizeof(val), 0) == 0) {
strcpy(saddr_str, addr_to_str(key.saddr));
strcpy(daddr_str, addr_to_str(key.daddr));
printf(" %s -> %s : %llu bytes\n",
saddr_str, daddr_str, val.bytes);
}
memcpy(&prev_key, &key, sizeof(key));
}
}
// エントリーポイントとなるmain関数
int main(int argc, char **argv) {
struct bpf_link *links[2] = {NULL, NULL};
struct bpf_map *tx_map = NULL;
struct bpf_map *rx_map = NULL;
signal(SIGINT, sig_handler);
signal(SIGTERM, sig_handler);
// 1. eBPFオブジェクトファイルをロード
bpf_obj = bpf_object__open_file("agent.o", NULL);
if (libbpf_get_error(bpf_obj)) {
fprintf(stderr, "ERROR: opening BPF object file failed\n");
return 1;
}
// 2. eBPFプログラムをカーネルにロード
if (bpf_object__load(bpf_obj)) {
fprintf(stderr, "ERROR: loading BPF object file failed\n");
goto cleanup;
}
// 3. kprobeをアタッチ
links[0] = bpf_program__attach(bpf_object__find_program_by_name(bpf_obj, "trace_tcp_sendmsg"));
if (libbpf_get_error(links[0])) {
fprintf(stderr, "ERROR: attaching trace_tcp_sendmsg kprobe failed\n");
goto cleanup;
}
links[1] = bpf_program__attach(bpf_object__find_program_by_name(bpf_obj, "trace_tcp_cleanup_rbuf"));
if (libbpf_get_error(links[1])) {
fprintf(stderr, "ERROR: attaching trace_tcp_cleanup_rbuf kprobe failed\n");
goto cleanup;
}
printf("Successfully loaded and attached BPF programs. Watching TCP traffic...\n");
printf("Press Ctrl+C to exit.\n");
// 4. eBPFマップへのポインタを取得
tx_map = bpf_object__find_map_by_name(bpf_obj, "tx_stats");
rx_map = bpf_object__find_map_by_name(bpf_obj, "rx_stats");
if (!tx_map || !rx_map) {
fprintf(stderr, "ERROR: finding maps in BPF object failed\n");
goto cleanup;
}
// 5. 5秒ごとにマップの内容を表示するループ
while (!exiting) {
sleep(5);
print_stats(tx_map, rx_map);
}
cleanup:
// 6. クリーンアップ
printf("Detaching and cleaning up...\n");
if (links[0]) bpf_link__destroy(links[0]);
if (links[1]) bpf_link__destroy(links[1]);
bpf_object__close(bpf_obj);
printf("Done.\n");
return 0;
}
Makefile
eBPFプログラムとそのユーザー空間プログラムのコンパイルを自動化します。
CLANG = clang
GCC = gcc
BPF_CFLAGS = -g -O2 -target bpf -c -D__TARGET_ARCH_x86
CHECKER_CFLAGS = -g -Wall
CHECKER_LDFLAGS = -lbpf
# ソースファイルと出力ファイル
BPF_SRC = agent.c
BPF_OBJ = agent.o
CHECKER_SRC = agent_checker.c
CHECKER_BIN = agent_checker
VMLINUX_H = vmlinux.h
COMMON_HEADER = bpf_common.h
.PHONY: all clean checker
all: $(VMLINUX_H) $(BPF_OBJ) $(CHECKER_BIN)
# vmlinux.hの生成
# vmlinux.hをincludeすることで、eBPFプログラムはカーネルの型定義を参照できるようになります
$(VMLINUX_H):
bpftool btf dump file /sys/kernel/btf/vmlinux format c > $(VMLINUX_H)
# eBPFプログラム(agent.c)をオブジェクトファイル(agent.o)にコンパイル
$(BPF_OBJ): $(BPF_SRC) $(VMLINUX_H) $(COMMON_HEADER)
$(CLANG) $(BPF_CFLAGS) -I. $(BPF_SRC) -o $(BPF_OBJ)
# ユーザー空間のチェッカープログラムをコンパイル
$(CHECKER_BIN): $(CHECKER_SRC) $(COMMON_HEADER)
$(GCC) $(CHECKER_CFLAGS) $(CHECKER_SRC) -o $(CHECKER_BIN) $(CHECKER_LDFLAGS)
checker: $(CHECKER_BIN)
clean:
rm -f $(BPF_OBJ) $(CHECKER_BIN) $(VMLINUX_H)
コンパイル
Makefileを実行します。
$ sudo make clean all
rm -f agent.o ../agent_checker vmlinux.h
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
clang -g -O2 -target bpf -c -D__TARGET_ARCH_x86 -I. agent.c -o agent.o
gcc -g -Wall agent_checker.c -o ../agent_checker -lbpf
agent_checker
が作成されました。
$ ls
Makefile agent.c agent.o agent_checker agent_checker.c bpf_common.h vmlinux.h
eBPFプログラムの実行
agent_checker
を実行します。
$ sudo ./agent_checker
Successfully loaded and attached BPF programs. Watching TCP traffic...
Press Ctrl+C to exit.
別ターミナルからgoogle.com宛にcurl
を実行します。
$ curl -LI google.com
google.com宛の通信が表示されました。142.250.198.14
のIPを含む通信が該当します。
--- TX Stats ---
10.146.0.35 -> 59.132.57.185 : 3696 bytes
--- RX Stats ---
59.132.57.185 -> 10.146.0.35 : 3564 bytes
...
--- TX Stats ---
10.146.0.35 -> 169.254.169.254 : 454 bytes
10.146.0.35 -> 142.250.198.14 : 74 bytes
10.146.0.35 -> 59.132.57.185 : 36339 bytes
10.146.0.35 -> 216.58.220.132 : 78 bytes
--- RX Stats ---
142.250.198.14 -> 10.146.0.35 : 554 bytes
59.132.57.185 -> 10.146.0.35 : 30446 bytes
169.254.169.254 -> 10.146.0.35 : 12896 bytes
216.58.220.132 -> 10.146.0.35 : 1050 bytes
終わりに
カーネルのTCP送受信をフックして、通信量を標準出力するeBPFプログラムを実行できました。eBPFプログラム特有のヘルパー関数やトレースポイント、フックするカーネル関数の実装など見るべきポイントが多く、慣れが必要だなと感じました。
eBPFプログラムはLinuxカーネル内部で発生するほぼ全てのイベントをフックすることができるため、選択肢として持っておくと非常に便利ですね。
次回は、Kubernetes環境でeBPFプログラムを実行してみたいと思います。
参考情報
以下、参考情報を記載しています。
eBPFプログラムから参照している関数について
今回利用した関数やデータ型の詳細は、以下のURLから確認して下さい。
- struct sock
- BPF_KPROBE
- bpf_probe_read_kernel
- bpf_map_lookup_elem
- bpf_map_update_elem
- bpf_object__find_map_by_name
- bpf_map__get_next_key
eBPFプログラムの設計
eBPFプログラムは、なるべくシンプルで高速な処理となるように設計する必要があります。eBPFプログラムの処理が遅いとカーネル全体の動作が遅延し、システム全体のパフォーマンスに深刻な影響を与えてしまいます。
また、Linuxカーネルを保護するためにeBPFプログラムには様々な制約が設けられています。
__sync_fetch_and_add について
__sync_fetch_and_add
とは、GCCやClangコンパイラが提供するアトミック操作のための特殊な組み込み関数(Built-in Function)です。
eBPFプログラムは、複数のCPUコアで同時に実行される可能性があります。__sync_fetch_and_add
関数は、処理の競合(レースコンディション)が発生しないように値の更新をアトミックに行います。これにより、複数CPUからの同時アクセスが発生しても安全にカウンターを増やすことができます。