<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Marvin</title>
    <description>The latest articles on DEV Community by Marvin (@marvin_07d673a268af6c6d24).</description>
    <link>https://dev.to/marvin_07d673a268af6c6d24</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4216032%2Fc43b4713-44ce-4c71-bfce-50f8a72cb402.png</url>
      <title>DEV Community: Marvin</title>
      <link>https://dev.to/marvin_07d673a268af6c6d24</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marvin_07d673a268af6c6d24"/>
    <language>en</language>
    <item>
      <title>Live-Migrating an OpenShift Virtualization VM Across an EVPN MAC-VRF</title>
      <dc:creator>Marvin</dc:creator>
      <pubDate>Fri, 30 Oct 2026 21:03:01 +0000</pubDate>
      <link>https://dev.to/marvin_07d673a268af6c6d24/live-migrating-an-openshift-virtualization-vm-across-an-evpn-mac-vrf-3mp4</link>
      <guid>https://dev.to/marvin_07d673a268af6c6d24/live-migrating-an-openshift-virtualization-vm-across-an-evpn-mac-vrf-3mp4</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from &lt;a href="https://otherthings.cloud/posts/live-migrating-a-vm-across-an-evpn-fabric/" rel="noopener noreferrer"&gt;otherthings.cloud&lt;/a&gt;, where the rest of my homelab notes live.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://otherthings.cloud/posts/advertising-pod-subnets-into-evpn-fabric/" rel="noopener noreferrer"&gt;last post&lt;/a&gt; joined the OpenShift 4.22 cluster to the fabric through the IP-VRF: pods on their own subnet, routed to &lt;code&gt;host-a&lt;/code&gt;. It ended with the MAC-VRF side untested. This post tests that side.&lt;/p&gt;

&lt;p&gt;A MAC-VRF stretches a Layer 2 segment across the EVPN fabric for east-west bridged traffic and workloads such as VM live migration. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;NOTE:&lt;/strong&gt; See the &lt;a href="https://ovn-kubernetes.io/master/features/bgp-integration/evpn/" rel="noopener noreferrer"&gt;OVN-Kubernetes EVPN documentation&lt;/a&gt; for the MAC-VRF definition.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wanted a VM on OpenShift to sit on the same subnet as &lt;code&gt;host-a&lt;/code&gt; and &lt;code&gt;host-b&lt;/code&gt;: &lt;code&gt;192.168.40.0/24&lt;/code&gt;. The two leaves already stretch this subnet between them as VNI 100 in my lab. &lt;/p&gt;

&lt;p&gt;Then I wanted to live-migrate the VM to another node (cp1 -&amp;gt; cp2) while &lt;code&gt;host-b&lt;/code&gt; continuously pinged it, and see what the fabric made of the move.&lt;/p&gt;

&lt;p&gt;The VM kept its IP and its MAC. The fabric treated the migration as an EVPN MAC move, with a sequence number increasing each time. &lt;code&gt;host-b&lt;/code&gt; lost two pings during the migration. &lt;/p&gt;

&lt;p&gt;There was also a subnet-planning consequence that I found later in the test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
                    spine1  192.168.30.2
                    (BGP route reflector)
         ┌───────────────┬────────┬────────┬───────────┐
         │               │        │        │           │
      leaf1           leaf2     cp1      cp2          cp3
   192.168.30.11   .30.12    .31.21   .31.22       .31.23
      VTEP           VTEP     VTEP     VTEP         VTEP
         │              │        └────────┬──────-─────┘
     host-a         host-b                │
    .40.10          .40.11              vm-a1
         │              │              .40.32
         └──────────────┴────────────────┘
                192.168.40.0/24
                   VNI 100

                    cp1  ─────→  cp2
                         VM migration

   cluster nodes as VTEPs: cp1 192.168.31.21 · cp2 .22 · cp3 .23
      vm-a1 192.168.40.32 — moves between cp1 and cp2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vm-a1&lt;/code&gt; starts on worker &lt;code&gt;cp1&lt;/code&gt; and is live-migrated to worker &lt;code&gt;cp2&lt;/code&gt;, while &lt;code&gt;host-b&lt;/code&gt; continuously pings &lt;code&gt;vm-a1&lt;/code&gt;:&lt;code&gt;192.168.40.32&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The network
&lt;/h2&gt;

&lt;p&gt;The VM's network is a &lt;code&gt;ClusterUserDefinedNetwork&lt;/code&gt; with &lt;code&gt;topology: Layer2&lt;/code&gt; and &lt;code&gt;transport: EVPN&lt;/code&gt;. In other words, this creates a Layer 2 primary CUDN across the EVPN network as MAC-VRF:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;evpn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;vtep&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;evpn-vtep&lt;/span&gt;
      &lt;span class="na"&gt;macVRF&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;vni&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
        &lt;span class="na"&gt;routeTarget&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;65000:100"&lt;/span&gt;
    &lt;span class="na"&gt;layer2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Primary&lt;/span&gt;
      &lt;span class="na"&gt;subnets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;               &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.40.0/24"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;infrastructureSubnets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.40.0/29"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;defaultGatewayIPs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.40.1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;reservedSubnets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.40.8/29"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.40.16/28"&lt;/span&gt;
      &lt;span class="na"&gt;ipam&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enabled&lt;/span&gt;
        &lt;span class="na"&gt;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Persistent&lt;/span&gt;
      &lt;span class="na"&gt;mtu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1400&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three parts of it matter here.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;macVRF.vni: 100&lt;/code&gt; is the number the leaves already use for this segment. The VNI is what identifies an L2 segment across the fabric. Using the same number puts the VM in the same broadcast domain as &lt;code&gt;host-a&lt;/code&gt; and &lt;code&gt;host-b&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The subnet lists are a fence. By default OVN-Kubernetes allocates from the whole &lt;code&gt;/24&lt;/code&gt;, but &lt;code&gt;.1&lt;/code&gt;, &lt;code&gt;.10&lt;/code&gt; and &lt;code&gt;.11&lt;/code&gt; already belong to the fabric. &lt;code&gt;infrastructureSubnets&lt;/code&gt; holds the gateway, and &lt;code&gt;reservedSubnets&lt;/code&gt; keeps &lt;code&gt;.8&lt;/code&gt; to &lt;code&gt;.31&lt;/code&gt; away from the allocator. VMs get &lt;code&gt;.32&lt;/code&gt; and up. The spec is immutable, so the fence cannot be widened later.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ipam.lifecycle: Persistent&lt;/code&gt; is what should keep the VM's address across a migration. Whether it does is one of the things I checked.&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;ipVRF&lt;/code&gt;. My first design had one, using VNI 101, the fabric's tenant VRF. The API accepted the object, and then this appeared in its &lt;code&gt;NetworkCreated&lt;/code&gt; condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        "message": "failed to allocate EVPN IDs: failed to reserve VNIs: VNI 101 on VTEP \"evpn-vtep\" is already reserved by network \"cudn-tenant-a\"",
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cudn-tenant-a&lt;/code&gt; is the network from the last post, and it already holds 101. A VNI can be reserved by only one network on a VTEP. So this network is L2 only.&lt;/p&gt;

