A second pod network with Multus on an EVPN VXLAN fabric (part 1 of 3)

Part 1 of 3. A second pod network with Multus on an EVPN VXLAN fabric, measured: what Services, NetworkPolicy and probes miss, where Cilium drops an asymmetric reply, how rp_filter combines, and how host-local gives two pods one address.
Multus on an EVPN VXLAN fabric

On this page

This is part 1 of a three-part series on a two-site edge for an AI inference API: part 1, a second pod network with Multus on the EVPN VXLAN fabric; part 2, the F5 BIG-IP edge: BGP route health, Advanced WAF and CIS; part 3, failover between the two sites with BIG-IP DNS.

An inference pod usually needs two networks. The cluster network carries the API calls, the readiness probes and the Kubernetes Service that a load balancer points at. A second network carries bulk traffic, such as model weights from storage and cache transfers between replicas, at jumbo MTU straight onto the fabric. Multus adds that second interface in a few lines of YAML. Services, NetworkPolicy and probes keep working only on the first one.

This part measures what changes when a pod has net1 next to eth0 on an EVPN VXLAN fabric. The outputs come from one recorded run plus two short addenda against the same pods. Two results were less obvious: where an asymmetric reply gets dropped, and how two nodes end up with the same address.

The lab

The fabric is Enterprise SONiC 4.6: a spine and two leafs, an eBGP underlay, an EVPN overlay and symmetric IRB with anycast gateways on both leafs. Every Kubernetes node is multihomed to both leafs with EVPN multihoming. Each node has one LACP bond, each bond is an Ethernet segment, and leaf1 is the designated forwarder on every segment by preference (200 against 100). Kubernetes 1.35 is built with kubeadm and uses Cilium as the cluster network. Multus 4.3, the thick plugin, adds net1: a macvlan in bridge mode on the node's VLAN 20 sub-interface at MTU 9000, with addresses from Whereabouts. VLAN 10 carries the nodes in one VRF. VLANs 20 and 30 share a second VRF, and a test host on VLAN 30 acts as a client on another subnet.

Diagram: a spine, two leafs, a worker node dual-homed by an LACP bond, VLAN 10 and VLAN 20 sub-interfaces, a pod with eth0 on Cilium and net1 as macvlan on VLAN 20, and a remote host on VLAN 30.
The test bench. The node routes eth0's traffic; net1 puts the pod's own MAC on VLAN 20, which the fabric carries as an L2VNI.

What the run shows

  • Services, EndpointSlices and cluster DNS return only the eth0 address. The net1 address appears only in the pod's network-status annotation.
  • A deny-all NetworkPolicy blocks eth0. Requests to net1 still get an answer.
  • The leafs learn the pod's net1 MAC and advertise it in EVPN like any host.
  • 9000-byte packets cross the fabric routed, inside VXLAN, without fragmenting.
  • A request from another subnet that arrives on net1 is answered out of eth0, and Cilium drops the reply on the node. Routes in the attachment fix it; the sbr plugin fixes it too.
  • For rp_filter, the kernel uses the higher of the global and per-interface values, and the drop counters match that in all five combinations tested.
  • host-local, given the same range on every node, hands two pods the same address.
  • With net1 cut off, an HTTP readiness probe stays green. A probe that pings through net1 fails.

What the Kubernetes API sees

The pod below has both attachments. eth0 holds the Cilium address and the default route. net1 is a macvlan child of bond0.20 at MTU 9000, with an address from the data subnet and one extra route, to the VLAN 30 subnet.

Terminal: kubectl get pod, ip -br addr showing net1 10.1.20.103 and eth0 10.1.128.46, ip route with a default via eth0 and 10.1.30.0/24 via 10.1.20.1 on net1, and ip -d link showing macvlan mode bridge mtu 9000.
Pod pa: addresses, routes and the macvlan link.

The attachment is a NetworkAttachmentDefinition. The first one below uses Whereabouts for addresses and a routes list for subnets that should be answered through net1. The second chains the CNI sbr plugin instead. The asymmetric-reply section compares the two.

