Skip to content

Part 8: eBPF Datapath Internals ​

Review baseline: Cilium 1.20.2 (stable, 2026-09-16); Cilium CLI 0.20.1; Hubble CLI 1.20.2. Items marked [1.21 pre-release] come from v1.21.0-pre.3 and may change before GA. Last Updated: October 15, 2026

This chapter opens the Cilium datapath: which BPF programs run, where they attach, how they call each other, which maps they read, how the agent compiles and loads them, and how one packet is processed hop by hop. The generic eBPF primer (history, verifier, map types) stays in Part 2: eBPF. Overlay and routing behavior is in Part 9; IPAM in Part 10.

Stale-blog warning: cilium_tunnel_map no longer exists. Older posts and some upstream sample listings show a cilium_tunnel_map that mapped a Pod CIDR to a remote node IP. Cilium 1.17 still had it in BPF (bpf/lib/encap.h) and cilium-dbg bpf tunnel list dumped it. It was dropped from the datapath in 1.18 (the 1.18.14 BPF tree has no reference; the agent only unpins a leftover map), and the Go package was removed in 1.19. In Cilium 1.20.2 no BPF program references it. The remote node (tunnel endpoint) is stored in the value of cilium_ipcache_v2 (field tunnel_endpoint, with the flag flag_has_tunnel_ep). There is no cilium-dbg bpf tunnel list command in 1.18 or 1.20. If a guide tells you to bpftool map dump the tunnel map to debug overlay routing, dump the ipcache instead (cilium-dbg bpf ipcache list). One upstream development doc still lists the map in a sample output; that does not mean the map is live.

Learning Objectives ​

  • Name each Cilium BPF object, its entry programs, and the device or cgroup hook each one attaches to.
  • Explain why Cilium uses tail calls, and how policy for a destination endpoint is executed from the sender's program.
  • Read the key/value layout of the policy, ipcache, conntrack, and service maps and size them with the right flags.
  • Describe the agent-side flow: ELF template cache, .rodata.config constants, map pinning, attach, commit.
  • Trace a Pod -> ClusterIP -> Pod connection and list the map lookups at every hop.
  • Inspect a live node with cilium-dbg bpf ... and bpftool.

1. Program Set and Attach Points ​

Cilium compiles a small number of C sources under bpf/. Each produces one ELF object; each object has one or more entry programs (ELF section tc/entry, xdp/entry, cgroup/...) and many tail programs (section tc/tail). Program names shown by bpftool prog show carry the prefix cil_.

1.1 Entry programs in 1.20.2 ​

ELF object (source)Entry programAttached toPurpose
bpf_lxc.ocil_from_containeringress of the host-side lxc* veth (that is, traffic leaving the Pod); on netkit, the primary hook of the netkit peerPer-endpoint egress path: identity, CT, egress policy, service LB (per-packet mode), forward
bpf_lxc.ocil_to_containeregress of the host-side lxc* (traffic toward the Pod, delivered through the stack)Ingress policy for stack-delivered traffic (endpoint routes, L7 proxy returns). Attached only when the endpoint needs an egress program
bpf_lxc.ocil_lxc_policy, cil_lxc_policy_egressnot attached to a device; stored in the global prog arrays cilium_call_policy / cilium_egresscall_policyDestination-ingress and source-egress policy, called by other programs
bpf_host.ocil_from_netdevingress of every native/external deviceHost-bound and forwarded traffic, NodePort, identity from ipcache, host firewall
bpf_host.ocil_to_netdevegress of native devices (attached only when masquerading, NodePort, WireGuard or IPsec need it)BPF masquerade, NodePort SNAT, EDT/bandwidth, encryption marks
bpf_host.ocil_from_hostegress of cilium_hostHost stack -> Pod traffic
bpf_host.ocil_to_hostingress of cilium_host and cilium_netPod -> host traffic, host firewall
bpf_host.ocil_host_policyprog-array slot (host endpoint policy)Host firewall policy
bpf_overlay.ocil_from_overlay, cil_to_overlayingress / egress of cilium_vxlan or cilium_geneveReads the tunnel ID as the source identity
bpf_wireguard.ocil_from_wireguard, cil_to_wireguardcilium_wg0 ingress (always) / egress (only when required)Marks decrypted traffic
bpf_xdp.ocil_xdp_entryXDP on native devicesCIDR prefilter and NodePort/DSR acceleration
bpf_sock.ocil_sock4_connect, cil_sock4_sendmsg, cil_sock4_recvmsg, cil_sock4_getpeername, cil_sock4_pre_bind, cil_sock4_post_bind, IPv6 equivalents, cil_sock_releasecgroup v2 sock_addr / sock hooks at the cgroup rootSocket-level load balancing
bpf_sock_term.ocil_sock_{tcp,udp}_destroy_v{4,6}BPF iterators (iter/tcp, iter/udp) calling the bpf_sock_destroy kfuncTerminate sockets connected to deleted backends; the agent falls back to netlink sock_diag if the kfunc is unavailable
bpf_probes.o, bpf_alignchecker.on/arun once with BPF_PROG_RUN at startupKernel capability probes; C/Go struct layout check

bpf_network.c (cil_from_network, IPsec decrypt on native-device ingress) exists in 1.18.14 and 1.19.8 and is removed in 1.20.

                       node A                                   node B
 pod C eth0 ----veth---- lxc_C           lxc_B ----veth---- pod B eth0
   |  cgroup/connect4          |             ^                     
   |  (bpf_sock.o)             | tcx ingress |  redirect_peer
   |                           v cil_from_container                 
   |                    +-------------------+   (tail calls, maps)
   |                    |   bpf_lxc.o       |-------+
   |                    +-------------------+       | encap / FIB redirect
   |                           |                     v
   |        cilium_host  <-----+          cilium_vxlan (cil_to_overlay)  ==underlay==>
   |        cil_from_host/cil_to_host        eth0 (cil_from_netdev / cil_to_netdev)
   |                                         eth0 XDP (cil_xdp_entry, optional)

1.2 tcx, legacy tc, netkit ​

