KubeVirt networking, one binding at a time

You install KubeVirt, apply your first VirtualMachine, and it boots. Now you want to use it, reaching it over the network, so you exec in, run ip a, and the guest says 10.0.2.2. Then you check from the cluster side, and Kubernetes says 10.42.0.195.

Which IP do you have to use and how?

Every KubeVirt VM you run is scheduled inside an ordinary pod of its own. That pod gets an interface and a cluster IP the same way any pod does, before the VM exists at all. The guest operating system does not replace that networking. It attaches to it, through a piece of wiring KubeVirt calls a binding. The binding is the thing that decides whether your guest OS sees 10.0.2.2 or 10.42.0.195, whether it can hold a real cluster address, and whether it can reach a second network at all.

This piece is about that binding layer. We will start with the two bindings on the primary interface, masquerade and bridge, look at passt as a modern third option, and use real output to show exactly what the guest sees under each and why they differ. Then we will add a second interface on a network you define, using Multus, and see if anything changes when pointing at a different network.

The pod networking layer

Before any binding exists, the pod already has working networking. To see why, it helps to know how a VM is actually bootstrapped, because there are three layers between the object you apply and the guest that boots.

When you kubectl apply a VirtualMachine, you are not creating something the kernel can run, you are creating a desired-state object, the same way a Deployment is. This object is the vm custom resource, and records what the virtual machine should be and whether it should be running.

When you decide to start your VM, KubeVirt creates a second object from it: a VirtualMachineInstance, the vmi custom resource, which represents one actual running VM. The relationship mirrors Deployment and Pod. The vm is the durable definition you edit, and the vmi is the running thing that comes and goes.

The vmi still is not something that runs on its own. To execute the guest, KubeVirt schedules a pod for it, named virt-launcher-<vm-name>-<hash>, and it is an ordinary pod that the Kubernetes scheduler places on a node like any other. Inside that pod, the virt-launcher process starts the hypervisor process that runs your guest, which on a default KVM setup is QEMU. I said “default” because starting with KubeVirt 1.8 the backend is pluggable through a Hypervisor Abstraction Layer, with KVM and QEMU as the default and alternatives possible in a near future. For this article, we will consider KVM/QEMU our hypervisor layer.

$ kubectl get vm,vmi,pod -n net-bindings
NAME                                       ...
virtualmachine.kubevirt.io/vm-bridge       ...
virtualmachine.kubevirt.io/vm-masquerade   ...
virtualmachine.kubevirt.io/vm-passt        ...

NAME                                               ...
virtualmachineinstance.kubevirt.io/vm-bridge       ...
virtualmachineinstance.kubevirt.io/vm-masquerade   ...
virtualmachineinstance.kubevirt.io/vm-passt        ...

NAME                                     ...
pod/virt-launcher-vm-bridge-fdsx7        ...
pod/virt-launcher-vm-masquerade-vxm7w    ...
pod/virt-launcher-vm-passt-q9q57         ...

So the layering, top to bottom, is the vm (the definition) producing a vmi (the running instance), backed by a virt-launcher pod (the thing the cluster actually schedules), hosting the hypervisor process (the guest itself).

That layering is the whole reason the pod has networking before the guest does. The virt-launcher pod is scheduled, admitted, and given its interface and cluster IP by your CNI as part of normal pod startup, exactly like any other pod, and only then does the hypervisor boot the guest inside it. The guest operating system has not booted yet and the pod is already reachable.

So the question to answer is not “how does this VM get on the network”. The pod is already on the network. The question is narrower and more precise: what relationship does the guest’s virtual NIC have to the pod’s existing network interface? That relationship is the binding, and KubeVirt ships more than one strategy for it. Let’s see what they are.

Masquerade binding: the guest is NAT’d behind the pod

Masquerade is the default, and in the following manifest the only line that is necessary to set this binding is: masquerade: {} on the one interface, attached to the pod network:

apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: vm-masquerade
  namespace: net-bindings
