Linux Lesson 32 of 47

Advanced Linux Networking: Bonding/Teaming, VLANs, Bridges, veth, Namespaces & Policy Routing

Everything you have configured so far — a static IP, a default route, a DNS server — treats the host as a single endpoint plugged into someone else’s network. This lesson flips that around. Here you build the network itself, inside one kernel: you bond physical links, tag VLANs, run a software switch, cut isolated network stacks out of thin air, and route traffic by policy rather than destination. These are not exotic tricks. They are the exact primitives that KVM hosts, OpenStack computes, Docker daemons, and every Kubernetes node assemble automatically — and the day one of those “just works” abstractions breaks, you are the person who has to know what veth, bridge fdb, and ip rule actually do underneath.

This is an expert lesson and it assumes Tier-2 fluency. If ip addr, ip route, CIDR, the outbound packet path, and ss -tulpn are not already second nature, work through Linux Networking Fundamentals: ip, nmcli, DNS & ss first — that lesson ends exactly where this one begins. Everything below should be typed on a throwaway VM or a privileged container, never on a box you cannot reach by console, because half of these commands are the network you are connected over.

You should already own Why it matters here
ip addr / ip link / ip route Every construct below is created and inspected with ip; ifconfig/brctl/vconfig are all legacy
CIDR and gateways Bridges, VLANs and namespaces each need an address plan; policy routing is pure route math
The outbound packet path Bonding, bridging and tunnels each insert themselves at a specific point on that path
ss, ping, tcpdump basics Debugging virtual networks is 90% “capture on the right interface”
Root / sudo and CAP_NET_ADMIN Creating links and namespaces is privileged; a plain container needs --privileged or --cap-add=NET_ADMIN

Almost everything below is iproute2 and in-kernel modules that ship with every distro; a few legacy or optional tools need a package. The families differ, so here is the map:

Tool Provided by Debian/Ubuntu (apt) RHEL/Fedora/Rocky (dnf)
ip, bridge, ss iproute2 preinstalled (iproute2) preinstalled (iproute)
bonding / 8021q / vxlan / dummy in-kernel modules modprobe <mod> (built-in) modprobe <mod> (built-in)
nmcli NetworkManager network-manager NetworkManager
teamd / teamdctl libteam (deprecated in RHEL 9+) libteam-utils teamd
brctl (legacy) bridge-utils bridge-utils bridge-utils
vconfig (legacy) vlan vlan (removed — use ip … type vlan)
wg / wg-quick WireGuard wireguard-tools wireguard-tools

Why this matters

A modern Linux host is rarely one machine on one wire. It is a rack of virtual machines and containers, each of which believes it has its own network card, its own IP, its own routing table — all multiplexed over one or two real NICs by kernel objects you can create by hand in about thirty seconds. When a VM can’t reach its gateway, when a container gets no DHCP lease, when “the bond is up but throughput is half of what we paid for,” the fault is almost never in the physical network. It is in one of these host-side constructs, and the vendors’ GUIs hide exactly the layer that broke.

The mental model to carry through this whole lesson is a stack of Lego bricks that each do one job at Layer 2 or Layer 3. A bond takes several NICs and presents one. A VLAN sub-interface takes one link and presents several tagged segments. A bridge is a switch: it takes several links and forwards frames between them by MAC. A veth pair is a cable with two ends you can put in different places. A network namespace is a private, empty network stack — its own interfaces, addresses, routes, and firewall — that you wire to the rest of the world with one of the bricks above. Policy routing changes the one decision the kernel makes for every packet: which routing table do I even consult? Stack the bricks and you have reproduced, by hand, what container runtimes and hypervisors do for you.

Two reasons to learn this the hard way rather than trusting the abstraction. First, debuggability: docker network and virsh net-dumpxml are thin wrappers over ip link, bridge, and iptables, and when they misbehave the only way out is to read the primitives directly. Second, the constructs compose: the real production pattern is bond → VLAN → bridge → veth → namespace, all at once, and if you only ever met each brick inside a different tool you will not recognise the stack when it is in front of you at 3 a.m. We finish by building exactly that stack end to end.

A note on scope: the kernel objects here (namespaces, veth, bridges) are the networking half of Linux containers; the process/isolation half — PID, mount, and user namespaces plus cgroups — is a topic in its own right, and virtualization with KVM/QEMU/libvirt layers tap devices onto these same bridges. This lesson is deliberately the networking foundation both of those build on.


Link aggregation, part 1: bonding

Bonding joins two or more physical NICs into one logical interface (bond0) for two payoffs: bandwidth (spread flows across members) and resilience (survive a dead cable, port, or NIC). The behaviour is chosen by a mode, and choosing the wrong mode — or a mode whose switch requirements you did not meet — is the single most common bonding mistake. Learn the modes first; everything else is tuning.

The bonding modes

There are seven modes, numbered 0–6, each also having a name. The ones that matter in practice are 1 (active-backup) for “I just want failover and my switch is dumb,” 4 (802.3ad/LACP) for “proper aggregation with a managed switch,” and 5/6 for “aggregation without touching the switch.”

Mode Number Load balance Failover Switch support needed Use it when
balance-rr 0 Yes (round-robin per packet) Yes Yes — static EtherChannel Max single-flow throughput on a directly cabled pair; tolerates out-of-order
active-backup 1 No Yes None Default safe choice; two switches for redundancy; you control nothing on the switch
balance-xor 2 Yes (hash) Yes Yes — static EtherChannel Deterministic per-flow balance without LACP negotiation
broadcast 3 No Yes Yes Rare; sends every frame on every member (special HA fabrics)
802.3ad (LACP) 4 Yes (hash) Yes Yes — LACP port-channel The datacenter default: dynamic, negotiated, monitored aggregation
balance-tlb 5 Yes (TX by load) Yes None Aggregate outbound without switch config; RX rides one member
balance-alb 6 Yes (TX + RX via ARP) Yes None Aggregate both directions without switch config (ARP-negotiated RX)

The trap is that modes 0, 2, and 4 require the switch to be configured to match — an EtherChannel/port-channel for 0 and 2, an LACP link-aggregation group for 4. If the switch ports are just two ordinary access ports, a mode-4 bond comes “up” but never forms an aggregator, and you get exactly one member’s worth of bandwidth and no failover across the pair. Modes 1, 5, and 6 need nothing on the switch, which is why active-backup (1) is the correct default whenever you do not control the switch.