For per-device programs the loader (pkg/datapath/loader/tc.go) chooses the mechanism in this order:

  1. tcx (Helm bpf.enableTCX: true, the default): a bpf_link attached with link.AttachTCX at the tail anchor. The link is pinned under /sys/fs/bpf/cilium/endpoints/<id>/links/ (endpoints) or /sys/fs/bpf/cilium/devices/<dev>/links/ (devices). Updating a program updates the link atomically, so there is no window with a missing program during a reload. Requires kernel 6.6 or newer. After a successful tcx attach, stale legacy filters are removed.
  2. Legacy tc: if the kernel returns "not supported" or enableTCX: false, the loader uses a clsact qdisc with cls_bpf in direct-action mode, and detaches any stale tcx link.
  3. Hard failure only when neither works: the agent reports that it needs tc-bpf support or tcx (Linux 6.6 or newer).

netkit (maturity: beta): bpf.datapathMode: veth | netkit | netkit-l2 | auto. The default is veth. With netkit, the Pod-specific programs attach inside the netkit peer device; other devices still use tcx, and selecting netkit turns tcx on automatically. Source-enforced prerequisites (canUseNetkit): kernel probe passes (upstream docs say 6.8 or newer; the probe error text in the source mentions 6.7), BPF host routing (no bpf.hostLegacyRouting), BPF masquerade, kube-proxy replacement, and no bpf.tproxy (it needs bpf_sk_assign, valid only at tc ingress). auto is new in 1.20: it probes and falls back to veth with a warning. Once Pods exist as veth, switching to netkit or auto for them is refused; roll new node groups instead.

MechanismKernelPinned objectReload behaviorMaturity in Cilium
Legacy tc (clsact + cls_bpf)5.10 baselinefilter on devicefilter replacestable, fallback
tcx6.6+bpf_link under /sys/fs/bpf/cilium/...atomic link updatestable, default when available
netkit6.8+ (docs)netkit link on peeratomic link updatebeta

1.3 XDP and cgroup hooks ​

  • XDP (cil_xdp_entry) is attached when loadBalancer.acceleration is native or best-effort. It runs a CIDR prefilter (cilium_cidr_v4_fix/dyn LPM maps) and the NodePort/DSR fast path. The loader retries up to four attach permutations (attach type and the XDP_HAS_FRAGS flag) when the driver returns EINVAL.
  • cgroup hooks are attached once at the cgroup root (agent flag --cgroup-root, default /run/cilium/cgroupv2) with bpf_link, pinned under /sys/fs/bpf/cilium/socketlb/links/cgroup. They run in every Pod netns unless socketLB.hostNamespaceOnly=true.

2. Tail Calls and the Policy-Program Structure ​

2.1 Why tail calls ​

A tail call replaces the running program with another without returning. Cilium uses them for three reasons: the verifier limit on one program's complexity (1M instructions), the 512-byte stack, and the ability to keep large optional features out of the common path. Maximum chain length is 33 tail calls. Mixing bpf-to-bpf calls with tail calls requires kernel 5.10, one reason for the 5.10 baseline.

State crosses a tail call in two places: skb->cb[] (the CB_* slots, such as CB_SRC_LABEL) and per-CPU scratch maps cilium_tail_call_buffer4/6, which hold, for example, the conntrack tuple and result computed by the CT tail program.

2.2 Per-endpoint call arrays ​

Each endpoint has a private PROG_ARRAY named cilium_calls_<endpoint-id, 5 digits> (cilium_calls_00312). The host endpoint uses cilium_calls_hostns_<id>, and each native device uses cilium_calls_netdev_<ifindex>. Other loaders use cilium_calls_overlay_2 (the overlay device, named with the reserved world identity 2), cilium_calls_wireguard_<ifindex> and cilium_calls_xdp_<ifindex>. They are declared with CILIUM_CALL_SIZE entries and a pin flag that means "replace the pin on every load; never reuse an old array".

VersionCILIUM_CALL_SIZE
1.18.1448
1.19.849
1.20.250
1.21.0-pre.350

Slots are static: the call site is inline assembly with a constant slot number. The loader reads BTF decl-tags (tail:cilium_calls/<idx>) on each tc/tail program, fills the array, and prunes tail programs that are unreachable once the constants are applied (section 5). Slot groups in 1.20:

SlotsMeaning
1-2, 4-5drop/error notification, ICMPv6 neighbor solicitation, time-exceeded
6ARP
3unused (a numbering gap kept to avoid conflicts when backporting)
7, 10IPv4 / IPv6 from-LXC (aliased for netdev, overlay, WireGuard entry)
8-9IPv4-to-IPv6 and IPv6-to-IPv4 translation (CILIUM_CALL_IPV46_RFC6052, CILIUM_CALL_IPV64_RFC6052; NAT46/NAT64)
11-14to-LXC policy only, to-endpoint
15-25, 36-39NodePort NAT, rev-NAT, DSR, SNAT forward; from-host
26-27from-LXC continuation (second half of the egress path, splits verifier complexity)
28-33CT ingress / CT ingress policy-only / CT egress, IPv4 and IPv6
34-35, 40SRv6, inter-cluster rev-SNAT
41-47continuation from host/netdev, "no service" ICMP unreachable, multicast delivery
48-49policy-denied ICMP response (new in 1.20, used when policy_deny_response is enabled)

2.3 Global policy program arrays ​

Policy is not in the per-endpoint call array. cil_lxc_policy and cil_lxc_policy_egress are inserted into two global prog arrays, indexed by endpoint ID and sized ENDPOINTS_MAP_SIZE:

ArrayIndexRuns
cilium_call_policydestination endpoint IDdestination ingress policy
cilium_egresscall_policysource endpoint IDsource egress policy

The loader writes these slots before attaching the tc/tcx program (PolicyMap.Update(ep id, PolicyProg)). A sender that finishes its L3 rewrite for a local destination calls tail_call_policy(ep->lxc_id). This matters because with BPF host routing the packet is moved into the destination Pod's netns with bpf_redirect_peer, which skips the destination's tc egress hook. Calling the destination's policy program from the sender is how same-node ingress policy is still enforced. If the slot is empty (endpoint terminating), the packet is dropped with DROP_EP_NOT_READY.

cil_to_container still exists for stack-delivered traffic, so destination ingress policy can run either from the sender's tail call or from that egress hook.

3. Policy Enforcement Path ​

3.1 Policy map ​