spec:
  runStrategy: Always
  template:
    metadata:
      labels:
        kubevirt.io/vm: vm-masquerade
    spec:
      domain:
        devices:
          disks:
            - name: containerdisk
              disk: { bus: virtio }
            - name: cloudinitdisk
              disk: { bus: virtio }
          interfaces:
            - name: pod-net
              masquerade: {}        # <-- here the binding type is set
        resources:
          requests:
            memory: 1Gi
      networks:
        - name: pod-net
          pod: {}
      volumes:
        - name: containerdisk
          containerDisk:
            image: quay.io/containerdisks/fedora:latest
        - name: cloudinitdisk
          cloudInitNoCloud:
            userData: |-
              #cloud-config
              password: kubevirt
              chpasswd: { expire: False }
              ssh_pwauth: True

Apply it, start it, and exec into the guest to ask where it lives:

$ virtctl console vm-masquerade -n net-bindings

[fedora@vm-masquerade ~]$ ip a
2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 ...
    inet 10.0.2.2/24 brd 10.0.2.255 scope global dynamic enp1s0

[fedora@vm-masquerade ~]$ ip route
default via 10.0.2.1 dev enp1s0 proto dhcp src 10.0.2.2 metric 100
10.0.2.0/24 dev enp1s0 proto kernel scope link src 10.0.2.2 metric 100

The guest believes it lives on 10.0.2.0/24 with a gateway at 10.0.2.1. Now let’s look at the same VM from the cluster’s point of view:

$ kubectl get vmi vm-masquerade -o jsonpath='{.status.interfaces[0].ipAddress}' -n net-bindings
10.42.0.195

$ kubectl get pod -l kubevirt.io/created-by -o wide -n net-bindings
NAME                                READY   STATUS    IP            NODE
virt-launcher-vm-masquerade-vxm7w   3/3     Running   10.42.0.195   node01

Two different addresses for one VM. Here is the whole point of masquerade: KubeVirt runs NAT inside the pod’s network namespace, so the guest can be a completely Kubernetes-unaware Fedora image and still receive traffic, because the binding translates between the pod IP the cluster knows and the private address the guest holds. This is why masquerade just works for most cases.

The VM looks like any other pod to the cluster, it gets a pod IP for free, and it survives the guest knowing nothing about Kubernetes. The IP the cluster sees and the IP the guest sees are different, and you are paying for a layer of NAT. There is also a subtler cost. Masquerade forwards only the ports you declare in the interface’s ports list, and because the guest sits behind NAT inside the pod and shares the pod’s single IP, those forwarded ports share the pod’s port space with whatever else runs in the pod. The virt-launcher pod is not only the guest; it runs KubeVirt’s own infrastructure containers alongside it. In practice this is almost never a problem, but if a declared port forward ever refuses to work or a guest service cannot bind a port it should own, the shared port namespace is the first place to look. Bridge mode, where the guest owns the interface outright, has no shared port space and so cannot hit this at all.

Bridge: the guest is the pod IP

Bridge mode answers the opposite question. Where masquerade hides the pod IP from the guest, bridge hands it over directly. The manifest is the masquerade manifest above, with exactly one line changed, the binding on the interface:

apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: vm-bridge
  namespace: net-bindings
spec:
  runStrategy: Always
  template:
    metadata:
      labels:
        kubevirt.io/vm: vm-bridge
    spec:
      domain:
        devices:
          disks:
            - name: containerdisk
              disk: { bus: virtio }
            - name: cloudinitdisk
              disk: { bus: virtio }
          interfaces:
            - name: pod-net
              bridge: {}             # <-- the ONLY difference
        resources:
          requests:
            memory: 1Gi
      networks:
        - name: pod-net
          pod: {}
      volumes:
        - name: containerdisk
          containerDisk:
            image: quay.io/containerdisks/fedora:latest
        - name: cloudinitdisk
          cloudInitNoCloud:
            userData: |-
              #cloud-config
              password: kubevirt
              chpasswd: { expire: False }
              ssh_pwauth: True

Everything else is identical. Exec into the bridged VM and the picture is different:

$ virtctl console vm-bridge -n net-bindings

[fedora@vm-bridge ~]$ ip a
2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 ...
    inet 10.42.0.194/24 brd 10.42.0.255 scope global dynamic enp1s0

[fedora@vm-bridge ~]$ ip route
default via 10.42.0.1 dev enp1s0 proto dhcp src 10.42.0.194 metric 100
10.42.0.0/24 dev enp1s0 proto kernel scope link src 10.42.0.194 metric 100

This time the guest holds 10.42.0.194, a real cluster pod address, and its gateway is the cluster’s pod gateway. Now check it against the cluster:

$ kubectl get vmi vm-bridge -o jsonpath='{.status.interfaces[0].ipAddress}' -n net-bindings
10.42.0.194

$ kubectl get pod -l kubevirt.io/created-by -o wide -n net-bindings
NAME                                READY   STATUS    IP            NODE
virt-launcher-vm-bridge-fdsx7       3/3     Running   10.42.0.194   node01

The guest and the cluster agree. One IP, seen the same way from inside and outside. When is a bridge binding necessary? The clearest case is software that advertises its own address to other machines. Clustered databases, message brokers, and anything with a peer-discovery or gossip protocol works by reading its own NIC, finding an IP, and telling its peers to reach it there. Under masquerade that guest reads 10.0.2.2, advertises it, and every peer fails, because that address is private to the pod and routes nowhere. Under bridge the guest reads 10.42.0.194, advertises that, and peers reach it, because the address the guest sees is the address that actually works. The same logic covers anything that checks its interface and expects the truth: monitoring agents that report their address, licensing tied to a visible IP, software that refuses to start if its hostname and IP disagree.

There is a second family of cases where it is not about advertisement but about the network itself: some protocols embed addresses in their payload or open return connections back to the source; some guests must get their address from a DHCP server on a real network, or be seen by that network at their real MAC, or speak anything that operates at layer 2. All these cases are well covered by bridge.

So bridge is the binding you choose when the guest has to be a real, directly addressed participant on its network rather than a NAT-ed tenant behind the pod IP. And notice what we just did: we bridged onto the pod network. The guest got a genuine, routable cluster address, the cluster and guest agreed on it, and Services still work because it is still a pod IP. That is bridge mode used well. The interface you bridge onto is the pod’s own, which exists to serve this one pod, so nothing else is affected.

Passt binding: a userspace network stack

Masquerade and bridge are the two classic bindings, and they make opposite choices. Passt is newer, and it does not slot neatly onto either side of that split. It is a userspace, rootless networking backend, and as of KubeVirt 1.8 it is promoted from an optional plugin toward a first-class binding. The interesting part is what the guest actually sees, because it is not what either of the other two would lead you to expect.

Passt needs a little more setup than the built-in bindings, and both steps are worth knowing because neither is obvious. First, the binding is behind a feature gate that you enable on the KubeVirt custom resource:

$ kubectl patch kubevirt kubevirt -n kubevirt --type=merge \
  -p '{"spec":{"configuration":{"developerConfiguration":{"featureGates":["PasstBinding"]}}}}'
$ kubectl -n kubevirt wait kv kubevirt --for condition=Available --timeout=5m

Second, the compute container needs to run unconfined under AppArmor. Passt sandboxes itself by remounting the root filesystem inside its own namespace, and the default AppArmor profile denies exactly that mount. Without the annotation the pod never comes up. With it, passt does its self-sandboxing and starts cleanly:

apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: vm-passt
  namespace: net-bindings
spec:
  runStrategy: Always
  template:
    metadata:
      labels:
        kubevirt.io/vm: vm-passt
      annotations:
        # passt self-sandboxes by remounting / inside a namespace; the default
        # AppArmor profile denies that mount. Run the compute container unconfined.
        container.apparmor.security.beta.kubernetes.io/compute: unconfined
    spec:
      domain:
        devices:
          disks:
            - name: containerdisk
              disk: { bus: virtio }
            - name: cloudinitdisk
              disk: { bus: virtio }
          interfaces:
            - name: pod-net
              passtBinding: {}
              ports:
                - name: ssh
                  port: 22
                  protocol: TCP
        resources:
          requests:
            memory: 1Gi
      networks:
        - name: pod-net
          pod: {}
      volumes:
        - name: containerdisk
          containerDisk:
            image: quay.io/containerdisks/fedora:latest
        - name: cloudinitdisk
          cloudInitNoCloud:
            userData: |-
              #cloud-config
              password: kubevirt
              chpasswd: { expire: False }
              ssh_pwauth: True

Notice the ports list on the interface. Hold that thought, it comes back in a moment. Exec into the guest and look at what it got:

$ virtctl console vm-passt -n net-bindings

[fedora@vm-passt ~]$ ip a
2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 65520 ...
    link/ether 52:54:00:fc:99:e3 brd ff:ff:ff:ff:ff:ff
    inet 10.42.0.198/24 brd 10.42.0.255 scope global enp1s0

[fedora@vm-passt ~]$ ip route
default via 10.42.0.1 dev enp1s0 proto dhcp src 10.42.0.198 metric 100
10.42.0.0/24 dev enp1s0 proto kernel scope link src 10.42.0.198 metric 100

Read that against the other two. The guest holds 10.42.0.198, the pod IP, the same way bridge does, not a private 10.0.2.2 the way masquerade does. The guest and the cluster agree on the address.

But look at the details and passt is clearly not bridge either. The MTU is 65520, not the 1450 both other bindings showed. The MAC is the QEMU default 52:54:00 range, and the address has no dynamic flag and an infinite lifetime, where the others were DHCP-assigned.

Passt is not bridging the guest onto the pod’s interface and it is not NAT-ing it in the kernel. It is a userspace TCP/IP stack: the guest talks to passt, passt talks to the network, and because that conversation is not bounded by a physical link, it can advertise a huge MTU. The pod IP the guest holds is presented by passt, not leased from the pod network.

This is where the ports list pays off, and it is the sharpest practical difference. Passt only forwards the ports you declare. From a debug pod on the cluster, reach the passt VM at its pod IP and try the port we declared, then one we did not:

$ kubectl run probe --rm -it --image=nicolaka/netshoot --restart=Never --command -- bash

probe:~# ping -c2 10.42.0.198
PING 10.42.0.198 (10.42.0.198) 56(84) bytes of data.
64 bytes from 10.42.0.198: icmp_seq=1 ttl=64 time=0.079 ms
64 bytes from 10.42.0.198: icmp_seq=2 ttl=64 time=0.033 ms

probe:~# nc -zv 10.42.0.198 22
Connection to 10.42.0.198 22 port [tcp/ssh] succeeded!

probe:~# nc -zv -w3 10.42.0.198 8080
nc: connect to 10.42.0.198 port 8080 (tcp) failed: Connection refused

Port 22 is reachable because we declared it. Port 8080 is refused because we did not. The guest itself is not filtering anything; passt is, at the userspace boundary, forwarding declared ports and dropping the rest. This is the same shape as masquerade’s port-forwarding model, so passt shares that ergonomic with masquerade even though it shares the guest-sees-the-pod-IP behavior with bridge. It sits deliberately between the two.

So where does passt fit? It gives the guest the real pod IP, like bridge, without moving the pod’s kernel interface into the VM, and it forwards a declared set of ports, like masquerade, without running privileged NAT in the pod. It is userspace and rootless, which is the security story that motivated it. The cost is that it is a different and younger path, with the feature gate and the AppArmor caveat above, and a userspace stack in the data path rather than the kernel. For the common pod-network case where you want the guest to see its own real IP but do not want bridge’s interface-takeover or masquerade’s privileged NAT, passt is increasingly the answer, and its promotion to a core binding in 1.8 reflects that.

How a packet actually reaches the VM

So far we have looked at what the guest sees. The more practical question is how you reach it, and here the bindings converge on the same answer at the level you operate, then diverge only in the last hop.

Nothing in the cluster routes to the guest’s 10.0.2.2, and nothing should ever target a pod IP directly, because it changes every time the VM restarts or moves to another node. What you use is a Service.

apiVersion: v1
kind: Service
metadata:
  name: vm-masquerade-service
  namespace: net-bindings
spec:
  selector:
    kubevirt.io/vm: vm-masquerade
  ports:
    - port: 80
      targetPort: 80

The labels you set in the VM’s pod template, kubevirt.io/vm: vm-masquerade in these manifests, are applied to the VMI and to the virt-launcher pod, so a Service selects the VM exactly as it selects any pod. KubeVirt also stamps its own labels on the pod, including vm.kubevirt.io/name, if you would rather select on something it sets for you.

$ kubectl get vm,vmi,pod -n net-bindings --show-labels
NAME                                       STATUS    LABELS
virtualmachine.kubevirt.io/vm-bridge       Running   <none>
virtualmachine.kubevirt.io/vm-masquerade   Running   <none>
virtualmachine.kubevirt.io/vm-passt        Running   <none>

NAME                                               PHASE     LABELS
virtualmachineinstance.../vm-bridge                Running   ...,kubevirt.io/vm=vm-bridge
virtualmachineinstance.../vm-masquerade            Running   ...,kubevirt.io/vm=vm-masquerade
virtualmachineinstance.../vm-passt                 Running   ...,kubevirt.io/vm=vm-passt

NAME                                STATUS    LABELS
pod/virt-launcher-vm-bridge-6tvg6       Running   ...,kubevirt.io/vm=vm-bridge,vm.kubevirt.io/name=vm-bridge,...
pod/virt-launcher-vm-masquerade-nlssw   Running   ...,kubevirt.io/vm=vm-masquerade,vm.kubevirt.io/name=vm-masquerade,...
pod/virt-launcher-vm-passt-w5vm8        Running   ...,kubevirt.io/vm=vm-passt,vm.kubevirt.io/name=vm-passt,...

From the Service’s point of view a VM is just a pod that happens to run a guest.

The difference is entirely inside the pod, in the last hop from pod IP to guest. Under masquerade, the packet arrives at the pod IP and KubeVirt’s NAT rules translate it to the guest’s private address before handing it to the guest’s network interface. The guest answers from 10.0.2.2, and the NAT translates the reply back, so the caller only ever sees the pod IP.

Under bridge, there is no translation: the guest already holds the pod IP, so the packet crosses the in-pod bridge and reaches the guest unchanged.

Under passt, the userspace stack accepts the connection on the pod IP and forwards it to the guest for the ports you declared.

Same entry point, same Service, different final step.

You target the Service, the Service targets the pod, and the binding decides what happens between the pod’s interface and the guest’s.

Outbound traffic mirrors this:

Under masquerade the guest’s traffic is source-NAT’d to the pod IP, so to the rest of the cluster it looks like it comes from an ordinary pod.

Under bridge the guest already holds the pod IP, so its traffic carries that address as its source with no translation at all.

Under passt the userspace stack sends on the guest’s behalf and the traffic leaves sourced from the pod IP, the same result as masquerade reached by a different mechanism. In every case, what the rest of the cluster sees is a pod.

One caveat worth stating because it drives real binding decisions: live migration behaves differently across bindings. With the three VMs running side by side, the cluster reported masquerade and passt as live-migratable and bridge as not:

$ kubectl -n net-bindings get vmi -o wide
NAME            PHASE     IP            NODENAME        READY   LIVE-MIGRATABLE
vm-bridge       Running   10.42.0.194   node01          True    False
vm-masquerade   Running   10.42.0.195   node01          True    True
vm-passt        Running   10.42.0.198   node01          True    True

The reason follows directly from what each binding does to the guest’s address. A migration moves the guest to a new pod on another node, and that new pod gets a new pod IP. What differs is what that means for the guest.

Under masquerade the guest holds a fixed private address, 10.0.2.2, which has nothing to do with the pod’s cluster IP. The pod IP behind it changes on migration like any pod’s would, but the guest never saw the pod IP in the first place, so nothing inside the guest has to change.

Under passt the guest does hold the pod IP, so a migration is more involved: the target pod gets a new IP, the guest keeps its original address until it makes a fresh DHCP request, and passt’s userspace stack is what keeps existing TCP connections alive across the move, which is the property that makes passt migratable in practice rather than the address staying put.

Bridge is where things get harder. The guest holds the pod IP directly, and a plain pod’s cluster IP cannot be carried to a pod on another node, so bridge on the pod network blocks live migration by default. It can be made to work, but only with a CNI that specifically supports retaining the pod IP through migration. Ovn-kubernetes and Kube-OVN implement exactly this, enabled with the kubevirt.io/allow-pod-bridge-network-live-migration annotation.

So the migratability the table reports is real, but it does not mean the address is fixed. If live migration matters to you, the binding choice and the CNI under it are the things to verify, because this is the area where the bindings differ most in practice.

Reaching a VM from outside the cluster

What if you want to reach the VM from outside the cluster, as a machine on your real network rather than as a pod behind a Service?