Key bonding tunables

A mode is not enough; you must tell the bond how to detect a dead member and, for balancing modes, how to hash flows onto members.

Option What it does Typical value
miimon Link-state poll interval (ms) via the driver’s MII status 100
updelay / downdelay Wait N ms before enabling / disabling a member after a link change (debounce flapping) 200 / 200 (multiple of miimon)
arp_interval + arp_ip_target Alternative liveness check: ping a target IP instead of trusting carrier when carrier lies (some virtual NICs)
xmit_hash_policy How flows map to members in modes 2/4: layer2, layer2+3, layer3+4, encap3+4 layer3+4 (spreads by src/dst port too)
lacp_rate LACPDU rate in mode 4: slow (30 s) or fast (1 s) fast for quicker failure detection
ad_select Mode-4 aggregator selection: stable, bandwidth, count bandwidth
primary Preferred active member in mode 1 (e.g. the 10G over the 1G) enp1s0
fail_over_mac In mode 1, whether the bond MAC follows the active member: none/active/follow none (keep a stable MAC) unless VMs need it

xmit_hash_policy is worth internalising: with the default layer2, all traffic between the same two MACs (e.g. host ↔ one gateway) lands on one member — so a single big transfer never exceeds one link’s speed no matter how many members you have. layer3+4 hashes on IP and TCP/UDP port, so many connections spread out. Aggregation gives you aggregate bandwidth across many flows, never a faster single flow (except mode 0, which pays for it with reordering).

Creating a bond — three ways

The runtime way (ip link) is best for understanding and labs; nmcli is what you use on a real, persistent server; the /etc/modprobe.d + kernel-module way is the old style you will still meet.

# --- Runtime, iproute2 (gone on reboot) ---
sudo modprobe bonding                       # load the driver if not already
sudo ip link add bond0 type bond mode 802.3ad miimon 100 \
     xmit_hash_policy layer3+4 lacp_rate fast
sudo ip link set enp1s0 down                # a member must be down to enslave
sudo ip link set enp2s0 down
sudo ip link set enp1s0 master bond0        # enslave both NICs
sudo ip link set enp2s0 master bond0
sudo ip link set bond0 up
sudo ip addr add 192.0.2.10/24 dev bond0    # address goes on the BOND, not the members
# --- Persistent, NetworkManager (the real-server way) ---
sudo nmcli con add type bond con-name bond0 ifname bond0 \
     bond.options "mode=802.3ad,miimon=100,lacp_rate=fast,xmit_hash_policy=layer3+4"
sudo nmcli con add type ethernet con-name bond0-p1 ifname enp1s0 master bond0
sudo nmcli con add type ethernet con-name bond0-p2 ifname enp2s0 master bond0
sudo nmcli con mod bond0 ipv4.method manual ipv4.addresses 192.0.2.10/24 \
     ipv4.gateway 192.0.2.1 ipv4.dns 1.1.1.1
sudo nmcli con up bond0

Note the golden rule visible in both: the IP address lives on bond0, never on the member NICs. The members become anonymous carriers; the bond owns the address, and on a bridged host even the bond may own no address (the bridge does — we get there).

Verifying a bond — and the file that never lies

/proc/net/bonding/bond0 is the ground truth. It shows the mode, each member’s link state, and — for mode 4 — the negotiated aggregator and the LACP partner (your switch). If the partner MAC is all-zeros, the switch is not speaking LACP and you are not aggregating.

cat /proc/net/bonding/bond0
Bonding Mode: IEEE 802.3ad Dynamic link aggregation
Transmit Hash Policy: layer3+4 (1)
MII Status: up
802.3ad info
Aggregator ID: 1
Number of ports: 2
Partner Mac Address: 00:1c:73:aa:bb:cc      # <-- non-zero = switch is doing LACP
Slave Interface: enp1s0
MII Status: up
Slave Interface: enp2s0
MII Status: up
To check Command
Full bond state, members, LACP partner cat /proc/net/bonding/bond0
Mode, hash policy, member roles, kernel view ip -d link show bond0
Live error/drop counters per interface ip -s link show bond0
Which member a flow will use there is no single command — read the hash policy and test with real flows
Force failover in mode 1 (test) ip link set enp1s0 down then re-check /proc/net/bonding/bond0

Link aggregation, part 2: teaming

Teaming (the teamd userspace daemon + libteam kernel driver) was Red Hat’s newer, more modular answer to bonding: the fast path stays in the kernel while policy (“runners”) lives in a small userspace daemon controlled by teamdctl, configured with JSON, and inspectable over D-Bus. In practice, bonding won. Team is functionally a superset of what most people need from bonding, but the kernel bonding driver is more widely supported, and Red Hat has deprecated teamd from RHEL 9 onward and recommends bonding. Learn teaming because you will meet it on RHEL 7/8 fleets; reach for bonding on anything new.

The team equivalent of a bond mode is a runner:

team runner Bonding equivalent Notes
activebackup mode 1 Failover only, no switch config
roundrobin mode 0 Per-packet round robin
loadbalance mode 2/5 Hash-based, optional active TX balancing (BPF hash)
lacp mode 4 (802.3ad) Dynamic LACP aggregation
broadcast mode 3 Send on all ports
# Create a team with the activebackup runner via nmcli
sudo nmcli con add type team con-name team0 ifname team0 \
     config '{"runner": {"name": "activebackup"}}'
sudo nmcli con add type ethernet con-name team0-p1 ifname enp1s0 master team0
sudo nmcli con add type ethernet con-name team0-p2 ifname enp2s0 master team0
sudo nmcli con up team0

# Inspect and control with teamdctl (the /proc/net/bonding analogue)
sudo teamdctl team0 state          # human-readable state + active port
sudo teamdctl team0 state dump     # full JSON config + runtime
sudo teamdctl team0 port config dump
Bonding Teaming
Kernel driver bonding (mature, ubiquitous) team (libteam)
Control sysfs, /proc/net/bonding/ teamd daemon, teamdctl, D-Bus
Config format module options / bond.options JSON
Extensibility fixed modes pluggable runners, BPF hash, port-priority in userspace
Status file /proc/net/bonding/<b> teamdctl <t> state
Distro direction preferred everywhere, incl. RHEL 9+ deprecated in RHEL 9+
Pick it when almost always maintaining an existing RHEL 7/8 team