Each endpoint has cilium_policy_v3_<id> (1.20; cilium_policy_v2_ in 1.18/1.19). Type LPM_TRIE, flags BPF_F_NO_PREALLOC | BPF_F_RDONLY_PROG_COND, size --bpf-policy-map-max (default 16384 per endpoint).

policy_key  { lpm prefixlen; u32 sec_label;   /* remote identity, 0 = wildcard */
              u8 egress:1;  u8 protocol;      /* 0 = any */
              be16 dport; }                   /* prefix match => port ranges */
policy_entry{ be16 proxy_port;                /* != 0 => redirect to L7 proxy */
              deny:1; lpm_prefix_length:5; auth_type:7; has_explicit_auth_type:1;
              u32 precedence; u32 cookie; }

The LPM prefix covers sec_label and egress exactly, protocol all-or-nothing, and dport as a bit prefix, which is how port ranges become a few entries.

3.2 Lookup algorithm ​

For a flow with remote identity R, protocol P, port D:

  1. Specific lookup with sec_label = R. If the hit carries maximum precedence (a deny), stop.
  2. Aggregate lookup: the identity's aggregate (for example a CIDR group) then sec_label = 0 (wildcard L3).
  3. Choose the higher precedence (deny over proxy redirect over allow), then the longer LPM prefix. No hit means DROP_POLICY.

The match type is reported in events (L3+L4, L3 only, L4 only, protocol only, all). ICMP type is carried in dport when ICMP rules are enabled, and "fragmentation needed" is always allowed.

3.3 Verdict and side effects ​

  • Deny: DROP_POLICY_DENY and a drop event. Allow with proxy_port != 0: redirect to the proxy (section 3.5). Allow with auth_type != 0: DROP_POLICY_AUTH_REQUIRED unless cilium_auth_map has a valid entry.
  • Egress is enforced by the source endpoint (handle_ipv4_from_lxc), only for CT_NEW and CT_ESTABLISHED; replies (CT_REPLY, CT_RELATED) skip policy. Ingress is enforced by the destination (ipv4_policy() in its policy program) and creates the ingress CT entry after the allow.
  • A policy-verdict event is emitted on deny and on a new allowed connection. Hairpin traffic (Pod to its own service IP) skips policy.
  • Optional per-entry counters go to cilium_policystats (--bpf-policy-stats-map-max 65536).

3.4 Policy changes do not recompile ​

Policy updates are map operations on cilium_policy_v3_<id> (applyPolicyMapChangesLocked). The object is rebuilt and reloaded only if the endpoint or node configuration hash changes. A CiliumNetworkPolicy edit therefore never triggers clang.

3.5 L7 and FQDN redirect ​

When proxy_port != 0, ctx_redirect_to_proxy4() sends the flow to Envoy or the DNS proxy. Two implementations exist:

MethodHowMaturity
iptables TPROXY (default)datapath sets mark = MARK_MAGIC_TO_PROXY | proxy_port << 16 and returns OK; CILIUM_PRE_mangle rules with xt_socket/TPROXY steer it to the listenerstable
bpf.tproxy: truebpf_sk_assign assigns the listener socket at tc ingressbeta, default off, incompatible with netkit (agent refuses to start in 1.19.8, 1.20 and 1.21.0-pre.3; no such check found in the 1.18.14 source)

Proxy ports come from --proxy-portrange-min/max (10000-20000). The CT entry records proxy_redirect so replies return to the proxy. L7 events do not come from BPF; Envoy and the DNS proxy report access-log records to the agent.

Mutual authentication (auth_type) is deprecated in 1.20 (use ztunnel); [1.21 pre-release] removes --bpf-auth-map-max and cilium_auth_map, and the entry's auth bits become reserved.

4. Map Catalog ​

Global maps are pinned by name under /sys/fs/bpf/tc/globals/; links and plugin objects are under /sys/fs/bpf/cilium/. Helm: bpf.root (default /sys/fs/bpf), bpf.autoMount.enabled.

4.1 Core maps ​

MapTypeKey -> Value (essentials)Notes
cilium_lxcHASHendpoint IP (+family, cluster_id) -> {ifindex, lxc_id, flags, mac, node_mac, sec_id, parent_ifindex}local endpoints and host IPs; 65535 entries (lxcmap.MaxEntries)
cilium_ipcache_v2LPM_TRIE{cluster_id, family, ip/prefix} -> {sec_identity, tunnel_endpoint, key, flag_skip_tunnel, flag_has_tunnel_ep, flag_ipv6_tunnel_ep, flag_remote_cluster}identity and tunnel endpoint; 512000 entries (ipcachemap.MaxEntries)
cilium_policy_v3_<id>LPM_TRIEsee 3.1per endpoint
cilium_calls_<id>, _hostns_, _netdev_PROG_ARRAYslot -> programprivate, replaced on each load
cilium_call_policy, cilium_egresscall_policyPROG_ARRAYendpoint ID -> policy programglobal
cilium_ct4_global, cilium_ct6_globalLRU_HASH5-tuple + direction flags -> ct_entryTCP
cilium_ct_any4_global, cilium_ct_any6_globalLRU_HASHsamenon-TCP
cilium_snat_v4_external (+v6)LRU_HASHCT tuple -> NAT entry (to_saddr/to_sport or to_daddr/to_dport)masquerade, NodePort SNAT
cilium_nodeport_neigh4/6LRU_HASHnext-hop IP -> MAC
cilium_node_map_v2HASH{family, ip} -> {node id, spi}IPsec/WireGuard/ClusterMesh node IDs (v1 removed in 1.19)
cilium_devices (new 1.20)HASHifindex -> {mac, l3}synced by the agent
cilium_runtime_configARRAYindex -> u64 (time offset, agent liveness)cilium-dbg bpf config list
cilium_metricsPERCPU_HASH{reason, direction, line, file} -> {count, bytes}feeds forward/drop counters
cilium_eventsPERF_EVENT_ARRAYCPU -> perf fdmonitor and Hubble source (section 8)
cilium_signalsPERF_EVENT_ARRAYdatapath -> agent signalsfor example CT-full triggers GC

