eBPF & the Cilium Datapath
The CCA blueprint gives eBPF one domain worth ten percent, and tells you just enough to say a verifier proves programs safe, a JIT compiles them to native code, and maps hold shared state. That's the right amount of depth for an associate exam — and the wrong amount for actually reasoning about a datapath you're on call for. This page goes underneath the exam: what a kernel hook actually is and which ones Cilium attaches to, what the verifier can and can't prove and why it rejects the programs it rejects, what a JIT buys you that an interpreter doesn't, what BTF and CO-RE solve, and the mechanical reason a hash-map lookup keeps a flat cost curve where iptables' rule chain does not. None of this is asked on the CCA. All of it is what separates knowing the vocabulary from being able to read a bpftool prog show dump during an incident and know what you're looking at.
Imagine the kernel is a very strict amusement park, and every ride has a "you must be this safe to run" sign. eBPF lets you bring your own little program and bolt it onto a ride — but before it ever spins, a park inspector (the verifier) reads every single line and refuses to let it run unless it can prove, just by reading, that it will always finish, never wander outside the fence, and never touch memory it wasn't handed. Once it passes, a translator (the JIT) rewrites your program from a universal instruction booklet into the exact native language the ride's motor speaks, so it runs at full ride speed instead of being read line-by-line every time. And instead of scribbling notes on a single shared clipboard that every ride operator has to flip through page by page, your program gets its own labeled filing cabinet (a map) that answers "what do I know about this guest" in one instant reach, no matter how many guests are in the park.
eBPF is four things, not one
☺ Like you're 10: "eBPF" sounds like one gadget. It's actually four separate pieces working together — a program, a bouncer, a translator, and a filing cabinet.
eBPF (extended Berkeley Packet Filter — the name is a historical fossil; it now runs far past packet filtering) lets you load small programs into the Linux kernel at runtime, with no kernel module to compile and no reboot. What makes that safe enough to do in production, on a shared multi-tenant kernel, is that it's never really "one thing" — it's a pipeline of four distinct stages, and each stage exists specifically to make the next one trustworthy:
- Bytecode — your program, written in a restricted subset of C (or Rust, via
aya), compiled by Clang/LLVM down to eBPF's own instruction set: a small, RISC-like set of ~110 opcodes operating on eleven 64-bit registers. - The verifier — a static analyzer built into the kernel that walks every possible execution path of that bytecode before it's allowed to run, and rejects anything it can't prove terminates and stays memory-safe.
- The JIT compiler — once verified, translates the bytecode into native machine code for the host's actual CPU (x86-64, arm64, …), so the loaded program runs at compiled speed, not interpreted speed.
- Maps — key/value data structures living in kernel memory that a program reads and writes as it runs, and that userspace can also read and write via the
bpf()syscall — the one sanctioned channel between kernel-resident state and the control plane that manages it.
Cilium's cilium-agent is that control plane. It doesn't move packets itself — it compiles eBPF programs from the cluster's current policy and identity state, loads them onto the node's kernel hooks, and keeps their maps populated as pods, Services, and CiliumNetworkPolicy objects change. The actual packet-moving work happens entirely in kernel space, at line rate, without ever crossing back into userspace for a decision.
Kernel hooks: where in the stack a program actually runs
☺ Like you're 10: A packet doesn't just teleport into a pod — it passes several checkpoints on the way in, and Cilium can bolt a program onto more than one of them, choosing the earliest checkpoint that already has enough information to decide.
"eBPF" names the mechanism; a program type names which kernel hook a given program is allowed to attach to, and what context (which struct, which fields) it's handed when it runs. Cilium uses several, and picking the earliest usable one is the whole performance story:
- XDP (eXpress Data Path) — runs in the network driver, before the kernel has even allocated an
sk_bufffor the packet. The earliest possible hook, and the cheapest: Cilium uses it at the node's ingress for its fastest DDoS-drop and load-balancing paths, when the NIC driver supports native XDP. - tc (traffic control) ingress/egress — attaches to the classifier/action layer already used for QoS. This is Cilium's main workhorse:
bpf_lxcruns on a pod's veth pair,bpf_hoston the physical NIC, andbpf_overlayon the VXLAN/Geneve interface when running in tunnel mode. It has a fullsk_buffto work with — later than XDP, but with more of the packet's context already built. - Socket-level hooks —
cgroup/connect4,cgroup/connect6, andsockopsattach at the socket layer itself, before a packet is even constructed. This is how kube-proxy replacement does its earliest work: when a pod callsconnect()to a Service's ClusterIP, a socket-level program can rewrite the destination to a live backend's real pod IP right there, so the kernel never builds a packet addressed to a virtual IP that has to be NAT'd back out later. - Tracing hooks — kprobes, tracepoints, and
perf_eventsattach to arbitrary kernel functions or static tracepoints. Cilium doesn't move packets here, but Hubble's deeper visibility and tools likebpftracelean on exactly this category to observe kernel behavior with no source changes.
The pattern across all of them: eBPF doesn't replace the network stack with something else — it lets you observe and redirect at whichever existing layer already has the cheapest, earliest answer to the question you're asking, and skip every layer downstream of that answer entirely.
The verifier: proof by inspection, not by running it
☺ Like you're 10: Before your program ever runs for real, a very thorough reader goes through it line by line and refuses to let it start unless it can already prove — just from reading — that it will finish and won't reach outside its own sandbox.
Loading arbitrary code into the kernel is normally how you get a kernel module, and a kernel module can crash the whole machine. The verifier is what makes eBPF a different, much safer category: it statically proves a set of safety properties about a program before it's allowed to run at all, and it proves them without executing a single instruction. Concretely, it walks every reachable path through the bytecode's control-flow graph and checks:
- Bounded execution — historically, no unbounded loops at all (every backward jump had to prove termination by other means); recent kernels (5.3+) allow bounded loops via the
bpf_loop()helper, where the iteration count itself is proven finite. Either way, "does this halt" has to be answerable before load time, not discovered at runtime. - Memory safety — every pointer dereference is checked against what the verifier can prove about that pointer's type and valid range at that exact point in the program. Reading past the end of a packet buffer, or reading uninitialized stack memory, is a load-time rejection, not a runtime fault.
- Register and stack discipline — a fixed 512-byte stack, a bounded number of tracked register states, and no raw pointer arithmetic that the verifier can't follow.
- A restricted helper allowlist — a program can't call arbitrary kernel functions; it can only call from a small, explicitly exposed set of BPF helper functions (map lookups, checksum updates, redirect calls), each type-checked against the calling program type.
- A total instruction budget — historically capped at 4096 instructions per program (much higher today, on the order of a million on modern kernels, since the verifier's own complexity budget became the real constraint rather than a flat instruction count).
The practical consequence, and the one thing worth internalizing past what the CCA asks: a verifier rejection is not a bug in your program in the normal sense — it's the verifier failing to construct a proof, which is a narrower thing than the program actually being unsafe. A loop you know terminates can still be rejected if you wrote it in a shape the verifier's path-exploration can't follow; the fix is usually restructuring the code so the safety argument becomes visible to the analysis, not the underlying logic being wrong. This is the single most common source of "why won't my eBPF program load" confusion once you're past introductory material — and exactly why bpftool prog load or a Cilium agent log showing a verifier rejection is worth reading closely rather than treating as an opaque failure.
Verifier behavior is not static across kernel versions — later kernels have relaxed several of these limits (bounded loops, larger instruction budgets, better bounds-tracking for loop-carried values), so a program that fails to verify on one node's kernel can load cleanly on another. This is a real production trap in heterogeneous clusters: a Cilium upgrade or DaemonSet rollout can behave differently node to node purely because of kernel version skew, not because of anything Cilium itself changed. Checking uname -r against your Cilium version's documented minimum kernel is a genuinely useful first move when eBPF-related pods behave inconsistently across a fleet.
JIT compilation: from a portable instruction set to native speed
☺ Like you're 10: Once the inspector clears your program, a translator rewrites it from a universal instruction booklet into the exact machine language the computer's processor actually speaks — so it runs at full speed instead of being re-read one line at a time, forever.
Verified bytecode is still just bytecode — an interpreter could run it directly, decoding and executing one instruction at a time, but that cost is paid on every single packet, forever. The JIT (just-in-time) compiler instead translates the whole verified program, once, into native machine code for the host's actual architecture, and that compiled version is what actually runs on every subsequent packet. On Linux this has been the default since kernel 4.x for the major architectures; the bytecode interpreter still exists mainly as a fallback and a reference implementation for architectures without a mature eBPF JIT backend.
The instruction set eBPF bytecode targets was deliberately designed to be a close cousin of real CPU instruction sets — eleven general-purpose 64-bit registers, a calling convention, comparison and arithmetic ops that map almost one-to-one onto x86-64 or arm64 equivalents. That closeness is what makes the JIT simple and fast to run (compilation happens at load time, not per-packet) and what makes the compiled output competitive with hand-written C — there isn't a large abstraction gap between the portable bytecode and the native code it becomes. This is the concrete mechanism behind a line the CCA blueprint states as a fact without deriving it: eBPF programs run "at line rate." They run at line rate because by the time a packet crosses the hook, there's no interpretation, no bytecode dispatch loop, and no userspace round-trip in the hot path — just compiled native instructions and a map lookup.
BTF and CO-RE: portable programs without recompiling per kernel
☺ Like you're 10: Kernel internals shift slightly from version to version, like furniture getting rearranged between houses. Instead of rebuilding your program separately for every single house, BTF is a floor plan the kernel hands over, and CO-RE lets your program read that floor plan and adjust itself on the spot.
An eBPF program that reads kernel data structures directly — inspecting a struct sk_buff or a task_struct field, the way Cilium's deeper tracing programs and Hubble's socket-level visibility do — has a portability problem: the exact byte offset of a given field inside those structs can shift between kernel versions and distro configs. The traditional fix was compiling a separate binary per target kernel, using that kernel's own headers. BTF (BPF Type Format) is a compact, embedded description of every kernel type's actual layout on the running kernel, exposed at /sys/kernel/btf/vmlinux. CO-RE (Compile Once – Run Everywhere) is the technique built on top of it: the compiler emits special relocation records for every field access instead of a fixed offset, and at load time — not compile time — the loader reads the running kernel's own BTF and patches those offsets to match, before the program is ever handed to the verifier.
The result is one compiled artifact that loads correctly on any BTF-enabled kernel, without recompiling against that kernel's headers or shipping a kernel-specific build per node. This is exactly what lets a single Cilium container image work across a fleet of nodes running different kernel patch levels — which is the practical reason CO-RE is the default assumption in Cilium's own build going back several major releases, quietly replacing what used to require an in-cluster compiler sidecar (Cilium's older "eBPF probe compile at runtime" model) with a load-time relocation step.
Why a hash-map datapath outscales a rule chain
☺ Like you're 10: iptables checks a growing list of rules one at a time for every single packet, like reading a longer and longer instruction sheet from the top every time. eBPF instead reaches straight into a filing cabinet and pulls the answer out in one motion, no matter how big the cabinet gets.
The CCA blueprint states this contrast as a fact; here's the mechanism underneath it. iptables (and its Kubernetes usage inside kube-proxy's iptables mode) represents rules as an ordered, linear chain that the kernel walks sequentially for every packet, evaluating each rule's match conditions until one hits. Every Service, every NetworkPolicy-equivalent rule, every additional endpoint adds more entries to that chain — so the cost of evaluating a single packet grows with the total size of the ruleset, which itself grows with cluster size. That's algorithmically O(n) in the number of rules, walked fresh on every packet, and it's the specific, mechanical reason large iptables rule sets became a documented scaling ceiling for kube-proxy well before Cilium existed.
A BPF map — specifically the hash-table map type Cilium uses for policy verdicts, identities, and connection tracking — replaces sequential matching with a hash lookup: compute a key (say, a source/destination identity pair, or a 5-tuple), hash it, and read the bucket directly. That's O(1) in the expected case, regardless of how many total entries the map holds. Doubling the number of Services or policies doesn't make an existing packet's lookup any slower; it just makes the map itself larger. Combine that with the socket-level early redirection covered above — a pod-to-Service connection gets its destination rewritten to a real backend IP at connect() time, so the packet that eventually gets constructed is already addressed correctly and never needs a NAT/conntrack table lookup to translate a virtual IP at all — and you get a datapath whose per-packet cost stays essentially flat as the cluster grows, instead of climbing with it.
The exam-level story — "eBPF is faster than iptables" — is true but incomplete. The precise version is: a hash-map lookup's cost doesn't grow with the number of entries, while a linear chain-walk's cost does, and Cilium compounds that advantage by moving the decision as early as possible (often to the socket layer, before a packet with a virtual IP is ever constructed) so entire layers of the traditional path — NAT, conntrack, a second hop through the bridge — are skipped rather than merely sped up.
Reading it live: bpftool, cilium monitor, and what a verifier failure looks like
☺ Like you're 10: All of this stops being theory the moment you actually look — the tools that show you a real, loaded program running on a real node.
bpftool, maintained in the kernel source tree, is the general-purpose inspection tool for anything loaded on a box, Cilium or otherwise:
# List every loaded eBPF program on this node, by type and attach point sudo bpftool prog show # Dump one program's JIT-compiled instructions sudo bpftool prog dump jited id <PROG_ID> # List every loaded map, and peek at one's live contents sudo bpftool map show sudo bpftool map dump id <MAP_ID> # Cilium's own agent CLI wraps the same underlying state # in domain-specific terms — endpoints, identities, policy cilium-dbg endpoint list cilium-dbg bpf policy get <ENDPOINT_ID> cilium-dbg monitor --type drop # live-stream every packet an eBPF program drops, and why
A verifier rejection surfaces in the kernel log and in whichever tool attempted the load — for Cilium, that generally means an error in the cilium-agent logs at program-load time, naming the specific instruction and the safety property it couldn't prove. It reads unfamiliar the first time and is genuinely useful the second: it is telling you exactly which pointer, at exactly which instruction offset, it couldn't bound — not "your program is bad" in the abstract.
On any Linux box with a recent kernel (5.x+) and root, run sudo bpftool prog show even with no Cilium installed — most modern distros already have a handful of eBPF programs loaded for things you didn't ask for: systemd's cgroup accounting, a firewall, sometimes a security agent. Pick one, run sudo bpftool prog dump jited id <ID> against it, and you'll see literal disassembled machine instructions — the JIT's actual output, for a program that started life as a few dozen lines of restricted C. That's the whole pipeline this page describes, sitting on a machine you probably already have.
Foxy: The CCA blueprint just says "eBPF is a hash map instead of a linear chain." Is that actually the whole story?
Pip the Hummingbird: It's the ten-percent story. The real one has four moving parts: bytecode compiled from restricted C, a verifier that proves it safe by reading it — never by running it — a JIT that turns it into native instructions, and maps that hold the state both the kernel program and the userspace agent can reach.
Gizmo the Gremlin: Sounds slow. Why not just skip the verifier and load whatever you want? Kernel modules do it. 🤑
Timmy the Turtle: Because a kernel module that misbehaves can take the whole machine down with it. The verifier is the entire reason eBPF gets to run in the kernel at all without that risk — it's not overhead, it's the safety contract.
Professor Owl: And it's not one hook either. XDP, tc, socket-level — Cilium picks whichever is earliest and cheapest for the question it's asking. Kube-proxy replacement rewrites a Service IP at connect() time, before a packet even exists to be NAT'd.
Ellie the Elephant: And when a node's kernel is older than its neighbor's, the verifier's own limits can differ between them — same Cilium image, different behavior, purely from kernel skew. That's a real fleet bug I've watched happen.
Pip: Which is exactly why BTF and CO-RE matter — one compiled program, and the loader patches its struct offsets to match whatever kernel it actually lands on. You don't need a compiler sidecar per node anymore.
1. Name the four stages an eBPF program passes through between source code and a running kernel hook. 2. What does the verifier actually prove, and how is a verifier rejection different from "your program's logic is wrong"? 3. What does the JIT compiler do, and why does eBPF's instruction set being close to a real CPU's make that fast? 4. Name three kernel hook points Cilium attaches programs to, and explain why the socket-level hook can avoid NAT/conntrack entirely for a Service call. 5. What problem do BTF and CO-RE solve, and what used to be the alternative? 6. Why is a BPF hash-map lookup's cost roughly constant as a cluster grows, while an iptables chain-walk's cost is not?
Check your answers
- Restricted C compiled to eBPF bytecode by Clang/LLVM; verification by the in-kernel verifier; JIT compilation to native machine code; attachment to a kernel hook, where it runs at line rate against real traffic.
- The verifier statically proves a program will terminate and stay memory-safe — bounded loops, checked pointer dereferences, a restricted helper allowlist, a bounded stack — without ever executing the program. A rejection means the verifier couldn't construct that proof for the way the code is written, which is narrower than the program actually being unsafe; the fix is often restructuring the code so the safety argument becomes visible to the verifier's path analysis, not fixing broken logic.
- It translates verified bytecode, once at load time, into native machine code for the host CPU, so every subsequent packet runs compiled instructions instead of being interpreted one bytecode instruction at a time. eBPF's instruction set was deliberately designed close to real CPU instruction sets, which keeps the JIT simple and its output competitive with hand-written native code.
- XDP (in the driver, before an
sk_buffexists), tc ingress/egress (bpf_lxc,bpf_host,bpf_overlay), and socket-level hooks (cgroup/connect4/connect6,sockops). The socket-level hook runs atconnect()time, before a packet is even constructed — it rewrites the destination to a real backend pod IP directly, so the packet built afterward is already correctly addressed and never needs a NAT/conntrack lookup to translate a virtual Service IP. - Kernel data structure layouts (field offsets) can shift between kernel versions and distro builds, which used to require compiling a program separately per target kernel using that kernel's own headers. BTF exposes the running kernel's actual type layout; CO-RE lets the loader patch a single compiled program's field-offset relocations against that kernel's BTF at load time, so one build runs correctly across a fleet of differing kernels.
- A hash-map lookup computes a key, hashes it, and reads a bucket directly — expected O(1) regardless of how many entries the map holds. An iptables chain is walked sequentially per packet, so its cost is O(n) in the number of rules, and the ruleset itself grows as the cluster grows — so per-packet cost climbs with cluster size for iptables but stays flat for the eBPF map.