VLANs: 802.1Q tagging

A VLAN (Virtual LAN) slices one physical Layer-2 segment into many isolated ones. The mechanism is 802.1Q: a 4-byte tag inserted into the Ethernet header carrying a 12-bit VLAN ID (VID) (1–4094 usable). On Linux you consume a trunk by creating sub-interfaces — one virtual interface per VID on top of a parent link — and each sub-interface behaves like a NIC plugged into that VLAN only.

802.1Q fact Detail
Tag size 4 bytes (TPID 0x8100 + PCP 3b + DEI 1b + VID 12b)
VID range 14094 usable (0 = priority-only, 4095 reserved)
Kernel module 8021q (modprobe 8021q)
Sub-interface name conventionally parent.VID, e.g. bond0.10 (any name allowed with name)
MTU cost tag adds 4 bytes; parent must allow 1504 or set VLAN MTU 1500 with parent 1504
QinQ (802.1ad) double-tagging (type vlan proto 802.1ad) for provider-in-customer VLANs

An access port carries one untagged VLAN; a trunk port carries many tagged VLANs. A Linux host with VLAN sub-interfaces is acting as if plugged into a trunk: the switch port must be configured to trunk and to permit each VID you use, or the tagged frames are silently discarded.

Port type Frames Linux side Switch side
Access Untagged, single VLAN plain eth0, no sub-interface switchport mode access
Trunk Tagged, many VLANs eth0.10, eth0.20, … sub-interfaces switchport mode trunk + allowed VIDs
Native/hybrid One untagged + tagged rest address on eth0 and sub-interfaces trunk with a native VLAN
# --- Runtime: an 802.1Q sub-interface for VLAN 10 on the bond ---
sudo modprobe 8021q
sudo ip link add link bond0 name bond0.10 type vlan id 10
sudo ip link set bond0.10 up
sudo ip addr add 10.0.10.1/24 dev bond0.10       # this host in VLAN 10
sudo ip link add link bond0 name bond0.20 type vlan id 20   # a second VLAN, same wire
# --- Persistent: nmcli VLAN over the bond ---
sudo nmcli con add type vlan con-name bond0.10 ifname bond0.10 dev bond0 id 10 \
     ipv4.method manual ipv4.addresses 10.0.10.1/24
# Verify the tag is really there
ip -d link show bond0.10        # shows: vlan protocol 802.1Q id 10 <REORDER_HDR>

The legacy tool here is vconfig (from the vlan package): vconfig add bond0 10. It still works but is deprecated in favour of ip link ... type vlan — do not learn it as your primary tool. Note there is a second, more powerful VLAN model — VLAN-aware bridges (bridge vlan) — where a single VLAN-filtering bridge tags/untags per port instead of you creating a sub-interface per VID; that is what libvirt and OVS-style setups often use, and we touch it under bridges.


Bridges: the Linux software switch

A Linux bridge is a fully functional Layer-2 Ethernet switch implemented in the kernel. Give it member ports (physical NICs, bonds, VLAN sub-interfaces, veths, VM tap devices), and it learns which MAC lives behind which port into its forwarding database (fdb), forwards unicast to the right port, floods broadcast/unknown, and can run STP to break loops. This is the object VMs and containers attach to: virbr0 (libvirt) and docker0 (Docker) are just bridges with a helpful name.

# Create a bridge and give it a member + an address
sudo ip link add name br0 type bridge
sudo ip link set br0 up
sudo ip link set bond0.10 master br0      # enslave the VLAN sub-interface to the bridge
sudo ip addr add 10.0.10.1/24 dev br0     # address goes on br0, NOT on bond0.10 anymore
sudo ip link set bond0.10 up

The same golden rule as bonding applies one level up: once an interface is a bridge port, it must not keep an IP — the address belongs to the bridge (br0). This is the classic “I bridged my NIC and lost connectivity” mistake: the IP was still on eth0, which is now a dumb switch port.

Inspecting a bridge with the bridge command

The modern tool is bridge (iproute2). It replaces the deprecated brctl (from bridge-utils).

Task bridge (modern) brctl (legacy)
List bridges/ports bridge link show / ip link show type bridge brctl show
Show learned MACs (fdb) bridge fdb show br br0 brctl showmacs br0
Show/set STP state ip link set br0 type bridge stp_state 1 brctl stp br0 on
Per-VLAN filtering bridge vlan show (not supported)
Multicast (IGMP snoop) db bridge mdb show (not supported)
Add a port ip link set eth0 master br0 brctl addif br0 eth0
bridge fdb show br br0        # every MAC the switch has learned, and its port
bridge -s fdb show           # with ageing timers (default 300s)
bridge link show             # ports, their state, STP role

STP and bridge tunables

STP prevents loops when bridges interconnect. On a single host with one uplink you often disable STP to skip the ~30 s listening/learning delay that stalls a booting VM’s link; enable it only where loops are possible.

Parameter Meaning Set with
stp_state Spanning Tree on/off ip link set br0 type bridge stp_state 0
forward_delay listen+learn delay before forwarding ip link set br0 type bridge forward_delay 0
ageing_time how long a learned MAC survives idle (cs) ip link set br0 type bridge ageing_time 30000
vlan_filtering make the bridge VLAN-aware (per-port tags) ip link add br0 type bridge vlan_filtering 1
priority STP root election weight ip link set br0 type bridge priority 8192

One more bridge subtlety that bites container and firewall people: bridged frames can be made to traverse iptables/nftables via the br_netfilter module and net.bridge.bridge-nf-call-iptables=1. That is why a FORWARD chain policy of DROP (Docker sets this) can silently kill bridge traffic — a cross-over with host firewalling covered in Linux Firewalls: firewalld, nftables & iptables.


veth pairs and network namespaces

A network namespace is a private, independent copy of the kernel’s entire network stack: its own interfaces, addresses, routing tables, ARP/neighbour table, sockets, and nftables ruleset. A fresh namespace starts with nothing but a down lo — it cannot reach anything until you give it a link. That link is almost always one end of a veth pair.