Configuration: two NetworkAttachmentDefinitions, data with macvlan on bond0.20, mtu 9000, Whereabouts range 101 to 199 and a route to the remote subnet, and data-sbr chaining macvlan with the sbr plugin.
The two attachments from the lab repository. The subnet placeholders are filled in per site, and the [...] line is the YAML document separator.

From the cluster's side, a headless Service returns the eth0 address and cluster DNS resolves to it. The EndpointSlice for the model servers lists only their eth0 addresses. A client that needs the data network has to learn the address some other way, from the k8s.v1.cni.cncf.io/network-status annotation or from its own discovery.

Terminal: the EndpointSlice address 10.1.128.46, dig returning 10.1.128.46, the inference EndpointSlice listing two pod IPs, and the pod list with the same addresses.
EndpointSlices and cluster DNS return eth0 addresses only.

NetworkPolicy

A namespace-wide deny-all ingress policy is applied, then a neighbouring pod calls both addresses of the same pod. The request to eth0 times out. The request to net1 returns 200.

Terminal: the NetworkPolicy spec with an empty podSelector and policyTypes Ingress, curl to the eth0 address returning 000 with exit code 28, curl to the net1 address returning 200.
deny-all ingress: eth0 times out, net1 returns 200.

This is expected. The cluster network's plugin enforces NetworkPolicy, and net1 traffic never passes through it. Filter a secondary network somewhere else: in the fabric, with an ACL on the VLAN or a separate VRF as in this lab; on the host; or with a plugin built for multi-network policy.

The fabric learns pods

macvlan gives each pod its own MAC on the wire, so the leafs learn pods the same way they learn servers. The pod's net1 MAC is in both leafs' MAC tables on the node's PortChannel. EVPN advertises it as a type-2 route twice, once with only the MAC and once with the MAC and its IP. The Ethernet segment table shows the multihoming underneath: each node's bond is local and remote on leaf1, with leaf2 as the peer.

Terminal: the pod MAC on PortChannel2 in VLAN 20 on both leafs, EVPN type-2 routes with and without the IP 10.1.20.103, and the EVPN Ethernet segment table.
One pod's net1 MAC in both leafs' MAC tables and in EVPN, and the Ethernet segments on leaf1.

Every pod on the data network adds a MAC entry and a host route to the fabric, so size its tables for pods as well as servers.

Jumbo frames

The data VLAN runs at 9000 on the pods and 9216 on every fabric interface, which leaves room for the VXLAN header. A don't-fragment ping with an 8972-byte payload (9000 bytes of IP) reaches the host on the other subnet with no loss. One byte more fails on the pod itself, before anything reaches the wire. The spine capture shows the packet routed into the L3VNI, VNI 10900, with 8980 bytes of ICMP inside the VXLAN frame.

Terminal: ping -M do -s 8972 to 10.1.30.10 with zero loss, ping -s 8973 failing with message too large, and tcpdump on the spine showing VXLAN vni 10900 carrying ICMP length 8980.
Jumbo ping to the VLAN 30 host and the spine capture of the same packet. Excerpt of the recorded run; the MTU check and the pod-to-pod ping above it are left out.

Asymmetric replies

The test host on VLAN 30 pings the net1 address of pod pc, whose attachment has no route back to VLAN 30. The request arrives on net1 and the reply follows the default route out of eth0. Nothing comes back to the client, and a capture on the node's uplink sees nothing leave.

Terminal: the pod's routes with only a default via eth0, ip route get 10.1.30.10 via eth0, 100 percent loss from the remote host, a tcpdump on the node's bond0.10 capturing zero packets, the leaf's node VRF with only the node subnet, and the Cilium drop monitor showing drop (Invalid source ip) for EchoReply from 10.1.20.105.
Pod pc has no return route on net1. Two captures joined: the Cilium drop monitor ran in a second recording against the same pod.

Cilium's drop monitor gives the reason: Invalid source ip. The reply carries the net1 address as its source and tries to leave through eth0. Cilium drops anything leaving a pod's interface with a source address that doesn't belong to that endpoint. The leaf's node VRF, which holds only the node subnet, has no route to VLAN 30 either.

Both fixes worked in the run. The first adds the remote subnet to the attachment's IPAM routes, so pod pa answers those requests through net1 and the data gateway. The second chains the sbr plugin. Pod pe gets a rule that sends anything sourced from its net1 address to a separate table whose default route is the data gateway.

