Goとcilium/ebpfで構築するハイパフォーマンスなeBPFネットワークメトリクス

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

6か月前、私たちのPrometheus node-exporter環境は限界に達しました。2,500ノード規模のKubernetesクラスタ全体で、ピーク時120万req/secのトラフィック下において、/proc/net/tcpのパースやソケットバッファのサンプリングを行うユーザー空間のオブザーバビリティエージェントが、CPU全体の約15%を消費していたのです。そこで私たちはこれらのポーリング処理を廃止し、cilium/ebpfを活用したGo管理のカスタムeBPFプローブへと移行しました。その結果、オブザーバビリティ関連のCPU使用率は即座に0.8%未満まで激減しました。

カーネルレベルのトレーシングを活用すれば、カーネル本体の変更や不安定なアウトオブツリーのカーネルモジュールに頼ることなく、ワイヤスピードでソケットメトリクスを収集できます。以下に、GoでeBPFネットワークテレメトリをコンパイル、ロード、ストリーミングするための本番向けブループリントを示します。

クイックスタート(5分)

本番コンテナ内にLLVMやClangなどの重量級ツールチェーンを組み込む必要はもうありません。Ciliumのbpf2goユーティリティを使用すれば、go generateの実行時にC言語のソースコードをeBPFバイトコードへと事前コンパイルし、クリーンで型安全なGoバインディングを出力できます。

1. 前提ツールのインストール

標準的なリングバッファをサポートするにはカーネル5.15以上が必要です。また、以下の主要ビルドツールもインストールします。

sudo apt-get update && sudo apt-get install -y clang llvm libbpf-dev linux-headers-$(uname -r)
go install github.com/cilium/ebpf/cmd/bpf2go@latest

2. eBPFカーネルCコードの作成

これをkprobe_tcp.cとして保存します。ソケット接続前にstruct sockaddr引数から宛先IPとポートを受け取るtcp_v4_connectにアタッチします。

//go:build ignore

#include <linux/bpf.h>
#include <linux/in.h>
#include <linux/ptrace.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_endian.h>

struct event {
    __u32 saddr;
    __u32 daddr;
    __u16 dport;
};

struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 16 * 1024 * 1024); // 16 MB リングバッファ
} events SEC(".maps");

SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(tcp_v4_connect, struct sock *sk, struct sockaddr *uaddr) {
    struct sockaddr_in *usin = (struct sockaddr_in *)uaddr;
    struct event *e;

    e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
    if (!e) {
        return 0; // バッファ飽和のため、カーネルのスループットを保護するためにドロップ
    }

    BPF_CORE_READ_INTO(&e->daddr, usin, sin_addr.s_addr);
    BPF_CORE_READ_INTO(&e->dport, usin, sin_port);
    BPF_CORE_READ_INTO(&e->saddr, sk, __sk_common.skc_rcv_saddr);

    bpf_ringbuf_submit(e, 0);
    return 0;
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

3. バインディングの生成とイベントのストリーミング

main.goを作成します。//go:generateの行により、Cコードがリトルエンディアンアーキテクチャ向けの埋め込みオブジェクトファイルとしてコンパイルされます。

package main

//go:generate bpf2go -target bpfel kprobeTCP kprobe_tcp.c

import (
	"bytes"
	"encoding/binary"
	"errors"
	"fmt"
	"log"
	"net"
	"os"
	"os/signal"
	"syscall"

	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/ringbuf"
	"github.com/cilium/ebpf/rlimit"
)

type tcpEvent struct {
	Saddr uint32
	Daddr uint32
	Dport uint16
}

func main() {
	// カーネル 5.11 未満ではロックメモリ上限の引き上げが必要
	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatalf("memlockの解除に失敗しました: %v", err)
	}

	objs := kprobeTCPObjects{}
	if err := loadKprobeTCPObjects(&objs, nil); err != nil {
		log.Fatalf("eBPFオブジェクトのロードに失敗しました: %v", err)
	}
	defer objs.Close()

	kp, err := link.Kprobe("tcp_v4_connect", objs.TcpV4Connect, nil)
	if err != nil {
		log.Fatalf("kprobeのアタッチに失敗しました: %v", err)
	}
	defer kp.Close()

	rd, err := ringbuf.NewReader(objs.Events)
	if err != nil {
		log.Fatalf("ringbufのオープンに失敗しました: %v", err)
	}
	defer rd.Close()

	log.Println("プローブがアタッチされました。送信TCP接続をストリーミングしています...")

	stop := make(chan os.Signal, 1)
	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

	go func() {
		var event tcpEvent
		for {
			record, err := rd.Read()
			if err != nil {
				if errors.Is(err, ringbuf.ErrClosed) {
					return
				}
				log.Printf("Ringbuf読み込みエラー: %v", err)
				continue
			}

			if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
				continue
			}

			dstIP := make(net.IP, 4)
			binary.LittleEndian.PutUint32(dstIP, event.Daddr)
			// ネットワークバイトオーダーからポートを変換
			port := (event.Dport>>8) | (event.Dport<<8)
			fmt.Printf("接続 -> %s:%d\n", dstIP, port)
		}
	}()

	<-stop
	log.Println("コレクターをシャットダウンしています...")
}