A veth pair is two interfaces created together and joined back-to-back — a virtual patch cable. A frame entering veth-a exits veth-b and vice-versa. The container pattern is: create a pair, leave one end in the host (plug it into a bridge), and move the other end into a namespace. That is literally what Docker and every CNI plugin do, once per container.

Command What it does
ip netns add ns1 Create namespace ns1 (a file appears in /run/netns/)
ip netns list List namespaces
ip netns exec ns1 <cmd> Run a command inside ns1’s network stack
ip -n ns1 <ip-subcmd> Shorthand: run an ip subcommand in ns1
ip link add veth-a type veth peer name veth-b Create a veth pair (both ends in the host to start)
ip link set veth-b netns ns1 Move one end into ns1 (it vanishes from the host)
ip link set veth-a master br0 Plug the host end into a bridge
ip netns exec ns1 ip link set lo up Bring up loopback inside the namespace (needed!)
ip netns del ns1 Delete the namespace (its interfaces go too)
nsenter --net=/run/netns/ns1 <cmd> Enter a namespace by path (works for container netns too)

Here is the complete, canonical wiring of one namespace to a bridge — memorise this shape, because it is the whole of container networking:

# 1. A bridge acting as this network's switch/gateway
sudo ip link add br0 type bridge
sudo ip addr add 10.0.10.1/24 dev br0        # br0 is the gateway for the subnet
sudo ip link set br0 up

# 2. A namespace = a "container"
sudo ip netns add ns1

# 3. A veth pair: veth-h stays on the host, veth-ns goes inside
sudo ip link add veth-h type veth peer name veth-ns
sudo ip link set veth-h master br0           # host end -> plug into the bridge
sudo ip link set veth-h up
sudo ip link set veth-ns netns ns1           # other end -> into the namespace

# 4. Configure the namespace's stack
sudo ip netns exec ns1 ip link set lo up
sudo ip netns exec ns1 ip link set veth-ns up
sudo ip netns exec ns1 ip addr add 10.0.10.2/24 dev veth-ns
sudo ip netns exec ns1 ip route add default via 10.0.10.1   # gateway = br0

# 5. Prove it
sudo ip netns exec ns1 ping -c1 10.0.10.1    # namespace -> host/bridge: works

Two things trip everyone the first time. First, you must ip link set ... up both ends and lo inside the namespace — a namespace with a down interface looks configured but is dead. Second, the namespace reaching the internet additionally needs the host to forward (net.ipv4.ip_forward=1) and usually a NAT/masquerade rule — the bridge only gets it as far as the host.

Namespace internals Where
Persistent (ip-managed) namespaces /run/netns/<name> (bind-mounted nsfs)
A process’s live namespaces /proc/<pid>/ns/net (a symlink like net:[4026531992])
Docker container’s netns not in /run/netns by default — enter via nsenter -t <pid> -n
Make a container netns visible to ip netns ln -s /proc/<pid>/ns/net /run/netns/<name>

That last row is a genuinely useful trick: Docker hides its namespaces, but symlink a container’s /proc/<pid>/ns/net into /run/netns/ and suddenly ip netns exec and netns-aware tcpdump work on it.


macvlan and ipvlan: real LAN IPs without a bridge

A bridge + veth gives a container an IP on a virtual subnet you then have to route or NAT. Sometimes you want the container to appear directly on the physical LAN — its own MAC, a real DHCP lease, reachable by everyone else on the segment. That is macvlan and ipvlan: virtual sub-interfaces of a parent link (a NIC, bond, or VLAN sub-interface) that put the child straight onto the parent’s L2 segment.

macvlan gives each child its own MAC address. Modes:

macvlan mode Behaviour
bridge Children talk to each other and to the outside directly (most common)
vepa All child traffic goes up to the external switch (needs hairpin/802.1Qbg)
private Children cannot talk to each other, only outward
passthru One child gets exclusive use of the parent (for a VM that wants full control)

ipvlan makes all children share the parent’s MAC, distinguishing them by IP. Use it when the switch does MAC-limiting/port-security or when a cloud provider allows only one MAC per port. Modes:

ipvlan mode Behaviour
l2 Like macvlan-bridge but one MAC; children on same subnet
l3 Parent routes between children; no broadcast/ARP crosses; children can be different subnets
l3s l3 plus symmetric routing so iptables conntrack works
# macvlan child with a real LAN presence (own MAC), on VLAN 10
sudo ip link add macv0 link bond0.10 type macvlan mode bridge
sudo ip link set macv0 netns ns1            # hand it to a container
sudo ip netns exec ns1 ip link set macv0 up
sudo ip netns exec ns1 dhclient macv0       # gets a REAL lease from the LAN DHCP

# ipvlan L2 child (shares parent MAC) — for MAC-restricted switch ports
sudo ip link add ipv0 link bond0.10 type ipvlan mode l2

The famous macvlan gotcha: the parent host cannot reach its own macvlan children (and vice-versa). By kernel design, traffic from the host out the parent NIC does not loop back to a macvlan child on the same NIC. If the host must talk to the container, add a second macvlan interface on the host itself as a shim, or use a bridge instead. This is the number-one “why can’t I ping my container from the docker host on a macvlan network” question.

Now the comparison you will reach for when choosing how a workload attaches:

Aspect Bridge + veth macvlan ipvlan
Child gets its own MAC Yes (veth) Yes No (shares parent MAC)
On the physical LAN directly No (virtual subnet) Yes Yes
Host ↔ child by default Yes No (isolation gotcha) Yes (esp. l3s)
Needs switch config No No (but switch must allow many MACs) No (works with port-security)
Broadcast/ARP within group Yes Yes (l2) l2: yes · l3: no
Overhead / speed switch + veth hops very low very low
Typical use VMs, default Docker/K8s container gets a real DHCP LAN IP many containers, one-MAC-per-port cloud/switch
Provided by virbr0, docker0, CNI bridge Docker macvlan driver, some CNIs Docker ipvlan, some CNIs

Routing in depth: policy routing

Ordinary routing answers one question — for this destination IP, what is the next hop? — from a single table (main). Policy routing changes which table the kernel even consults, letting you route on the source, an inbound interface, or a firewall mark, not just the destination. This is how you run dual-WAN, keep a tenant’s replies on the link they arrived on, or send backup traffic out a metered line while everything else takes the fast one.