&lt;p&gt;For this test, that costs nothing. The VM reaches &lt;code&gt;host-a&lt;/code&gt; and &lt;code&gt;host-b&lt;/code&gt; by bridging, and bridging needs no VRF. It does mean the VM's segment has no routed path to anything off the subnet.&lt;/p&gt;

&lt;p&gt;The VM runs CentOS Stream 9 with the &lt;code&gt;l2bridge&lt;/code&gt; interface binding. Its disk is an RWX PVC, because live migration needs shared storage. Before anything moved, the VMI already reported &lt;code&gt;LIVE-MIGRATABLE True&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Everything needed to build it — the four manifests in apply order, and the commands for watching a migration — is in &lt;a href="https://github.com/mabehiro/other-things-lab/tree/main/2026-09-openshift-virt-macvrf" rel="noopener noreferrer"&gt;other-things-lab/2026-09-openshift-virt-macvrf&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bridged, not routed
&lt;/h2&gt;

&lt;p&gt;From the VM's console, to &lt;code&gt;host-b&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[cloud-user@vm-a1 ~]$ ping -c 5 192.168.40.11
PING 192.168.40.11 (192.168.40.11) 56(84) bytes of data.
64 bytes from 192.168.40.11: icmp_seq=1 ttl=64 time=4.39 ms
64 bytes from 192.168.40.11: icmp_seq=2 ttl=64 time=1.58 ms
64 bytes from 192.168.40.11: icmp_seq=3 ttl=64 time=1.19 ms
64 bytes from 192.168.40.11: icmp_seq=4 ttl=64 time=1.25 ms
64 bytes from 192.168.40.11: icmp_seq=5 ttl=64 time=1.29 ms

--- 192.168.40.11 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4007ms
rtt min/avg/max/mdev = 1.194/1.939/4.387/1.230 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ttl=64&lt;/code&gt; means no router is in the path. Linux sends with 64, and every router hop takes one off.&lt;/p&gt;

&lt;p&gt;On leaf1, during the same test to &lt;code&gt;host-a&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@leaf1 ~]# tcpdump -ni ens18 'udp port 4789' -c 10 -v
dropped privs to tcpdump
tcpdump: listening on ens18, link-type EN10MB (Ethernet), snapshot length 262144 bytes
13:31:59.271470 IP (tos 0x0, ttl 64, id 61870, offset 0, flags [none], proto UDP (17), length 134)
    192.168.31.21.57978 &amp;gt; 192.168.30.11.vxlan: VXLAN, flags [I] (0x08), vni 100
IP (tos 0x0, ttl 64, id 50680, offset 0, flags [DF], proto ICMP (1), length 84)
    192.168.40.32 &amp;gt; 192.168.40.10: ICMP echo request, id 2, seq 1, length 64
13:31:59.272963 IP (tos 0x0, ttl 64, id 17652, offset 0, flags [none], proto UDP (17), length 134)
    192.168.30.11.44658 &amp;gt; 192.168.31.21.vxlan: VXLAN, flags [I] (0x08), vni 100
IP (tos 0x0, ttl 64, id 54707, offset 0, flags [none], proto ICMP (1), length 84)
    192.168.40.10 &amp;gt; 192.168.40.32: ICMP echo reply, id 2, seq 1, length 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request arrives in VNI 100, from &lt;code&gt;192.168.31.21&lt;/code&gt;, which is cp1's VTEP. The reply goes back the same way. The VM is on the fabric's segment itself, not on a network routed to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before the VM migration
&lt;/h2&gt;

&lt;p&gt;The VM's MAC is &lt;code&gt;0a:58:c0:a8:28:20&lt;/code&gt;. The last four bytes are &lt;code&gt;192.168.40.32&lt;/code&gt; in hex, because OVN-Kubernetes derives the MAC from the IP. So if the IP survives the migration, the MAC should survive too.&lt;/p&gt;

&lt;p&gt;On spine1, the VM has two Type-2 routes, one for the MAC alone and one for MAC plus IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@spine1 ~]# vtysh -c 'show bgp l2vpn evpn route type macip' | grep -i -A3 '0a:58:c0:a8:28:20'
 *&amp;gt;i[2]:[0]:[48]:[0a:58:c0:a8:28:20]
                    192.168.31.21                 100      0 i
                    RT:65000:100 ET:8
 *&amp;gt;i[2]:[0]:[48]:[0a:58:c0:a8:28:20]:[32]:[192.168.40.32]
                    192.168.31.21                 100      0 i
                    RT:65000:100 ET:8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both have next hop &lt;code&gt;192.168.31.21&lt;/code&gt;, which is cp1.&lt;/p&gt;