go generate && sudo go run .を実行します。別のターミナルを開いてcurl https://example.comなどのリクエストを送信すると、イベントが即座に表示されるのを確認できます。

アーキテクチャと内部構造

なぜBCCから移行するのでしょうか?その違いは、ランタイムのフットプリントとパイプラインの決定性(再現性)にあります。

Pure GoとBCCの比較

BCCは、組み込みのLLVMコンパイラを使用して各ターゲットマシン上でオンザフライにプローブのソースコードをコンパイルします。高密度なクラスタ環境において、これは運用上の深刻な問題を引き起こします。

  • ワーカーノードに大容量のカーネル開発用ヘッダーとLLVMパッケージを導入する必要があり、ベースコンテナイメージが380MB〜500MBも肥大化します。
  • Podの再起動やスケジューリングのたびにJITコンパイルが走り、数秒間にわたって1つのCPUコアの使用率が100%に跳ね上がります。
  • ベースイメージ間のわずかなglibcやClangライブラリの差異により、コンパイルエラーが発生します。

静的コンパイルはこの問題を完全に解消します。bpf2goは通常のCIステップでELFバイトコードをビルドします。起動時、GoランタイムはELFバイナリを読み取り、BTF(BPF Type Format)テーブルを解析して、bpf()システムコール経由で直接プログラムをロードします。CGOは不要で、共有システムライブラリも不要。起動時のコンパイル遅延も一切ありません。

カーネルからユーザー空間へのリングバッファ

プローブはカーネル内の隔離された仮想マシン内で実行されます。プロセスのヒープメモリに直接書き込んだり、任意のシステムコールを発行したりすることはできません。代わりに、BPF Mapを介して境界を越えてデータをやり取りします。

各接続試行における通信パイプラインは以下のとおりです。

  1. プローブのトリガー: カーネルがtcp_v4_connectを実行し、約15〜30ナノ秒でロード済みのバイトコードへと処理を分岐させます。
  2. ゼロコピー予約: bpf_ringbuf_reserveが共有リングバッファ内のメモリを直接確保します。バッファが満杯の場合は、ネットワークパケットの処理を遅延させないようレコードをドロップします。
  3. 統合メモリアーキテクチャ: 従来のPERF_EVENT_ARRAY構造では、CPUごとに個別バッファを割り当てていました。そのため、Core 0がアイドル状態でCore 15がネットワーク割り込みを処理している場合、Core 0のバッファが空のままでもCore 15側でパケットドロップが発生していました。モダンなBPF_MAP_TYPE_RINGBUFはすべてのコアで単一のマルチプロデューサバッファを共有するため、デーモンあたりのRAM消費量を64MBから16MBへと削減します。
  4. Epollウェイクアップ: Goのringbufリーダーは、カーネルが新しいデータを通知するまでepollイベント記述子上でスリープするため、アイドル時のユーザー空間CPU使用率は0%に保たれます。

高度な活用法: カーネル内レイテンシ集計

すべてのパケットイベントをユーザー空間にストリーミングすると、トラフィックの急増時にGoのチャネルが簡単にパンクしてしまいます。ノードが毎秒80,000リクエストを処理するような環境では、まずカーネルマップ内でレイテンシを直接集約します。

ハッシュマップによるハンドシェイクレイテンシの追跡

接続にかかる時間を計測するには、kprobeとkretprobeを組み合わせます。一意のスレッドID(bpf_get_current_pid_tgid())をキーとして開始タイムスタンプを保存し、関数のリターン時に経過時間を計算します。

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 32768);
    __type(key, __u64);
    __type(value, __u64);
} start_times SEC(".maps");

SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(trace_connect_entry) {
    __u64 pid_tgid = bpf_get_current_pid_tgid();
    __u64 ts = bpf_ktime_get_ns();
    bpf_map_update_elem(&start_times, &pid_tgid, &ts, BPF_ANY);
    return 0;
}