The kernel actually has multiple routing tables and a rule database (the RPDB) that decides, per packet, which table to search first.

Table ID Purpose
local 255 Auto: local & broadcast addresses (do not edit)
main 254 The normal table ip route shows by default
default 253 Empty by convention; post-processing
custom 1–252 Yours: name them in /etc/iproute2/rt_tables
# 1. Name two custom tables (readability; numbers also work directly)
echo "100 wan1" | sudo tee -a /etc/iproute2/rt_tables
echo "101 wan2" | sudo tee -a /etc/iproute2/rt_tables

# 2. Give each table its own default gateway
sudo ip route add default via 203.0.113.1 dev eth0 table wan1
sudo ip route add default via 198.51.100.1 dev eth1 table wan2

# 3. Rules: pick the table by SOURCE address (source-based routing)
sudo ip rule add from 203.0.113.10 table wan1 priority 100
sudo ip rule add from 198.51.100.10 table wan2 priority 101

# 4. Or pick the table by firewall mark (set the mark in nftables/iptables)
sudo ip rule add fwmark 0x1 table wan2 priority 200

# 5. Verify the decision
ip rule show
ip route show table wan1
ip route get 8.8.8.8 from 198.51.100.10     # shows which table/gw/iface wins

An ip rule is matched top-down by priority (lower = first); the first rule whose selectors match sends the packet to that table. The selectors:

ip rule selector Matches on
from <prefix> Source address (the classic source-based route)
to <prefix> Destination address
iif <dev> Inbound interface (great for router/namespace hosts)
oif <dev> Outbound interface
fwmark <mark> A mark set by the firewall (-j MARK / meta mark set)
tos <value> DSCP/ToS bits
priority <n> Rule order (lower first); always set it explicitly

The policy-routing command set, condensed:

Command Purpose
ip rule add from 10.0.0.0/24 table 100 priority 1000 Route this source via table 100
ip rule add iif veth-h table 100 Route what arrives on veth-h via table 100
ip rule add fwmark 0x1 table 100 Route firewall-marked packets via table 100
ip rule del priority 1000 Delete a rule by its priority
ip route add default via <gw> dev <if> table 100 Populate a custom table
ip route show table 100 Dump one table
ip route get <dst> from <src> Ask the kernel which route/table it will pick
ip route flush table 100 Empty a custom table

Multiple default gateways and ECMP

You can also load-balance within one table across several next hops — ECMP (Equal-Cost Multi-Path). The kernel hashes each flow onto a next hop, so connections spread while a single connection stays put (no reordering).

# One default route, two next hops, weighted equally
sudo ip route add default \
     nexthop via 203.0.113.1 dev eth0 weight 1 \
     nexthop via 198.51.100.1 dev eth1 weight 1

# Hash per Layer-4 flow (src/dst IP + ports) instead of just src/dst IP
sudo sysctl -w net.ipv4.fib_multipath_hash_policy=1
Technique Balances Failover Best for
Two tables + ip rule By source/mark (deterministic) manual/scripted dual-WAN with strict return-path pinning
ECMP nexthop route Per-flow hash drops dead nexthop if link tracked one logical uplink over two equal paths
active-backup bond none (one link) automatic, sub-second pure resilience, no source logic

The rp_filter trap

Policy routing routinely creates asymmetric paths (in on WAN2, the reply also forced out WAN2). Strict reverse-path filtering (net.ipv4.conf.*.rp_filter=1) drops any packet whose source would not route back out the interface it arrived on — silently. On any host doing policy or asymmetric routing, set loose mode.

# 0 = off, 1 = strict (default on many distros), 2 = loose (use this)
sudo sysctl -w net.ipv4.conf.all.rp_filter=2
sudo sysctl -w net.ipv4.conf.eth1.rp_filter=2
rp_filter Behaviour When
0 No reverse-path check you fully trust/anti-spoof elsewhere
1 Strict: drop if return path ≠ arrival iface single-homed hosts (default)
2 Loose: drop only if source routes nowhere at all multi-homed / policy-routed hosts

Tunnels and overlays: a taste of VXLAN, GRE and WireGuard

The constructs so far are all local to one L2 segment. Overlay networks stretch a virtual network across L3 boundaries — across racks, datacenters, or the internet — by encapsulating frames/packets inside other packets. You will not master these here; you should recognise them, because container overlays and cloud SDN are built on them.

Overlay Layer Encapsulation Port/Proto Overhead Encrypted Typical use
VXLAN L2 over UDP Ethernet-in-UDP, 24-bit VNI (16M nets) UDP 4789 ~50 B No Docker overlay, K8s (flannel/Calico VXLAN), OpenStack
Geneve L2 over UDP like VXLAN + extensible TLV options UDP 6081 ~50 B+ No OVN, newer SDN
GRE L3 IP-in-IP with GRE header IP proto 47 ~24 B No simple point-to-point router tunnels
WireGuard L3 encrypted UDP, public-key peers UDP (any) ~60 B Yes secure site-to-site & road-warrior VPN
# VXLAN segment (VNI 10) with a unicast remote peer, plugged into a bridge
sudo ip link add vxlan10 type vxlan id 10 dev bond0.10 dstport 4789 \
     local 203.0.113.4 remote 203.0.113.5
sudo ip link set vxlan10 master br0
sudo ip link set vxlan10 up

# GRE point-to-point L3 tunnel between two routers
sudo ip link add gre1 type gre local 203.0.113.4 remote 203.0.113.5 ttl 255

# WireGuard encrypted L3 interface (keys/peers configured separately)
sudo ip link add wg0 type wireguard
sudo ip addr add 10.9.0.1/24 dev wg0
sudo ip link set wg0 up

The recurring overlay bug is MTU. VXLAN steals ~50 bytes from every frame; if the inner interface still advertises 1500 while the underlay is also 1500, large packets fragment or blackhole. Fix it by lowering the inner MTU (e.g. 1450) or, better, raising the underlay to jumbo (9000) end to end.

Path MTU consideration
Underlay physical/bond Raise to 9000 (jumbo) if the whole L2 path supports it
VXLAN inner ≤ underlay − 50 (e.g. 1450 over a 1500 underlay)
VLAN sub-interface parent must allow 1504 to keep a full 1500 inner
WireGuard typically 1420 to leave room for its header

