L2 forwarding & learning
An L2 network is a single broadcast domain stretched across every node that hosts it, sharing one IP pool (see Networks). There is no kernel bridge spanning nodes; instead two cluster-scoped resources hold the L2 forwarding state, and every node projects them into its eBPF maps.
FDBEntry and Neighbor
Section titled “FDBEntry and Neighbor”The data plane forwards from two maps, each backed by a resource that is the cluster-wide source of truth:
- FDBEntry —
{network, MAC}→ a delivery target and the endpoint’s policy segment. Drives both L2 forwarding and L3→L2 resolution. - Neighbor —
{network, IP}→ MAC. Answers ARP and NDP locally (suppression) and resolves L3→L2; only IPs inside the network’s prefixes get one.
Every node watches both resources and programs its own copy of the maps. When an entry’s endpoint is local, the delivery target resolves to that pod’s device; when it is on another node, to that node’s SRv6 SID, so a frame for a remote MAC is encapsulated. The control plane never writes a node’s maps directly.
Each resource splits into:
- spec — the key: the network, the MAC or IP, and a
sourceofstaticorlearned. The network, MAC and IP are immutable. - status — the resolved binding: the owning endpoint, the managing node, the segment (FDBEntry), the resolved MAC (Neighbor), and a
lastObservedtimestamp for learned entries.
source separates two writers — the static path and the learning loop. A static and a learned entry for the same key cannot coexist, since both derive the same name from their key, so source decides which writer owns it: the learning loop never touches a static entry.
BUM traffic
Section titled “BUM traffic”An L2 network spans nodes with no flood path — Lace never replicates a frame across the overlay. Broadcast, unknown-unicast, and multicast (BUM) frames are dropped.
The exception is the address resolution that IP cannot function without: ARP requests (IPv4) and NDP Neighbor Solicitations (IPv6) are supported. The node answers them locally with a synthetic response built from the bindings it already holds: when the queried address is a known Neighbor, it turns the request into the matching reply — an ARP reply or a Neighbor Advertisement carrying the target’s MAC — and sends it straight back to the asking pod, so the request never leaves the node. A request for an address the node does not know is dropped, as are all other broadcast, multicast, and unknown-unicast frames.
Static entries
Section titled “Static entries”Ordinary pods have their MAC and addresses the moment they attach, so their bindings need no learning. When a pod joins an L2 network, the node hosting it creates the static FDBEntry for the pod’s MAC and a static Neighbor per address, and claims the endpoint with a finalizer.
Their lifecycle is tied to that endpoint: when the pod is removed, the node deletes them as part of tearing the endpoint down. Static entries carry no timestamp and are never swept.
Claiming a learned entry
Section titled “Claiming a learned entry”A pod’s own MAC or address can already be present as a learned entry when it attaches — any device on the network can put a frame carrying them on the wire before the pod exists. The static path then claims the entry instead of creating one: its source is upgraded to static and the pod’s node takes over as managing node, whichever node held it before.
The upgrade only runs in that direction. source may go from learned to static and never back, so once a pod owns a binding it stays pinned to that pod: it is no longer mobile, no longer swept on expiry, and no observation of the same MAC or IP behind another device can move it. Two pods claiming the same MAC or IP is a genuine conflict, not an upgrade — the entry stays with the node that holds it and the collision is logged.
Learning is currently allowed across the whole network prefix, which is what makes the claim necessary. Reserving the pod address range so those addresses can never be learned in the first place is a planned follow-up.
Learning
Section titled “Learning”Workloads whose MAC isn’t known up front — VMs, macvlan guests, live-migrated workloads — are discovered from their traffic instead.
The L2 entry program inspects the source {MAC, IP} of the frames it forwards (ARP and NDP, and every unicast IP frame, so even a quiet endpoint that only carries traffic is seen). A per-CPU table suppresses repeats so a busy binding isn’t reported on every packet; new or due-for-refresh bindings are emitted on a ring buffer.
Not every address behind a MAC should become an IP→MAC binding. The network’s prefixes are the allow-list:
- an address within a prefix is learned fully — the MAC (FDBEntry) and the IP→MAC binding (Neighbor),
- an address outside every prefix, or none yet, is learned as a partial — the MAC only, never a Neighbor. A router MAC fronting nested workloads forwards their out-of-prefix IPs under its own MAC; those must not enter the neighbor table, but the forwarding MAC itself still must.
From the data plane to resources
Section titled “From the data plane to resources”The node plugin reads the ring buffer, resolves each event’s source device to the local NetworkEndpoint that owns it, and stages the observation; a reconcile then creates or refreshes the matching learned FDBEntry and Neighbor. The node that owns the source endpoint is the one that writes the binding.
status.lastObserved records when the binding was last seen. To avoid a write on every packet of a hot binding, the managing node refreshes it only on a coarse cadence — though a change to the segment, endpoint, or managing node republishes immediately regardless.
Ownership and takeover
Section titled “Ownership and takeover”A learned entry is owned by the node that last observed it, recorded in status.managingNode. Only the node actually seeing a workload’s frames observes its binding, so ownership follows the workload: when it moves, the binding surfaces on the new node, which claims managingNode the moment it observes it. Takeover is not debounced — the data plane already rate-limits how often a binding resurfaces, so ownership follows a move as fast as it is seen.
Expiry and GC
Section titled “Expiry and GC”Because refresh is traffic-driven, a binding that goes silent must eventually be reaped. Each node periodically sweeps its own learned entries and deletes those whose lastObserved is older than the expiry window. Static entries, and entries managed by other nodes, are left alone.
The intervals are ordered refresh < expiry, so a binding that is briefly idle or mid-takeover is never deleted out from under an active workload. Each delete carries a resource-version precondition, so an entry refreshed between the sweep’s list and its delete is skipped rather than lost.
One gap: a managing node that dies cannot sweep its own entries; another node reclaims them only if it still sees the binding, so a binding genuinely gone under a dead node lingers.