SEC("kretprobe/tcp_v4_connect")
int BPF_KRETPROBE(trace_connect_exit, int ret) {
    __u64 pid_tgid = bpf_get_current_pid_tgid();
    __u64 *start_ts = bpf_map_lookup_elem(&start_times, &pid_tgid);

    if (start_ts) {
        if (ret == 0) {
            __u64 latency_ns = bpf_ktime_get_ns() - *start_ts;
            // カーネル内の BPF_MAP_TYPE_HISTOGRAM やカウンターにレイテンシを渡す
            (void)latency_ns;
        }
        bpf_map_delete_elem(&start_times, &pid_tgid);
    }
    return 0;
}

エントリからリターンまでのプローブを紐付ける際は、kretprobe内で入力パラメータに依存するのではなく、必ずbpf_get_current_pid_tgid()をキーにしてください。関数の実行中にCPUレジスタが再利用されるため、リターンプローブではエントリ時の引数を確実に参照できません。

CO-REによるポータビリティの確保

カーネルの構造体レイアウトはLinuxのリリースごとに変化します。カーネル5.4におけるstruct sockのフィールドオフセットは、6.2のものとは一致しません。再配置を行わない場合、プローブは破損したメモリを抽出するか、Verifierによって完全に拒否されます。

CO-RE(Compile Once – Run Everywhere)はこの問題を根本から解決します。任意のホストから包括的なvmlinux.hヘッダーを生成します。

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

BPF_CORE_READ()のようなマクロは、フィールドの再配置情報をBTFセクションに記録します。cilium/ebpfがプログラムをロードする際、実行中ホストの/sys/kernel/btf/vmlinuxをチェックし、バイトオフセットを実行時に書き換えます。これにより、単一の静的GoバイナリがリビルドなしでUbuntu 20.04、Debian 12、Amazon Linux 2023上で動作します。

本番環境で避けるべき落とし穴

何千台ものマシンにeBPFを展開すると、一般的なチュートリアルでは見過ごされがちな低レベルの制約に直面します。

1. 512バイトのスタック制限に注意

カーネルのVerifierがプローブごとに割り当てるスタックは、厳格に512バイトまでです。イベント構造体と並べてchar path[256]などを宣言すると、look like stack overflowエラーで即座にビルドに失敗します。256バイトを超えるペイロードを組み立てる場合は、CPUごとのスクラッチ配列マップ(BPF_MAP_TYPE_PERCPU_ARRAY)内に割り当てるか、リングバッファ内に直接領域を確保してください。

2. 構造体のアライメントとGoのデシリアライズ

Cコンパイラは、4バイトや8バイトのワードアライメントを満たすために隠れたパディングバイトを挿入します。C構造体のレイアウトがGo側のバイナリデコード定義と一致していない場合、メトリクスが誤ったフィールドにずれてしまいます。構造体のメンバーはサイズの大きい順に厳格に並べ、paholeを使用してメモリレイアウトを確認してください。

// 最適化されたレイアウト: 24バイト、内部パディングのギャップなし
struct event {
    __u64 timestamp_ns; // 8バイト (オフセット 0)
    __u32 saddr;        // 4バイト (オフセット 8)
    __u32 daddr;        // 4バイト (オフセット 12)
    __u16 dport;        // 2バイト (オフセット 16)
    __u8  protocol;     // 1バイト (オフセット 18)
    __u8  pad;          // 4バイトアライメントを揃えるための1バイト (オフセット 19)
    __u32 reserved;     // 構造体全体を24バイトに揃えるための4バイト (オフセット 20)
};

3. Linuxケーパビリティによるroot権限の回避

Goの監視デーモンを完全なroot権限で実行してはいけません。Linux 5.8以降では、setcapを使用してバイナリに必要なケーパビリティのみを細かく付与します。

sudo setcap cap_bpf,cap_perfmon,cap_net_admin+ep ./network-collector

また、Linux 5.11でマップのアカウンティングがメモリcgroupへ移行した点にも留意してください。5.4や5.8などの古いカーネルでは、マップをロードする前にrlimit.RemoveMemlock()を呼び出す必要があります。デフォルトの64KBロックメモリ上限のままでは、実用的なマップを初期化する際に即座に失敗します。

cilium/ebpfとGoの軽量な並行処理を組み合わせることで、極めてわずかなCPUコストでカーネルレベルの精度が得られます。ホストのコンピュートリソースを本来のユーザーワークロードに集中させつつ、詳細なネットワークの可視化を実現しましょう。

Share: