Негізгі мазмұнға өту

↗ Building Linux eBPF/XDP Applications on macOS with Colima, Docker, Clang, and Go

· 17 мин. оқу
Yevgeniy Goncharov
Maintainer of OpenBLD.net

Building Linux eBPF/XDP Applications on macOS

eBPF and XDP are increasingly used for network traffic filtering, observability, abuse protection, and performing extremely cheap packet inspections before traffic reaches the regular Linux networking stack.

There is one practical problem, though: if your primary workstation is a Mac — especially an Apple Silicon Mac — you cannot build and test Linux eBPF programs exactly the same way you build a normal Go application.

For a reproducible build environment, we need:

  • Go
  • Clang and LLVM
  • Linux headers
  • libbpf headers
  • bpf2go
  • Docker
  • a Linux runtime provided by Colima

In this guide, we will build a minimal XDP application using the following pipeline:

macOS

Colima

Docker Linux/arm64

clang -target bpf

eBPF bytecode

bpf2go

Go application

Linux/amd64 binary

Linux kernel

XDP

The XDP program itself will intentionally do nothing dangerous:

return XDP_PASS;

It will see incoming packets and immediately allow them to continue through the normal Linux networking stack.

That makes it a good first test of the complete eBPF/XDP development lifecycle without introducing packet filtering into the experiment.

How XDP Fits into the Linux Networking Path

XDP stands for eXpress Data Path.

It allows eBPF programs to execute very early in the packet receive path.

A simplified packet flow looks like this:

NIC

XDP / eBPF

Linux networking stack

nftables / routing

socket

application

An XDP program can return several actions, including:

  • XDP_PASS
  • XDP_DROP
  • XDP_TX
  • XDP_REDIRECT

For this guide, we will use only:

XDP_PASS

So our program will not alter the machine's network behavior.


Preparing macOS for Linux eBPF Builds

Install Homebrew

If Homebrew is already installed, skip this section.

Install it using:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

On Apple Silicon systems, Homebrew normally installs under:

/opt/homebrew

Verify the installation:

brew --version

If macOS requires the Xcode Command Line Tools, install them with:

xcode-select --install

Install Docker CLI and Colima

Docker Desktop is not required for this workflow.

Instead, we can use:

  • Docker CLI
  • Colima as the Linux VM and Docker runtime

Install both:

brew install docker colima

Start Colima:

colima start

Verify its status:

colima status

Then check Docker:

docker version
docker info

Run a simple container:

docker run --rm hello-world

If it starts successfully, Docker is ready.


Understanding the Architectures Involved

On an Apple Silicon Mac:

uname -m

should normally return:

arm64

During this guide, several different architectures will be involved:

Host macOS arm64
Docker builder linux/arm64
Target server linux/amd64
eBPF program BPF instruction set

This is intentional.

The Linux container runs natively as ARM64 under Colima, while Go will later cross-compile the userspace program for an AMD64 Linux server.

The eBPF program itself is compiled for the BPF virtual instruction set rather than directly for either ARM64 or AMD64.


Creating the Linux Build Environment

Pull the Go Linux Image

We will use Go 1.25 on Debian Bookworm.

Pull the ARM64 image:

docker pull --platform linux/arm64 golang:1.25-bookworm

Inspect it:

docker image inspect golang:1.25-bookworm \
--format '{{.Os}}/{{.Architecture}}'

Expected output:

linux/arm64

Despite Docker running on macOS, the container itself is Linux.

Create the Project Directory

Create a new project:

mkdir ebpf-xdp-demo
cd ebpf-xdp-demo

By the end of the guide, the project will look approximately like this:

ebpf-xdp-demo/
├── Dockerfile
├── filter.c
├── gen.go
├── main.go
├── go.mod
└── go.sum

Create the eBPF Builder Dockerfile

A standard Go container does not include everything required to compile eBPF code.

Create Dockerfile:

FROM golang:1.25-bookworm

RUN apt-get update && \
apt-get install -y --no-install-recommends \
clang \
llvm \
libbpf-dev \
linux-libc-dev \
bpftool \
make \
dpkg-dev \
file && \
rm -rf /var/lib/apt/lists/*

RUN ln -s \
/usr/include/$(dpkg-architecture -qDEB_HOST_MULTIARCH)/asm \
/usr/include/asm

WORKDIR /src

Build the image:

docker build -t xdp-builder .

Fixing Linux asm Headers Inside the Container

One of the more confusing errors when compiling eBPF programs on Debian-based systems is:

fatal error: 'asm/types.h' file not found

An eBPF source file commonly includes:

#include <linux/bpf.h>

Linux headers may then reference:

#include <asm/types.h>

Debian uses a multiarch directory layout.

On ARM64, the headers may live under:

/usr/include/aarch64-linux-gnu/asm/

On AMD64:

/usr/include/x86_64-linux-gnu/asm/

Some Clang/eBPF compilation paths expect them under:

/usr/include/asm/

That is why our Dockerfile creates this symlink:

RUN ln -s \
/usr/include/$(dpkg-architecture -qDEB_HOST_MULTIARCH)/asm \
/usr/include/asm

Using dpkg-architecture keeps the Dockerfile architecture-independent.


Verify the eBPF Build Toolchain

Before writing any code, verify that the container contains everything we need:

docker run --rm xdp-builder bash -c '
echo "=== Architecture ==="
uname -m

echo
echo "=== Go ==="
go version

echo
echo "=== Clang ==="
clang --version | head -1

echo
echo "=== LLVM ==="
llvm-config --version

echo
echo "=== Linux asm headers ==="
ls -l /usr/include/asm/types.h

echo
echo "=== bpftool ==="
bpftool version
'

My output looks like this:

=== Architecture ===
aarch64

=== Go ===
go version go1.25.14 linux/arm64

=== Clang ===
Debian clang version 14.0.6

=== LLVM ===
14.0.6

=== Linux asm headers ===
-rw-r--r-- 1 root root 31 Aug 3 18:40 /usr/include/asm/types.h

=== bpftool ===
bpftool v7.1.0
using libbpf v1.1
features: libbpf_strict

This confirms:

  • Linux architecture
  • Go installation
  • Clang
  • LLVM
  • Linux asm headers
  • bpftool

At this point, the build environment is ready.


Write the Minimal XDP Program

Create filter.c:

//go:build ignore

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

SEC("xdp")
int xdp_pass_all(struct xdp_md *ctx)
{
return XDP_PASS;
}

char __license[] SEC("license") = "Dual MIT/GPL";

The program contains only one XDP function:

SEC("xdp")
int xdp_pass_all(struct xdp_md *ctx)

and always returns:

XDP_PASS

Its behavior is therefore:

packet

XDP program

XDP_PASS

normal Linux networking

No packets are dropped, redirected, or modified.

The:

//go:build ignore

line prevents the regular Go build process from treating the C file as a normal package source file.


Generate Go Bindings with bpf2go

Create gen.go:

package main

//go:generate go tool bpf2go -tags linux bpf filter.c

bpf2go connects our C eBPF program to the Go application.

The process is roughly:

filter.c

clang

eBPF ELF object

bpf2go

generated Go bindings

embedded BPF bytecode

This means the final Go binary can carry the BPF program with it.


Initialize the Go Module

Initialize the module using the container:

docker run --rm \
-v "$PWD:/src" \
-w /src \
xdp-builder \
go mod init ebpf-xdp-demo

Add cilium/ebpf:

docker run --rm \
-v "$PWD:/src" \
-w /src \
xdp-builder \
go get github.com/cilium/ebpf

Add bpf2go as a Go tool dependency:

docker run --rm \
-v "$PWD:/src" \
-w /src \
xdp-builder \
go get -tool github.com/cilium/ebpf/cmd/bpf2go

Then:

docker run --rm \
-v "$PWD:/src" \
-w /src \
xdp-builder \
go mod tidy

Check the module:

cat go.mod

Go now tracks the required cilium/ebpf dependency and the bpf2go tool version.


Why eBPF Architecture Is Different from GOARCH

Our build container runs as:

linux/arm64

Later, we will build the Go executable for:

linux/amd64

But the eBPF C program is compiled for neither architecture.

Instead:

C

clang -target bpf

BPF instruction set

Conceptually:

┌── eBPF bytecode ──→ BPF VM / JIT
filter.c ── clang ─┤

main.go ─── Go ────┴── amd64 ELF ─────→ Linux CPU

Therefore:

GOARCH=amd64

controls the userspace Go executable.

It does not control the architecture of the embedded eBPF program.


Write the Go XDP Loader

Now we can write a minimal Go agent that loads the generated eBPF object and attaches it to a network interface.

Create main.go:

//go:build linux

package main

import (
"fmt"
"log"
"net"
"os"

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

func main() {
if len(os.Args) != 2 {
fmt.Fprintf(
os.Stderr,
"usage: %s <interface>\n",
os.Args[0],
)
os.Exit(1)
}

ifaceName := os.Args[1]

iface, err := net.InterfaceByName(ifaceName)
if err != nil {
log.Fatalf(
"lookup interface %q: %v",
ifaceName,
err,
)
}

if err := rlimit.RemoveMemlock(); err != nil {
log.Fatalf(
"remove memlock limit: %v",
err,
)
}

var objs bpfObjects

if err := loadBpfObjects(&objs, nil); err != nil {
log.Fatalf(
"load BPF objects: %v",
err,
)
}

defer objs.Close()

lnk, err := link.AttachXDP(link.XDPOptions{
Program: objs.XdpPassAll,
Interface: iface.Index,
})
if err != nil {
log.Fatalf(
"attach XDP to %s: %v",
ifaceName,
err,
)
}

defer lnk.Close()

log.Printf(
"XDP_PASS program attached to %s",
ifaceName,
)

log.Println("Press Enter to detach...")

_, _ = fmt.Scanln()
}

The program performs five basic operations:

  1. Resolves the network interface.
  2. Adjusts the memory lock limit required by BPF on systems where this is still necessary.
  3. Loads the generated BPF objects.
  4. Attaches the XDP program.
  5. Keeps the link alive until the process exits.

When the program terminates:

defer lnk.Close()

detaches the XDP link.


Build and Inspect the eBPF Program

Now we can perform generation, inspection, and Go cross-compilation in one container invocation.

docker run --rm \
-v "$PWD:/src" \
-w /src \
xdp-builder \
bash -c '
set -e

echo "=== 1. GENERATING eBPF BYTECODE ==="
go generate ./...

echo
echo "=== 2. VERIFYING eBPF ELF OBJECT ==="
file bpf_bpfel.o

echo
echo "=== 3. DISASSEMBLING eBPF PROGRAM ==="
llvm-objdump -d bpf_bpfel.o

echo
echo "=== 4. BUILDING LINUX/AMD64 GO BINARY ==="
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64 \
go build -o xdp-demo .

echo
echo "=== 5. VERIFYING FINAL BINARY ==="
file xdp-demo

echo
echo "Build completed successfully."
'

I would strongly keep:

set -e

My output looks like this:

=== 1. GENERATING eBPF BYTECODE ===
go: downloading github.com/cilium/ebpf v0.22.0
go: downloading golang.org/x/sys v0.43.0

=== 2. VERIFYING eBPF ELF OBJECT ===
bpf_bpfel.o: ELF 64-bit LSB relocatable, eBPF, version 1 (SYSV), not stripped

=== 3. DISASSEMBLING eBPF PROGRAM ===

bpf_bpfel.o: file format elf64-bpf

Disassembly of section xdp:

0000000000000000 <xdp_pass_all>:
0: b7 00 00 00 02 00 00 00 r0 = 2
1: 95 00 00 00 00 00 00 00 exit

=== 4. BUILDING LINUX/AMD64 GO BINARY ===

=== 5. VERIFYING FINAL BINARY ===
xdp-demo: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=d36146e9be1af1f7ebb40da72e6e5af05eb5c340, with debug_info, not stripped

Build completed successfully.

Without it, a failed intermediate command may not stop the shell in the way you expect when this build sequence evolves later.


Understanding the Smoke Test

The first interesting artifact is:

bpf_bpfel.o

Run:

file bpf_bpfel.o

The output should resemble:

ELF 64-bit LSB relocatable, eBPF, version 1 (SYSV)

This confirms that Clang generated an actual eBPF ELF object rather than a normal ARM64 or AMD64 object.

Next:

bpftool prog dump obj bpf_bpfel.o

should show a very small program.

Conceptually:

0: r0 = 2
1: exit

Why 2?

Because the XDP action constants include:

XDP_ABORTED = 0
XDP_DROP = 1
XDP_PASS = 2
XDP_TX = 3
XDP_REDIRECT = 4

Our C statement:

return XDP_PASS;

therefore becomes approximately:

r0 = 2
exit

This is a useful smoke test because we can inspect the actual BPF instructions before deploying anything to a server.


Cross-Compile the Go Agent for Linux AMD64

The final build uses:

CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64 \
go build -o xdp-demo .

Our environment therefore performs this transition:

macOS arm64

Docker linux/arm64

Go cross-compiler

Linux amd64 executable

Verify the result:

file xdp-demo

Expected output should contain something similar to:

ELF 64-bit LSB executable, x86-64

Why Disable CGO?

Setting:

CGO_ENABLED=0

makes cross-compilation straightforward.

If the userspace application required CGO, building an AMD64 Linux executable from an ARM64 build container would also require an appropriate C cross-compiler and toolchain.

For a pure Go userspace loader, this complexity is unnecessary.


Deploy the XDP Agent to Linux

Copy the binary to the target server:

scp xdp-demo root@server:/usr/local/sbin/

On Linux:

chmod +x /usr/local/sbin/xdp-demo

Verify it:

file /usr/local/sbin/xdp-demo

Verify Kernel eBPF and XDP Support

Check the running kernel:

uname -r

Then inspect BPF support:

bpftool feature probe kernel

For a shorter view:

bpftool feature probe kernel | grep -E 'BPF|XDP'

You can also inspect the kernel configuration:

grep -E \
'CONFIG_(BPF|BPF_SYSCALL|BPF_JIT|XDP)' \
/boot/config-$(uname -r)

Typical BPF-enabled kernels include at least:

CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y

The exact feature set depends on the kernel and distribution.


Attach the XDP Program

Find the target network interface:

ip -br link

For example:

eth0

Run the program:

sudo /usr/local/sbin/xdp-demo eth0

You should see:

XDP_PASS program attached to eth0
Press Enter to detach...

My output:

sudo /usr/local/sbin/xdp-demo eth0 8 ms
2026/08/26 14:23:38 XDP_PASS program attached to eth0
2026/08/26 14:23:38 Press Enter to detach...

The network should continue operating normally because the program always returns:

XDP_PASS

Verify the Program Inside the Kernel

Open another terminal or SSH session.

List loaded BPF programs:

bpftool prog list

You should see a small program with a name similar to:

2525: xdp name xdp_pass_all tag 3b185187f1855c4c gpl
loaded_at 2026-08-26T14:23:38+0500 uid 0
xlated 16B jited 22B memlock 4096B
btf_id 419

Inspect network attachments:

bpftool net

Inspect the interface:

ip -details link show dev eth0

You should see an XDP program attached to the interface.

Once you know its program ID, inspect it directly:

bpftool prog show id <ID>

At this point, we have verified the complete chain:

C source

Clang

eBPF ELF

bpf2go

Go binary

BPF verifier

Linux kernel

XDP attachment

Verify That Network Traffic Still Works

While the XDP program is attached, test basic connectivity:

ping 1.1.1.1

If the machine provides DNS:

dig example.com

You can also test whatever service normally runs through the selected interface.

Because the XDP program returns XDP_PASS, packets should continue through the networking stack unchanged.


Detach the XDP Program Safely

Our Go program waits for Enter.

When Enter is pressed, the process exits and:

defer lnk.Close()

removes the XDP attachment.

Verify:

bpftool net

and:

ip -details link show dev eth0

The program should no longer be attached.


Emergency XDP Detach

Before experimenting with XDP on a remote server, it is worth knowing how to detach a program independently of the userspace agent.

A common command is:

ip link set dev eth0 xdp off

For generic XDP:

ip link set dev eth0 xdpgeneric off

Then verify:

ip -details link show dev eth0

This matters because a logically incorrect but verifier-valid program could contain:

return XDP_DROP;

and drop every packet reaching the interface.

If you are performing experiments over SSH, console or out-of-band access is always preferable when testing packet-drop logic for the first time.


Generic, Native, and Offloaded XDP

XDP can operate in several modes.

Generic XDP

Often referred to as:

xdpgeneric

or:

SKB mode

It does not require native XDP support from the network driver.

It is useful for:

  • development
  • testing
  • virtual machines
  • environments where driver-mode XDP is unavailable

Native XDP

Often called:

xdpdrv

The program runs directly in the driver's receive path.

This normally provides significantly better packet-processing performance than generic mode.

Hardware-Offloaded XDP

Some network adapters can execute supported BPF programs directly on the NIC.

This requires compatible hardware and drivers and is outside the scope of this guide.


Why XDP Is Particularly Interesting for DNS

A conventional DNS path may look like:

Internet

NIC

Linux networking stack

UDP/TCP :53

DNS daemon

With XDP:

Internet

NIC

XDP/eBPF

Linux networking stack

UDP/TCP :53

DNS daemon

This creates an opportunity to perform very inexpensive operations before traffic reaches sockets or the DNS application itself.

Potential examples include:

  • packet counters
  • basic protocol validation
  • simple allowlists
  • rate limiting
  • detection of obvious abuse patterns
  • dropping clearly unwanted traffic
  • telemetry exported through BPF maps

The important distinction is that XDP is most useful for cheap packet-level decisions.

Complex DNS policy and application logic usually still belongs in userspace.


The eBPF Verifier Does Not Validate Your Business Logic

The Linux kernel does not blindly execute arbitrary BPF programs.

A simplified load sequence looks like this:

userspace

BPF_PROG_LOAD

eBPF verifier

accepted program

JIT / interpreter

execution

The verifier checks whether the program can execute safely according to BPF rules.

But it does not understand what your application intended to do.

For example:

SEC("xdp")
int drop_everything(struct xdp_md *ctx)
{
return XDP_DROP;
}

may be a perfectly valid eBPF program.

It simply happens to be operationally disastrous on the wrong interface.

That distinction is important:

The verifier protects the kernel from unsafe BPF execution. It does not protect your network from incorrect filtering logic.


A Safer SRE Workflow for XDP Filters

For production packet filtering, an incremental rollout is much safer than immediately deploying XDP_DROP.

A useful progression is:

Observe

Count

Would Drop

Enforce

Stage 1 — Observe

Start by always passing packets:

return XDP_PASS;

Verify:

  • program loading
  • attachment
  • counters
  • CPU behavior
  • traffic behavior

Stage 2 — Count

Detect suspicious traffic, but do not drop it:

if (suspicious) {
increment_counter();
}

return XDP_PASS;

Stage 3 — Would Drop

Maintain telemetry that indicates which packets would have been dropped.

The traffic still passes.

This is where false positives should be investigated.

Stage 4 — Enforce

Only after validating the observed traffic should the final action become:

return XDP_DROP;

This approach is especially useful for DNS, where legitimate traffic patterns can vary considerably depending on NAT, resolvers, clients, and upstream infrastructure.


What We Built

We now have a reproducible eBPF/XDP development pipeline that starts on macOS and ends with an actual XDP program attached to Linux:

macOS ARM64

Homebrew

Docker CLI

Colima

Linux ARM64 builder

Clang / LLVM

filter.c

bpf2go

eBPF ELF

generated Go bindings

Go cross-build

Linux AMD64 executable

BPF verifier

XDP attach

network traffic

The eBPF side intentionally contains almost no logic:

SEC("xdp")
int xdp_pass_all(struct xdp_md *ctx)
{
return XDP_PASS;
}

That simplicity is useful.

It allows us to validate:

  • Docker and Colima
  • Linux headers
  • Clang BPF compilation
  • bpf2go
  • Go cross-compilation
  • ELF architecture
  • BPF bytecode
  • the Linux BPF verifier
  • XDP attachment
  • clean detach behavior

without introducing packet-filtering complexity.

From here, the same foundation can be extended with:

  • Ethernet parsing
  • IPv4 and IPv6 parsing
  • UDP and TCP parsing
  • DNS header inspection
  • BPF maps
  • packet counters
  • allowlists
  • temporary blocks
  • token-bucket rate limiting
  • userspace configuration
  • telemetry

At that point, the minimal XDP example becomes the foundation for a real network protection agent.


Quick Reference

Install the Environment

brew install docker colima

colima start

docker run --rm hello-world

Build the Builder Image

docker build -t xdp-builder .

Verify the Toolchain

docker run --rm xdp-builder bash -c '
uname -m
go version
clang --version | head -1
llvm-config --version
bpftool version
ls -l /usr/include/asm/types.h
'

Build and Inspect

docker run --rm \
-v "$PWD:/src" \
-w /src \
xdp-builder \
bash -c '
set -e

go generate ./...

file bpf_bpfel.o

bpftool prog dump obj bpf_bpfel.o

CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64 \
go build -o xdp-demo .

file xdp-demo
'

Run on Linux

sudo ./xdp-demo eth0

Inspect the Attachment

bpftool prog list

bpftool net

ip -details link show dev eth0

Emergency Detach

ip link set dev eth0 xdp off

For generic XDP:

ip link set dev eth0 xdpgeneric off

GitHub Source Code

On GitHub, you can find the complete source code for this guide at:

Follow OpenBLD