The tempting shortcut is to bridge the VM straight onto the node’s LAN, so it picks up an address on your physical network and looks like any other host. Resist it. Bridging onto the interface the node itself depends on means handing that interface to the guest, and getting it wrong might take the node’s own connectivity down with it. It is not worth risking the cluster to give one VM a LAN address.

The right way is the same pattern the rest of this section has been building toward: give the VM its own second interface, on a network built for that purpose, and leave the primary interface doing its job. That is what the next section is about.

A second interface on a network you define

So you need a way to attach a second interface to a different network. That is what Multus does. Multus is a meta-plugin: it lets a pod, and therefore a VM, have more than one network attachment.

It is worth being clear that this is not something specific for KubeVirt. Multi-network pods are not common but they have been around for a while, driven by workloads that genuinely need a second interface: telco network functions separating control-plane from data-plane traffic are on the front line, followed by pods attaching to SR-IOV hardware.

Multus is an optional component in a cluster, so it has to be installed first, as a DaemonSet that runs on each node and hooks into your existing CNI.

Before installing it, be aware that the upstream Multus installer assumes CNI lives where the filesystem hierarchy standard says it should: binaries in one well-known directory, configuration in another. Some Kubernetes distributions deliberately put CNI somewhere else. The moment a distribution relocates CNI, the assumptions baked into the upstream installer stop holding. The solution is to use an installer that already expects relocated CNI. The rke2-multus chart does exactly that. It comes from the RKE2 world and can be pointed at the right locations instead of the standard ones. The same chart works on k3s.

But now let’s move forward with our second interface.

Once Multus is installed, to implement it you have to describe each extra network once as a NetworkAttachmentDefinition (NAD), and then you reference that NAD from the VM spec.

The NAD says: “Here is a network and the CNI plugin that backs it”.

The VM spec says: “Give me an interface on that network”.

The result is a second NIC inside the guest, bound to a network you control, sitting alongside the primary.

Here is the NAD, a self-contained bridge with its own address range so it needs nothing pre-existing on the host:

apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
  name: secondary-net
  namespace: net-bindings
spec:
  config: |
    {
      "cniVersion": "0.3.1",
      "name": "secondary-net",
      "type": "bridge",
      "bridge": "secondary-br0",
      "ipam": {
        "type": "host-local",
        "subnet": "10.99.0.0/24",
        "rangeStart": "10.99.0.10",
        "rangeEnd": "10.99.0.50"
      }
    }

This config is worth pausing on, because there are two different things called bridge in this story and confusing them is common. The type: bridge here is a CNI bridge plugin, building and managing a Linux bridge named secondary-br0 with its own IPAM. That is a completely different thing from the KubeVirt bridge binding from the previous section. One is which CNI builds the network, this NAD. The other is how the guest NIC attaches to an interface, the binding. Keep them separate in your head, because in a moment you will see both words appear in the same manifest.

A word of honesty about this NAD: it is the simplest one that works. A self-contained bridge with host-local IPAM needs nothing outside the node, which is exactly why it suits an article about VM networking rather than about CNI. Real secondary networks are usually where the actual complexity lives: the plugin type (macvlan, ipvlan, SR-IOV, OVS), the IPAM strategy (host-local is per-node; anything spanning nodes wants DHCP or whereabouts), VLAN tags, MTUs, plugin chaining. Each of those is a decision with its own failure modes, and together they are their own article. What matters here is only the shape: a NAD defines a network, the VM references it, and the second interface appears.

Now the VM references the NAD. The secondary interface gets a binding just like the primary did, and a network that points at the NAD by name:

apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: vm-secondary
  namespace: net-bindings
spec:
  runStrategy: Always
  template:
    metadata:
      labels:
        kubevirt.io/vm: vm-secondary
    spec:
      domain:
        devices:
          disks:
            - name: containerdisk
              disk: { bus: virtio }
            - name: cloudinitdisk
              disk: { bus: virtio }
          interfaces:
            - name: pod-net
              masquerade: {}
            - name: secondary-net
              bridge: {}
        resources:
          requests:
            memory: 1Gi
      networks:
        - name: pod-net
          pod: {}
        - name: secondary-net
          multus:
            networkName: secondary-net
      volumes:
        - name: containerdisk
          containerDisk:
            image: quay.io/containerdisks/fedora:latest
        - name: cloudinitdisk
          cloudInitNoCloud:
            userData: |-
              #cloud-config
              password: kubevirt
              chpasswd: { expire: False }
              ssh_pwauth: True