&lt;p&gt;On leaf2, which is where &lt;code&gt;host-b&lt;/code&gt;'s traffic enters the fabric:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@leaf2 ~]# vtysh -c 'show evpn mac vni 100 mac 0a:58:c0:a8:28:20'
MAC: 0a:58:c0:a8:28:20
 Remote VTEP: 192.168.31.21
 Sync-info: neigh#: 0
 Local Seq: 0 Remote Seq: 0
 Uptime: 00:14:20
 Neighbors:
    192.168.40.32 Active
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Remote Seq: 0&lt;/code&gt; is the MAC Mobility sequence number. &lt;a href="https://www.rfc-editor.org/rfc/rfc7432.html#section-15" rel="noopener noreferrer"&gt;RFC 7432&lt;/a&gt; describes this sequence number as part of the mechanism used to detect MAC moves. When a MAC moves, the new advertisement can carry a higher sequence number, allowing other VTEPs to prefer the newer location.&lt;/p&gt;

&lt;p&gt;The alternative would be for the cluster to withdraw the old route and advertise a fresh one. In that case the sequence number would stay at 0. &lt;/p&gt;

&lt;p&gt;The kernel forwarding entry on leaf2 is also worth a look:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@leaf2 ~]# bridge fdb show | grep -i '0a:58:c0:a8:28:20'
0a:58:c0:a8:28:20 dev vxlan100 vlan 1 extern_learn master br100
0a:58:c0:a8:28:20 dev vxlan100 extern_learn master br100
0a:58:c0:a8:28:20 dev vxlan100 dst 192.168.31.21 self extern_learn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;extern_learn&lt;/code&gt; means FRR installed the entry from BGP. The leaf did not learn the VM's MAC from traffic.&lt;/p&gt;

&lt;p&gt;The important point before migration is therefore:&lt;br&gt;
&lt;code&gt;leaf2&lt;/code&gt; currently knows that &lt;code&gt;MAC 0a:58:c0:a8:28:20&lt;/code&gt; is reachable through &lt;code&gt;VTEP 192.168.31.21 (cp1)&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The VM live migration
&lt;/h2&gt;

&lt;p&gt;I migrated the VM in both directions: first from &lt;code&gt;cp1&lt;/code&gt; to &lt;code&gt;cp2&lt;/code&gt;, and then back from &lt;code&gt;cp2&lt;/code&gt; to &lt;code&gt;cp1&lt;/code&gt;. I'm quoting the second run, because its ping output is complete text and leaf2 wrote its capture to a file. The first run behaved the same way, but its ping summary survived only as a screenshot.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;host-b&lt;/code&gt; ran &lt;code&gt;ping -D -O -i 0.2 192.168.40.32&lt;/code&gt;. &lt;code&gt;-D&lt;/code&gt; adds a timestamp to each line, and &lt;code&gt;-O&lt;/code&gt; prints a line for every probe that gets no reply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1790191720.254543] 64 bytes from 192.168.40.32: icmp_seq=296 ttl=64 time=2.39 ms
[1790191720.454958] 64 bytes from 192.168.40.32: icmp_seq=297 ttl=64 time=1.99 ms
[1790191720.654829] 64 bytes from 192.168.40.32: icmp_seq=298 ttl=64 time=1.41 ms
[1790191721.060312] no answer yet for icmp_seq=299   ← migration
[1790191721.268299] no answer yet for icmp_seq=300
[1790191721.273476] 64 bytes from 192.168.40.32: icmp_seq=301 ttl=64 time=2.24 ms
[1790191721.470693] 64 bytes from 192.168.40.32: icmp_seq=302 ttl=64 time=1.77 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two missing replies (&lt;code&gt;icmp_seq=299 and 300&lt;/code&gt;) are the only packet-loss window during the migration.&lt;br&gt;
From &lt;code&gt;host-b&lt;/code&gt;'s side, 0.619 s passed between the reply to 298 and the reply to 301.&lt;/p&gt;

&lt;p&gt;leaf2 saw the same probes. Its clock is on local time, five hours behind the UTC timestamps above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;14:28:40.600748 IP 192.168.30.12.44524 &amp;gt; 192.168.31.22.4789: VXLAN, flags [I] (0x08), vni 100   IP 192.168.40.11 &amp;gt; 192.168.40.32: ICMP echo request, id 5, seq 298, length 64
14:28:40.601534 IP 192.168.31.22.53542 &amp;gt; 192.168.30.12.4789: VXLAN, flags [I] (0x08), vni 100   IP 192.168.40.32 &amp;gt; 192.168.40.11: ICMP echo reply, id 5, seq 298, length 64
14:28:40.801638 IP 192.168.30.12.44524 &amp;gt; 192.168.31.22.4789: VXLAN, flags [I] (0x08), vni 100   IP 192.168.40.11 &amp;gt; 192.168.40.32: ICMP echo request, id 5, seq 299, length 64
14:28:41.007759 IP 192.168.30.12.44524 &amp;gt; 192.168.31.22.4789: VXLAN, flags [I] (0x08), vni 100   IP 192.168.40.11 &amp;gt; 192.168.40.32: ICMP echo request, id 5, seq 300, length 64
14:28:41.215730 IP 192.168.30.12.44524 &amp;gt; 192.168.31.21.4789: VXLAN, flags [I] (0x08), vni 100   IP 192.168.40.11 &amp;gt; 192.168.40.32: ICMP echo request, id 5, seq 301, length 64
14:28:41.217416 IP 192.168.31.21.49680 &amp;gt; 192.168.30.12.4789: VXLAN, flags [I] (0x08), vni 100   IP 192.168.40.32 &amp;gt; 192.168.40.11: ICMP echo reply, id 5, seq 301, length 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seq 298 went to &lt;code&gt;192.168.31.22&lt;/code&gt;, cp2, and was answered.&lt;/p&gt;

&lt;p&gt;Seq 299 and 300 also went to cp2, but the VM was no longer answering there.&lt;/p&gt;

