Linux Bridges and Software Switching
A Linux bridge is an in-kernel implementation of an IEEE 802.1Q learning Ethernet switch: a software device that connects several network interfaces — virtual Ethernet pairs (veth Pairs), tap devices, physical NICs — into a single Layer-2 broadcast domain and forwards Ethernet frames between them based on the destination MAC (Media Access Control) address (kernel bridge doc). It does exactly what a physical switch does — learn which MAC lives behind which port by snooping source addresses, forward a frame to the single port that owns its destination MAC, and flood it to every port when the destination is unknown, broadcast, or multicast. This learning behaviour lives in
net/bridge/br_input.candbr_forward.c, and the learned table (the forwarding database, or FDB) lives inbr_fdb.c. Because the bridge is just anothernet_device, it is the workhorse that turns isolated container/VM network namespaces into a shared LAN: the classicdocker0/CNI-bridge model plugs each container’s veth into one bridge, and they talk to each other as if cabled to the same switch.
This note covers the kernel mechanism of the software bridge — how frames are learned, forwarded, flooded, and filtered, plus STP, VLAN filtering, and br_netfilter. The orchestration that assembles bridges for containers — CNI plugins, the bridge plugin, Pod Networking — consumes this primitive and is owned by Kubernetes MOC; this note stands on its own and the bridge facts are pinned to Linux 6.12 LTS source unless noted.
Mental Model — A Switch Made of Software
Think of the bridge as a virtual switch chassis. The bridge device itself (br0) is the chassis backplane; each bridge port (struct net_bridge_port) is a physical port on the front panel, and any net_device enslaved to the bridge — a veth end, a tap, an eth0 — is a cable plugged into that port. Frames arriving on any enslaved interface are intercepted before they reach the normal IP stack, handed to the bridge’s forwarding logic, and either delivered out one specific port, flooded to all ports, or passed up to the host’s own IP stack if the bridge itself is the destination.
flowchart TB subgraph HOST["Host network stack"] IP["br0 device<br/>(host IP lives here)"] end subgraph BR["Linux bridge br0 — software switch"] FDB["Forwarding DB (FDB)<br/>MAC → port, aging timers"] FWD["Forwarding logic<br/>br_handle_frame_finish()"] end P1["port: veth-a<br/>(container A)"] -->|"frame in"| FWD P2["port: veth-b<br/>(container B)"] --> FWD P3["port: eth0<br/>(physical uplink)"] --> FWD FWD <-->|"learn src / lookup dst"| FDB FWD -->|"unicast: known MAC"| P2 FWD -->|"flood: unknown/bcast/mcast"| P1 FWD -->|"flood"| P3 FWD -->|"dst == bridge MAC: deliver up"| IP
The Linux bridge as a virtual switch. What it shows: enslaved interfaces (veths, taps, NICs) are ports; every ingress frame hits the forwarding logic, which consults the FDB to either deliver out one port (known unicast), flood to all forwarding ports (unknown unicast / broadcast / multicast), or pass the frame up to the host IP stack when the destination MAC is the bridge’s own. The insight: the bridge is transparent at L3 — containers behind it share one subnet and never see the bridge as a hop — but it is an active, stateful L2 device whose entire intelligence is “learn source MACs, forward to known destinations, flood otherwise.”
How a Frame Flows Through the Bridge
Ingress capture: the rx_handler
A net_device becomes a bridge port when it is enslaved (ip link set veth0 master br0). Enslaving installs the bridge’s receive handler on the device; br_get_rx_handler() returns br_handle_frame for a normal port (br_input.c). When the core receive path (__netif_receive_skb_core, the NAPI-driven RX path) processes a frame on an enslaved device, it sees an installed rx_handler and calls it instead of delivering the frame to the protocol stack. This is the hook that makes bridging transparent: the frame never reaches ip_rcv() on the slave device; the bridge steals it first.
br_handle_frame() does early sanity work: it drops frames whose source MAC is not a valid unicast address (is_valid_ether_addr(eth_hdr(skb)->h_source)), runs skb_share_check() to get an exclusively-owned sk_buff (see struct sk_buff), and zeroes the bridge’s per-frame control block. It then special-cases link-local destination addresses in the reserved 01-80-C2-00-00-0X range — the addresses IEEE 802.1D reserves for bridge control protocols. A frame to 01-80-C2-00-00-00 (the Bridge Group Address, used by STP BPDUs) is, by default, not forwarded; it is consumed locally so the bridge’s own STP machine can process it. Addresses like 01-80-C2-00-00-0E (LLDP) and others are similarly trapped unless the administrator opts into forwarding them via group_fwd_mask. The comment in the source even reproduces the IEEE 802.1D Table 7-10 reserved-address assignments. For ordinary frames, br_handle_frame() checks the port’s STP state and, if forwarding or learning, dives into nf_hook_bridge_pre() (the bridge-family netfilter PREROUTING hook) which ultimately calls br_handle_frame_finish().
Learning the source MAC
Inside br_handle_frame_finish() (br_input.c), the bridge first decides whether ingress is allowed (port not disabled, VLAN allowed — see below), then learns:
/* insert into forwarding database after filtering to avoid spoofing */
if (p->flags & BR_LEARNING)
br_fdb_update(br, p, eth_hdr(skb)->h_source, vid, 0);This single call is the heart of a learning switch. br_fdb_update() (br_fdb.c) looks up the source MAC (and VLAN id vid) in the FDB hash table. If an entry exists, it refreshes the updated timestamp (resetting the aging clock) and, if the MAC has moved to a different port, atomically rewrites fdb->dst to point at the new port — this is roaming, how the bridge tracks a host that migrated from one port to another. If no entry exists, it creates one via fdb_create() under a spinlock. The comment “insert into forwarding database after filtering to avoid spoofing” is important: learning happens after ingress VLAN/state checks so a frame on a port that is not allowed to carry a VLAN cannot poison the FDB.
The forwarding decision
Having learned the source, the bridge looks up the destination. For a unicast frame:
case BR_PKT_UNICAST:
dst = br_fdb_find_rcu(br, eth_hdr(skb)->h_dest, vid);
break;Three outcomes follow (br_input.c):
- Local delivery. If
dstis found and flaggedBR_FDB_LOCAL— meaning the destination MAC is the bridge’s own address — the frame is passed up to the host stack viabr_pass_frame_up(), which re-pointsskb->devto the bridge device and runs theNF_BR_LOCAL_INhook beforenetif_receive_skb(). This is how the host’s own IP traffic (the address configured onbr0) gets delivered. - Known unicast forward. If
dstis found and points at a port,br_forward(dst->dst, skb, ...)(br_forward.c) sends the frame out that single port.should_deliver()gates this: the destination port must not be the ingress port (no hairpin unlessBR_HAIRPIN_MODE), must be inBR_STATE_FORWARDING, must pass egress VLAN checks, and must not be port-isolated from the source.__br_forward()then runs theNF_BR_FORWARDnetfilter hook and finallybr_dev_queue_push_xmit(), which pushes the Ethernet header back and callsdev_queue_xmit()to hand the frame to the egress port’s qdisc and driver. - Flood. If
dstis NULL (unknown unicast), or the frame is broadcast/multicast,br_flood()is called. It iterates every port (list_for_each_entry_rcu(p, &br->port_list, list)), skipping the ingress port and any port whose flood flags are off (BR_FLOODfor unicast,BR_BCAST_FLOODfor broadcast,BR_MCAST_FLOODfor multicast). To avoid copying thesk_bufffor the common case,br_flood()uses a one-step-behind trick: it remembers the previous deliverable port (prev) and onlyskb_clone()s for it when it finds a next one, forwarding the original out the last port — so a flood to N ports makes N-1 clones, not N.
The whole path runs under rcu_read_lock(), and the FDB and port list are RCU-protected read-mostly structures — exactly the RCU pattern, since the data plane reads the FDB on every frame but writes it only when a new MAC is learned.
FDB aging
A learned entry is not permanent — a host that goes quiet should eventually be forgotten so the bridge re-floods to find it. The default ageing time is BR_DEFAULT_AGEING_TIME = (300 * HZ) — 300 seconds, five minutes (include/linux/if_bridge.h, line 65). A delayed work item, br_fdb_cleanup() (br_fdb.c), walks the FDB and deletes any dynamic entry whose updated + ageing_time is in the past (static and externally-learned entries are skipped). Because the cleanup re-schedules itself for the soonest upcoming expiry (mod_delayed_work(... work_delay)), aging is event-paced, not a fixed poll. During an STP topology change the effective hold time drops to forward_delay (default 15 s) so stale entries flush faster — hold_time() returns br->topology_change ? br->forward_delay : br->ageing_time.
Configuration — Building and Inspecting a Bridge
The legacy tool was brctl (from bridge-utils); the modern interface is ip link and the bridge command from iproute2. The kernel exposes bridge creation as an rtnl_link type (man ip-link(8)).
# Create a bridge device (modern iproute2 way; brctl addbr br0 is the legacy equivalent)
ip link add name br0 type bridge ageing_time 30000 stp_state 0 # see units note below
# Enslave two veth ends and a physical NIC as ports
ip link set veth-a master br0 # plug container A's host-side veth into br0
ip link set veth-b master br0 # container B
ip link set eth1 master br0 # physical uplink
# Bring everything up
ip link set br0 up
ip link set veth-a up; ip link set veth-b up; ip link set eth1 up
# Give the host an IP on the bridge itself (br0 is a routable interface)
ip addr add 10.0.0.1/24 dev br0Line-by-line: type bridge selects the bridge rtnl link kind; ageing_time sets the FDB aging interval (the kernel default is 300 seconds — the iproute2 argument’s units are a known wrinkle, flagged below); stp_state 0 disables STP, which matches the kernel default (br->stp_enabled = BR_NO_STP, br_device.c). ip link set X master br0 enslaves a device — this is what installs the bridge rx_handler and creates the net_bridge_port. Note the host’s IP goes on br0, not on the enslaved NIC; a bridged eth1 is just a dumb port.
Inspect the learned table and ports:
bridge fdb show br br0 # the FDB: MAC, port, flags (permanent/self/master), VLAN
bridge link show # ports and their STP state
bridge vlan show # per-port VLAN membership when vlan_filtering is on
ip -d link show br0 # detailed bridge params: ageing_time, stp, vlan_filteringA typical bridge fdb show line — 52:54:00:aa:bb:cc dev veth-a master br0 — reads “MAC 52:54:... is reachable via port veth-a, learned by bridge br0.” Entries with the permanent flag are static (admin-added, never aged); self entries live in the device’s own FDB (relevant for offloaded/VXLAN FDBs).
Uncertain
Verify: the units of the iproute2
ageing_timeargument inip link add type bridge. Reason: iproute2 historically passed bridge timers in user-facing seconds while olderbrctl/sysfs used clock-ticks/centiseconds, and the man page text I consulted listsageing_time AGEING_TIMEwithout stating units. The kernel default is unambiguously 300 seconds (300 * HZ). To resolve: check the runningiproute2version’sip-link(8)BRIDGE section and confirm against/sys/class/net/br0/bridge/ageing_time(kernel sysfs is in clock-ticks). uncertain
VLAN Filtering — 802.1Q Inside the Bridge
By default a Linux bridge is VLAN-unaware: it treats all ports as one flat broadcast domain and ignores 802.1Q tags. Turning on vlan_filtering (ip link add ... type bridge vlan_filtering 1) makes it a proper 802.1Q VLAN-aware switch. Each port then has a set of permitted VLANs and a PVID (port VLAN id) — the VLAN assigned to untagged ingress frames. The kernel object is struct net_bridge_vlan, which carries the vid, flags, and per-VLAN STP state (kernel bridge doc). Ingress is gated by br_allowed_ingress() and egress by br_allowed_egress() (called from should_deliver()); a frame tagged with a VLAN a port is not a member of is dropped. The FDB itself is VLAN-scoped: lookups are keyed on (MAC, vid), so the same MAC can be learned independently per VLAN. This lets a single Linux bridge serve multiple isolated tenants over a shared trunk — the model OVS and many CNI VLAN setups rely on.
ip link add br0 type bridge vlan_filtering 1
bridge vlan add dev veth-a vid 10 pvid untagged # access port in VLAN 10
bridge vlan add dev eth1 vid 10 # trunk carries VLAN 10 tagged
bridge vlan add dev eth1 vid 20Here veth-a is an access port: untagged frames in get tagged VLAN 10 internally, and VLAN-10 frames out are sent untagged (pvid untagged). The eth1 uplink is a trunk carrying VLANs 10 and 20 with tags intact.
br_netfilter — Running iptables on Bridged Frames
A pure L2 bridge forwards frames without ever invoking the IP-layer firewall — which is sometimes a problem: operators want iptables rules to apply to traffic crossing the bridge (the classic “bridging firewall”). The br_netfilter module (CONFIG_BRIDGE_NETFILTER, module br_netfilter, described in source as “Linux ethernet netfilter firewall bridge”, br_netfilter_hooks.c) implements this. When loaded and enabled via the sysctls net.bridge.bridge-nf-call-iptables, bridge-nf-call-ip6tables, and bridge-nf-call-arptables, it causes bridged IP frames to also traverse the IPv4/IPv6 iptables/nftables hooks (The Netfilter Framework and Hooks) as if they were routed packets — the bridge transparently exposes L3 information to the L2 forwarding path.
This is powerful but a notorious source of surprises. br_netfilter is a single global module, not per-bridge or per-namespace in the obvious way, and enabling it can change the behaviour of every bridge on the host. In container land it has bitten many operators: Kubernetes historically required net.bridge.bridge-nf-call-iptables=1 so that kube-proxy’s iptables rules (Pod Networking) would see pod-to-pod traffic crossing the node bridge, and a missing or reset value silently broke Service routing or NetworkPolicy enforcement. The native L2 firewall for bridges is instead ebtables (legacy) or the bridge family of nftables (nft add table bridge filter), which hooks the NF_BR_* points (NF_BR_PRE_ROUTING, NF_BR_FORWARD, NF_BR_POST_ROUTING) you can see invoked throughout br_input.c/br_forward.c as NF_HOOK(NFPROTO_BRIDGE, ...).
Uncertain
Verify: whether, on 6.12, the
bridge-nf-call-*sysctls andbr_netfilterare network-namespace-scoped or still effectively global, and the exact default values of those sysctls when the module loads. Reason: the namespacing ofbr_netfilterhas changed across kernel versions and I did not trace the per-netns sysctl registration in the 6.12 source during this task. To resolve: read thebrnf_sysctl_call_tables/brnf_net_initpaths inbr_netfilter_hooks.cat v6.12 and confirm againstdocs.kernel.org/ current LWN coverage. uncertain
Spanning Tree Protocol (STP)
A bridge with redundant links creates loops, and a loop in an L2 network is catastrophic: a broadcast frame circulates forever, multiplied at every fork, melting the network (a “broadcast storm”). The Spanning Tree Protocol (IEEE 802.1D) prevents this by having bridges elect a root and disable redundant ports until the active topology is a loop-free tree. The Linux bridge has a built-in STP implementation (br_stp*.c in the Makefile) but it is disabled by default (br->stp_enabled = BR_NO_STP, br_device.c). The default timers are the 802.1D defaults: max_age = 20 s, hello_time = 2 s, forward_delay = 15 s (each stored as N * HZ). A port running STP transitions through blocking → listening → learning → forwarding, and the kernel encodes these as BR_STATE_*; only BR_STATE_FORWARDING allows data forwarding, while BR_STATE_LEARNING populates the FDB without forwarding. The forward_delay (15 s twice) is why a freshly-cabled STP port takes ~30 s to pass traffic — the cause of the classic “my server can’t reach the network for 30 seconds after boot” puzzle, fixed in practice with portfast-style edge-port config or simply leaving STP off on bridges with no loops (the container case).
Failure Modes and Misunderstandings
- “The bridge is a router.” It is not — it is L2-transparent. Containers on a bridge share one subnet and the bridge never decrements TTL or appears as a hop in
traceroute. If you need traffic to cross subnets, you route off the bridge (givebr0an IP and enableip_forward), not through it. - MAC flapping / roaming storms. If the same MAC appears on two ports rapidly (e.g. a misconfigured loop, or two veths with the same MAC), the FDB entry’s
dstflips back and forth on every frame — visible as constant roaming and erratic delivery. The fix is to find the loop or duplicate MAC, not to tune the bridge. br_netfiltersilently changing iptables semantics. Loading the module makes bridged frames traverse the IP firewall, so a rule written for routed traffic suddenly matches (or drops) bridged traffic. The reverse also bites: tooling that setsbridge-nf-call-iptables=0to “fix” something can break Kubernetes Service routing (see the uncertainty note above).- STP forwarding delay. A newly-enslaved port with STP on is in blocking/listening for
2 * forward_delay(~30 s) before forwarding. On bridges that can never loop (a single host’s container bridge), leave STP off. - Enslaving a NIC that has an IP. Moving
eth0under a bridge orphans its IP — the address must be reassigned tobr0. Doing this over the very interface you are SSH’d into will cut your session.
Alternatives and When to Choose Them
- macvlan / ipvlan. Instead of a bridge, attach virtual sub-interfaces directly to a parent NIC.
macvlangives each child its own MAC on the parent’s segment; it is lighter than a bridge (no FDB, no flooding logic) and useful when you want containers to appear as first-class hosts on the physical LAN, butmacvlanchildren cannot talk to the host by default. Choose a bridge when you need host↔container reachability and a private host-local subnet. - Open vSwitch (OVS). A far more capable software switch with OpenFlow programmability, full per-flow rules, and rich tunneling. OVS is the choice for SDN dataplanes (OpenStack, OVN/Cilium in some modes); the Linux bridge is the choice when you want a simple, zero-config L2 switch in the kernel with no userspace daemon.
- A pure tunnel device (no bridge). For multi-host overlays you often pair a bridge with a GENEVE device, or skip the bridge entirely and route per-pod with a CNI like Calico. Choose the bridge when local L2 adjacency is what you need; choose tunnels/routing when hosts are on different L3 segments.
Production Notes
The Linux bridge is the default container networking model. Docker’s default bridge network creates docker0, a Linux bridge, and attaches each container’s host-side veth to it; pods on many Kubernetes CNI plugins (the bridge CNI plugin, Flannel’s cni0, Calico in some modes) do the same per node. Because the bridge participates in the host’s netfilter path when br_netfilter is enabled, the interaction between the bridge and iptables/nftables (The Netfilter Framework and Hooks) is where most container-networking debugging happens — the net.bridge.bridge-nf-call-iptables sysctl is in nearly every Kubernetes node-setup guide for exactly this reason. For high packet rates, software bridging adds per-frame cost (FDB lookup, clone-on-flood, netfilter traversal); production large-scale dataplanes that need line rate move the forwarding into hardware (switchdev offload, where the kernel bridge programs a real ASIC) or bypass the bridge with eBPF (tc-BPF, e.g. Cilium’s bridgeless mode).
See Also
- veth Pairs — the virtual cables that plug container namespaces into the bridge
- Tunnel and Overlay Interfaces — VXLAN/GENEVE devices often bridged or paired with a bridge for multi-host overlays
- Network Namespaces — the isolation the bridge connects across
- The Netfilter Framework and Hooks — the hooks
br_netfilterand the nftablesbridgefamily attach to - nftables and the nf_tables Subsystem — the modern L2 firewall (
nft ... table bridge) - Pod Networking · Container Network Interface — the orchestration that assembles bridges for pods (Kubernetes view)
- Traffic Control Overview — the egress qdisc each bridge port hands frames to
- MOC: Linux Networking Stack MOC (§10, the container substrate)