Conntrack addresses are stored with source and destination swapped relative to the packet; the ct_entry holds lifetime, rx/tx_closing, rev_nat_index, proxy_redirect, from_l7lb, from_tunnel, src_sec_id, and the seen TCP flags. A single map holds egress, ingress and service-scope entries, separated by tuple.flags. Cluster Mesh adds per-cluster inner maps (cilium_per_cluster_ct_*) for overlapping IPs.

Timeouts (agent flags, 1.20.2): TCP established 2h13m20s, SYN 1m, FIN 10s, non-TCP 1m, service TCP 2h13m20s, service grace 1m.

4.2 Load-balancing maps ​

MapTypeKey -> ValueNotes
cilium_lb4_services_v2 (+6)HASH{address, dport, backend_slot, proto, scope} -> {backend_id or affinity/algorithm, count, rev_nat_index, flags, qcount}slot 0 is the frontend; slots 1..count hold backend IDs; scope separates external and internal (externalTrafficPolicy)
cilium_lb4_backends_v3 (+6)HASHbackend_id -> {address, port, proto, flags, cluster_id, zone}
cilium_lb4_reverse_nat (+6)HASHrev_nat_index -> {address, port}per-packet LB reply restore
cilium_lb4_reverse_sk (+6)LRU_HASH{socket cookie, backend addr, port} -> {original service addr, port, rev_nat_index}socket-LB reverse translation
cilium_lb4_affinity, cilium_lb_affinity_matchLRU_HASH / HASHclient (IP or netns cookie) + rev_nat -> backendsessionAffinity
cilium_lb4_source_rangeLPM_TRIE{rev_nat_id, cidr} -> flagloadBalancerSourceRanges
cilium_lb4_maglevHASH_OF_MAPSrev_nat_id -> lookup-table array--bpf-lb-algorithm maglev, table size --bpf-lb-maglev-table-size
cilium_lb4_healthLRU_HASHsocket cookie -> peer backendLB health datapath; kernel 5.12

The upper 8 bits of the service's affinity_timeout field carry the selection algorithm (random or maglev).

4.3 Feature maps (one line each) ​

cilium_ipmasq_v4/v6 (non-masquerade CIDRs), cilium_ipv4_frag_datagrams (8192 default), cilium_vtep_map, cilium_srv6_*, cilium_egress_gw_policy_v4_v2 (renamed from _v4 in 1.20), cilium_l2_responder_v4/v6, cilium_mcast_*, cilium_cidr_v4_fix/dyn (XDP prefilter), cilium_encrypt_state, cilium_auth_map, cilium_policystats, cilium_ratelimit, cilium_throttle, cilium_lb_act/cilium_lb_fct (active connection tracking, off by default), and per-CPU scratch maps (cilium_tail_call_buffer4/6, cilium_nodeport_nat_buffer, cilium_xdp_scratch, cilium_percpu_trace_id).

4.4 Sizing flags ​

Flag (Helm)Default (1.20.2)Affects
--bpf-map-dynamic-size-ratio (bpf.mapDynamicSizeRatio)0.0025 (0.25% of RAM); dynamic sizing is on by defaultCT TCP/any, NAT, socket reverse-NAT; the NodePort neighbor map follows the NAT size (the flag help says "CT, NAT and policy BPF maps", but the sizing code does not resize policy maps)
--bpf-ct-global-tcp-max (bpf.ctTcpMax)524288TCP CT
--bpf-ct-global-any-max (bpf.ctAnyMax)262144non-TCP CT
--bpf-nat-global-max (bpf.natMax)524288NAT; capped to 2/3 of the CT sum with a warning
--bpf-neigh-global-max (bpf.neighMax)524288NodePort neighbor
--bpf-policy-map-max (bpf.policyMapMax)16384 per endpoint (maximum 65536)policy
--bpf-fragments-map-max8192fragments
--bpf-node-map-max16384node map
--bpf-lb-map-max (bpf.lbMapMax)65536fallback for six per-LB-map flags (--bpf-lb-service-map-max, -service-backend-map-max, -rev-nat-map-max, -affinity-map-max, -source-range-map-max, -maglev-map-max), each defaulting to 0 = use lb-map-max; present in source (pkg/loadbalancer/config.go) but not listed in the generated cmdref, which shows only --bpf-lb-map-max
--bpf-sock-rev-map-maxdynamic, defaults to CT-any sizesocket reverse map
bpf.distributedLRU.enabledfalseper-CPU LRU free lists; use with larger maps
bpf.preallocateMapsfalsetrades memory for lower insert latency

Dynamic sizing computes entries = default_entries * (ratio * RAM) / total_default_bytes, clamped to a floor per map (CT TCP 128Ki, CT any 64Ki, NAT 128Ki, socket reverse 64Ki) and a ceiling of 16Mi (2^24). Setting a flag explicitly disables dynamic sizing for that map only. The agent logs option <name> set by dynamic sizing to <N> when it applies. Upstream's tuned performance profile uses ratio 0.08.

Resizing a map recreates it, and entries are lost until repopulated. Resizing the LB maps or CT maps on a live node disrupts established connections. Plan resizes as a rolling node operation.

Monitor with cilium_bpf_map_pressure{map_name} (emitted above --bpf-policy-map-pressure-metrics-threshold, default 0.1), cilium_bpf_map_capacity, and cilium_bpf_map_ops_total.

5. From Agent to Kernel: Compile, Load, Attach ​

5.1 Flow ​

agent start
  |  write node headers  (StateDir/globals: node_config.h, netdev_config.h, ...)
  |  hash node config -> if changed, wipe template cache (StateDir/templates)
  v