Stacking it: bond → VLAN → bridge → veth → namespace

Now the payoff. In a real virtualization or container host, these bricks are stacked into one datapath: two NICs bonded for resilience, split into VLANs, each VLAN presented as a bridge, and each guest wired to its bridge by a veth pair (or, for VMs, a tap device). The diagram below is that exact stack, left to right — this is the single most important picture in the lesson.

The physical NICs feed a bond (LACP aggregation, badge 1); the bond is split by 802.1Q tags into per-VLAN sub-interfaces (badge 2); each VLAN sub-interface is enslaved to a bridge that acts as a software switch (badge 3); and each namespace/container is plugged into that bridge by a veth pair (badge 4) — or, bypassing the bridge, given a real LAN IP by macvlan/ipvlan (badge 5) — while policy routing decides which uplink each tenant’s traffic leaves by (badge 6).

A layered Linux host network datapath drawn left to right in five zones. Zone one, PHYSICAL NICS, shows two 10-gigabit cards eth0 and eth1 on the wire. Zone two, BOND, aggregates them into bond0 running 802.3ad LACP with a miimon 100-millisecond member watchdog. Zone three, VLANs, splits the bonded uplink into two 802.1Q sub-interfaces bond0.10 and bond0.20 carrying tag IDs 10 and 20. Zone four, BRIDGE, enslaves a VLAN sub-interface into br0, a software switch that learns MACs into its forwarding database, with a veth pair whose one end is plugged into the bridge. Zone five, NAMESPACES, shows network namespace ns1 holding its own stack and routing table, a container at 10.0.10.2/24, and a health check confirming it can ping its gateway 10.0.10.1. Flows run aggregate links, then tag 802.1Q, then enslave to bridge, then veth into netns. Six numbered badges annotate LACP aggregation on the bond, the 802.1Q tag on the VLAN, the bridge as a software switch, the veth pair connecting the namespace, macvlan as the real-LAN-IP alternative on the container, and policy routing on the namespace's own table.

Built by hand, the stack is just the previous sections in sequence:

# 1. Bond two NICs (LACP)
sudo ip link add bond0 type bond mode 802.3ad miimon 100 xmit_hash_policy layer3+4
sudo ip link set enp1s0 down; sudo ip link set enp1s0 master bond0
sudo ip link set enp2s0 down; sudo ip link set enp2s0 master bond0
sudo ip link set bond0 up

# 2. VLAN 10 sub-interface on the bond
sudo ip link add link bond0 name bond0.10 type vlan id 10
sudo ip link set bond0.10 up

# 3. A bridge for VLAN 10; the VLAN sub-interface is its uplink port
sudo ip link add br10 type bridge
sudo ip link set bond0.10 master br10
sudo ip addr add 10.0.10.1/24 dev br10     # br10 = the gateway for this segment
sudo ip link set br10 up

# 4. A namespace wired to the bridge by a veth pair (repeat per container)
sudo ip netns add tenant-a
sudo ip link add veth-a type veth peer name eth0-a
sudo ip link set veth-a master br10; sudo ip link set veth-a up
sudo ip link set eth0-a netns tenant-a
sudo ip netns exec tenant-a ip link set lo up
sudo ip netns exec tenant-a ip link set eth0-a up
sudo ip netns exec tenant-a ip addr add 10.0.10.11/24 dev eth0-a
sudo ip netns exec tenant-a ip route add default via 10.0.10.1

That is the whole datacenter host pattern: physical resilience at the bottom, tenant isolation in the middle, per-guest links at the top. Swap the veth for a tap and you have a KVM host; let a CNI plugin run these same commands and you have a Kubernetes node.


Hands-on lab

Fully self-contained — it needs no real second NIC and no managed switch. Everything uses namespaces, veth, dummy, and bridge, so it runs on any Linux VM, WSL2, or a --privileged container. Run as root (or sudo each line).

⚠️ Do this on a throwaway VM/container, not a box you rely on — you will be creating and deleting links and a bond. None of these commands touch disks, but a fat-fingered ip link del on a real uplink will drop you.

Step 1 — Two namespaces across a bridge (the container pattern).

ip link add br-lab type bridge
ip addr add 10.10.0.1/24 dev br-lab
ip link set br-lab up

for ns in red blue; do
  ip netns add $ns
  ip link add veth-$ns type veth peer name in-$ns
  ip link set veth-$ns master br-lab
  ip link set veth-$ns up
  ip link set in-$ns netns $ns
  ip netns exec $ns ip link set lo up
  ip netns exec $ns ip link set in-$ns up
done
ip netns exec red  ip addr add 10.10.0.2/24 dev in-red
ip netns exec blue ip addr add 10.10.0.3/24 dev in-blue

You should see, after bridge link show, two ports (veth-red, veth-blue) in state forwarding. What just happened: you built a switch (br-lab) and plugged two isolated network stacks into it with virtual cables.

Step 2 — Prove L2 connectivity and watch the bridge learn.

ip netns exec red ping -c2 10.10.0.3      # red -> blue across the bridge
bridge fdb show br br-lab | grep -v permanent

Expected: the ping succeeds, and bridge fdb now lists the MACs of in-red/in-blue learned on their veth ports. What just happened: the bridge learned each MAC from the traffic and now forwards between the namespaces like a real switch.

Step 3 — Give the namespaces a gateway and default route.

ip netns exec red  ip route add default via 10.10.0.1
ip netns exec blue ip route add default via 10.10.0.1
ip netns exec red ping -c2 10.10.0.1      # namespace -> host (the bridge IP)

Expected: replies from 10.10.0.1. What just happened: br-lab is now the gateway; the namespaces can reach the host. (Reaching the internet would additionally need sysctl -w net.ipv4.ip_forward=1 plus a masquerade rule — try it if you have NAT set up.)

Step 4 — Add an 802.1Q VLAN inside the lab.

ip netns exec red ip link add link in-red name in-red.50 type vlan id 50
ip netns exec red ip link set in-red.50 up
ip netns exec red ip -d link show in-red.50 | grep 802.1Q

Expected: vlan protocol 802.1Q id 50. What just happened: you created a tagged sub-interface; frames it sends now carry VLAN tag 50 (a peer would need the matching tag to talk to it).

