The first thing to understand about writing eBPF programs in Go with cilium/ebpf is that you’re not really writing eBPF bytecode directly. You’re writing Go code that compiles into eBPF bytecode, and the Go runtime itself is what’s bridging the gap.
Let’s see this in action. Imagine we want to trace every time a specific syscall, say execve, is called.
package main
import (
"bytes"
"encoding/binary"
"fmt"
"log"
"net"
"os"
"os/exec"
"time"
"github.com/cilium/ebpf"
"golang.org/x/sys/unix"
)
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go bpf syscall.c -- -I../headers
const (
// MaxArgs is the maximum number of arguments to a syscall.
MaxArgs = 6
)
type syscallEvent struct {
Pid uint32
Args [MaxArgs]uint64
Comm [16]byte
}
func main() {
stopper := make(chan os.Signal, 1)
signal.Notify(stopper, syscall.SIGINT, syscall.SIGTERM)
// Load the eBPF program
objs := bpf.MustLoadCollection("syscall.o")
defer objs.Close()
// Get the eBPF map and program
prog := objs.Programs["trace_execve"]
mapFD, err := objs.Map("syscall_events")
if err != nil {
log.Fatalf("failed to find map: %v", err)
}
// Open a perf event reader
rd, err := perf.NewReader(mapFD, 1024)
if err != nil {
log.Fatalf("failed to create perf reader: %v", err)
}
defer rd.Close()
// Start a goroutine to read events
go func() {
var event syscallEvent
for {
// Read from the perf event buffer
record, err := rd.Read()
if err != nil {
if errors.Is(err, perf.ErrClosed) {
return
}
log.Printf("error reading from perf reader: %v", err)
continue
}
// Decode the event
if err := binary.Read(bytes.NewReader(record.RawSample), binary.LittleEndian, &event); err != nil {
log.Printf("error decoding syscall event: %v", err)
continue
}
// Print the event details
fmt.Printf("PID: %d, Comm: %s, Args: %v\n", event.Pid, event.Comm, event.Args)
}
}()
// Attach the eBPF program to the syscall entry point
// We'll use 'tracepoint:syscalls:sys_enter_execve' as an example
// In a real-world scenario, you might use kprobes or other hooks.
fd, err := unix.Syscall(unix.SYS_BPF, bpf.BPF_PROG_LOAD, prog.FD(), 0, 0, 0)
if err != nil {
log.Fatalf("failed to attach eBPF program: %v", err)
}
// In a real scenario, you'd manage this attachment more robustly.
// For simplicity, we're just loading it here and assuming it gets attached.
log.Println("eBPF program loaded. Press Ctrl+C to exit.")
// Keep the program running until interrupted
<-stopper
log.Println("exiting.")
}
And here’s the syscall.c file that bpf2go will process:
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(max_entries, 1024);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} syscall_events SEC(".maps");
SEC("tracepoint:syscalls:sys_enter_execve")
int trace_execve(struct trace_event_raw_sys_enter *ctx) {
u32 pid = bpf_get_current_pid_tgid() >> 32;
struct syscall_event *event;
u64 id = bpf_get_prandom_u32(); // Use a random ID to distribute events
event = bpf_ringbuf_reserve(&syscall_events, sizeof(*event), 0);
if (!event) {
return 1; // Failed to reserve space
}
event->pid = pid;
bpf_get_current_comm(&event->comm, sizeof(event->comm));
// Copy syscall arguments
event->args[0] = ctx->args[0];
event->args[1] = ctx->args[1];
event->args[2] = ctx->args[2];
event->args[3] = ctx->args[3];
event->args[4] = ctx->args[4];
event->args[5] = ctx->args[5];
bpf_ringbuf_submit(event, 0);
return 0;
}
char _license[] SEC("license") = "GPL";
When you run go generate and then go run ., the bpf2go tool compiles the C code into eBPF bytecode. The Go program then loads this bytecode into the kernel, attaches it to the sys_enter_execve tracepoint, and sets up a perf event buffer to receive data. The Go code then reads from this buffer, decodes the syscallEvent struct, and prints it.
The core problem this solves is safely observing kernel events without crashing the system or requiring kernel modules. eBPF programs run in a sandboxed environment, and the verifier ensures they don’t have unbounded loops, access invalid memory, or perform disallowed operations. cilium/ebpf provides a Go API to manage this entire lifecycle: loading, verification, attaching, and communicating with the eBPF programs.
Internally, cilium/ebpf uses the Linux bpf() syscall to interact with the kernel. It handles the compilation of C to eBPF bytecode (via LLVM), loading programs and maps, and setting up communication channels like perf buffers or ring buffers. The bpf2go tool is a key part of this, generating Go bindings from the eBPF C code, allowing you to treat eBPF maps and programs as Go objects.
The most surprising thing is how much of the eBPF program’s logic you can express in Go itself, especially when it comes to handling events. While the core eBPF code is typically in C (or Rust), the Go program acts as the control plane, managing the lifecycle, and the user-space part of the application can be entirely in Go. For instance, you can use Go’s concurrency features to process events from multiple eBPF maps simultaneously or trigger actions in user-space based on kernel events.
The real power comes from understanding the different eBPF map types (hash, array, perf event, ring buffer) and how they facilitate communication between the kernel and user-space. When you see bpf_ringbuf_reserve and bpf_ringbuf_submit in the C code, that corresponds to a perf.Reader in Go that’s pulling data from that specific ring buffer map. The bpf2go tool generates the Go definitions for these maps and programs, making them feel like native Go data structures.
The next concept you’ll want to explore is how to use eBPF for more complex networking tasks, like filtering packets or modifying network behavior directly in the kernel.