Terminal: a pod with a route to 10.1.30.0/24 via 10.1.20.1 answering pings with zero loss, then a pod with ip rule from 10.1.20.106 lookup 100 and a table 100 default via 10.1.20.1, also answering with zero loss.
Pod pa with IPAM routes and pod pe with sbr, both answering the VLAN 30 host.

Use explicit routes when you know which subnets use the data network, which in an inference fabric is usually the case. Use sbr when clients on net1 can come from anywhere and listing them isn't practical.

rp_filter

Reverse-path filtering decides whether the kernel accepts that asymmetric request at all. It uses the higher number of conf.all.rp_filter and the interface's own value, where 0 is off, 1 is strict and 2 is loose. Because loose is the higher number, it wins over strict. Each row below sets both values, sends three requests from the VLAN 30 host to pod pc, and reads the pod's counters before and after.

Terminal: five settings of all and net1 rp_filter with counters: 0 and 0 accept with 3 replies sent, 0 and 1 drop 3, 1 and 0 drop 3, 2 and 1 accept, 1 and 2 accept.
Five combinations of all and net1, three requests each, with the pod's reverse-path drop and echo-reply counters.

With strict as the higher value, either globally (1 and 0) or on net1 (0 and 1), all three requests were dropped by the reverse-path filter. With loose on either level, they were accepted, including the row with strict on the other level. A global strict value overrides a per-interface 0, so set net1 to 2 explicitly if the design depends on loose mode. If net1 drops a remote client, check both values and the reverse-path counter first.

host-local IPAM

host-local keeps its allocation state on each node's disk, so two nodes given the same range allocate from it independently. Two pods, one per node, both got 10.1.20.203. An ARP for that address got replies from two different MACs. Leaf1's EVPN table held a MAC/IP route for 10.1.20.203 with only one of them, the MAC of the pod on the second node. Both nodes' host-local stores show the same reservation.

Terminal: two pods on two nodes, both net1 10.1.20.203 with different MACs, arping receiving replies from both MACs, the leaf's EVPN table with only one MAC for 10.1.20.203, both nodes' host-local stores holding 10.1.20.203, and the Whereabouts pool listing the pods allocated from it.
Two pods with the same host-local address, and the Whereabouts pool. Three captures joined: the ARP probe and the store listing ran in a second recording against the same pods.

Whereabouts keeps one pool for the whole cluster. Every pod that used it in the run, across both nodes and both namespaces, appears in that one pool with its own allocation. On a subnet shared by several nodes, use Whereabouts, static addresses, or a different host-local range per node, never the same range everywhere.

Readiness probes

The kubelet sends readiness probes to the pod's cluster address, which is on eth0. VLAN 20 was removed from one node's Ethernet segment on both leafs, which cuts off net1 on that node, and pings to it failed. Pod pa, with an HTTP readiness probe, stayed Ready and stayed in its Service. Pod pf, whose readiness probe pings the data gateway through net1, was NotReady at the next check and marked not ready in the EndpointSlice. With VLAN 20 back, both were Ready again.

Terminal: two pods Ready, VLAN 20 removed from PortChannel 2 on both leafs, pings to net1 failing, pa still 1/1 Ready while pf shows 0/1, the EndpointSlice listing pf ready=false, and both Ready again after restore.
VLAN 20 removed from one node's segment: the HTTP probe stays green, the probe through net1 fails. Excerpt of the recorded run; [...] marks lines left out.

If a replica is useless without its data network, make its readiness probe use that network. An exec probe that reaches something on net1, such as the gateway, a storage target or a peer, is enough. The probe then depends on the fabric, so keep it cheap and give it a failure threshold that tolerates a dropped packet.

The next part puts an F5 BIG-IP in front of the inference API on this fabric.

The eBGP underlay and EVPN VXLAN fabric this post runs on are built step by step in my book, BGP for AI Infrastructure, including where Multus and the backend rails meet the pod.

Subscribe to LevelUp I.T. newsletter and stay updated.

Don't miss anything. Get all the latest posts delivered straight to your inbox. It's free!
Great! Check your inbox and click the link to confirm your subscription.
Error! Please enter a valid email address!