Step 5 — Build an active-backup bond from dummies and force a failover.

modprobe bonding
modprobe dummy
ip link add bond-lab type bond mode active-backup miimon 100
ip link add d0 type dummy; ip link add d1 type dummy
ip link set d0 down; ip link set d0 master bond-lab
ip link set d1 down; ip link set d1 master bond-lab
ip link set bond-lab up
grep -E "Currently Active Slave|MII Status" /proc/net/bonding/bond-lab
ip link set d0 down                          # simulate a cable pull on the active
grep "Currently Active Slave" /proc/net/bonding/bond-lab   # -> now d1

Expected: the active slave flips from d0 to d1. What just happened: you watched a bond fail over — no switch required because active-backup needs none.

Step 6 — Policy routing with two dummy “uplinks.”

ip link add w1 type dummy; ip addr add 203.0.113.10/24 dev w1; ip link set w1 up
ip link add w2 type dummy; ip addr add 198.51.100.10/24 dev w2; ip link set w2 up
echo "100 wan1" >> /etc/iproute2/rt_tables
ip route add default via 203.0.113.1 dev w1 table wan1
ip rule add from 203.0.113.10 table wan1 priority 100
ip route get 8.8.8.8 from 203.0.113.10       # -> chooses table wan1 / dev w1
ip route get 8.8.8.8 from 198.51.100.10      # -> falls back to main table

Expected: the two ip route get calls resolve to different interfaces. What just happened: one source address was steered into a custom table by an ip rule, proving source-based routing.

Step 7 — Clean up (leave the box as you found it).

for ns in red blue; do ip netns del $ns; done
ip link del br-lab; ip link del bond-lab
ip link del d0 2>/dev/null; ip link del d1 2>/dev/null
ip link del w1; ip link del w2
ip rule del priority 100 2>/dev/null
sed -i '/^100 wan1$/d' /etc/iproute2/rt_tables

What just happened: deleting a namespace removes its interfaces; deleting a bridge/bond removes the object. Runtime ip state is gone on reboot anyway, but tidy is tidy.


Common mistakes and troubleshooting

Debugging a virtual network is almost entirely about asking the right object the right question — and, in a stack of five interfaces, capturing on the correct one. Keep this toolkit in muscle memory:

Question Command
Detailed kernel view (mode, vlan id, master, MTU) ip -d link show <dev>
Per-interface error/drop counters ip -s link show <dev>
MACs the bridge has learned + their ports bridge fdb show br br0
Bridge ports, STP state and role bridge link show
Per-VLAN membership (VLAN-aware bridge) bridge vlan show
Which route / table / gateway a packet takes ip route get <dst> from <src>
Neighbour (ARP) table ip neigh show
Capture inside a namespace ip netns exec ns tcpdump -i <iface>
Enter a Docker container’s netns by PID nsenter -t <pid> -n ip addr

The counters from ip -s link are where “it’s slow / it drops” becomes a diagnosis. What each field means and when to worry:

ip -s link field Meaning Worry when
RX/TX bytes, packets traffic volume in each direction zero where you expect traffic (link/route/firewall)
errors malformed / CRC frames climbing → bad cable, SFP, or duplex mismatch
dropped no buffer or no handler for the frame climbing → ring-buffer / qdisc pressure, or wrong VLAN
overrun NIC FIFO overflowed before the CPU drained it climbing → IRQ/CPU can’t keep up (tune RSS/coalescing)
carrier physical link up↔down transitions climbing → flapping cable or port
collisions half-duplex collisions any value on modern full-duplex = duplex mismatch

With the toolkit in hand, the symptom map:

Symptom Likely cause Fix
Bond “up” but only one link’s bandwidth Switch not doing LACP; Partner Mac is 00:00:… in /proc/net/bonding Configure the switch port-channel for LACP, or verify with the network team
Single big transfer never exceeds one link xmit_hash_policy=layer2 hashes on MAC only Set xmit_hash_policy=layer3+4; remember aggregation ≠ faster single flow
Lost connectivity right after bridging a NIC IP still on the member; a bridge port must not hold an IP Move the address to br0 (ip addr del … dev eth0; ip addr add … dev br0)
Namespace can’t ping anything, config “looks fine” An interface (or lo) is still DOWN, or no default route in the ns ip netns exec ns ip link set … up on every iface incl. lo; add a default route
Container gets no DHCP lease on a VLAN Switch port not trunking that VID, or wrong parent Trunk the VID on the switch; confirm ip -d link shows the right id
Host can’t reach its own macvlan container macvlan parent-isolation is by design Use a bridge, or add a host-side macvlan shim interface
Dual-WAN replies silently dropped Strict rp_filter=1 drops the asymmetric return path sysctl net.ipv4.conf.all.rp_filter=2 (loose)
Bridged VM/container traffic blocked FORWARD chain policy DROP + br_netfilter sends bridge frames to iptables Add a FORWARD accept rule for the bridge, or review the firewall policy
Overlay works for small packets, hangs on big ones MTU: VXLAN/WireGuard overhead not accounted for Lower inner MTU (1450/1420) or raise underlay to jumbo end to end
veth end “disappeared” from the host It was moved into a namespace (that is the point) Find it with ip -n <ns> link; a veth in a deleted ns is gone

The three that cost the most time:

1. Capturing on the wrong interface. In a stack of bond → VLAN → bridge → veth, a packet exists at five names. tcpdump -i bond0 shows tagged frames; tcpdump -i bond0.10 shows them untagged; tcpdump -i br10 shows post-switching; and the container’s traffic is only visible inside its namespace: ip netns exec tenant-a tcpdump -i eth0-a. If a capture is empty, you are almost always one layer off. Walk the stack outward from the container until packets appear — where they stop is where the fault is.

2. The rp_filter silent drop. Multi-homing and policy routing create return paths that strict reverse-path filtering hates. There is no log, no error — the kernel just discards the packet as a spoof. Any time you add a second uplink or an ip rule and traffic mysteriously vanishes one-way, set rp_filter=2 before anything else, then debug.