&lt;p&gt;Seq 301 went to &lt;code&gt;192.168.31.21&lt;/code&gt;, cp1, and was answered 1.7 ms later.&lt;/p&gt;

&lt;p&gt;So the fabric didn't drop the lost probes. It delivered them to where the VM had been. Between &lt;code&gt;41.007759&lt;/code&gt; and &lt;code&gt;41.215730&lt;/code&gt;, leaf2 changed one destination. Nothing was flooded, and no packet went to both nodes.&lt;/p&gt;

&lt;p&gt;As leaf2 saw it, 616 ms passed between the last reply from cp2 and the first reply from cp1. The VM was unreachable for somewhere between about 0.2 s and 0.6 s.&lt;/p&gt;

&lt;p&gt;The migration's own timeline, in UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❯ oc get vmim kubevirt-migrate-vm-bh22k -n vm-a -o jsonpath='{range.status.phaseTransitionTimestamps[*]}{.phase}{"\t"}{.phaseTransitionTimestamp}{"\n"}{end}'
Pending    2026-09-23T19:28:36Z
Scheduling    2026-09-23T19:28:36Z
Scheduled    2026-09-23T19:28:39Z
PreparingTarget    2026-09-23T19:28:39Z
TargetReady    2026-09-23T19:28:39Z
Running    2026-09-23T19:28:39Z
Succeeded    2026-09-23T19:28:41Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Succeeded&lt;/code&gt; is at &lt;code&gt;19:28:41&lt;/code&gt;. The lost probes are at &lt;code&gt;1790191721&lt;/code&gt;, which is also 19:28:41 UTC. The loss falls in the second the migration finished.&lt;/p&gt;

&lt;p&gt;I can't split those ~600 ms. Part of it is the VM paused at the end of the migration. Part of it is the new Type-2 route reaching leaf2.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sequence number
&lt;/h2&gt;

&lt;p&gt;After the first move, from cp1 to cp2, spine1 read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@spine1 ~]# vtysh -c 'show bgp l2vpn evpn route type macip' | grep -i  -A3 0a:58:c0:a8:28:20
 *&amp;gt;i[2]:[0]:[48]:[0a:58:c0:a8:28:20]
                    192.168.31.22                 100      0 i
                    RT:65000:100 ET:8 MM:1
 *&amp;gt;i[2]:[0]:[48]:[0a:58:c0:a8:28:20]:[32]:[192.168.40.32]
                    192.168.31.22                 100      0 i
                    RT:65000:100 ET:8 MM:1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and leaf2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;leaf2# show evpn mac vni 100 mac 0a:58:c0:a8:28:20
MAC: 0a:58:c0:a8:28:20
 Remote VTEP: 192.168.31.22
 Sync-info: neigh#: 0
 Local Seq: 0 Remote Seq: 1
 Uptime: 00:22:57
 Neighbors:
    192.168.40.32 Active
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MAC is the same, the next hop is now cp2, and &lt;code&gt;MM:1&lt;/code&gt; is the MAC Mobility community with sequence 1. The cluster announced a move rather than a new MAC. FRR on leaf2 accepted it, and re-pointed its forwarding entry from BGP.&lt;/p&gt;

&lt;p&gt;After the move back to cp1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@spine1 ~]#  vtysh -c 'show bgp l2vpn evpn route type macip' | grep -i -A3 0a:58:c0:a8:28:20
 *&amp;gt;i[2]:[0]:[48]:[0a:58:c0:a8:28:20]
                    192.168.31.21                 100      0 i
                    RT:65000:100 ET:8 MM:2
 *&amp;gt;i[2]:[0]:[48]:[0a:58:c0:a8:28:20]:[32]:[192.168.40.32]
                    192.168.31.21                 100      0 i
                    RT:65000:100 ET:8 MM:2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MM:2&lt;/code&gt;. The VM went back to a VTEP it had used before. &lt;/p&gt;

&lt;p&gt;The upstream OVN-Kubernetes &lt;a href="https://github.com/ovn-kubernetes/ovn-kubernetes/blob/master/docs/features/bgp-integration/evpn.md" rel="noopener noreferrer"&gt;EVPN documentation&lt;/a&gt; describes this for KubeVirt live migration: &lt;em&gt;"the target node advertises the same MAC/IP with a higher sequence number."&lt;/em&gt; In my lab it did, both times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The VM IP address
&lt;/h2&gt;

&lt;p&gt;Before the move:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❯ oc get vmi -n vm-a -o wide
NAME    AGE   PHASE     IP              NODENAME       READY   LIVE-MIGRATABLE   PAUSED
vm-a1   10m   Running   192.168.40.32   ocp-evpn-cp1   True    True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❯ oc get vmi -n vm-a -o wide
NAME    AGE   PHASE     IP              NODENAME       READY   LIVE-MIGRATABLE   PAUSED
vm-a1   29m   Running   192.168.40.32   ocp-evpn-cp2   True    True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node changed, but the IP address did not. The &lt;code&gt;IPAMClaim&lt;/code&gt; still holds &lt;code&gt;192.168.40.32/24&lt;/code&gt;. &lt;code&gt;Persistent&lt;/code&gt; did exactly what its name suggests: the VM kept its IP address across a live migration.&lt;/p&gt;

&lt;p&gt;One detail I noticed in this setup: because I reused the fabric's existing gateway address (&lt;code&gt;192.168.40.1&lt;/code&gt;) as the CUDN gateway, both the fabric and OVN-Kubernetes answered ARP for the same IP with different MAC addresses. This is specific to how I built this lab and is separate from the L2 VM migration use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the subnet costs other networks
&lt;/h2&gt;

&lt;p&gt;This is one thing I can take away from this test.&lt;/p&gt;

