felix/design/dataplane.md
Felix's Linux dataplane is a single codebase (InternalDataplane
in dataplane/linux/) switched between iptables, nftables and
eBPF modes — BPFEnabled/NFTablesMode select which managers and
behaviours are wired into the same object. All three modes share
the manager/driver model, the main event loop, the
OnUpdate/apply() cycle, and the restart-and-resync doctrine.
This doc owns that shared architecture (all three modes) plus the
*tables (iptables/nftables)-specific parts — the Table
abstraction, rule generation and dispatch chains, IP sets. eBPF mode
reuses the shared architecture but adds its own managers (notably
bpfEndpointManager), BPF maps and packet path, documented in the
bpf-* family (start at bpf-overview.md); a
BPF change typically needs both docs. Windows is a separate
dataplane, covered here only as a contrast.
Read this file before editing felix/dataplane/linux/,
felix/iptables/, felix/nftables/, felix/generictables/,
felix/rules/, felix/ipsets/, felix/markbits/,
felix/routetable/, felix/routerule/, or felix/vxlanfdb/ (plus
the bpf-* family for BPF-specific files). The input boundary — the
protobuf messages the dataplane receives — is the other end of the
contract in calc-graph.md; the
dataplane API section below
is where that contract is written down. Build/test commands are in
felix/CLAUDE.md; the whole-Felix overview is in
felix/DESIGN.md.
*tables" means the legacy netfilter dataplane, iptables or
nftables — any non-BPF Linux dataplane Felix can program. Where
a statement is backend-specific the backend is named.The calc graph → managers (convert) → drivers (reconcile) layering,
and the dataplane's other jobs — reacting to interface changes,
detecting drift, reporting programming status — are described in
DESIGN.md §1. This
section covers where the manager/driver boundary flexes.
The split is not rigid. Some managers are vertically integrated
(manager and reconciliation logic in one object); others are split
with shared reconciliation logic in a driver. The
bpfEndpointManager is the extreme case — a combined
manager+driver whose reconciliation is complex enough that the two
roles are tightly coupled. When you can, prefer the split: let a
driver absorb the resync complexity behind a declarative
"here is the desired state" API and keep the manager simple (see
Restart, resync and mark-and-sweep).
bpfEndpointManager style only when the coupling is genuinely
irreducible.Every manager implements the Manager interface
(dataplane/linux/int_dataplane.go):
type Manager interface {
OnUpdate(protoBufMsg any)
CompleteDeferredWork() error
}
(Extended interfaces: ManagerWithRouteTables,
ManagerWithRouteRules expose route syncers to the loop;
UpdateBatchResolver lets a manager resolve cross-manager state
before any programming begins.)
OnUpdate must be cheap and must not perform kernel I/O (no
netlink call, iptables-restore, subprocess fork, or BPF map
write). It
may push desired state into the in-memory ipsets/
generictables.Table objects, which queue the change (the
Manager doc-comment permits this; masqManager.OnUpdate does
exactly this via AddMembers/UpdateChain). The pattern: stash the
message and mark the affected resources dirty; all reconciliation
and kernel mutation happen later in CompleteDeferredWork(), walking
the dirty set.
Why the split:
apply() over a
coalesced batch (Felix throttles apply() under load so batches
grow) is the difference between keeping up and not.iptables-restore, subprocess
fork, BPF map write) in an OnUpdate is a bug. It belongs in
CompleteDeferredWork. Flag it.OnUpdate that does work proportional to anything other than the
size of the single message is suspect — the point is to be cheap
and defer.InternalDataplane.apply() (dataplane/linux/int_dataplane.go) is
the throttled reconciliation cycle. The message-receive loop fans
each protobuf message out to every manager's OnUpdate, then
schedules an apply(); under load, applies are rate-limited so
messages coalesce into bigger batches.
apply() runs in a deliberate order, because dataplane resources
have dependencies on each other — most importantly iptables rules
reference IP sets, and the kernel refuses both to create a rule
referencing an unknown IP set and to delete an in-use IP set. The
order is:
dataplaneNeedsSync; it will be re-set if anything fails.UpdateBatchResolver) a chance to resolve cross-manager state
from the batch. This can produce an update from one manager to
another (e.g. an endpoint manager telling the BPF endpoint
manager about a HEP) that must land before either starts
programming.CompleteDeferredWork() on every
manager.apply(); only its QueueResync is gated
on forceXDPRefresh. This is Felix's legacy XDP —
untracked-policy XDP layered on iptables mode (xdpState), no
longer enhanced — not the modern XDP path in the proper BPF
dataplane (see the bpf-* design family). Don't confuse the two.forceRouteRefresh resyncs the route tables, the routing rules,
and the VXLAN FDBs; forceIPSetsRefresh resyncs the IP sets.*tables, now that referenced IP sets exist.If any step fails, the manager/driver keeps its pending state, the
loop sets dataplaneNeedsSync, and apply() will run again. The
failure philosophy below governs how far a
single failure is allowed to halt the rest of the cycle.
The IP-set ordering in steps 6/9/10 is the dataplane half of the cross-layer "never reference an IP set before it's programmed" invariant; the calc graph's flush order is the other half (see IP sets).
apply() ordering must preserve: IP set creates
before *tables; IP set deletes after *tables; the resolution
pass before the programming pass. Re-ordering these silently
breaks the dependency invariants and only fails under specific
timings.apply() work that isn't gated on a dirty flag (i.e.
runs even when nothing changed) erodes the throttling benefit.
Walk the dirty set, don't rescan the world every cycle.Error handling in the loop is, by the maintainers' own assessment, a relatively weak area of the architecture — treat changes here with care. The governing principles:
*tables
programming consistently fails, because that can leave the
node open. After retries are exhausted, Felix gives up and
panics rather than run indefinitely in an unknown, possibly
insecure state.These are distinct mechanisms with overlapping effect:
apply() leaves work dirty
and re-runs soon. The re-run is paced by the leaky-bucket
applyThrottle (with a ~10s retryTicker as a backstop), not by
exponential backoff; exponential backoff is only used inside
the iptables Table.Apply() loop. This recovers from transient
failures.forceRouteRefresh, forceIPSetsRefresh,
XDP): on a timer, queue a resync on the drivers even when
nothing is known to be wrong. This is the belt-and-braces defence
against drift Felix didn't cause and wasn't told about — the
drift-detection job noted in
DESIGN.md §1.
Most drivers resync everything in one pass. The legacy ipsets
driver is the exception: a periodic refresh there is satisfied
incrementally over several apply loops (see
IP sets), because re-listing every set at once is too
slow. Start-of-day and error-triggered ipset resyncs stay full and
synchronous.apply() press past a failure must confirm it
isn't crossing a dependency or security interlock — the case
where blocking is mandatory.*tables programming error (rather than
keeping it dirty / eventually failing loudly) risks leaving the
node silently open. The fail-closed-then-panic behaviour for
persistent *tables failure is intentional; don't soften it
without a strong argument.This doctrine shapes every driver and is the part most often gotten wrong when adding a feature that creates kernel state.
Felix must be restartable at any moment (upgrade, config change, crash) and, on restart, resync with the dataplane and converge to the current desired state with minimal disruption — including cleaning up resources created by a previous Felix instance whose datastore state may have been completely different. The restarted Felix has no memory of what the old one did and gets no "resource X was deleted" event for state the old Felix created but the new datastore no longer wants.
Two consequences:
apply() after that is what triggers
cleanup. If it swept earlier it would delete state it simply
hadn't been told about yet. (This is why the calc graph must
never fabricate in-sync — see
calc-graph.md → In-sync semantics.)
Several drivers are architected so the first Apply()/resync
call performs the read-back-and-reconcile.Therefore: any feature that creates a new kind of kernel resource must, up front, design how a freshly-restarted Felix will recognise that resource as Calico's, for later cleanup. This is a first-class design question, not an afterthought.
The kernel subsystems differ a lot in what they support, which is why the identification mechanism differs per driver:
| Subsystem | How Felix recognises its own state |
|---|---|
iptables (iptables/) | A rule comment with our prefix and a hash of the input rule. Needed because iptables-save output does not round-trip — the kernel re-canonicalises some constructs and the tools reformat others (the one concrete documented case is TCP-flag matches, per iptables/actions.go; MARK/CONNMARK are rendered to round-trip). The comment lets Felix (i) identify Calico rules even outside Calico-owned chains, and (ii) detect drift: read-back hash ≠ desired hash ⇒ reprogram. (Does not defend against malicious tampering that preserves the comment — out of threat model.) |
nftables (nftables/) | Inherited the iptables hash/prefix approach for porting ease, but doesn't strictly need it: if it's in our table, it's ours. A future simplification. |
ip rules (routerule/) | No marking support. Identified by the tables they jump to being Felix-owned. Imperfect — a config change can confuse it. |
routes (routetable/) | Heuristic, because the first implementation didn't uniformly use the route proto field (it should have): in a Felix-owned table ⇒ ours; carries our proto ⇒ ours; points down a cali-owned veth ⇒ ours; etc. The classifier is the OwnershipPolicy interface — MainTableOwnershipPolicy.RouteIsOurs/IfaceIsOurs in routetable/ownershippol/. (Not to be confused with RouteClass, which is a same-CIDR conflict tie-breaker among desired routes, not an ownership test.) |
| IP sets, iptables chains, veths | Identified by name prefix (cali...). |
(RouteClass, ifaceName) is the
ownership key: RouteTable.SetRoutes() replaces the whole
desired set for that pair, so two managers that write to the same
pair silently delete each other's routes. Each independent producer
therefore needs its own class — which is why the same-subnet routes
(shared parent device) and the IPAM-block drop routes (shared
InterfaceNone) are split per encapsulation type.Recognising "our" resources is not only about cleanup — it is also security-critical, because the dataplane has to keep doing the right thing in the windows when Felix is down, restarting, or behind. The motivating race:
cali* veth and plugs in
the pod before that endpoint round-trips through the datastore
back to Felix — so for a moment the interface exists but Felix has
no policy for it.Therefore the iptables/nftables dispatch chains (the
dispatch trie/map)
fail closed: they drop traffic to/from any cali* interface
that isn't explicitly allow-listed. This is a Felix responsibility,
not the CNI plugin's: the CNI plugin relies on Felix having already
programmed that catch-all drop rule, so a freshly-plugged veth carries
no traffic until Felix programs its policy — and the rule stays in the
kernel even if Felix is stopped entirely.
The general doctrine, which applies to any dataplane change: think about what happens if Felix crashes or stops at this exact point. Existing, already-secured traffic must keep flowing; anything that can't yet be secured properly must fail closed, not fall open.
This section is the one place the calc-graph→dataplane contract is
written down; calc-graph.md links here for the
consumer view, and documents the producer-side ordering machinery
(the EventSequencer).
proto.* messages in
felix/proto/, delivered to managers' OnUpdate. It
exists because Felix was split Python→Go along this seam during a
rewrite; the protobuf encoding is an artefact of that history.EventSequencer flushes in dependency-safe order, so the
dataplane may assume references arrive before referents and are
removed after them: IP sets before policies, policies/profiles
before the endpoints that reference them, VTEPs before routes. The
authoritative ordering and its rationale are in
calc-graph.md → Flush order is the dependency contract.calc-graph.md → The missing-resource tension;
a dataplane that consumes a pass-through signal owns the receiving
half.proto.* message shape (field added/removed/
repurposed, message semantics changed) is a change to this
contract: update this section and
calc-graph.md, and consider the VPP
consumer — flag it for a heads-up.EventSequencer
guarantee, not by incidental current behaviour.*tables Table abstractionThe iptables (iptables/table.go) and nftables (nftables/) Table
types, over the shared generictables.Table interface, own
reconciliation of *tables chains and rules. The model:
cali-* chains outright and reconciles them
to the desired contents. Into kernel-owned chains it inserts
(or appends) only its own jump rules and recognises them by hash
comment (see identification).generictables.RuleHasher);
on resync Felix reads back, compares hashes, and reprograms only
what differs — giving minimal-delta, non-disruptive updates.iptables-restore is used for performance and atomicity.
Applying the whole update through one iptables-restore is much
faster than issuing individual iptables calls, and lands as a
single atomic per-table transaction (see the goals comment in
iptables/table.go). Note this does not make full rewrites
free: Felix still computes the hash delta and reprograms only
changed rules, partly to avoid resetting iptables packet/byte
counters on rules that didn't change.Apply() has failed several times in a row, the
nftables backend recreates the table in case the change it is
trying to make is incompatible with the current state. The
delete table and add table go in the same transaction as
the rewrite of every chain and rule, and the sets and maps in the
table are marked for reprogramming at the same time. A failed
attempt therefore leaves the existing ruleset alone rather than
stripping the base chains and their hooks, which would leave
traffic unfiltered. iptables has no equivalent; its retry loop
only backs off.The two backends are kept behaviourally aligned, but parity is a deliberate decision, not an automatic requirement:
NFTablesFlowTableOffload programs a flowtable object plus a flow offload rule in filter FORWARD, handing established flows to the
kernel's software fast path. The fast path fires from the ingress
hook of a member device and short-circuits straight to the output
device, so an offloaded flow never reaches FORWARD or POSTROUTING.
Anything Felix renders into those hooks stops applying for the life of
the flowtable entry.
Two invariants follow:
The offload rule sits ahead of the dispatch jumps. It matches
RELATED,ESTABLISHED only, so NEW and INVALID packets still
traverse policy. Per-endpoint chains accept established traffic
before any NFLOG rule, so policy attribution in flow logs is
unaffected; connection byte counts come from nf_conntrack_acct
rather than from Felix.
The offload rule excludes endpoints whose features need those
hooks, by IP. flowtableExclusionManager in
flowtable_mgr.go maintains
the no-flow-offload IP set, holding the IPs of endpoints with DSCP
marking (rendered into mangle POSTROUTING) or a connection or packet
rate limit (rendered into the endpoint's filter chain), and the
offload rule matches neither source nor destination in that set.
Bandwidth QoS is
enforced by tc on the veth, which the fast path still traverses, so
it does not disqualify an endpoint.
Keeping the endpoint's veth out of the flowtable device set is not a substitute, and this was measured rather than assumed: the offload rule creates the entry whichever devices the flow uses, and the fast path then fires from the ingress device. A plain pod talking to a connection-limited pod still short-circuits at its own veth, skipping the limit. Only the reply direction would be protected.
Membership is also gated on the interface being up, in both the
endpoint manager (workload veths) and
flowtable_mgr.go (overlay and
pattern-matched host devices). nft rejects the whole transaction if a
flowtable names a device the kernel doesn't have, which takes down the
entire table.
no-flow-offload set and cover it in fv/flowtable_test.go.*tables rule semantics must first decide the
iptables/nftables story explicitly (both? nft-only? — see above),
and should carry FV coverage in the relevant mode(s)
(make fv and/or make fv-nft).felix/rules/ is the *tables rule-rendering layer: it
converts lists of Calico-internal rules/endpoints/etc into concrete
*tables rules.
sortAndDivideEndpointNamesToPrefixTree /
buildSingleDispatchChainTree in rules/dispatch.go): roughly one
branch per distinct next-character rather than one rule per
endpoint. This is the *tables analogue of the BPF fast-path
discipline: keep per-packet work sub-linear in the number of local
endpoints. The dispatch chains also fail closed: a cali*
interface that isn't in the trie is dropped, which is the
security-critical default that protects not-yet-known workloads
(see Fail closed while Felix isn't running).MarkBitsManager (felix/markbits/, e.g. NextSingleBitMark)
allocates bits for *tables modes from the configured range. (BPF
uses a fixed, congested range whose individual bits are managed
in a BPF header file — see the BPF design family.) An allocation
that exhausts the range, or collides with bits another subsystem
expects, is a startup/runtime failure.MarkBitsManager against the configured range, and must not
assume a specific bit is free. The Enterprise build allocates
further bits from the same range, so a bit that looks free here may
collide there — check downstream before adding one.felix/ipsets/ reconciles kernel IP sets against
desired membership. Three behaviours look odd until you know the
kernel constraints behind them:
*tables updates. You
cannot delete an in-use IP set, so a set being removed must first
have every referencing rule removed — which the apply() ordering
(delete IP sets after *tables) guarantees.ipset destroy
is surprisingly slow (~40ms) and serialised in the kernel: Felix
caps deletions per iteration (MaxIPSetDeletionsPerIteration = 1,
rescheduling with a ~100ms floor), so a big policy teardown of
thousands of sets doesn't stall the whole dataplane on cleanup.ipset list <name> (needed for an
ipset compatibility issue), so re-listing every set on each refresh
is far too slow. A refresh instead lists only the names cheaply,
repairs any set that vanished or appeared unexpectedly right away,
and re-checks the surviving sets' contents from a two-tier queue
(resyncQueue) drained a time-boxed batch per apply
(BackgroundResyncTimeBudget), paced by the same ≤100ms reschedule
as deletions. The must tier (start-of-day and error-forced
resyncs) is drained fully before Felix trusts the dataplane; the
background tier (periodic refresh) is spread over apply loops. A
desired set found missing from the name listing is repaired in the
same apply — its dataplane view is cleared so the normal
create-path recreates it — rather than being queued for a wasted
per-set list. On nodes with many sets the queue may never fully
drain between refreshes; re-adds keep an entry's queue position, so
the sweep degrades into a continuous rolling scan and a given set's
contents are re-checked roughly once per sweep time, which can
exceed IpsetsRefreshInterval. That is the intended trade-off, not
a pacing bug.The cross-layer invariant: never reference an IP set before it is programmed. This is enforced jointly by the calc graph and the dataplane, and a change to either alone breaks it:
EventSequencer flush order), andapply() ordering creates IP sets before *tables and
deletes them after.apply(), must preserve the joint
"reference only after programmed / delete only after
dereferenced" invariant. Reason about both layers together.felix/routetable/,
felix/routerule/ and
felix/vxlanfdb/ are drivers in the sense above:
managers (vxlanManager, ipipManager, wireguardManager,
noEncapManager, …) compute desired routes/rules/FDB entries and
hand them off; these drivers reconcile against netlink.
They follow the same doctrine as the rest of the dataplane — start-of-day resync, minimal-disruption deltas, mark-and-sweep of orphans — but with the weakest identification story (see the identification table): routes by a heuristic blend of owned-table / owned-proto / points-down-a-cali-veth, ip rules only by the tables they jump to. That makes ownership classification and resync correctness the delicate part of any change here.
The deep netlink-level design of route resync (grace periods for CNI races, conntrack cleanup on IP moves, etc.) is large enough to warrant its own future sub-design; this section covers only how the route drivers fit the dataplane architecture.
The manager/driver topology mirrors the kernel's structure. Because the Linux kernel runs IPv6 largely as a separate plane, Felix instantiates a second copy of the managers and drivers for IPv6, and the two are ships in the night — IPv4 and IPv6 updates can often run in parallel for that reason.
The standard failure here is touching only the IPv4 instance.
Legitimate asymmetries are rare on the *tables path. Two notes:
Not everything is duplicated per family. Some components are
deliberately single instances that feed the per-family managers.
The live migration monitor (dataplane/linux/live_migration.go) is
the model: one FSM per workload, driven by GARP detection and timers
that have nothing to do with IP family, whose state changes are then
pushed into the endpoint managers — which own the per-family route
programming.
For those components the dual-stack trap is not "did you duplicate
the code" but "did you fan out to every family's manager". A
singleton holding a single reference to its downstream manager
silently serves IPv4 only, and nothing fails loudly: the IPv6
manager simply never learns the state, so its routes keep their
default behaviour. That was CORE-12806 — the live migration
monitor's listener field was assigned the IPv4 endpoint manager
only, so IPv6 workload routes were never suppressed on a migration
target nor elevated after cutover, and IPv6 traffic to a migrating
VM could black-hole for the duration of the migration. Prefer a list
plus an explicit registration call (registerListener) over a
single-valued field: a missing family then shows up as a missing
call at the construction site in int_dataplane.go, alongside the
RegisterManager call it belongs with.
int_dataplane.go: every per-family manager
the component needs should have a matching registration call. A
single-valued reference field where a list belongs is the smell.Windows has its own dataplane (dataplane/windows/) and a full
design is out of scope here, but it's worth recording what carries
over and what doesn't, because it sharpens the Linux model:
The recurring ways dataplane changes go wrong, and the recipe that avoids most of them.
Start simple: dirty-flag + start-of-day resync. For a
low-traffic manager, don't try to handle each individual update
incrementally. Code the start-of-day resync first, driven by a single
dirty bool:
dirty = true so the resync runs on the first
in-sync apply().OnUpdate, stash the data and set dirty.CompleteDeferredWork, if dirty, reconcile the whole thing
and clear it.That's often all a low-traffic manager needs. Even when it's not the
most efficient possible design, it's a 100%-correct one — enough
to write the felix/fv FV tests against and optimise later.
The hard part is a non-disruptive resync — reconciling without flapping resources that are already correct. There's no shortcut:
cali prefix/comment where supported is ideal) so you can tell
yours apart — this is the same requirement as
mark-and-sweep.The shortcut, where the kernel offers it: atomic full-state replacement. If the kernel can atomically replace the whole resource set and GC orphans itself, you can skip read-back entirely: compute the full desired state and hand it over atomically. This is often the API a driver presents to its manager even when the kernel underneath doesn't work that way — the driver absorbs the read-back/reconcile/cleanup so the manager just declares desired state. (This is exactly why the manager/driver split pays off.)
BPF map versioning (felix/bpf/maps/maps.go) shows the discipline
that goes with this. By default a map is rebuilt from desired
state at upgrade time, exactly like every other resync — no
special handling. The copy/migrate path is reserved for maps whose
contents are sourced by the BPF programs themselves, not by
Felix — in practice the conntrack map. For those,
PinnedMap.EnsureExists/Upgrade repins the live map aside to
<path>_old, builds the new-layout map in the normal pin path,
copies entries across (CopyDeltaFromOldMap/copyFromOldMap), and
drops _old — crash-safe because a restart mid-migration finds
_old and rolls forward. The migration logic is fiddly; apply it
only to maps that genuinely can't be rebuilt.
OnUpdate. Breaks batching and the
safe-before-in-sync boundary. Defer to CompleteDeferredWork.*tables rule changes that
only land on one backend; or forgetting that BPF mode reuses some
of this code (e.g. parts of felix/rules/).The repo-wide doc-update rule
(.claude/CLAUDE.md → Documentation map,
mirrored in
.github/copilot-instructions.md)
applies. For the Linux dataplane, "changes how it works" means: a
new manager or driver, or a change to the manager/driver split; a
change to the apply() ordering or the OnUpdate/CompleteDeferredWork
contract; a new kind of kernel resource or a change to how Calico
resources are identified for resync; a change to the *tables Table
reconciliation, dispatch-chain structure, mark-bit allocation, IP-set
ordering, or route ownership classification; or a change to the
proto.* dataplane API. Update the relevant section of this file in
the same PR — and calc-graph.md too if the
dataplane API contract
changes. This file is the source of truth for the Linux dataplane's
invariants.