Two interfaces, two networks. The primary, named pod-net, stays masqueraded on the pod network. The secondary, named secondary-net, is bound with the KubeVirt bridge: {} binding onto the Multus network defined by the NAD. There are the two bridges, side by side in one file: the CNI type: bridge that built secondary-br0, and the KubeVirt bridge: {} that attaches the guest NIC onto it.

Notice also that the guest knows nothing about pod networks, NADs, or Multus. All of the intelligence lives in the binding layer.

The proof, from inside the running VM:

$ virtctl console vm-secondary -n net-bindings

[fedora@vm-secondary ~]$ ip a
2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 ...
    inet 10.0.2.2/24 brd 10.0.2.255 scope global dynamic enp1s0
3: enp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ...
    inet 10.99.0.10/24 brd 10.99.0.255 scope global dynamic enp2s0

There it is. The masqueraded primary at 10.0.2.2, exactly as the binding section established, and the new secondary enp2s0 at 10.99.0.10 from the NAD’s IPAM range. The VM now lives on two networks at once.

How traffic reaches the second interface

Earlier we worked out how a packet reaches the VM on its primary interface: through a Service, targeting the pod IP, with the binding deciding the last hop. It is tempting to assume the second interface works the same way. It does not, and the difference is the whole reason you would add one.

The secondary interface is not part of the pod network. Services select pods and route to the pod IP, and the pod IP is the address of the primary interface. The address on the second interface, 10.99.0.10 here, is invisible to the mechanism that makes Services work. Kube-proxy does not know about it, cluster DNS does not resolve to it, and there is no Service you can write whose selector reaches the VM on this interface, because this interface does not live in the network Services operate over.

So how do you reach the VM here? Directly, by its address on that network, from something else attached to the same network. The second interface behaves like any NIC on any network: it has an address, and the other members of the network address it by that address. That is exactly the point. The reason you added a secondary network was to make the VM a first-class member of a real network, a VLAN, a storage segment, an L2 domain, rather than a thing hidden behind pod-network abstractions. Having done that, you reach it the way you reach any host on that network, and the cluster is not in the path at all.

The primary interface answers “how does the cluster reach this VM,” and the answer is the pod-network way: a Service, the pod IP, the binding’s last hop.

The secondary interface answers “how does this VM participate in a network the cluster does not manage,” and the answer is the opposite: directly, on its own address.

A KubeVirt VM can be a cluster citizen on one interface and a member of an external network on another, at the same time, because the two interfaces are governed by entirely different rules. That is the real power of the secondary network, and it is why the primary binding and the Multus interface are not two versions of one thing but two different tools.

One model, all the way down

Everything in this article came from a single idea, and it is worth saying once more now that you have seen it play out. A VM’s networking is the pod’s networking, plus a binding that decides how the guest attaches to it.

We now know that the VM with two addresses was a VM using masquerade. Pick bridge and the guest holds the pod IP directly. Pick passt and the guest still holds the pod IP, but through a userspace stack that forwards the ports you declare. Reach the VM from the cluster and you use a Service targeting the pod IP, because the VM is a pod. Add a network the cluster does not manage and you attach a second interface through Multus, the same way any pod would, and reach it directly on that network.

So, back to the question raised in the first place: the guest said 10.0.2.2, the cluster said 10.42.0.195, which one to use and how. The answer is neither. You never target the guest’s address and you never hardcode the pod IP; you point a Service at the VM’s label and let it find the pod, exactly as you would for anything else running on the cluster. The two addresses were one pod and one binding, seen from two sides.

That the answer is so ordinary is the whole point. Once a VM is a pod with a binding, its networking stops being a separate discipline and becomes something you already know. And it is worth watching where the bindings themselves are going, because that is the part still moving. Passt is the clearest signal: newer than masquerade and bridge, userspace and rootless, promoted to a core binding in 1.8, and already the answer when you want the guest to hold its own real IP without bridge’s interface takeover or masquerade’s privileged NAT. The model stays the same, a VM is a pod plus a binding. The bindings are just getting better at it.

Scroll to Top