felix/design/bpf-encap-fragments-icmp.md
How BPF shapes packets that don't go straight onto the wire: the flow-mode VXLAN device that lets BPF set per-packet tunnel keys (covering both NodePort-forwarding VXLAN and the pod-to-pod overlay; reserved VNI 0xca11c0), IPv4 fragment defrag/tracking on HEPs, and BPF-synthesised ICMP errors (TTL-exceeded, frag-needed/MTU, port-unreachable) that replace the kernel's normal ICMP emission when BPF bypasses the host stack.
This is one of several sub-designs for the eBPF dataplane. See
bpf-overview.md for the packet-path mental
model, the fast-path cost rule, and the cross-cutting review notes
that apply to every BPF change. The full set of sub-designs is
listed in felix/DESIGN.md.
In the *tables dataplane, Calico creates a VXLAN device bound to a
specific VNI, source address and parent interface; the kernel then
performs encap/decap based on routing and the FDB.
For BPF, this is the wrong shape. We want the BPF program to decide per packet whether something goes into the tunnel, where it comes out, and with what inner/outer addresses. The VXLAN device should just apply the encap envelope the program has specified.
Felix therefore creates the VXLAN device in flow-based (aka
"external") mode when BPF is enabled: no fixed VNI, no fixed source,
no fixed destination. netlink.Vxlan.FlowBased = true in
felix/dataplane/linux/vxlan_mgr.go. The BPF program writes the
tunnel key (destination IP, VNI) via bpf_skb_set_tunnel_key before
redirecting to the device, and the device applies the envelope. On
ingress, the device decaps and the BPF program reads the original
key.
A flow-based device is incompatible with a fixed-VNI device. If Felix
detects a mismatched existing device on startup, it recreates it
(vxlanLinksIncompat in vxlan_mgr.go).
Kernel VXLAN in flow-based mode ties a device to an IP family implicitly — two flow-based devices with the same port in the same namespace produce ambiguity. Calico's compromise, when the cluster is dualstack:
vxlan.calico) is created; it handles both
v4 and v6.maintainIPOnly mode: it programs the
v6 local IP on the existing v4 device and programs v6 ARP/neigh
entries, but does not create or manage its own device.vxlan-v6.calico device is torn down if it exists
(cleanUpVXLANDevice(VXLANIfaceNameV6) in int_dataplane.go).Constants: VXLANIfaceNameV4 = "vxlan.calico",
VXLANIfaceNameV6 = "vxlan-v6.calico" in
felix/dataplane/linux/dataplanedefs/dataplane_defs.go.
Because one device serves both families, its MTU cannot be family-specific. The device is set to the maximum MTU and per-family MTU is enforced by the routes that point to it — for workload traffic, by the workload veth MTU; for host-originated traffic, by the MTU on the host route.
The VXLAN manager continues to program routes into the host routing
table — pod CIDR via vxlan.calico, remote host IP via the parent
interface. These routes are still needed because:
The FDB (vxlanfdb/) is not used to route tunnel packets in BPF
mode — the BPF program sets the tunnel key directly — but it is still
populated with neigh entries so the kernel can resolve a peer MAC
when a packet does take the host-stack path.
The v6 manager explicitly sets vxlanfdb.WithNeighUpdatesOnly() in
BPF dualstack mode because the device is shared and full FDB updates
would conflict with the v4 manager.
*tables to BPFSwitching an existing *tables cluster to BPF recreates the VXLAN
device (flow-based is incompatible with the pre-existing fixed-VNI
shape). Deleting and recreating the device drops the kernel's
conntrack for flows that were established through the old device —
one of the unavoidable costs of the dataplane switch. This is
separate from the broader "preserve pre-existing TCP flows" story
in bpf-conntrack-flowstate.md → Switching from *tables to eBPF, which handles flows whose conntrack was in *tables rather
than pinned to a specific device.
vxlan_mgr.go needs
a matching entry in vxlanLinksIncompat so that a mismatched
existing device is detected and recreated; otherwise the BPF
programs and the device disagree on how tunnel keys are set.vxlan-v6.calico device
in BPF mode breaks tunnel-key resolution.bpf_skb_set_tunnel_key (or equivalent) on a flow-based device;
the device will not apply anything that does not come in via the
tunnel key.Fragments are hostile to stateless BPF programs. Only the first fragment carries L4 headers, so only the first fragment can be keyed against conntrack. Fragments may arrive out of order, so even the first fragment may arrive second. BPF cannot pause a packet waiting for more; it must allow, drop or modify immediately.
Calico defragments only on HEP ingress, where the cluster has no control over the network that produced the fragments. Workload traffic is assumed not to be reordered (the workload and the host share a kernel, and modern kernels do not reorder small local packets).
The implementation is a tail-called sub-program,
calico_tc_skb_ipv4_frag in felix/bpf-gpl/tc_ip_frag.c,
registered as SubProgIPFrag in felix/bpf/hook/map.go. It is
IPv4-only.
The algorithm:
cali_v4_frgfwd,
FwdMap in felix/bpf/ipfrags/map.go) that records the L4
ports and the disposition reached by policy. Subsequent fragments
match on (src_ip, dst_ip, ip_id) and are allowed through without
policy re-evaluation.cali_v4_frags). Once all
fragments are in, the program reassembles the packet in place and
re-runs policy on the full payload.cali_v4_frgtmp) is reassembly
scratch space — each CPU gets a 1.5k-byte buffer so the program
doesn't blow the BPF stack.CALI_ST_SKIP_REDIR_ONCE) rather than bpf_redirect'd. Why: the
reassembled packet is almost certainly larger than the next-hop's
MTU, and the host stack is the only component that can
re-fragment it for the next hop.The reference design document lays out several properties that are not strictly satisfied and this is deliberate:
The IPFRAG_TIMEOUT global controls how long a fragment-tracking
entry may live. Absent a configured value, the kernel's default
fragment timeout applies. A timer-based cleanup removes entries that
were never completed (e.g. a last fragment that never arrived).
calico_tc_skb_ipv4_frag has SetProgramAutoload(false) when
the attach type cannot defrag, and loading retries with it
disabled on verifier failure; see LoadObj in
felix/bpf/hook/map.go). A new sub-program the defrag path
depends on must tolerate SubProgIPFrag being absent.When BPF forwards a packet with bpf_redirect* or decides to drop
one, the kernel's IP stack is bypassed — and with it, the kernel's
ordinary ICMP-error emission. Without an explicit BPF replacement,
common network diagnostics would silently break:
traceroute and mtr would stop working on
BPF-forwarded paths because the forwarder never sent the expected
ICMP Time Exceeded.ICMP error generation lives in felix/bpf-gpl/icmp.h,
icmp4.h (IPv4 icmp_v4_reply) and icmp6.h (IPv6
icmp_v6_reply). The main TC program uses the icmp_too_big
label and similar entry points; the actual packet-building runs
in a dedicated sub-program, calico_tc_skb_send_icmp_replies
(registered as the ICMP-class sub-program in
felix/bpf/hook/map.go).
Building an ICMP error requires:
This is non-trivial work that would make the main program too large for the verifier, and most packets never need it. Splitting ICMP error generation into its own sub-program keeps the fast path small — the main program only tail-calls into it on the rare path where an error is actually needed.
ip_ttl_exceeded in bpf.h tests for TTL==1
(IPv4) / hop-limit==1 (IPv6) on a host-egress path. If that
holds and the packet would have been forwarded, the program
generates an ICMP Time Exceeded and drops the packet instead.vxlan_encap_too_big and similar checks in tc.c compare the
projected post-encap size against the next-hop MTU; on too-big
the program jumps to icmp_too_big and generates ICMP
frag-needed (v4) / packet-too-big (v6) with the right MTU value.ICMP error generation is explicitly a slow path. The main program decides to generate one only on conditions that should be rare in normal operation (TTL exhaustion, PMTU mismatch); the work happens in a tail-called sub-program, not inline. Expensive work here is acceptable.
*tables path.icmp_too_big with the
correct MTU — otherwise PMTU discovery breaks for that
encapsulation.bpf_l3_csum_replace,
bpf_l4_csum_replace). Broken checksums fail silently at the
receiver.felix/bpf/hook/map.go — a change to when it is applicable
(e.g. disabling it for a new attach type) must go through
GetApplicableSubProgs.A change to how the BPF dataplane works in the area this file covers must update the relevant section in the same PR — new mechanism, new flag, new map field, new config knob, or any change to the packet path. Exemptions: (a) bug fix restoring documented behaviour, (b) mechanical refactor with no observable change, (c) comment / log-message edits, (d) dependency bumps. If in doubt, update.
Cross-cutting rules that apply to every BPF change (map
versioning, mark discipline, sub-program registration, kernel-
version sensitivity) live in
bpf-overview.md → Cross-cutting review notes.