VyOSXXXVI · ECMPECMP
Per-class ECMP — policy routes, alternate tables, and the fwmark VyOS owns
What you'll learn
- Build a per-class ECMP set: a policy route that selects a table, and a table with several next-hops
- Read the nftables mark and `ip rule` selector VyOS renders, without trying to author them
- State what VyOS 1.5 does not expose — an operator-chosen fwmark that selects a table for forwarded traffic — and what replaces it
- Predict which next-hop a flow takes, given the kernel's actual default hash policy
- Diagnose the silent failures: no interface attachment, an empty table, and traffic that never enters the policy at all
Prerequisites
- PBR rules — the policy route tree, its interface, ip rule, and the table it points at
- Source-based routing — binding traffic to a table by source
- ECMP concept — Equal-Cost Multi-Path, kernel flow-based or route-based hashing, throughput scaling
- ECMP configuration — multiple next-hops, max-paths, load-balancing algorithm
Verified against VyOS 1.5.x LTS (circinus) · VyOS 1.4.x (sagitta) — legacy · FRRouting 10.x (VyOS 1.5) · Linux kernel 6.6 LTS (VyOS 1.5 base) · strongSwan 5.9.x (IPsec) · WireGuard 1.0.x (kernel module + userspace tooling) · 2026-08-19
Ordinary ECMP gives every destination one set of next-hops. Per-class ECMP gives different traffic classes different sets: voice across two circuits from one provider, bulk data across two from another, each class load-sharing within its own pair.
VyOS 1.5 builds this out of two pieces you have already met separately — a policy route that selects an alternate routing table, and a table holding several next-hops for the same prefix. What ties them together in the kernel is a firewall mark, and the useful thing to understand about that mark is that it is not yours.
The shape of the configuration
flowchart LR
P["Packet in on eth1"]
PR["policy route PBR-CLASSES<br/>rules matched in order"]
T1["table 100<br/>two next-hops via ISP-A"]
T2["table 200<br/>two next-hops via ISP-B"]
M["table main<br/>everything else"]
H1{"multipath hash"}
H2{"multipath hash"}
P --> PR
PR -- "rule 20: udp/5060 → set table 100" --> T1
PR -- "rule 30: guest subnet → set table 200" --> T2
PR -- "no rule matched" --> M
T1 --> H1
T2 --> H2
H1 --> A1["198.51.100.1"]
H1 --> A2["198.51.100.5"]
H2 --> B1["203.0.113.1"]
H2 --> B2["203.0.113.5"]
Three parts, and all three are required. Two of them are the policy route you built in Part XIII; the third is what makes this lesson about ECMP rather than about PBR.
1. The tables, each an ECMP set
set protocols static table 100 route 0.0.0.0/0 next-hop 198.51.100.1
set protocols static table 100 route 0.0.0.0/0 next-hop 198.51.100.5
set protocols static table 200 route 0.0.0.0/0 next-hop 203.0.113.1
set protocols static table 200 route 0.0.0.0/0 next-hop 203.0.113.5
Two next-hops for one prefix in one table, at the same distance, is what makes the entry multipath. The distance is the whole condition: two next-hops at distance 1 and 2 are a primary and a floating backup, not an ECMP pair, and nothing in the output shouts about the difference. Leave the distance alone unless you mean to break the sharing.
2. The policy route that selects a table
set policy route PBR-CLASSES description 'per-class ECMP sets'
set policy route PBR-CLASSES rule 10 description 'internal traffic stays on main'
set policy route PBR-CLASSES rule 10 destination group network-group 'INTERNAL-NETS'
set policy route PBR-CLASSES rule 10 set table 'main'
set policy route PBR-CLASSES rule 20 description 'SIP signalling across the voice pair'
set policy route PBR-CLASSES rule 20 protocol 'udp'
set policy route PBR-CLASSES rule 20 destination port '5060'
set policy route PBR-CLASSES rule 20 set table '100'
set policy route PBR-CLASSES rule 30 description 'guest subnet across the bulk pair'
set policy route PBR-CLASSES rule 30 source address '192.168.100.0/24'
set policy route PBR-CLASSES rule 30 set table '200'
Rules are evaluated in numeric order and the first match wins, which
is why rule 10 exists: without an explicit early rule sending internal
traffic to main, a packet between two internal subnets that happens
to be UDP/5060 would be sent out of a WAN circuit.
3. The attachment
set policy route PBR-CLASSES interface 'eth1'
commit
save
A policy route with no interface is configuration that never runs. It
commits cleanly, appears in show configuration, and matches nothing,
because nothing is feeding it packets. From VyOS 1.4 onward this
attachment is a property of the policy rather than of the interface;
the 1.3 form set interfaces ethernet eth1 policy route PBR-CLASSES
is gone.
The fwmark, and why it is not yours
Underneath, VyOS renders that policy as an nftables rule that marks
the packet and an ip rule that matches the mark and looks up the
table.
vyos@vyos:~$ ip rule show0: from all lookup local
32764: from all fwmark 0x7fffff37 lookup 200
32765: from all fwmark 0x7fffff9b lookup 100
32766: from all lookup main
32767: from all lookup defaultIllustrative output
The mark is derived from the table id — on current images by
subtracting it from 0x7fffffff — and both the mark and the rule
priority are chosen by VyOS. Check the shape rather than the numbers:
a selector for each table you configured, all of them sitting above
from all lookup main at priority 32766. A selector below that line
is unreachable, because from all lookup main matches everything.
The hash inside the table
Having reached table 100, a packet meets a multipath entry with two next-hops, and the kernel picks one by hashing. Which fields it hashes is not what most operators assume.
net.ipv4.fib_multipath_hash_policy | Fields hashed |
|---|---|
0 (default) | Layer 3 — source and destination address only |
1 | Layer 4 — addresses, protocol, and source and destination ports |
2 | Inner layer 3, for encapsulated traffic |
There is no per-packet mode in the IPv4 FIB. Every setting here is a per-flow hash; they differ only in what counts as a flow. A given combination of hashed fields always maps to the same next-hop, which is what keeps a TCP connection on one path.
For per-class ECMP the default is often exactly wrong. Take the voice
example: SIP signalling from one PBX to one provider SBC is a single
pair of addresses, so under policy 0 every SIP packet takes one
next-hop and the second circuit in the pair carries none of it. The
class was separated successfully and then pinned to half of the
capacity it was given.
set system sysctl parameter net.ipv4.fib_multipath_hash_policy value 1
set system sysctl parameter net.ipv6.fib_multipath_hash_policy value 1
commit
save
IPv4 and IPv6 have separate knobs. Setting the sysctl through the
configuration tree rather than with sysctl -w is what makes it
survive a reboot and appear in the configuration archive.
Validation
Work outward from the policy, and finish with a real lookup rather than an inference.
# 1. Is the policy attached? This is the line most reviews miss.
show configuration commands | match 'policy route PBR-CLASSES'
# 2. Did the router install the selectors, above the main rule?
ip rule show
# 3. Are the tables populated, and are the entries multipath?
ip route show table 100
ip route show table 200
# 4. What did the rules actually match? The counters do not lie.
show firewall statistics
nft list ruleset
Then prove the path for one flow of each class:
DST=203.0.113.20
for SRC in 10.10.0.5 192.168.100.5; do
ip route get "$DST" from "$SRC" iif eth1
done
ip route get runs a real lookup with the selectors you supply and
reports the next-hop the kernel would use. Two sources that should
take different classes returning the same next-hop is the answer,
before anyone opens a ticket.
For the hash itself, generate several flows that differ only in port and watch where they leave:
tcpdump -ni eth2 -c 20 'host 203.0.113.20'
tcpdump -ni eth3 -c 20 'host 203.0.113.20'
Both interfaces carrying traffic means the hash is spreading. One interface carrying all of it, with a layer-3 hash policy and a single pair of addresses, is the expected outcome rather than a fault.
Failure modes
The policy has no interface attachment
Everything is configured, nothing happens, and no command reports an error. The policy is not in any packet’s path.
Diagnostic: show configuration commands | match 'policy route' and
look for the interface line specifically. ip rule show will also
be missing the selector, because VyOS installs it as part of the
attachment.
The selected table has no matching route
The rule fires, the table is consulted, nothing matches — and the
packet is not dropped. The routing policy database continues to
the next rule, reaches from all lookup main, and the packet leaves
by the default route.
This is the failure that wastes the most time, because every
individual check looks healthy: the policy exists, the rule matched,
the counters increment, and the traffic still goes the wrong way. The
diagnosis is ip route show table 100 returning nothing.
Populate the table in the same change as the policy, not the one after.
The traffic never enters the policy
policy route acts on traffic arriving in through the attached
interface. Two classes of traffic are therefore invisible to it:
- Traffic the router originates itself — a ping or an SNMP poll
from the router does not arrive on any interface. It needs
set policy local-route, which is a separate rule set with its own matches. - Traffic arriving on an interface with no policy attached — including the return direction of a flow you carefully steered outbound. If the return path also needs steering, it needs its own attachment on the interface it arrives on.
Diagnostic: ip route get with iif naming the interface the traffic
really arrives on, not the one you were thinking about.
The class is separated but pinned to one next-hop
The policy works, the table has both next-hops, and one circuit is idle. This is the hash, not the policy: a layer-3 hash policy with one pair of addresses has exactly one answer.
Diagnostic: cat /proc/sys/net/ipv4/fib_multipath_hash_policy. Fix by
raising it to 1 through set system sysctl parameter, and re-test
with flows that differ in port.
A rule ordering surprise
A packet matches an earlier, broader rule and never reaches the specific one written for it. First match wins, and rule numbers are the only ordering.
Diagnostic: read the rules in numeric order and find the first that
matches your flow, rather than the one you intended. show firewall statistics shows which rules are actually counting.
A next-hop is down but still in the table
A static next-hop is withdrawn when it becomes unreachable, but “the far end is broken” is not the same as “the next-hop is unreachable” — a live link to a dead provider keeps its next-hop resolvable, and the hash keeps sending half the class into it.
Attaching BFD to the next-hops is what converts a far-side failure into a withdrawal the router can act on. Part XXXII covers the configuration; the point here is that ECMP has no health check of its own and will happily balance into a black hole.
Rollback
# Take the policy out of the path — the fastest, least destructive step
delete policy route PBR-CLASSES interface
commit-confirm 5
# Remove one class without touching the others
delete policy route PBR-CLASSES rule 20
commit-confirm 5
# Remove the whole policy and its tables
delete policy route PBR-CLASSES
delete protocols static table 100
delete protocols static table 200
commit
# Or return to a previous revision
rollback N
commit
Deleting the interface attachment is the surgical rollback: the rules and the tables stay in the configuration for inspection, and all traffic immediately falls back to the main table. That is usually the right first move during an incident, because it restores a known behaviour without discarding the evidence.
Use commit-confirm for anything that changes which path management
traffic takes. A policy route that captures your own SSH session’s
return path and sends it out a circuit that cannot reach you is
recoverable only by the timer.
Production discipline
Cross-course references
- Part XIII (
vyos-xiii-03-pbr-rules) covers the policy route tree, its attachment, and theip rulerender in detail. - Part XIV (
vyos-xiv-02-multiple-tables,vyos-xiv-04-table-source-bind) covers routing tables and the policy database. - Part XXXVI (
vyos-xxxvi-01-ecmp-concept,vyos-xxxvi-02-ecmp-config) covers the multipath entry and the hash in the main table. - Part XXXIX (
vyos-xxxix-04-wan-policy-routing) applies the same mechanism to multi-WAN, including the router-originated traffic case. - Part XLV (
vyos-xlv-03-classification-and-matching) covers the QoS classifier, which is the consumer aset markaction actually has.
Quiz
Knowledge check · 4 questions
Q1. A policy route rule matches and does `set table 100`, but table 100 has no route covering the destination. What happens to the packet?
Q2. On VyOS 1.5 you can mark forwarded packets by class in one rule and then match that mark in a later rule to select a routing table.
Q3. A voice class is correctly steered to its own table with two next-hops, and one of the two circuits carries no traffic at all. Explain it.
R1 has `set policy route PBR-CLASSES rule 20 protocol udp`, `destination port 5060`, `set table 100`, attached to eth1. Table 100 has two default routes, via 198.51.100.1 on eth2 and via 198.51.100.5 on eth3, both at the default distance. All SIP signalling is between one PBX at 10.10.0.5 and one provider SBC at 203.0.113.20. `tcpdump` on eth2 shows every SIP packet; eth3 shows none. `ip route show table 100` lists both next-hops. The circuits are both up and the provider confirms both are usable.
Q4. A newly committed per-class ECMP policy appears to do nothing at all. Find the two reasons it can look like that, and distinguish them.
An operator commits a policy route PBR-CLASSES with rules matching a guest subnet to table 200, and creates table 200 with two next-hops. After the commit, guest traffic still leaves by the main table's default route. There is no commit error. `show configuration commands | match 'policy route'` lists the rules. The operator is about to raise a ticket saying policy routing is broken on VyOS 1.5.
Passing score: 75%. Answers are checked in this browser.