template config hash (compile-time #defines only)
  |  cache hit?  -> reuse StateDir/templates/<sha>/bpf_lxc.o
  |  miss        -> clang -O2 --target=bpf -mcpu=v3|v2|v1 ... -> new ELF template
  v
LoadCollection (per endpoint / device)
  |  copy spec -> patch maps -> rename maps (cilium_calls -> cilium_calls_<id>, cilium_policy -> cilium_policy_v3_<id>)
  |  write .rodata.config (endpoint ID, IPs, MAC, ifindex, feature switches)
  |  dead-code analysis -> remove unused tail calls and maps
  |  fill tail-call arrays from BTF decl tags
  |  load into kernel (verifier); reuse pinned global maps, replace incompatible ones
  v
attach: policy progs into cilium_call_policy/egresscall_policy
        -> tcx/tc/netkit entry programs -> commit() map pins -> endpoint routes

5.2 ELF templates and .rodata.config ​

The expensive compile step still runs in the agent (the container image ships clang/LLVM; running the agent natively needs LLVM 18.1 or newer). To avoid recompiling per Pod, Cilium hashes only the template configuration: #define ENABLE_* flags that change code shape. All endpoints with the same flags share one object under StateDir/templates/<sha>/. List them with cilium-dbg bpf sha list.

Per-endpoint values are not compiled in. They are globals in the .rodata.config section declared with DECLARE_CONFIG(type, name, desc) and read with CONFIG(name). The agent writes the values into a copy of the spec before load; the kernel freezes the read-only data, so the verifier sees the values as constants and prunes dead branches. This requires dead-code-elimination support (kernel 5.1 or newer). Go-side structs are generated by dpgen from BTF decl-tags (pkg/datapath/config/*_config.go). The effective constants are dumped per endpoint/device as JSON (for example bpf_lxc.json) in the state directory and are included in cilium sysdump.

Each release moves more #define switches into .rodata.config, which means fewer distinct templates and fewer recompiles. Counting DECLARE_CONFIG/NODE_CONFIG declarations with grep over the BPF tree gives 18 (1.18.14), 43 (1.19.8), 73 (1.20.2) and 104 ([1.21 pre-release], v1.21.0-pre.3), using grep -rE '^\s*(DECLARE_CONFIG|NODE_CONFIG)\(' bpf --include=*.h --include=*.c | wc -l in each tag's checkout. Treat these counts as a rough trend, not an API.

5.3 Regeneration ​

On each endpoint regeneration the agent computes the endpoint header hash. If it equals the last one, only maps are synchronized (RegenerateWithoutDatapath, which is the policy-change case). Otherwise headers are written and the datapath is reloaded. Relevant metrics: cilium_endpoint_regenerations_total, endpoint_regeneration_time_stats_seconds (stages such as bpfCompilation, bpfLoadProg, bpfWaitForELF), and cilium_endpoint_component_status (new in 1.20).

Order matters: commit() of map pins must happen after all entry programs are attached, otherwise a new program could tail-call into an old array and "miss" a tail call.

5.4 Verifier testing ​

Cilium compile-tests the maximum-feature builds against kernels 5.10, 6.1 and net-next (bpf/complexity-tests/). Verifier failures at load time are logged with the full verifier output.

6. Kernel and Feature Matrix ​

6.1 Hard startup requirements (1.20.2) ​

The agent refuses to start without: bpf() and JIT; tc-bpf or tcx; bpf_skb_change_tail; bpf_get_socket_cookie; bpf_get_current_cgroup_id; bpf_fib_lookup; dead-code elimination; writable skb->queue_mapping; large (1M instruction) programs; bpf_skb_adjust_room with BPF_ADJ_ROOM_MAC; bpf_jiffies64; BPF_MAP_LOOKUP_BATCH; bpf_get_netns_cookie; bpf_sk_assign; bpf_get_cgroup_classid; bpf_perf_event_output in cgroup sock_addr; bpf_csum_level; bpf_skb_change_head; bpf_redirect_neigh; bpf_redirect_peer. This is why the documented minimum is kernel 5.10 (RHEL 8.10 with 4.18 is an explicit exception because of Red Hat backports). The legacy per-helper header probing is now empty; the 5.10 baseline assumes all helpers.

Kernel config options listed upstream include BPF, BPF_SYSCALL, BPF_JIT, NET_CLS_BPF, NET_CLS_ACT, NET_SCH_INGRESS, CGROUPS, CGROUP_BPF, DEBUG_INFO_BTF, and feature extras such as VXLAN, GENEVE, NET_SCH_FQ (bandwidth manager) and NETKIT.

6.2 Optional accelerators ​

FeatureMinimum kernelMaturity / note
tcx links6.6default when available; legacy tc otherwise
netkit (bpf.datapathMode)6.8 (docs; source probe text says 6.7)beta
IPv6 BIG TCP5.19needs supported NIC driver
IPv4 BIG TCP6.3
BBR for Bandwidth Manager5.18
LB health-check datapath5.12
Multicast5.10 on AMD64; 6.0 on AArch64
bpf_sock_destroy kfuncnot stated in sourcefalls back to netlink sock_diag automatically

The datapath does not use CO-RE relocation helpers (a search of the 1.20.2 bpf/ tree for preserve_access_index finds only a UAPI header comment): it builds against its own headers and the UAPI __sk_buff. BTF is used for map definitions, the .rodata.config data section and decl-tags, and CONFIG_DEBUG_INFO_BTF=y is required.

On EKS, check the real node kernel with uname -r and cilium-dbg status before enabling tcx- or netkit-dependent settings; this chapter does not assume which AMI ships which kernel.

7. Worked Trace: Pod -> Service -> Pod ​

Illustrative setup (numbers are examples; structures come from source): client Pod C 10.0.1.10, identity 4711, endpoint ID 312, on node A. Service web ClusterIP 10.100.0.20:80/TCP, rev_nat_index 42, two backends: B1 10.0.1.55 (node A, endpoint 87) and B2 10.0.2.77 (node B), both identity 9001. Settings: IPv4, veth, tcx, BPF host routing, kubeProxyReplacement: true (which forces socket-LB on), socketLB.hostNamespaceOnly=false.

Hop 0: connect() in the Pod, cgroup/connect4 (cil_sock4_connect) ​

  1. Look up cilium_lb4_services_v2 with {10.100.0.20, 80, TCP, slot 0, scope 0}. No entry: leave the sockaddr untouched. Entry with count=2.
  2. Pick a slot: random for TCP (socket cookie for UDP). Look up the service key with that slot to get backend_id, then cilium_lb4_backends_v3[backend_id] to get 10.0.2.77:80. If sessionAffinity is set, cilium_lb4_affinity is consulted first.
  3. Write cilium_lb4_reverse_sk[{cookie, 10.0.2.77, 80}] = {10.100.0.20, 80, 42}.
  4. Rewrite user_ip4/user_port in the syscall. No DNAT happens on the wire. tcpdump inside the Pod shows the backend IP, a frequent surprise. A trace_sock_notify event is emitted when socket-LB tracing is on.

Hop 1: SYN leaves the Pod, cil_from_container on the host-side veth (tcx ingress) ​

StepActionMaps touched
1clear metadata, send_trace_notify(TRACE_FROM_LXC) (subject to aggregation)cilium_events
2tail call slot 7 (IPv4 from-LXC)cilium_calls_00312
3per-packet LB skipped (socket-LB already translated)none
4tail call slot 30 (CT egress): lookup CT_NEW, tuple stored in per-CPU buffercilium_ct4_global, cilium_tail_call_buffer4
5tail call slot 26 (handle_ipv4_from_lxc)cilium_calls_00312
6resolve destination identity: LPM lookup of 10.0.2.77 gives identity 9001 and (overlay) the node B tunnel endpoint; a miss means worldcilium_ipcache_v2
7egress policy: {egress=1, sec_label=9001, TCP, 80}, two-pass lookup; allow or drop; a new allowed flow emits a policy-verdict eventcilium_policy_v3_00312, cilium_events, cilium_metrics on drop
8if proxy_port != 0: mark for proxy, flag CT proxy_redirect, hand to the stack (section 3.5)
9ct_create4(CT_EGRESS): insert entry, SYN lifetime 1m until ACK, then 2h13m20scilium_ct4_global (and CT-any for related ICMP)
10choose the forwarding path (below)

Forwarding choices at step 10:

  • Destination on the same node (B1): cilium_lxc[dst] returns {ifindex, lxc_id=87, mac, node_mac}; the L2 header is rewritten; metadata (CB_SRC_LABEL=4711, delivery flags) is set; then tail_call_policy(87) runs B1's cil_lxc_policy out of cilium_call_policy (Hop 3).
  • Remote, overlay: flag_has_tunnel_ep is set in the ipcache value, so bpf_skb_set_tunnel_key sets tunnel_id = source identity 4711 and remote_ipv4 = node B; the packet is redirected to cilium_vxlan/cilium_geneve, where cil_to_overlay runs before kernel encapsulation. Pod-to-pod traffic is not masqueraded.
  • Remote, native routing with BPF host routing: bpf_fib_lookup then redirect to the NIC. With bpf.hostLegacyRouting=true the packet instead returns OK to the kernel stack and netfilter. There is no VNI; the receiver re-derives the source identity from ipcache by source IP (or from marks with IPsec/WireGuard).

Hop 2 (remote only): node B receives ​

  • Overlay: cil_from_netdev passes UDP 8472 (VXLAN) / 6081 (Geneve) to the stack; the kernel decapsulates onto cilium_vxlan; cil_from_overlay reads bpf_skb_get_tunnel_key, maps tunnel_id to the source identity (an identity of host is rejected with DROP_INVALID_IDENTITY), stores it in CB_SRC_LABEL, emits TRACE_FROM_OVERLAY, then looks up cilium_lxc[10.0.2.77] and calls tail_call_policy(ep).
  • Native routing: cil_from_netdev performs the same steps with the source identity from cilium_ipcache_v2[src ip].

Hop 3: destination ingress policy and delivery ​

  1. cil_lxc_policy runs the CT-ingress tail call: cilium_ct4_global lookup, CT_NEW.
  2. Ingress policy: cilium_policy_v3_00087 with {egress=0, sec_label=4711, TCP, 80}. Verdict event emitted.
  3. ct_create4(CT_INGRESS) records src_sec_id=4711, from_tunnel, proxy_redirect.
  4. Delivery: bpf_redirect_peer into the Pod netns (veth) or redirect (netkit), TRACE_TO_LXC event. The backend's stack answers with source 10.0.2.77; it never sees the ClusterIP.

Hop 4: reply and close ​

  • B's cil_from_container finds the ingress CT entry as CT_REPLY, so policy is skipped; the packet is encapsulated back with VNI 9001 (or routed natively). On node A, tail_call_policy(312) runs C's policy program; CT_REPLY skips policy; redirect_peer delivers to C.
  • Socket-LB reverse: the app believes it talks to 10.100.0.20. getpeername and recvmsg hooks look up cilium_lb4_reverse_sk[{cookie, 10.0.2.77, 80}] and return the service address.
  • Without socket-LB (host-namespace-only mode, L7 LB, SCTP): Hop 1 performs DNAT in bpf_lxc and stores rev_nat_index in the CT entry; replies use cilium_lb4_reverse_nat[rev_nat_index] to restore the source.
  • FIN/RST sets tx/rx_closing and drops the lifetime to the FIN timeout (10s); agent CT GC deletes expired entries. cilium_lb4_reverse_sk entries linger until LRU eviction or cil_sock_release.

Per-hop lookup checklist ​

HopProgramLookups / writes
0cil_sock4_connectlb4_services_v2 x2, lb4_backends_v3, lb4_affinity (optional), write lb4_reverse_sk
1cil_from_container + tailscalls_<id> x3, tail_call_buffer4, ct4_global (lookup, create), ipcache_v2, policy_v3_<src> (egress), cilium_lxc, call_policy, events, metrics
2cil_from_overlay / cil_from_netdevtunnel key or ipcache_v2, cilium_lxc, call_policy
3cil_lxc_policy (destination)ct4_global, policy_v3_<dst> (ingress), ct4_global create, events
4reverse path, recvmsgct4_global (reply), lb4_reverse_sk or lb4_reverse_nat

1.20 behavior change that affects this trace ​

With kube-proxy replacement and socketLB.enabled=false or socketLB.hostNamespaceOnly=true, Pod-to-NodePort connections inside the cluster are now load-balanced at the client Pod's tc hook. Client egress and backend ingress NetworkPolicies must allow the traffic accordingly.

8. Hubble Event Pipeline ​

BPF program --ctx_event_output--> cilium_events (PERF_EVENT_ARRAY, per CPU)
      |   trace_notify / drop_notify / policy_verdict_notify / debug / capture / trace_sock_notify
      v
agent monitor (pkg/monitor/agent): perf reader, 64 pages per CPU, opened only if a consumer exists
      |-- lost samples (kernel buffer overrun) -> MonitorStatus.Lost
      |-- unix-socket listeners (cilium-dbg monitor), queue --monitor-queue-size
      v
Hubble consumer -> observer queue (--hubble-event-queue-size)
      |   full => LostEvent source=OBSERVER_EVENTS_QUEUE, hubble_lost_events_total
      v
Hubble parser (identity/endpoint/service enrichment) -> ring buffer (--hubble-event-buffer-capacity)
      v
gRPC: hubble observe / Relay / UI

Facts to remember:

  • The event map is a perf event array in 1.20 and 1.21-pre.3, not a BPF ring buffer.
  • Volume control in the datapath: bpf.monitorAggregation (Helm default medium; agent flag default None). At medium and above, intra-node receive traces are suppressed and established flows report only when bpf.monitorInterval (5s) elapses or a flag in bpf.monitorFlags is seen. Per-class toggles: bpf.events.drop.enabled, bpf.events.policyVerdict.enabled, bpf.events.trace.enabled (all true by default), and an experimental rate limit bpf.events.default.rateLimit / burstLimit (both set or both zero).
  • Flows in hubble observe live in an in-memory ring (hubble.eventBufferCapacity, must be 2^n - 1, default 4095, maximum 65535). They are best-effort: perf-buffer overruns and observer-queue overflow both lose events, and they are counted under different source labels (perf_event_ring_buffer, observer_events_queue).
  • L7 events do not originate in BPF; they arrive as agent events from the proxies.

9. Inspection Tooling ​

9.1 cilium-dbg ​

bash
CILIUM_POD=$(kubectl -n kube-system get pod -l k8s-app=cilium -o jsonpath='{.items[0].metadata.name}')
kc() { kubectl -n kube-system exec "$CILIUM_POD" -c cilium-agent -- "$@"; }

kc cilium-dbg status --verbose         # KPR, datapath mode (configured vs operational), BPF map pressure
kc cilium-dbg bpf fs show              # bpffs mount
kc cilium-dbg map list -v              # pinned maps and sizes
kc cilium-dbg bpf endpoint list        # cilium_lxc
kc cilium-dbg bpf ipcache list         # identity + tunnel endpoint per IP/CIDR
kc cilium-dbg bpf ct list global       # conntrack
kc cilium-dbg bpf nat list             # SNAT maps
kc cilium-dbg bpf lb list --frontends  # also --backends, --revnat, --source-ranges
kc cilium-dbg bpf lb maglev list       # only with Maglev
kc cilium-dbg bpf policy get --all -n  # every cilium_policy_v3_*, numeric identities
kc cilium-dbg bpf metrics list         # cilium_metrics
kc cilium-dbg bpf config list          # cilium_runtime_config
kc cilium-dbg bpf sha list             # compiled ELF templates
kc cilium-dbg monitor -t drop -t policy-verdict --related-to <ENDPOINT_ID>

cilium-dbg map get <name> shows the agent's cached view, not necessarily the kernel's; cilium-dbg map events <name> shows the update log. There is no bpf tunnel and no bpf calls subcommand.

9.2 bpftool (run on the node) ​

bash
sudo bpftool net show                                     # tcx/tc/xdp/netkit attachments per device
sudo bpftool prog show | grep -E 'cil_|tail_'             # entry and tail programs
sudo bpftool map show pinned /sys/fs/bpf/tc/globals/cilium_ct4_global
sudo bpftool cgroup tree /run/cilium/cgroupv2             # socket-LB hooks
sudo ls /sys/fs/bpf/cilium/endpoints/*/links /sys/fs/bpf/cilium/devices/*/links

Treat pinned maps as read-only. Do not write raw bytes into Cilium maps; the agent's cached state would diverge. The agent image may not include bpftool; this review did not verify it. Run it from the node or a debug Pod if absent.

[1.21 pre-release] --enable-bpf-stats (Helm bpf.stats.enabled, default false) enables kernel run-time statistics and adds a bpf/stats/report hive command, exposed as cilium-dbg bpf stats report (--sort avg|total|runs, --pod, --device, --json), that shows per-program run counts and average run time.

10. Datapath Plugins (beta, new in 1.20) ​

Datapath Plugins let an external component run its own BPF programs around Cilium's entry points without forking the datapath.

  • Enable with Helm datapathPlugins.enabled=true (default false; agent --enable-datapath-plugins); state directory datapathPlugins.stateDir (default /var/run/cilium/plugins).
  • Register a plugin with the CiliumDatapathPlugin CRD (cilium.io/v2alpha1): spec.attachmentPolicy: Always | BestEffort and spec.version.
  • The agent talks to each plugin over gRPC on <stateDir>/<name>/plugin.sock using the PrepareCollection and InstrumentCollection calls. Cilium generates a dispatcher so plugin programs of type BPF_PROG_TYPE_EXT (freplace) run before or after Cilium's entry programs for tc, XDP and cgroup sock hooks.
  • The plugin Pod needs SYS_ADMIN. Any create, update or delete of a plugin CR triggers a full datapath reinitialization. There is no cross-version compatibility guarantee. Absent in 1.18 and 1.19.

Use it for experiments and vendor integrations, not as a general extension point for production changes yet.

11. Labs ​

Prerequisites for all labs: a disposable Linux cluster running Cilium 1.20.2 with kube-proxy replacement, node shell access (SSM or equivalent) and kubectl. Nothing here exposes anything outside the cluster.

Lab 1: Map every Cilium program on a node ​

  1. On a node, run sudo bpftool net show and sudo bpftool prog show | grep cil_.
  2. List pinned links with sudo ls /sys/fs/bpf/cilium/endpoints/*/links /sys/fs/bpf/cilium/devices/*/links.
  3. Run kc cilium-dbg bpf sha list.

Expected: cil_from_netdev on each native device, cil_from_container per lxc* device, cil_to_host/cil_from_host on cilium_host, and (overlay) cil_from_overlay/cil_to_overlay on cilium_vxlan or cilium_geneve. On kernels 6.6 or newer, per-endpoint link pins exist; on older kernels, tc filter show dev <lxc> ingress lists the legacy filter instead. Cleanup: none (read-only).

Lab 2: Policy changes do not recompile ​

  1. Note kc cilium-dbg bpf sha list and the endpoint's map with kc cilium-dbg bpf policy get <ENDPOINT_ID>.
  2. Apply a CiliumNetworkPolicy allowing a client label to TCP/80 on a test backend.
  3. Re-run both commands.

Expected: a new ingress entry with the client's numeric identity appears in the backend endpoint's policy map; the template list is unchanged. Add an explicit deny rule and observe an entry with the deny flag. Cleanup: delete the policy objects. This should be rehearsed in a namespace with no other policies, because cluster-wide default-deny interacts.

Lab 3: Follow one connection through the maps ​

  1. Create a Deployment with two replicas and a ClusterIP Service in a test namespace, and a client Pod.
  2. From the client, curl the ClusterIP. In another terminal run cilium-dbg monitor -t trace -t policy-verdict --related-to <CLIENT_ENDPOINT_ID> on the client's node.
  3. Run bpf lb list --frontends, --backends, and bpf ct list global | grep <backend ip>.
  4. Compare tcpdump -ni eth0 port 80 in the client's netns with the Service IP.

Expected: the service frontend lists the backends; a CT entry exists for the chosen backend; with socket-LB on, the capture shows the backend IP, not the ClusterIP. Optional: with socketLB.hostNamespaceOnly=true the capture shows the ClusterIP (per-packet LB). That switch changes how traffic is load-balanced cluster-wide, so apply it only on a test cluster and revert by restoring the previous Helm value.

Lab 4: Hubble loss accounting ​

  1. Set hubble.eventQueueSize to a deliberately small value on a test cluster (rollback: restore the previous value, which restarts agents).
  2. Generate load with a traffic tool in a test namespace.
  3. Query the agent metrics for hubble_lost_events_total by source.

Expected: observer_events_queue increases when the queue overflows, and the agent logs "hubble events queue is full". Perf-buffer overruns would instead appear as perf_event_ring_buffer.

Lab 5 (advanced): netkit on a fresh node group ​

Only on nodes with a kernel that supports netkit. Install or upgrade a new node group with bpf.datapathMode=auto, then check cilium-dbg status for configured versus operational mode, ip -d link show for the device type, and bpftool net show for netkit attachments. Setting bpf.tproxy=true together with netkit makes the agent refuse netkit in 1.19 and later (auto falls back to veth). Rollback: do not switch existing veth Pods in place; drain and replace nodes.

Version Delta ​

Item1.18.141.19.81.20.21.21.0-pre.3
Minimum kernel (docs)5.10 (4.18 on RHEL 8.10)5.105.105.10
bpf_network.cpresentpresentremovedremoved
CILIUM_CALL_SIZE48495050
Config variables (grep count)184373104
Policy map namecilium_policy_v2_cilium_policy_v2_cilium_policy_v3_cilium_policy_v3_ (auth bits reserved)
Node mapv1 + v2v2 onlyv2v2
Egress gateway map..._v4..._v4..._v4_v2..._v4_v2
cilium_devices mapnonoyesyes
--datapath-mode valuesveth, netkit, netkit-l2same+ auto+ auto
bpf.tproxybeta (no netkit check found)beta; refuses netkitbeta; refuses netkitbeta; refuses netkit
Datapath Pluginsnonoyes (beta)yes
Mutual auth map (--bpf-auth-map-max)yesyesyes, mutual auth deprecatedremoved
--enable-bpf-statsnononoyes
--node-port-mode, --node-port-algorithmpresentpresentremoved (use --bpf-lb-mode, --bpf-lb-algorithm)removed
Policy-denied ICMP responsenonoyes (tail slots 48-49)yes

The 1.21 upgrade notes in the pre-release tree still carry the 1.20 text, so no authoritative 1.21 delta exists yet.

Troubleshooting ​

SymptomLikely causeCheck
Drops with DROP_EP_NOT_READY (code -203)policy slot empty while an endpoint is being created or deletedcilium-dbg endpoint list; transient during churn, persistent means regeneration failing
"Missed tail call" dropsarray not populated or commit ordering broken after a failed reloadagent log for load errors; cilium-dbg endpoint log <id>; restart the agent Pod
New connections fail, cilium_bpf_map_pressure near 1 for a CT mapCT/NAT map fullcilium-dbg map list -v; raise bpf.mapDynamicSizeRatio or the explicit max; keep NAT at most 2/3 of CT; remember resize drops entries
Services stop matching after growthLB map limit reachedbpf.lbMapMax (affects six maps); resize is disruptive
Agent will not start, "Require support for tcx links" or helper errorskernel below baselineuname -r, cilium-dbg status; move to a supported AMI
Programs on legacy tc instead of tcxkernel older than 6.6 or enableTCX: falsebpftool net show, tc filter show
netkit refusedmissing prerequisite (host routing, BPF masquerade, KPR, tproxy, kernel)agent log reason; cilium-dbg status configured vs operational mode
Verifier rejection at loadunusual feature combination or kernel limitfull verifier log in agent stderr; compare with the 5.10/6.1/net-next tested builds
hubble observe misses flowsperf-buffer or observer-queue loss, aggregation too high, ring too smallhubble_lost_events_total by source; bpf.monitorAggregation; hubble.eventBufferCapacity
Old guide says dump cilium_tunnel_mapmap removeduse cilium-dbg bpf ipcache list
Pod -> NodePort breaks after upgrade to 1.20 with socket-LB off or host-namespace-onlyLB now at client Pod tc hookallow in client egress and backend ingress policies

Validation Limits and Sources ​

The labs were not executed against a live cluster in this review; commands and flags were checked against upstream source and documentation. IDs, IPs and identities in the trace are illustrative. Source facts were read from the Cilium v1.20.2 tree (for example bpf/bpf_lxc.c, bpf/lib/tailcall.h, bpf/lib/policy.h, bpf/lib/eps.h, pkg/datapath/loader/tc.go, pkg/bpf/collection.go, Documentation/cmdref/cilium-agent.md); entry-line numbers move between releases, so verify against your exact tag. Not verified: whether bpftool ships in the agent image; the exact kernel version that introduced the bpf_sock_destroy kfunc; the [1.21 pre-release] stats report was read from source (cilium-dbg/cmd/bpf_stats_report.go) but not run.

Quiz ​

Test your understanding with the Part 8 quiz.