&lt;p&gt;The pods from the &lt;a href="https://otherthings.cloud/posts/advertising-pod-subnets-into-evpn-fabric/" rel="noopener noreferrer"&gt;last post&lt;/a&gt; are in &lt;code&gt;tenant-a&lt;/code&gt; on OpenShift, and they route to &lt;code&gt;host-a&lt;/code&gt; over VNI 101. OpenShift isolates user-defined networks from each other by design. The &lt;a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.22/html/multiple_networks/primary-networks#nw-udn-overview_user-defined-networks" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; says they &lt;em&gt;"are isolated by default."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cudn-vm-a&lt;/code&gt; covers &lt;code&gt;192.168.40.0/24&lt;/code&gt;, and &lt;code&gt;host-a&lt;/code&gt; lives at &lt;code&gt;.10&lt;/code&gt;, inside it. To OVN-Kubernetes, a &lt;code&gt;tenant-a&lt;/code&gt; pod sending to &lt;code&gt;.10&lt;/code&gt; looks like traffic crossing into another user-defined network. So it is dropped.&lt;/p&gt;

&lt;p&gt;I tested this one change at a time, with no VM. With only the &lt;code&gt;vm-a&lt;/code&gt; namespace created, a &lt;code&gt;tenant-a&lt;/code&gt; pod to &lt;code&gt;host-a&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== tenant-pod -&amp;gt; 192.168.40.10
64 bytes from 192.168.40.10: icmp_seq=1 ttl=62 time=2.46 ms
64 bytes from 192.168.40.10: icmp_seq=2 ttl=62 time=1.60 ms
64 bytes from 192.168.40.10: icmp_seq=3 ttl=62 time=1.23 ms
3 packets transmitted, 3 received, 0% packet loss, time 2002ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After creating &lt;code&gt;cudn-vm-a&lt;/code&gt;, before even adding its RouteAdvertisements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== tenant-pod -&amp;gt; 192.168.40.10
3 packets transmitted, 0 received, 100% packet loss, time 2041ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson is that putting an EVPN network on a subnet already used by another network has a cost.&lt;br&gt;
In my lab, &lt;code&gt;192.168.40.0/24&lt;/code&gt; is both the fabric-facing subnet for the VM network and the subnet where &lt;code&gt;host-a&lt;/code&gt; lives.&lt;/p&gt;

&lt;p&gt;That means this post's setup and the last post's setup cannot coexist as-is. The addressing needs to be planned before the first &lt;code&gt;oc apply&lt;/code&gt;. But this is exactly why I test these things in the lab. I found a constraint I would not have seen from the configuration alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A VM on OpenShift joined a subnet the fabric already stretched. It then moved between nodes and kept its IP and MAC. The fabric treated the move as an EVPN MAC move. The same Type-2 route came back from a new VTEP with a higher sequence number, &lt;code&gt;MM:1&lt;/code&gt; and then &lt;code&gt;MM:2&lt;/code&gt;, and leaf2 re-pointed one forwarding entry. host-b lost two pings.&lt;/p&gt;

&lt;p&gt;The 616 ms is how this lab behaved, not a production number. Every node here, including the leaves, spine and cluster nodes, is a VM on one Proxmox host. There was one VM, it was idle during both migrations, and the pings were 0.2 s apart. A guest writing memory quickly would take longer to migrate, and I didn't try one.&lt;/p&gt;

&lt;p&gt;The migration worked, but I found a planning mistake. Putting the VM network on the fabric’s own subnet cut the &lt;code&gt;tenant-a&lt;/code&gt; pods off from &lt;code&gt;host-a&lt;/code&gt;. So the address plan has to come first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;So far, these posts have carried cluster traffic across the fabric inside VXLAN tunnels.&lt;/p&gt;

&lt;p&gt;Next I plan to try &lt;code&gt;NoOverlayMode&lt;/code&gt; with CUDN, where pod traffic is routed on the underlay without a tunnel.&lt;/p&gt;

&lt;p&gt;Thank you for reading.&lt;br&gt;&lt;br&gt;
See you!&lt;/p&gt;

</description>
      <category>openshift</category>
      <category>kubernetes</category>
      <category>networking</category>
      <category>kubevirt</category>
    </item>
    <item>
      <title>NoOverlay mode on OpenShift 4.22</title>
      <dc:creator>Marvin</dc:creator>
      <pubDate>Thu, 29 Oct 2026 21:14:45 +0000</pubDate>
      <link>https://dev.to/marvin_07d673a268af6c6d24/nooverlay-mode-on-openshift-422-2p7j</link>
      <guid>https://dev.to/marvin_07d673a268af6c6d24/nooverlay-mode-on-openshift-422-2p7j</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from &lt;a href="https://otherthings.cloud/posts/nooverlay-cudn-on-the-fabric/" rel="noopener noreferrer"&gt;otherthings.cloud&lt;/a&gt;, where the rest of my homelab notes live.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://otherthings.cloud/posts/live-migrating-a-vm-across-an-evpn-fabric/" rel="noopener noreferrer"&gt;last post&lt;/a&gt; ended by saying NoOverlay mode was next. This is that post.&lt;/p&gt;

&lt;p&gt;In OpenShift 4.22, a user-defined network can drop the overlay entirely. Pod traffic between nodes goes out as plain IP. Each node's pod subnet is advertised over BGP, and the network routes it like any other prefix.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.22/html/advanced_networking/bgp-routing#nw-no-overlay-overview_about-bgp-routing" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; gives one reason for it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You can use no-overlay mode to route layer 3 pod traffic directly over the underlay network with BGP, which reduces encapsulation overhead and improves east-west performance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Performance is the one thing I can't measure honestly. My cluster is three nested VMs on one Proxmox host. So this post leaves speed for later and asks the other questions: what goes on the wire, what the network sees, and what it took to get there.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;With BGP on the node's primary NIC, a pod reaches the outside world with its own IP, and the outside world reaches it back, without any change to host settings. My first build used a second NIC for BGP, and that version didn't round-trip. And two NoOverlay networks stayed isolated from each other, even though the fabric would happily have routed between them.&lt;/p&gt;