3. Forgetting that runtime ip state is not persistent. Every ip link add, ip addr add, ip route add, and ip rule add in this lesson is gone on reboot. Labs are fine; production is not. Persist with nmcli connection profiles, netplan YAML, systemd-networkd .network/.netdev files, or the distro’s ifcfg/dispatcher scripts. A bond that works until the next reboot and then vanishes has burned many an evening.


Cheat-sheet

Task Command
Load bonding/vlan/dummy drivers modprobe bonding · modprobe 8021q · modprobe dummy
Create LACP bond ip link add bond0 type bond mode 802.3ad miimon 100 xmit_hash_policy layer3+4
Enslave a NIC to a bond ip link set eth0 down && ip link set eth0 master bond0
Bond ground truth cat /proc/net/bonding/bond0
Create VLAN sub-interface ip link add link bond0 name bond0.10 type vlan id 10
Verify the 802.1Q tag ip -d link show bond0.10
Create a bridge + add a port ip link add br0 type bridge && ip link set bond0.10 master br0
Show learned MACs bridge fdb show br br0
Disable STP delay ip link set br0 type bridge stp_state 0 forward_delay 0
New namespace ip netns add ns1
Run in a namespace ip netns exec ns1 <cmd> or ip -n ns1 <ip-subcmd>
Create veth pair ip link add A type veth peer name B
Move veth end into ns ip link set B netns ns1
macvlan child (own MAC) ip link add macv0 link bond0.10 type macvlan mode bridge
ipvlan child (shared MAC) ip link add ipv0 link bond0.10 type ipvlan mode l2
Name a routing table echo "100 wan1" >> /etc/iproute2/rt_tables
Route by source ip rule add from 10.0.0.0/24 table 100 priority 1000
Populate a table ip route add default via <gw> dev <if> table 100
Ask the kernel its decision ip route get 8.8.8.8 from <src>
ECMP default route ip route add default nexthop via A dev eth0 nexthop via B dev eth1
Loose reverse-path filter sysctl -w net.ipv4.conf.all.rp_filter=2
Per-interface error/drop stats ip -s link show <dev>
VXLAN segment ip link add vxlan10 type vxlan id 10 dev eth0 dstport 4789 remote <ip>
Netns-aware capture ip netns exec ns1 tcpdump -i <iface>

Interview and exam questions

Q: A colleague set up an 802.3ad bond and complains throughput is stuck at one link’s speed. What are the two most likely causes? A: (1) The switch is not configured with a matching LACP port-channel, so no aggregator formed — check /proc/net/bonding/bond0 for an all-zero Partner Mac Address. (2) xmit_hash_policy is the default layer2, so a single MAC-to-MAC flow always lands on one member — set layer3+4. Also remember aggregation never speeds up a single flow (except mode 0).

Q: You need bonded resilience but you do not control the switch. Which mode? A: active-backup (mode 1) — or balance-tlb/balance-alb (5/6) if you want some outbound/both-way balancing. All three need zero switch configuration; modes 0/2/4 do.

Q: Why is teaming worth knowing but not your first choice on a new RHEL 9 box? A: teamd/libteam is deprecated from RHEL 9; Red Hat recommends kernel bonding. Teaming (JSON config, teamdctl, pluggable runners) is elegant but bonding is more universally supported and now the recommended path.

Q: What is the difference between an access port and a trunk port, and which does a Linux host with VLAN sub-interfaces emulate? A: An access port carries one untagged VLAN; a trunk carries many tagged VLANs (802.1Q). A host with eth0.10, eth0.20 sub-interfaces behaves like a device on a trunk — the switch port must trunk and permit those VIDs.

Q: After enslaving eth0 to br0 you lost connectivity. Why, and how do you fix it? A: The IP was still on eth0, which is now a dumb bridge port. Move the address to the bridge: ip addr del … dev eth0; ip addr add … dev br0. The bridge owns the address; ports do not.

Q: Explain the exact steps to connect a network namespace to a bridge — the container pattern. A: Create the bridge; create a veth pair; enslave one end to the bridge and bring it up; move the other end into the namespace; inside the namespace bring up lo and the veth end, assign an address, add a default route via the bridge’s IP. That is precisely what Docker/CNI automate.

Q: A container on a macvlan network can reach the LAN and other hosts, but the Docker host cannot ping the container. Bug or expected? A: Expected. By kernel design a host cannot reach its own macvlan children on the same parent. Use a bridge, or add a host-side macvlan shim interface, if host↔container traffic is required.

Q: When would you choose ipvlan over macvlan? A: When the switch enforces port-security / a single MAC per port, or a cloud provider allows one MAC per NIC — ipvlan shares the parent’s MAC (distinguishing children by IP) while macvlan gives each child its own MAC.

Q: (RHCSA-style) Route all traffic from source 192.168.50.0/24 out gateway 10.0.0.1 while everything else uses the normal default. Outline the commands. A: echo "100 tenant" >> /etc/iproute2/rt_tables; ip route add default via 10.0.0.1 table tenant; ip rule add from 192.168.50.0/24 table tenant priority 1000; verify with ip route get <dst> from 192.168.50.5. Persist it in NetworkManager dispatcher/nmcli for reboots.

Q: You added a second WAN and now inbound-then-reply connections on it silently fail. First thing to check? A: rp_filter. Strict reverse-path filtering (=1) drops the asymmetric return path with no log. Set net.ipv4.conf.all.rp_filter=2 (loose) and pin the return path with policy routing.

Q: What does VXLAN give you that a VLAN does not, and what is its number-one operational gotcha? A: VXLAN carries L2 over L3 (UDP 4789) with a 24-bit VNI (~16M segments vs 4094 VLANs), so it stretches a segment across routed networks — the basis of container/cloud overlays. The gotcha is MTU: its ~50-byte overhead blackholes large packets unless you lower the inner MTU or use jumbo underlay.

Q: In a bond → VLAN → bridge → veth stack, a container has no connectivity. How do you localise the fault? A: Capture outward layer by layer: inside the ns (ip netns exec … tcpdump -i eth0-a), then br10, then bond0.10, then bond0. Where packets stop appearing is the broken layer. Also check every interface is up, the bridge fdb learned the MAC, and the switch trunks the VID.


Key takeaways

linuxnetworkingbondinglacp802.3adteamingvlan802.1qbridgevethnetwork-namespacesmacvlanipvlanpolicy-routingvxlaniproute2
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments