Skip to content

VM with a direct public IP

By the end of this guide a virtual machine holds a routable address on its own interface, reaches the internet as itself, and serves a page to the outside world.

For the same thing in the console, see the console walkthrough. For why the path looks like this, see the overview.

1. Create the public subnet

Create a project carrying the range. The network, the advertisement and your access to the project are built from it:

cat <<EOF | oc apply -f -
apiVersion: v1
kind: Namespace
metadata:
  name: <subnet>
  annotations:
    hoolia.eu/subnet-cidr: <cidr>
    hoolia.eu/subnet-type: Public
EOF
namespace/<subnet> created

The range comes from the public pool with a prefix of /28 or /29.

The platform takes the first two usable addresses

The first is the gateway your VM routes through and the second is the node management port. OVN reserves both itself, so a /29 gives four usable addresses rather than six. A /30 would give none, which is why it is rejected. See the address table.

Confirm the network came up before going further - a network that failed to start still looks present:

oc get clusteruserdefinednetworks.k8s.ovn.org <subnet>
oc get routeadvertisements.k8s.ovn.org <subnet>

The advertisement must report Accepted.

2. Deploy a VM into it

Any VM created in that project attaches to the subnet automatically - there is no network field to set. This one installs a web server on first boot so there is something to reach:

apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: pubvm
  namespace: <subnet>
spec:
  runStrategy: Always
  template:
    spec:
      domain:
        cpu:
          cores: 2
        memory:
          guest: 2Gi
        devices:
          disks:
          - name: rootdisk
            disk:
              bus: virtio
          - name: cloudinit
            disk:
              bus: virtio
          interfaces:
          - name: default
            binding:
              name: l2bridge
      networks:
      - name: default
        pod: {}
      volumes:
      - name: rootdisk
        dataVolume:
          name: pubvm-root
      - name: cloudinit
        cloudInitNoCloud:
          userData: |
            #cloud-config
            user: cloud-user
            ssh_authorized_keys:
            - ssh-ed25519 AAAA... you@example
            packages:
            - httpd
            - firewalld
            runcmd:
            - systemctl enable --now firewalld
            - firewall-cmd --permanent --add-service=http
            - firewall-cmd --permanent --add-service=https
            - firewall-cmd --reload
            - echo "ingress OK" > /var/www/html/index.html
            - restorecon -Rv /var/www/html
            - systemctl enable --now httpd
  dataVolumeTemplates:
  - metadata:
      name: pubvm-root
    spec:
      sourceRef:
        kind: DataSource
        name: centos-stream9
        namespace: openshift-virtualization-os-images
      storage:
        resources:
          requests:
            storage: 30Gi

The guest opens its own ports. Nothing in the platform does it for you, and SELinux stays enforcing - restorecon is what makes the page readable by the web server.

3. Find the address

oc -n <subnet> get vmi pubvm
NAME    AGE   PHASE     IP              NODENAME           READY
pubvm   25m   Running   146.19.177.11   berry-compute48    True

That address is on the VM's own interface, not on a Service.

4. Check egress

Log in over SSH, on the address itself:

ssh cloud-user@146.19.177.11

The address and the default route are visible from inside the guest:

$ ip -4 addr show | grep global
    inet 146.19.177.11/29 brd 146.19.177.15 scope global dynamic noprefixroute eth0
$ ip route | grep default
default via 146.19.177.9 dev eth0 proto dhcp src 146.19.177.11 metric 100

The gateway is the first usable address of the range. Outbound traffic works, and arrives as the VM's own address rather than a translated one:

$ curl -sI https://www.google.com | head -1
HTTP/2 200
$ curl -s https://ifconfig.me
146.19.177.11
$ ping -c2 1.1.1.1 | tail -2
2 packets transmitted, 2 received, 0% packet loss, time 1002ms
rtt min/avg/max/mdev = 7.428/7.919/8.411/0.491 ms

ifconfig.me reporting the VM's own address is the check that matters - it confirms there is no NAT in the path.

5. Check ingress

From outside the cluster:

$ curl -s http://146.19.177.11/
ingress OK

If that hangs, the guest firewall is the first thing to look at, not the platform:

$ sudo firewall-cmd --list-services
cockpit dhcpv6-client http https ssh
$ getenforce
Enforcing

The platform only filters if the project has a firewall that selects the VM. The project of a public subnet is created without any, so this list is empty unless you added one:

oc -n <subnet> get networkpolicy

Restrict what reaches the VM

A NetworkPolicy selects the pod the VM runs in, so it filters a VM like any other workload. The platform labels that pod with vm.kubevirt.io/name, so the VM's name is all the selector needs. This one admits SSH and HTTP from anywhere and nothing else:

cat <<EOF | oc apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: pubvm-from-internet
  namespace: <subnet>
spec:
  podSelector:
    matchLabels:
      vm.kubevirt.io/name: pubvm
  policyTypes:
  - Ingress
  ingress:
  - from:
    - ipBlock:
        cidr: 0.0.0.0/0
    ports:
    - protocol: TCP
      port: 22
    - protocol: TCP
      port: 80
EOF

It applies within seconds. Ports 22 and 80 keep answering from outside; everything else, ping included, is dropped from then on, whatever the guest firewall allows. A rule with no ports at all admits every protocol, ICMP included. Outbound traffic is not affected, and deleting the firewall opens the VM again. See Firewalls for narrower sources than the whole internet.

List

oc -n <subnet> get vmi
oc get clusteruserdefinednetworks.k8s.ovn.org

Change

A VM's address comes from the subnet and is not something you set, so there is no address to edit. To publish different ports, change the guest firewall. If you added a firewall that selects the VM, its ports list has to change as well.

The subnet's range cannot change either. Making it private means deleting the advertisement; see Change.

Delete

Delete the VM first, then the subnet's three objects in order:

oc -n <subnet> delete vm pubvm
oc delete clusteruserdefinednetworks.k8s.ovn.org <subnet>

Delete the network, not the project

The project and the advertisement belong to the network and are removed with it. Deleting the project on its own never finishes: it holds a network attachment whose finalizer is released only when the network goes.

Do not reuse a subnet name or range straight away

Deleting a network does not remove the per-node state behind it. Recreating a subnet under a name or range that has been used before can bind to that leftover state, and the new subnet then passes no traffic even though every object reports healthy. Pick a fresh name and a fresh range.