&lt;p&gt;I hope the diagram is readable at this size. If it isn't, click it to open it full size.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://otherthings.cloud/images/nooverlay-cudn-on-the-fabric/topology.svg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffb561q03s42txfpj8n2y.png" alt="Lab topology: three OpenShift nodes and spine1 on VLAN 20, with each node's NoOverlay pod subnet advertised to spine1 over iBGP" width="800" height="675"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the final layout, with BGP on the primary NIC. VLAN 30 is idle here, but my first build peered over it, as the next sections show. The packets in "On the wire" were captured during that first build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: one cluster, both paths
&lt;/h2&gt;

&lt;p&gt;No-overlay mode is &lt;a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.22/html/advanced_networking/bgp-routing#no-overlay-mode-bgp-routing" rel="noopener noreferrer"&gt;Technology Preview&lt;/a&gt; in 4.22. Using it requires the &lt;code&gt;TechPreviewNoUpgrade&lt;/code&gt; feature set, which can't be undone and blocks minor-version updates. I didn't want that on the cluster from the earlier posts, so I built a separate three-node cluster, &lt;code&gt;ocp-nooverlay&lt;/code&gt;, for this one.&lt;/p&gt;

&lt;p&gt;The default pod network stayed on Geneve. Only a new CUDN uses NoOverlay. That gives one cluster with both paths side by side, and every other variable is the same.&lt;/p&gt;

&lt;p&gt;The docs show a &lt;code&gt;noOverlayOptions&lt;/code&gt; block on the CUDN. On my build, with the feature gate on, that field doesn't exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: field "noOverlayOptions" does not exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the CUDN carries &lt;code&gt;transport: NoOverlay&lt;/code&gt; and nothing else. Its first status was not what I expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TransportAccepted    False    No RouteAdvertisements CR is advertising the pod networks.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transport isn't accepted until something advertises the network. Once the &lt;code&gt;RouteAdvertisements&lt;/code&gt; existed, it changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TransportAccepted   True    Transport has been configured as 'no-overlay'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  First build: BGP on a second NIC
&lt;/h2&gt;

&lt;p&gt;Each node has two NICs. The first is the machine network (VLAN 20, behind &lt;code&gt;br-ex&lt;/code&gt;). The second sits on VLAN 30, where &lt;code&gt;spine1&lt;/code&gt;, the route reflector from the earlier posts, lives. The EVPN cluster peered with &lt;code&gt;spine1&lt;/code&gt; over that second NIC, so I copied the layout.&lt;/p&gt;

&lt;p&gt;The routes arrived as planned. Each node learned the other two nodes' pod &lt;code&gt;/24&lt;/code&gt;s from &lt;code&gt;spine1&lt;/code&gt;, with next hops on VLAN 30:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== ocp-nooverlay-cp1
192.168.52.0/24 nhid 378 via 192.168.30.50 dev enp6s19 proto bgp metric 20
192.168.53.0/24 nhid 376 via 192.168.30.51 dev enp6s19 proto bgp metric 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pod to pod across nodes worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;64 bytes from 192.168.52.5: icmp_seq=1 ttl=60 time=20.6 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  On the wire
&lt;/h3&gt;

&lt;p&gt;I expected that ping on the VLAN 30 NIC, &lt;code&gt;enp6s19&lt;/code&gt;, since that's where the routes point. The capture there caught nothing: a 24-byte file, just the pcap header.&lt;/p&gt;

&lt;p&gt;The packets were on the other NIC. A capture on all interfaces found them leaving through &lt;code&gt;enp6s18&lt;/code&gt;, the machine network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;22:35:49.274114 enp6s18 Out ifindex 2 52:54:00:00:20:51 ethertype IPv4 (0x0800), length 104: (tos 0x0, ttl 62, id 4202, offset 0, flags [DF], proto ICMP (1), length 84)
    192.168.54.5 &amp;gt; 192.168.52.5: ICMP echo request, id 9, seq 1, length 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's plain ICMP, pod IP to pod IP, with no outer header. The IP packet is 84 bytes.&lt;/p&gt;

&lt;p&gt;For comparison, here is a ping between two pods on the default Geneve network, on the same NIC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;22:33:10.419329 enp6s18 Out ifindex 2 52:54:00:00:20:51 ethertype IPv4 (0x0800), length 162: (tos 0x0, ttl 64, id 13416, offset 0, flags [DF], proto UDP (17), length 142)
    192.168.20.51.63217 &amp;gt; 192.168.20.52.6081: Geneve, Flags [C], vni 0xff0003, proto TEB (0x6558), options [class Open Virtual Networking (OVN) (0x102) type 0x80(C) len 8]
    0a:58:64:58:00:04 &amp;gt; 0a:58:64:58:00:03, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 63, id 26285, offset 0, flags [DF], proto ICMP (1), length 84)
    10.130.0.146 &amp;gt; 10.128.0.173: ICMP echo request, id 8, seq 1, length 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same 84-byte ICMP packet travels inside a 142-byte UDP packet to port 6081. That's 58 bytes of encapsulation: the 50 bytes VXLAN costs, plus an 8-byte OVN option. NoOverlay adds nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The MTU didn't change
&lt;/h3&gt;

&lt;p&gt;With no encapsulation, I expected the pod MTU to grow to the underlay's 1500. It didn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3: ovn-udn1@if157: &amp;lt;BROADCAST,MULTICAST,UP,LOWER_UP&amp;gt; mtu 1400 qdisc noqueue state UP mode DEFAULT group default qlen 1000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 1500-byte ping with don't-fragment set never left the pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ping: local error: message too long, mtu=1400
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The NoOverlay CUDN took the cluster's 1400. The CUDN spec has an &lt;code&gt;mtu&lt;/code&gt; field, but I haven't tested whether a NoOverlay network accepts 1500 there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Off the cluster: out un-NATed, nothing back
&lt;/h2&gt;

&lt;p&gt;The next question was what the network sees when a pod talks to something outside the cluster. I pinged &lt;code&gt;spine1&lt;/code&gt; from a NoOverlay pod and captured on &lt;code&gt;spine1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;18:12:24.444219 48:a9:8a:38:16:62 &amp;gt; bc:24:11:3a:b2:ba, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 61, id 32720, offset 0, flags [DF], proto ICMP (1), length 84)
    192.168.54.5 &amp;gt; 192.168.30.2: ICMP echo request, id 12, seq 1, length 64
18:12:24.444624 bc:24:11:3a:b2:ba &amp;gt; 52:54:00:00:30:49, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 64, id 65249, offset 0, flags [none], proto ICMP (1), length 84)
    192.168.30.2 &amp;gt; 192.168.54.5: ICMP echo reply, id 12, seq 1, length 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source is &lt;code&gt;192.168.54.5&lt;/code&gt;, the pod's own address, not the node's. That's what I wanted to see.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;spine1&lt;/code&gt; replied, too. But the pod never got the reply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5 packets transmitted, 0 received, 100% packet loss, time 4072ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pinging the pod from &lt;code&gt;spine1&lt;/code&gt; failed the same way.&lt;/p&gt;

&lt;p&gt;The two directions took different paths. The request left the node by its default route, &lt;code&gt;via 192.168.20.1 dev br-ex&lt;/code&gt;, on the machine network, and was routed to &lt;code&gt;spine1&lt;/code&gt; from there. The reply followed &lt;code&gt;spine1&lt;/code&gt;'s BGP route, which points at the node's VLAN 30 address. So the reply arrived on &lt;code&gt;enp6s19&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;OVN-Kubernetes doesn't manage &lt;code&gt;enp6s19&lt;/code&gt;, and forwarding is off on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;net.ipv4.conf.enp6s19.forwarding = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node knows where the pod is. It just won't forward a packet arriving on that NIC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ocp-nooverlay-cp1 /]# ip route get 192.168.54.5 from 192.168.30.2 iif enp6s19
RTNETLINK answers: No route to host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Host settings on that NIC can change this. I didn't want a design that depends on tuning the host. So I moved the BGP session instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Peering on the primary NIC
&lt;/h2&gt;

&lt;p&gt;I gave &lt;code&gt;spine1&lt;/code&gt; a leg on VLAN 20 and pointed the cluster's &lt;code&gt;FRRConfiguration&lt;/code&gt; at that address instead. Nothing else in the cluster changed.&lt;/p&gt;

&lt;p&gt;FRR-K8s used each node's VLAN 20 address as the next hop with no extra configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; *&amp;gt;i192.168.52.0/24  192.168.20.52            0    100      0 i
 *&amp;gt;i192.168.53.0/24  192.168.20.53            0    100      0 i
 *&amp;gt;i192.168.54.0/24  192.168.20.51            0    100      0 i
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the same test again, from both ends, captured on &lt;code&gt;spine1&lt;/code&gt;'s VLAN 20 NIC, &lt;code&gt;ens19&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The pod pings &lt;code&gt;spine1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;13:32:11.370234 ens19 In  ifindex 3 52:54:00:00:20:51 ethertype IPv4 (0x0800), length 104: (tos 0x0, ttl 62, id 53389, offset 0, flags [DF], proto ICMP (1), length 84)
    192.168.54.5 &amp;gt; 192.168.20.25: ICMP echo request, id 44, seq 1, length 64
13:32:11.370396 ens19 Out ifindex 3 52:54:00:00:20:25 ethertype IPv4 (0x0800), length 104: (tos 0x0, ttl 64, id 22216, offset 0, flags [none], proto ICMP (1), length 84)
    192.168.20.25 &amp;gt; 192.168.54.5: ICMP echo reply, id 44, seq 1, length 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;spine1&lt;/code&gt; pings the pod at its own address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;13:32:34.108343 ens19 Out ifindex 3 52:54:00:00:20:25 ethertype IPv4 (0x0800), length 104: (tos 0x0, ttl 64, id 25468, offset 0, flags [DF], proto ICMP (1), length 84)
    192.168.20.25 &amp;gt; 192.168.54.5: ICMP echo request, id 4, seq 1, length 64
13:32:34.110169 ens19 In  ifindex 3 52:54:00:00:20:51 ethertype IPv4 (0x0800), length 104: (tos 0x0, ttl 62, id 7458, offset 0, flags [none], proto ICMP (1), length 84)
    192.168.54.5 &amp;gt; 192.168.20.25: ICMP echo reply, id 4, seq 1, length 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both directions were 5/5. There's no NodePort, Service or LoadBalancer in the path. The pod is just an address on the network.&lt;/p&gt;

&lt;p&gt;A pod on the default Geneve network, pinging the same target over the same wire, arrives as its node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;13:32:38.383135 ens19 In  ifindex 3 52:54:00:00:20:51 ethertype IPv4 (0x0800), length 104: (tos 0x0, ttl 62, id 32935, offset 0, flags [DF], proto ICMP (1), length 84)
    192.168.20.51 &amp;gt; 192.168.20.25: ICMP echo request, id 48889, seq 1, length 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole difference between the two networks, as &lt;code&gt;spine1&lt;/code&gt; sees it: &lt;code&gt;192.168.54.5&lt;/code&gt; against &lt;code&gt;192.168.20.51&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Afterwards I read the node's settings again. Nothing had been changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;net.ipv4.ip_forward = 0
net.ipv4.conf.enp6s19.forwarding = 0
net.ipv4.conf.enp6s19.rp_filter = 1
net.ipv4.conf.enp6s18.forwarding = 0
net.ipv4.conf.br-ex.forwarding = 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forwarding is on only for &lt;code&gt;br-ex&lt;/code&gt;, the interface OVN-Kubernetes manages. With BGP on the primary NIC, traffic arrives on &lt;code&gt;br-ex&lt;/code&gt;, so that's all it needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolation still holds
&lt;/h2&gt;

&lt;p&gt;One question was still bothering me. With Geneve, two user-defined networks are kept apart, and I'd measured that in an earlier lab: pods on different UDNs can't reach each other. But with NoOverlay there is no tunnel to keep them apart. Every network is just routes.&lt;/p&gt;

&lt;p&gt;So I created a second NoOverlay CUDN, B, next to A. &lt;code&gt;spine1&lt;/code&gt; now holds both networks' subnets in one table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; *&amp;gt;i192.168.52.0/24  192.168.20.52            0    100      0 i
 *&amp;gt;i192.168.53.0/24  192.168.20.53            0    100      0 i
 *&amp;gt;i192.168.54.0/24  192.168.20.51            0    100      0 i
 *&amp;gt;i192.168.56.0/24  192.168.20.52            0    100      0 i
 *&amp;gt;i192.168.57.0/24  192.168.20.53            0    100      0 i
 *&amp;gt;i192.168.58.0/24  192.168.20.51            0    100      0 i
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node had a route too. On cp1, the kernel's table for network A knows how to reach B's pod on cp2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.168.56.4 from 192.168.54.5 via 192.168.20.52 dev br-ex table 1067 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both controls passed first: B's pods reached each other, and &lt;code&gt;spine1&lt;/code&gt; reached B's pod. Then A to B:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PING 192.168.56.4 (192.168.56.4) 56(84) bytes of data.

--- 192.168.56.4 ping statistics ---
5 packets transmitted, 0 received, 100% packet loss, time 4078ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A to B failed on the same node and across nodes, and B to A failed too. Every case lost every packet, silently. That's the same result, with the same silence, as Geneve UDNs.&lt;/p&gt;

&lt;p&gt;The kernel route was never used. In a capture on cp1, the packets appear only on pod A's own port, and never on &lt;code&gt;enp6s18&lt;/code&gt;. The capture on cp2 was empty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;14:41:05.545535 ?     P   ifindex 157 0a:58:c0:a8:36:05 ethertype IPv4 (0x0800), length 104: (tos 0x0, ttl 64, id 16698, offset 0, flags [DF], proto ICMP (1), length 84)
    192.168.54.5 &amp;gt; 192.168.56.4: ICMP echo request, id 52, seq 1, length 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the packet is dropped inside the first node, before it reaches any routing table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that drops it
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;NOTE:&lt;/strong&gt; The commands for these traces and lookups, so you can find the same rules on your own cluster, are in &lt;a href="https://github.com/mabehiro/other-things-lab/blob/main/2026-10-openshift-nooverlay/isolation-trace.md" rel="noopener noreferrer"&gt;&lt;code&gt;isolation-trace.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To find out where, I used &lt;code&gt;ovn-trace&lt;/code&gt;. OVN turns its configuration (switches, routers, ACLs) into a long list of numbered rules. &lt;code&gt;ovn-trace&lt;/code&gt; runs a made-up packet through that list without sending anything, and prints each rule the packet matches, in order. I gave it a ping from A's pod on cp1 to B's pod.&lt;/p&gt;

&lt;p&gt;Most of the rules it passed are routine. This is the one that stopped it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;20. ls_in_acl_after_lb_eval (northd.c:7608): reg8[30..31] == 0 &amp;amp;&amp;amp; reg0[9] == 1 &amp;amp;&amp;amp; ((ip4.src == $a10109792604843350142 &amp;amp;&amp;amp; ip4.dst == $a10109792604843350142)), priority 2050, uuid a5d41540
    reg8[17] = 1;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part that matters is in the double brackets. If the source and the destination are both in &lt;code&gt;$a10109792604843350142&lt;/code&gt;, the rule sets &lt;code&gt;reg8[17]&lt;/code&gt;, which marks the packet to be dropped. The trace ends at the next rule. Nothing after it forwards the packet.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$a10109792604843350142&lt;/code&gt; is an address set: a named list of subnets that rules can refer to. OVN's database shows what's in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;addresses           : ["192.168.52.0/22", "192.168.56.0/22"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are the whole subnets of A and B. Each network is a /22, and &lt;code&gt;spine1&lt;/code&gt;'s table above shows the /24 slice each node takes from it. So the rule drops any packet whose source and destination are both on the list of advertised networks.&lt;/p&gt;

&lt;p&gt;That would drop A to A too. It doesn't, because a rule with higher priority wins. When I traced A pod to A pod, this rule matched first and let the packet through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;20. ls_in_acl_after_lb_eval (northd.c:7480): reg8[30..31] == 0 &amp;amp;&amp;amp; (((ip4.src == 192.168.52.0/22) &amp;amp;&amp;amp; (ip4.dst == 192.168.52.0/22))), priority 2100, uuid 72ab9e68
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It matches only traffic that stays inside A's own subnet, and at 2100 it beats the drop rule at 2050. Between them, the two rules allow traffic within a network and drop traffic between networks.&lt;/p&gt;

&lt;p&gt;In OVN's database, both the address set and the rule are owned by &lt;code&gt;AdvertisedNetwork&lt;/code&gt;, not by anything named for NoOverlay. From that I'd guess the same rule separates any advertised UDNs, Geneve or EVPN included. I haven't tested that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Peer on the primary NIC, and NoOverlay does what it says: plain packets, the pod's own address on the network, and nothing to tune on the host. Isolation between networks survives the loss of the tunnel, because it never depended on the tunnel.&lt;/p&gt;

&lt;p&gt;The part I couldn't measure is the reason the feature exists: performance. That needs real hardware, and it's the next part of this series.&lt;/p&gt;

&lt;p&gt;Everything needed to build it is in &lt;a href="https://github.com/mabehiro/other-things-lab/tree/main/2026-10-openshift-nooverlay" rel="noopener noreferrer"&gt;&lt;code&gt;2026-10-openshift-nooverlay/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>openshift</category>
      <category>kubernetes</category>
      <category>networking</category>
      <category>bgp</category>
    </item>
  </channel>
</rss>
