Sunday, June 07, 2026

HyperConverged Kubernetes on Ubuntu

The following probably seems pretty inconsequential but having used OpenShift for a vast amount of time I figured it might be wise to brush up on other ways to provide a Kubernetes environment. In this case I wanted to build a 3 node HA Kubernetes setup similar to OpenShift's hyper-converged 3 node scenario. I also wanted to move away from the Red Hat Linux which I have been using for 20 years and add some a new spark to my life.

First we started by installing the latest version of Ubuntu 26.04 onto 3 KVM virtual machines. These virtual machines were on a Ampere Arm based system so everything shown here today is Arm based. While we will not be covering the installation of Ubuntu here I can say it was pretty straight forward. It was almost Window-ish in that it was pretty much next->next->finish.

Once we have our 3 virtual machines installed with Ubuntu we want to make sure all 3 systems are updated.

sudo apt update && sudo apt upgrade -y

Next we are going to install some packages we will need in our Kubernetes deployment on all 3 systems. Installing Haproxy and Keepalived brought back a lot of memories from the days when I used these to load balance services back at the University of Minnesota.

$ sudo apt install -y docker.io haproxy keepalived

With the packages installed we decided to first configure Haproxy which will act as our loadbalancer. The following configuration file needs to be created on all 3 nodes and will provide the loadbalancing of the Kubernetes api.

$ cat <<EOF | sudo tee /etc/haproxy/haproxy.cfg global stats socket /var/lib/haproxy/run/haproxy.sock mode 600 level admin expose-fd listeners defaults maxconn 40000 mode tcp log /var/run/haproxy/haproxy-log.sock local0 notice alert log-format "%ci:%cp -> %fi:%fp [%t] %ft %b/%s %Tw/%Tc/%Tt %B %ts %ac/%fc/%bc/%sc/%rc %sq/%bq" option dontlognull retries 3 timeout http-request 30s timeout queue 1m timeout connect 10s timeout client 86400s timeout server 86400s timeout tunnel 86400s timeout client-fin 1s timeout server-fin 1s frontend main bind :::9445 v4v6 default_backend masters listen health_check_http_url bind :::9444 v4v6 mode http monitor-uri /haproxy_ready option dontlognull listen monitor_check_http_url bind :::9454 v4v6 mode http monitor-uri /haproxy_monitor monitor fail if { nbsrv(masters) lt 1 } option dontlognull listen stats bind localhost:29445 mode http stats enable stats hide-version stats uri /haproxy_stats stats refresh 30s stats auth Username:Password backend masters timeout check 10s option httpchk GET /readyz HTTP/1.0 balance roundrobin server adlink-vm1 192.168.0.128:6443 weight 1 verify none check check-ssl inter 5s fall 3 rise 1 server adlink-vm2 192.168.0.129:6443 weight 1 verify none check check-ssl inter 5s fall 3 rise 1 server adlink-vm3 192.168.0.130:6443 weight 1 verify none check check-ssl inter 5s fall 3 rise 1 EOF

Once the haproxy.cfg has been created on all the nodes we can go ahead and enable, start and check status of the Haproxy service on all 3 nodes.

$ sudo systemctl enable haproxy Synchronizing state of haproxy.service with SysV service script with /usr/lib/systemd/systemd-sysv-install. Executing: /usr/lib/systemd/systemd-sysv-install enable haproxy $ sudo systemctl start haproxy $ sudo systemctl status haproxy ● haproxy.service - HAProxy Load Balancer Loaded: loaded (/usr/lib/systemd/system/haproxy.service; enabled; preset: enabled) Active: active (running) since Fri 2026-05-29 15:49:39 UTC; 1h 53min ago Invocation: 02fc45c6707c4c5b8b0c9b1106683e48 Docs: man:haproxy(1) file:/usr/share/doc/haproxy/configuration.txt.gz Main PID: 15789 (haproxy) Status: "Ready." Tasks: 9 (limit: 32645) Memory: 46.6M (peak: 47.3M) CPU: 119ms CGroup: /system.slice/haproxy.service ├─15789 /usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /run/haproxy-master.sock └─15791 /usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid -S /run/haproxy-master.sock May 29 15:49:39 adlink-vm1 systemd[1]: Starting haproxy.service - HAProxy Load Balancer... May 29 15:49:39 adlink-vm1 haproxy[15789]: [NOTICE] (15789) : Initializing new worker (15791) May 29 15:49:39 adlink-vm1 haproxy[15789]: [NOTICE] (15789) : Loading success. May 29 15:49:39 adlink-vm1 systemd[1]: Started haproxy.service - HAProxy Load Balancer.

With Haproxy running we can turn our attention to Keepalived. Keepalived is used to manage the virtual ip addresses that would need to float across the underlying physical nodes. The example below is used for both the api vip and the ingress vip. It should be noted that on each node the unicast_src_ip needs to the be the primary ip address of the node and the peers are the ip address of the other two nodes in the cluster. The interface it should run on should also be updated based on the environment.

$ cat <<EOF | sudo tee /etc/keepalived/keepalived.conf global_defs { enable_script_security script_user root max_auto_priority -1 vrrp_garp_master_refresh 60 } vrrp_instance api { state BACKUP interface enp1s0 virtual_router_id 55 priority 40 advert_int 1 unicast_src_ip 192.168.0.128 unicast_peer { 192.168.0.129 192.168.0.130 } authentication { auth_type PASS auth_pass api_vip } virtual_ipaddress { 192.168.0.134/32 label vip } } vrrp_instance ingress { state BACKUP interface enp1s0 virtual_router_id 166 priority 20 advert_int 1 unicast_src_ip 192.168.0.128 unicast_peer { 192.168.0.129 192.168.0.130 } authentication { auth_type PASS auth_pass ingress_vip } virtual_ipaddress { 192.168.0.135/32 label vip } } EOF

With the keepalived.conf created on all 3 nodes we can go ahead and enable, start and check status of the Keepalive deamons.

$ sudo systemctl enable keepalived Synchronizing state of keepalived.service with SysV service script with /usr/lib/systemd/systemd-sysv-install. Executing: /usr/lib/systemd/systemd-sysv-install enable keepalived $ sudo systemctl start keepalived $ sudo systemctl status keepalived ● keepalived.service - Keepalive Daemon (LVS and VRRP) Loaded: loaded (/usr/lib/systemd/system/keepalived.service; enabled; preset: enabled) Active: active (running) since Fri 2026-05-29 17:46:34 UTC; 5s ago Invocation: de54948695ec4d21ac1fc9a300c61791 Docs: man:keepalived(8) man:keepalived.conf(5) man:genhash(1) https://keepalived.org Main PID: 21347 (keepalived) Tasks: 2 (limit: 32645) Memory: 5.9M (peak: 6.4M) CPU: 22ms CGroup: /system.slice/keepalived.service ├─21347 /usr/sbin/keepalived --dont-fork └─21348 /usr/sbin/keepalived --dont-fork May 29 17:46:34 adlink-vm1 Keepalived[21347]: Command line: '/usr/sbin/keepalived' '--dont-fork' May 29 17:46:34 adlink-vm1 Keepalived[21347]: Configuration file /etc/keepalived/keepalived.conf May 29 17:46:34 adlink-vm1 Keepalived[21347]: Starting VRRP child process, pid=21348 May 29 17:46:34 adlink-vm1 Keepalived_vrrp[21348]: (/etc/keepalived/keepalived.conf: Line 45) Truncating auth_pass to 8 characters May 29 17:46:34 adlink-vm1 Keepalived[21347]: Startup complete May 29 17:46:34 adlink-vm1 systemd[1]: Started keepalived.service - Keepalive Daemon (LVS and VRRP). May 29 17:46:34 adlink-vm1 Keepalived_vrrp[21348]: (api) Entering BACKUP STATE (init) May 29 17:46:34 adlink-vm1 Keepalived_vrrp[21348]: (ingress) Entering BACKUP STATE (init) May 29 17:46:38 adlink-vm1 Keepalived_vrrp[21348]: (api) Entering MASTER STATE May 29 17:46:38 adlink-vm1 Keepalived_vrrp[21348]: (ingress) Entering MASTER STATE

With Keepalived setup we can move to applying a few additional changes to the 3 nodes themselves. The first one is to ensure swap is turned off in the environment both immediately and across reboots. The following syntax will achieve both.

$ sudo swapoff -a $ sudo sed -i '/\/swap.img/ s/^/#/' /etc/fstab ~

Next we need to ensure the kernel modules overlay and br_netfilter are loaded at boot time on all 3 nodes.

$ cat <<EOF | sudo tee /etc/modules-load.d/containerd.conf overlay br_netfilter EOF

We also need to manually load those modules now unless we would like to reboot then nodes instead.

$ sudo modprobe overlay; sudo modprobe br_netfilter

We can check that the modules are loaded with the following.

$ lsmod| egrep "overlay|br_netfilter" br_netfilter 32768 0 bridge 417792 1 br_netfilter overlay 217088 0

Next we need to set some sysctl variables to persist across reboots on all 3 nodes. Specifically we are setting bridge-nf-calls and ip fowarding.

$ cat <<EOF | sudo tee /etc/sysctl.d/kubernetes.conf net.bridge.bridge-nf-call-iptables=1 net.bridge.bridge-nf-call-ip6tables=1 net.ipv4.ip_forward=1 EOF

We can force a read of the systemctl values without rebooting by issuing the following command.

$ sudo sysctl --system (...) * Applying /etc/sysctl.d/kubernetes.conf ... (...) net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward = 1

Next we need to install Containerd on all 3 nodes.

$ sudo apt install -y containerd containerd is already the newest version (2.2.2-0ubuntu1). containerd set to manually installed. The following packages were automatically installed and are no longer required: linux-headers-7.0.0-14 linux-image-unsigned-7.0.0-14-generic linux-modules-7.0.0-14-generic linux-tools-7.0.0-14-generic linux-headers-7.0.0-14-generic linux-main-modules-zfs-7.0.0-14-generic linux-tools-7.0.0-14 Use 'sudo apt autoremove' to remove them. Summary: Upgrading: 0, Installing: 0, Removing: 0, Not Upgrading: 0

Once Containerd packages are installed on all the nodes we need to configure the default setup on all 3 nodes.

$ sudo mkdir -p /etc/containerd $ containerd config default | sudo tee /etc/containerd/config.toml version = 3 root = '/var/lib/containerd' state = '/run/containerd' temp = '' disabled_plugins = [] required_plugins = [] (...) [stream_processors.'io.containerd.ocicrypt.decoder.v1.tar.gzip'] accepts = ['application/vnd.oci.image.layer.v1.tar+gzip+encrypted'] returns = 'application/vnd.oci.image.layer.v1.tar+gzip' path = 'ctd-decoder' args = ['--decryption-keys-path', '/etc/containerd/ocicrypt/keys'] env = ['OCICRYPT_KEYPROVIDER_CONFIG=/etc/containerd/ocicrypt/ocicrypt_keyprovider.conf']

Next we will update the config we created and set the SystemCgroup to true.

$ sudo sed -i "s/SystemdCgroup =.*/SystemdCgroup = true/" /etc/containerd/config.toml $ grep SystemdCgroup /etc/containerd/config.toml SystemdCgroup = true

Finally we can restart Containerd and enable it so its starts on reboots across all 3 nodes.

$ sudo systemctl restart containerd $ sudo systemctl enable containerd

Now we can move onto setting up Kubernetes proper and the first thing we need are a few packages installed.

$ sudo apt-get install -y apt-transport-https ca-certificates curl gpg

Now we need to go ahead and create the following directory on all 3 nodes which is used to securely store cryptographic GPG/PGP keys for external, third-party software repositories. These keys allow your package manager (apt) to verify that the software you are downloading is authentic, untampered with, and published by a trusted source.

$ sudo mkdir -p -m 755 /etc/apt/keyrings

Next we can pull down the release key for Kubernetes 1.36 and store it in the keyrings directory we created on each of the 3 nodes.

$ curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.36/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

This command adds the official community-owned Kubernetes repository to the Debian/Ubuntu-based nodes. It allows the system's package manager (apt) to find, install, and update Kubernetes packages like kubeadm, kubelet, and kubectl.

$ echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.36/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.36/deb/ /

With the Kubernetes repository setup we can first confirm its listed with the following on all 3 nodes.

$ sudo apt-get update Hit:1 http://security.ubuntu.com/ubuntu resolute-security InRelease Hit:2 http://us.archive.ubuntu.com/ubuntu resolute InRelease Get:4 http://us.archive.ubuntu.com/ubuntu resolute-updates InRelease [136 kB] Get:3 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.36/deb InRelease [1,227 B] Get:5 https://prod-cdn.packages.k8s.io/repositories/isv:/kubernetes:/core:/stable:/v1.36/deb Packages [4,389 B] Hit:6 http://us.archive.ubuntu.com/ubuntu resolute-backports InRelease Get:7 http://us.archive.ubuntu.com/ubuntu resolute-updates/main arm64 Packages [154 kB] Get:8 http://us.archive.ubuntu.com/ubuntu resolute-updates/universe arm64 Packages [73.7 kB] Fetched 369 kB in 1s (706 kB/s) Reading package lists... Done

Next we can go ahead and install the following Kubernetes packages on all 3 nodes.

$ sudo apt-get install -y kubelet kubeadm kubectl

We will also want to set a hold on those packages so that they are not automatically updated in the event we do any other patching or upgrading on the 3 nodes.

$ sudo apt-mark hold kubelet kubeadm kubectl kubelet set on hold. kubeadm set on hold. kubectl set on hold.

Next on all 3 nodes we can enable Kubelet.

$ sudo systemctl enable --now kubelet; sudo systemctl enable kubelet

On each node we can also preemptively pull the images we will use down.

$ sudo kubeadm config images pull [config/images] Pulled registry.k8s.io/kube-apiserver:v1.36.1 [config/images] Pulled registry.k8s.io/kube-controller-manager:v1.36.1 [config/images] Pulled registry.k8s.io/kube-scheduler:v1.36.1 [config/images] Pulled registry.k8s.io/kube-proxy:v1.36.1 [config/images] Pulled registry.k8s.io/coredns/coredns:v1.14.2 [config/images] Pulled registry.k8s.io/pause:3.10.2 [config/images] Pulled registry.k8s.io/etcd:3.6.8-0

We are now finally ready to initialize the first control plane node of our Kubernetes cluster. We can do this with the following ensuring that we are pointing to our api VIP endpoint we configured in Keepalived.

$ sudo kubeadm init --control-plane-endpoint "192.168.0.134:6443" --upload-certs --pod-network-cidr=10.244.0.0/16 [init] Using Kubernetes version: v1.36.1 [preflight] Running pre-flight checks [preflight] Pulling images required for setting up a Kubernetes cluster [preflight] This might take a minute or two, depending on the speed of your internet connection [preflight] You can also perform this action beforehand using 'kubeadm config images pull' [certs] Using certificateDir folder "/etc/kubernetes/pki" [certs] Generating "ca" certificate and key [certs] Generating "apiserver" certificate and key [certs] apiserver serving cert is signed for DNS names [adlink-vm1 kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local] and IPs [10.96.0.1 192.168.0.128 192.168.0.134] [certs] Generating "apiserver-kubelet-client" certificate and key [certs] Generating "front-proxy-ca" certificate and key [certs] Generating "front-proxy-client" certificate and key [certs] Generating "etcd/ca" certificate and key [certs] Generating "etcd/server" certificate and key [certs] etcd/server serving cert is signed for DNS names [adlink-vm1 localhost] and IPs [192.168.0.128 127.0.0.1 ::1] [certs] Generating "etcd/peer" certificate and key [certs] etcd/peer serving cert is signed for DNS names [adlink-vm1 localhost] and IPs [192.168.0.128 127.0.0.1 ::1] [certs] Generating "etcd/healthcheck-client" certificate and key [certs] Generating "apiserver-etcd-client" certificate and key [certs] Generating "sa" key and public key [kubeconfig] Using kubeconfig folder "/etc/kubernetes" [kubeconfig] Writing "admin.conf" kubeconfig file [kubeconfig] Writing "super-admin.conf" kubeconfig file [kubeconfig] Writing "kubelet.conf" kubeconfig file [kubeconfig] Writing "controller-manager.conf" kubeconfig file [kubeconfig] Writing "scheduler.conf" kubeconfig file [etcd] Creating static Pod manifest for local etcd in "/etc/kubernetes/manifests" [control-plane] Using manifest folder "/etc/kubernetes/manifests" [control-plane] Creating static Pod manifest for "kube-apiserver" [control-plane] Creating static Pod manifest for "kube-controller-manager" [control-plane] Creating static Pod manifest for "kube-scheduler" [kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env" [kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/instance-config.yaml" [patches] Applied patch of type "application/strategic-merge-patch+json" to target "kubeletconfiguration" [kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml" [kubelet-start] Starting the kubelet [wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory "/etc/kubernetes/manifests" [kubelet-check] Waiting for a healthy kubelet at http://127.0.0.1:10248/healthz. This can take up to 4m0s [kubelet-check] The kubelet is healthy after 946.806µs [control-plane-check] Waiting for healthy control plane components. This can take up to 4m0s [control-plane-check] Checking kube-apiserver at https://192.168.0.128:6443/livez [control-plane-check] Checking kube-controller-manager at https://127.0.0.1:10257/healthz [control-plane-check] Checking kube-scheduler at https://127.0.0.1:10259/livez [control-plane-check] kube-controller-manager is healthy after 10.64314ms [control-plane-check] kube-scheduler is healthy after 11.778426ms [control-plane-check] kube-apiserver is healthy after 2.501924671s [upload-config] Storing the configuration used in ConfigMap "kubeadm-config" in the "kube-system" Namespace [kubelet] Creating a ConfigMap "kubelet-config" in namespace kube-system with the configuration for the kubelets in the cluster [upload-certs] Storing the certificates in Secret "kubeadm-certs" in the "kube-system" Namespace [upload-certs] Using certificate key: 096583740846141bbda4c93d3ce39587fd7eddd1a99cf149921682353c6ee1b0 [mark-control-plane] Marking the node adlink-vm1 as control-plane by adding the labels: [node-role.kubernetes.io/control-plane node.kubernetes.io/exclude-from-external-load-balancers] [mark-control-plane] Marking the node adlink-vm1 as control-plane by adding the taints [node-role.kubernetes.io/control-plane:NoSchedule] [bootstrap-token] Using token: 9usthb.lyb116njx475fnsc [bootstrap-token] Configuring bootstrap tokens, cluster-info ConfigMap, RBAC Roles [bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to get nodes [bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials [bootstrap-token] Configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token [bootstrap-token] Configured RBAC rules to allow certificate rotation for all node client certificates in the cluster [bootstrap-token] Configured RBAC rules to allow the API server kubelet client certificate to access the kubelet API [bootstrap-token] Creating the "cluster-info" ConfigMap in the "kube-public" namespace [kubelet-finalize] Updating "/etc/kubernetes/kubelet.conf" to point to a rotatable kubelet client certificate and key [addons] Applied essential addon: CoreDNS [addons] Applied essential addon: kube-proxy Your Kubernetes control-plane has initialized successfully! To start using your cluster, you need to run the following as a regular user: mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config Alternatively, if you are the root user, you can run: export KUBECONFIG=/etc/kubernetes/admin.conf You should now deploy a pod network to the cluster. Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at: https://kubernetes.io/docs/concepts/cluster-administration/addons/ You can now join any number of control-plane nodes running the following command on each as root: kubeadm join 192.168.0.134:6443 --token 9usthb.lyb116njx475fnsc \ --discovery-token-ca-cert-hash sha256:ce9ffc6a67c203511652d91fcb7fcccceeb6f59ba399b1e27452d2dc7b864281 \ --control-plane --certificate-key 096583740846141bbda4c93d3ce39587fd7eddd1a99cf149921682353c6ee1b0 Please note that the certificate-key gives access to cluster sensitive data, keep it secret! As a safeguard, uploaded-certs will be deleted in two hours; If necessary, you can use "kubeadm init phase upload-certs --upload-certs" to reload certs afterward. Then you can join any number of worker nodes by running the following on each as root: kubeadm join 192.168.0.134:6443 --token 9usthb.lyb116njx475fnsc \ --discovery-token-ca-cert-hash sha256:ce9ffc6a67c203511652d91fcb7fcccceeb6f59ba399b1e27452d2dc7b864281

Once the Kubeadm init is complete on the first node it tells us the commands to run on the other two nodes based on whether they are control-plane nodes or worker nodes. In our case we will be making the additional two Ubuntu nodes control-plane node. We can run the following commando on both nodes but we are only showing one node as an example here.

$ sudo kubeadm join 192.168.0.134:6443 --token 9usthb.lyb116njx475fnsc \ --discovery-token-ca-cert-hash sha256:ce9ffc6a67c203511652d91fcb7fcccceeb6f59ba399b1e27452d2dc7b864281 \ --control-plane --certificate-key 096583740846141bbda4c93d3ce39587fd7eddd1a99cf149921682353c6ee1b0 [sudo: authenticate] Password: [preflight] Running pre-flight checks [preflight] Reading configuration from the "kubeadm-config" ConfigMap in namespace "kube-system"... [preflight] Use 'kubeadm init phase upload-config kubeadm --config your-config-file' to re-upload it. W0529 18:44:42.301522 32377 utils.go:69] The recommended value for "bindAddress" in "KubeProxyConfiguration" is: ::; the provided value is: 0.0.0.0 [preflight] Running pre-flight checks before initializing the new control plane instance [preflight] Pulling images required for setting up a Kubernetes cluster [preflight] This might take a minute or two, depending on the speed of your internet connection [preflight] You can also perform this action beforehand using 'kubeadm config images pull' [download-certs] Downloading the certificates in Secret "kubeadm-certs" in the "kube-system" Namespace [download-certs] Saving the certificates to the folder: "/etc/kubernetes/pki" [certs] Using certificateDir folder "/etc/kubernetes/pki" [certs] Generating "etcd/healthcheck-client" certificate and key [certs] Generating "etcd/server" certificate and key [certs] etcd/server serving cert is signed for DNS names [adlink-vm2 localhost] and IPs [192.168.0.129 127.0.0.1 ::1] [certs] Generating "etcd/peer" certificate and key [certs] etcd/peer serving cert is signed for DNS names [adlink-vm2 localhost] and IPs [192.168.0.129 127.0.0.1 ::1] [certs] Generating "apiserver-etcd-client" certificate and key [certs] Generating "apiserver-kubelet-client" certificate and key [certs] Generating "apiserver" certificate and key [certs] apiserver serving cert is signed for DNS names [adlink-vm2 kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local] and IPs [10.96.0.1 192.168.0.129 192.168.0.134] [certs] Generating "front-proxy-client" certificate and key [certs] Valid certificates and keys now exist in "/etc/kubernetes/pki" [certs] Using the existing "sa" key [kubeconfig] Generating kubeconfig files [kubeconfig] Using kubeconfig folder "/etc/kubernetes" [kubeconfig] Writing "admin.conf" kubeconfig file [kubeconfig] Writing "controller-manager.conf" kubeconfig file [kubeconfig] Writing "scheduler.conf" kubeconfig file [control-plane] Using manifest folder "/etc/kubernetes/manifests" [control-plane] Creating static Pod manifest for "kube-apiserver" [control-plane] Creating static Pod manifest for "kube-controller-manager" [control-plane] Creating static Pod manifest for "kube-scheduler" [check-etcd] Checking that the etcd cluster is healthy [kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/instance-config.yaml" [patches] Applied patch of type "application/strategic-merge-patch+json" to target "kubeletconfiguration" [kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml" [kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env" [kubelet-start] Starting the kubelet [etcd] Announced new etcd member joining to the existing etcd cluster [etcd] Creating static Pod manifest for "etcd" [etcd] Waiting for the new etcd member to join the cluster. This can take up to 40s [kubelet-check] Waiting for a healthy kubelet at http://127.0.0.1:10248/healthz. This can take up to 4m0s [kubelet-check] The kubelet is healthy after 841.444µs [kubelet-start] Waiting for the kubelet to perform the TLS Bootstrap [mark-control-plane] Marking the node adlink-vm2 as control-plane by adding the labels: [node-role.kubernetes.io/control-plane node.kubernetes.io/exclude-from-external-load-balancers] [mark-control-plane] Marking the node adlink-vm2 as control-plane by adding the taints [node-role.kubernetes.io/control-plane:NoSchedule] [control-plane-check] Waiting for healthy control plane components. This can take up to 4m0s [control-plane-check] Checking kube-apiserver at https://192.168.0.129:6443/livez [control-plane-check] Checking kube-controller-manager at https://127.0.0.1:10257/healthz [control-plane-check] Checking kube-scheduler at https://127.0.0.1:10259/livez [control-plane-check] kube-scheduler is healthy after 9.522453ms [control-plane-check] kube-controller-manager is healthy after 10.606978ms [control-plane-check] kube-apiserver is healthy after 19.855349ms This node has joined the cluster and a new control plane instance was created: * Certificate signing request was sent to apiserver and approval was received. * The Kubelet was informed of the new secure connection details. * Control plane label and taint were applied to the new node. * The Kubernetes control plane instances scaled up. * A new etcd member was added to the local/stacked etcd cluster. To start administering your cluster from this node, you need to run the following as a regular user: mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config Run 'kubectl get nodes' to see this node join the cluster.

Now that all 3 nodes have been joined to the cluster we need to see what their state is in. We can do this by ssh-ing to any one of the 3 nodes and setting up the kubeconfig access.

$ mkdir -p $HOME/.kube $ sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config $ sudo chown $(id -u):$(id -g) $HOME/.kube/config

Once we have the kubeconfig setup we should be able to run our kubectl commands to get the node state. We can see our node state is NotReady. This is because we do not have Coredns pods yet or a CNI.

$ kubectl get nodes NAME STATUS ROLES AGE VERSION adlink-vm1 NotReady control-plane 21m v1.36.1 adlink-vm2 NotReady control-plane 55s v1.36.1 adlink-vm3 NotReady control-plane 32s v1.36.1

We need to deploy a CNI for our cluster to get to the ready state. To keep things simple we will deploy Flannel here.

~$ kubectl apply -f https://github.com/coreos/flannel/raw/master/Documentation/kube-flannel.yml namespace/kube-flannel created clusterrole.rbac.authorization.k8s.io/flannel created clusterrolebinding.rbac.authorization.k8s.io/flannel created serviceaccount/flannel created configmap/kube-flannel-cfg created daemonset.apps/kube-flannel-ds created

Once Flannel is deployed we should see pods launch on our control-plane nodes in the kube-flannel namespace.

$ kubectl get pods -n kube-flannel NAMESPACE NAME READY STATUS RESTARTS AGE kube-flannel kube-flannel-ds-6dglz 0/1 Init:1/2 0 7s kube-flannel kube-flannel-ds-72pr6 0/1 Init:1/2 0 7s kube-flannel kube-flannel-ds-s74wp 0/1 Init:1/2 0 7s

We can also take a look at our running pods across the cluster just to confirm everything looks good.

$ kubectl get pods -A NAMESPACE NAME READY STATUS RESTARTS AGE kube-flannel kube-flannel-ds-6dglz 1/1 Running 0 2d20h kube-flannel kube-flannel-ds-72pr6 1/1 Running 0 2d20h kube-flannel kube-flannel-ds-s74wp 1/1 Running 0 2d20h kube-system coredns-589f44dc88-n5mbw 1/1 Running 0 2d20h kube-system coredns-589f44dc88-zn7dj 1/1 Running 0 2d20h kube-system etcd-adlink-vm1 1/1 Running 0 2d20h kube-system etcd-adlink-vm2 1/1 Running 0 2d20h kube-system etcd-adlink-vm3 1/1 Running 0 2d20h kube-system kube-apiserver-adlink-vm1 1/1 Running 0 2d20h kube-system kube-apiserver-adlink-vm2 1/1 Running 0 2d20h kube-system kube-apiserver-adlink-vm3 1/1 Running 0 2d20h kube-system kube-controller-manager-adlink-vm1 1/1 Running 0 2d20h kube-system kube-controller-manager-adlink-vm2 1/1 Running 0 2d20h kube-system kube-controller-manager-adlink-vm3 1/1 Running 0 2d20h kube-system kube-proxy-2hnvq 1/1 Running 0 2d20h kube-system kube-proxy-mzb6h 1/1 Running 0 2d20h kube-system kube-proxy-tmzfl 1/1 Running 0 2d20h kube-system kube-scheduler-adlink-vm1 1/1 Running 0 2d20h kube-system kube-scheduler-adlink-vm2 1/1 Running 0 2d20h kube-system kube-scheduler-adlink-vm3 1/1 Running 0 2d20h

Let's see if our node state is now ready.

$ kubectl get nodes NAME STATUS ROLES AGE VERSION adlink-vm1 Ready control-plane 6m45s v1.36.1 adlink-vm2 Ready control-plane 4m17s v1.36.1 adlink-vm3 Ready control-plane 3m52s v1.36.1

Now that we have our Kubernetes cluster up and running let's launch a BusyBox pod. Below is the example pod yaml we will use.

$ cat busybox-pod.yaml apiVersion: v1 kind: Pod metadata: name: busybox namespace: default spec: containers: - image: busybox command: - sleep - "3600" imagePullPolicy: IfNotPresent name: busybox restartPolicy: Always

Let's create it on the cluster.

$ kubectl create -f busybox-pod.yaml pod/busybox created

Wait a minute the pod is sitting in pending.

$ kubectl get pods NAME READY STATUS RESTARTS AGE busybox 0/1 Pending 0 7s

Let's take a look at the description of the pod to see why its pending.  Appears it cannot be scheduled on any nodes.

$ kubectl describe pod busybox Name: busybox Namespace: default Priority: 0 Service Account: default Node: <none> Labels: <none> Annotations: <none> Status: Pending IP: IPs: <none> Containers: busybox: Image: busybox Port: <none> Host Port: <none> Command: sleep 3600 Environment: <none> Mounts: /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-894dl (ro) Conditions: Type Status PodScheduled False Volumes: kube-api-access-894dl: Type: Projected (a volume that contains injected data from multiple sources) TokenExpirationSeconds: 3607 ConfigMapName: kube-root-ca.crt Optional: false DownwardAPI: true QoS Class: BestEffort Node-Selectors: <none> Tolerations: node.kubernetes.io/not-ready:NoExecute op=Exists for 300s node.kubernetes.io/unreachable:NoExecute op=Exists for 300s Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning FailedScheduling 4m17s default-scheduler 0/3 nodes are available: 3 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.

The reason the BusyBox pod cannot be scheduled on any node is because all our nodes are control-plane nodes. We do not have any designated worker nodes at this point. However we can address this without the need for additional nodes by untainted our control plane nodes to allow workloads to run on them.

$ kubectl taint nodes adlink-vm1 node-role.kubernetes.io/control-plane:NoSchedule- node/adlink-vm1 untainted $ kubectl taint nodes adlink-vm2 node-role.kubernetes.io/control-plane:NoSchedule- node/adlink-vm2 untainted $ kubectl taint nodes adlink-vm3 node-role.kubernetes.io/control-plane:NoSchedule- node/adlink-vm3 untainted

If we check our BusyBox pod again we can see now it went from Pending to Running.

$ kubectl get pods NAME READY STATUS RESTARTS AGE busybox 1/1 Running 0 5m33s

Again while this blog might not seem to be bleeding edge it definitely gives a thorough overview of setting up Kubernetes in a 3 node HA scenario.

Thursday, May 07, 2026

NVIDIA OVS-DOCA via On-Cluster Layer OpenShift

In a previous blog I wrote about using off-cluster layering of NVIDIA's OVS-DOCA.  In this blog I want to use on-clustering layering which makes manageability even easier than with the off-cluster layer.   Before we begin though let's review what OVS-DOCA is all about.

What Is NVIDIA OVS-DOCA?

Open vSwitch (OVS) is a software-based network technology that enhances virtual machine (VM) communication within internal and external networks. Typically deployed in the hypervisor, OVS employs a software-based approach for packet switching, which can strain CPU resources, impacting system performance and network bandwidth utilization. Addressing this, NVIDIA's Accelerated Switching and Packet Processing (ASAP2) technology offloads OVS data-plane tasks to specialized hardware, like the embedded switch (eSwitch) within the NIC subsystem, while maintaining an unmodified OVS control-plane. This results in notably improved OVS performance without burdening the CPU.

NVIDIA's OVS-DOCA extends the traditional OVS-DPDK and OVS-Kernel data-path offload interfaces (DPIF), introducing OVS-DOCA as an additional DPIF implementation. OVS-DOCA, built upon NVIDIA's networking API, preserves the same interfaces as OVS-DPDK and OVS-Kernel while utilizing the DOCA Flow library with the additional OVS-DOCA DPIF. Unlike the use of the other DPIFs (DPDK, Kernel), OVS-DOCA DPIF exploits unique hardware offload mechanisms and application techniques, maximizing performance and features for NVIDA NICs and DPUs. This mode is especially efficient due to its architecture and DOCA library integration, enhancing e-switch configuration and accelerating hardware offloads beyond what the other modes can achieve.


Disclaimer: The following workflow of replacing OVS-DOCA in OpenShift is not currently a supported exercise. While using on-clustering is supported the process of switching out OVS with OVS-DOCA is purely experimental and for experimental purposes.

Workflow

The following on-cluster experiment of layering on NVIDIA OVS-DOCA was done on a single node OpenShift 4.20.15 environment on an x86 architecture. We assume in this document that the following has already been configured as its outside the scope of this document:

  • Hugepages have been configured by a machine configuration
  • The Logical Volume Manager Storage Operator has been installed and a LVM Cluster resource has been created

The workflow is broken down into five sections which cover environment configuration, creating the layer and then validating that it all works.

  • Configure OpenShift Internal Registry
  • Configure Configmap
  • Generate MachineOSConfig Custom Resource
  • Create MachineOSConfig Layer
  • Validate MachineOSConfig Layer

Configure OpenShift Internal Registry

The first thing we need to do is configured the OpenShift Internal Registry which is disabled by default. We will start by creating a persistent volume claim that will be consumed by the image registry. We will need to generate the following custom resource file.

$ cat <<EOF > imageregistry-pvc.yaml apiVersion: v1 kind: PersistentVolumeClaim metadata: name: registry-claim namespace: openshift-image-registry annotations: imageregistry.openshift.io: "true" spec: accessModes: - ReadWriteOnce volumeMode: Filesystem resources: requests: storage: 100Gi storageClassName: lvms-vg1 EOF

Once we have the file created we can create it on the cluster.

$ oc create -f imageregistry-pvc.yaml persistentvolumeclaim/registry-claim created

The results will show the following which is normal at this point.

$ oc get pvc -n openshift-image-registry NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE registry-claim Pending lvms-vg1 <unset> 22s

Next we will enable route creation for the registry and this could take a few minutes.

$ oc patch configs.imageregistry.operator.openshift.io/cluster --patch '{"spec":{"defaultRoute":true}}' --type=merge config.imageregistry.operator.openshift.io/cluster patched

We can proceed to allow the image registry operator to use our pvc we created above.

$ oc patch configs.imageregistry.operator.openshift.io/cluster --type=merge --patch '{"spec": {"storage":{"pvc":{"claim":"registry-claim"}}}}' config.imageregistry.operator.openshift.io/cluster patched

Then we can also set the rollout strategy. Since this is a single node a replica of 1 will do.

$ oc patch config.imageregistry.operator.openshift.io/cluster --type=merge -p '{"spec":{"rolloutStrategy":"Recreate","replicas":1}}' config.imageregistry.operator.openshift.io/cluster patched

Finally we will set the image registry to managed which will signal the operator to start the registry.

$ oc patch configs.imageregistry.operator.openshift.io/cluster --type=merge --patch '{"spec":{"managementState":"Managed"}}' config.imageregistry.operator.openshift.io/cluster patched

If everything went like planned we should now see the image registry pod running.

$ oc get pods -n openshift-image-registry NAME READY STATUS RESTARTS AGE cluster-image-registry-operator-6bcb795b48-jgzw9 1/1 Running 5 7h34m image-registry-676f99fd9c-8gf4m 1/1 Running 0 58s node-ca-bt8ns 1/1 Running 4 7h16m

We should also see that our persistent volume claim is now bound and also has an associated persistent volume from our lvms-vg1 storage class.

$ oc get pvc -n openshift-image-registry NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE registry-claim Bound pvc-c58510da-9eb9-4220-b101-4086f58b3311 100Gi RWO lvms-vg1 <unset> 6m $ oc get pv -n openshift-image-registry NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE pvc-c58510da-9eb9-4220-b101-4086f58b3311 100Gi RWO Delete Bound openshift-image-registry/registry-claim lvms-vg1 <unset> 2m34s

Now that our registry is running we need to make sure we can pull and push to the registry. First let's set some environment variables for our internal registry, the user, the namespace and the token creation.

$ export REGISTRY=image-registry.openshift-image-registry.svc:5000 $ export REGISTRY_USER=builder $ export REGISTRY_NAMESPACE=openshift-machine-config-operator $ export TOKEN=$(oc create token $REGISTRY_USER -n $REGISTRY_NAMESPACE --duration=$((900*24))h)

Next let's create the push-secret using the variables we set in the openshift-machine-config-operator namespace.

$ oc create secret docker-registry push-secret -n openshift-machine-config-operator --docker-server=$REGISTRY --docker-username=$REGISTRY_USER --docker-password=$TOKEN secret/push-secret created

Now we need to extract the push secret and the clusters global pull-secret.

$ oc extract secret/push-secret -n openshift-machine-config-operator --to=- > push-secret.json # .dockerconfigjson $ oc extract secret/pull-secret -n openshift-config --to=- > pull-secret.json # .dockerconfigjson

We will now take the push-secret and global pull secret into one merged secret.

$ jq -s '.[0] * .[1]' pull-secret.json push-secret.json > pull-and-push-secret.json

This new merged secret needs to be create as well in the openshift-machine-config-operator namespace.

$ oc create secret generic pull-and-push-secret -n openshift-machine-config-operator --from-file=.dockerconfigjson=pull-and-push-secret.json --type=kubernetes.io/dockerconfigjson secret/pull-and-push-secret created

We can also validate the secrets have been created.

$ oc get secrets -n openshift-machine-config-operator |grep push pull-and-push-secret kubernetes.io/dockerconfigjson 1 10s push-secret kubernetes.io/dockerconfigjson 1 114s

At this point our registry is configured and running and we can move onto the next workflow.

Configure Configmap

With the Machine Config Operator which will be doing the heavy lifting of building the OVS-DOCA RHCOS layer we can use configuration maps to map in values or files. In this case we want to generate a ConfigMap that will contain our DOCA repo. We will first generate the following custom resource file.

$ cat <<EOF > repos-configmap.yaml apiVersion: v1 kind: ConfigMap metadata: name: etc-yum-repos-d namespace: openshift-machine-config-operator data: doca.repo: | [doca] name=DOCA Online Repo baseurl=https://linux.mellanox.com/public/repo/doca/3.3.0/rhel9/x86_64/ enabled=1 gpgcheck=0 centos.repos: | [baseos] name=CentOS Stream $releasever - BaseOS baseurl=https://mirror.stream.centos.org/9-stream/BaseOS/x86_64/os gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial gpgcheck=0 repo_gpgcheck=0 metadata_expire=6h countme=1 enabled=1 [appstream] name=CentOS Stream $releasever - AppStream baseurl=https://mirror.stream.centos.org/9-stream/AppStream/x86_64/os gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial gpgcheck=0 repo_gpgcheck=0 metadata_expire=6h countme=1 enabled=1 [CRB] name=Centos Stream $releasever - CRB baseurl=https://mirror.stream.centos.org/9-stream/CRB/x86_64/os gpgcheck=1 repo_gpgcheck=0 metadata_expire=6h countme=1 enabled=1 EOF

With the file created we will go ahead and create it on the cluster.

$ oc create -f repos-configmap.yaml configmap/etc-yum-repos-d created

For sanity we can check that it was created.

$ oc get configmap -n openshift-machine-config-operator etc-yum-repos-d NAME DATA AGE etc-yum-repos-d 2 22s

What this ConfigMap will do is mount up the repo files created in the builder container when we go to create the OVS-DOCA RHCOS layer. If everything looks good we can move onto the next step in the workflow.

Generate MachineOSConfig Custom Resource

When using on-cluster layering we have to create a MachineOSConfig that will basically do the steps that the Dockerfile would have done if we were building an off-cluster layer. For our on-cluster layer we need to ensure it does the following:

  • Installs the dependencies needed
  • Upgrades some of the existing packages to a version suitable for OVS-DOCA
  • Removes the current Red Hat based version of OpenvSwitch
  • Replaces some packages with those from the DOCA repo
  • Installs the doca-all bundle which will contain the OVS-DOCA rpms including OpenvSwitch
  • Note that since this is a SNO setup our metadata name and machineConfigPool is master. On a multi-node cluster this would most likely be worker or the name of a custom machineConfigPool.

Below is the following custom resource file we will use.

$ cat <<EOF > on-cluster-rhcos-layer-mc.yaml.works apiVersion: machineconfiguration.openshift.io/v1 kind: MachineOSConfig metadata: name: master spec: machineConfigPool: name: master containerFile: - containerfileArch: NoArch content: |- FROM configs AS final RUN dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm && \ mkdir /var/opt && \ dnf install -y https://mirror.stream.centos.org/9-stream/CRB/x86_64/os/Packages/libyaml-devel-0.2.5-7.el9.x86_64.rpm && \ dnf install -y https://mirror.stream.centos.org/9-stream/CRB/x86_64/os/Packages/libpcap-devel-1.10.0-4.el9.x86_64.rpm && \ dnf install -y https://mirror.stream.centos.org/9-stream/CRB/x86_64/os/Packages/libzip-devel-1.7.3-7.el9.x86_64.rpm && \ dnf install -y libunwind jsoncpp openssl-devel kernel-devel kernel-headers && \ dnf upgrade -y unbound-libs unbound bzip2-libs bzip2-devel && \ rpm-ostree override remove openvswitch3.5 && \ rpm-ostree override replace libibverbs rdma-core --experimental --from repo='doca' && \ dnf install doca-all -y && \ rm -r -f /etc/yum.repos.d/* && \ dnf clean all -y && \ bootc container lint imageBuilder: imageBuilderType: Job baseImagePullSecret: name: pull-and-push-secret renderedImagePushSecret: name: push-secret renderedImagePushSpec: image-registry.openshift-image-registry.svc:5000/openshift-machine-config-operator/os-image:latest EOF

If everything looks correct we can move onto the next section in the workflow.

Create MachineOSConfig Layer

At this point we are ready to create our MachineOSConfig which will do the following:

  • Build the OVS-DOCA RHCOS layer image based on our requirements in the MachineOSConfig
  • Push that OVS-DOCA RHCOS layer image to the local OpenShift registry
  • Apply that OVS-DOCA RHCOS layer image to the system
  • Reboot the node for the changes to take effect

To kick things off we need to create MachineOSConfig on the cluster.

$ oc create -f on-cluster-rhcos-layer-mc.yaml machineosconfig.machineconfiguration.openshift.io/master created

Next we can look at the state of the build process by looking at the machineOSbuild state.

$ oc get machineOSbuild NAME PREPARED BUILDING SUCCEEDED INTERRUPTED FAILED AGE master-9c55a02933a10f5fc31c6bb5329e1f38 False True False False False 16s

While the machineOSbuild is building we should notice that two additional pods were created in the openshift-machine-config-operator namespace: machine-os-builer and build-master.

$ oc get pods -n openshift-machine-config-operator NAME READY STATUS RESTARTS AGE build-master-9c55a02933a10f5fc31c6bb5329e1f38-qr262 0/1 Init:0/1 0 30s kube-rbac-proxy-crio-nvd-srv-26.nvidia.eng.rdu2.dc.redhat.com 1/1 Running 7 27h machine-config-controller-847595d69d-q9jzb 2/2 Running 9 27h machine-config-daemon-php6s 2/2 Running 14 27h machine-config-nodes-crd-cleanup-29633172-tv2ml 0/1 Completed 0 27h machine-config-operator-6d4cbf84b4-q4z6c 2/2 Running 9 27h machine-config-server-vvxkk 1/1 Running 4 27h machine-os-builder-9d9d855dd-9xjjv 1/1 Running 0 39s

The build-master is where the actual build is happening and we can watch the execution of the build by tailing the logs of that pod and the image-build container inside. Below is an example snippet (the log can be incredibly long) of right at the end of a successful build.

$ oc logs -f -n openshift-machine-config-operator build-master-9c55a02933a10f5fc31c6bb5329e1f38-qr262 image-build (...) Copying blob sha256:25e5c12c08ced2f786717e0303aff37e3ce37f8e8171ae91fb298eee4e7af424 Copying blob sha256:7e6009a201a327b83674a7491fd077e59ecfb63b63c7a570359dbf84081c6aa0 Copying config sha256:a7f6fd648ae51526c4c113756eb1af4beac6c05c6c6f4988665f3258a89e42bf Writing manifest to image destination

Once the image-build container has finished building the image we can go back and check the status of the machineOSbuild to see it succeeded.

$ oc get machineOSbuild NAME PREPARED BUILDING SUCCEEDED INTERRUPTED FAILED AGE master-9c55a02933a10f5fc31c6bb5329e1f38 False False True False False 7m16s

We will also notice now that the master machineConfigPool is now in an updating state because the new RHCOS layer is being applied.

$ oc get mcp NAME CONFIG UPDATED UPDATING DEGRADED MACHINECOUNT READYMACHINECOUNT UPDATEDMACHINECOUNT DEGRADEDMACHINECOUNT AGE master rendered-master-c4363e454702b9a652e29fb24f28c7c7 False True False 1 0 0 0 27h worker rendered-worker-91833d06ff7a4b4563d843249bc12228 True False False 0 0 0 0 27h

Remember that as the OVS-DOCA RHCOS layer is being applied the node will reboot. Once the node comes back the image should be applied and we can see the update is complete by the following.

$ oc get mcp NAME CONFIG UPDATED UPDATING DEGRADED MACHINECOUNT READYMACHINECOUNT UPDATEDMACHINECOUNT DEGRADEDMACHINECOUNT AGE master rendered-master-c4363e454702b9a652e29fb24f28c7c7 True False False 1 1 1 0 27h worker rendered-worker-91833d06ff7a4b4563d843249bc12228 True False False 0 0 0 0 27h

If everything looks good we can move onto the next section of the workflow.

Validate MachineOSConfig Layer

Now that the RHCOS layer is applied to the system we can validate that the OVS-DOCA Openvswitch is in use. First we need to open a debug pod on the node where the image was applied.

$ oc debug node/nvd-srv-26.nvidia.eng.rdu2.dc.redhat.com Starting pod/nvd-srv-26nvidiaengrdu2dcredhatcom-debug-hlcdm ... To use host binaries, run `chroot /host`. Instead, if you need to access host namespaces, run `nsenter -a -t 1`. Pod IP: 10.6.135.5 If you don't see a command prompt, try pressing enter. sh-5.1# chroot /host

Next we can check for what version of openvswitch is installed. The output below shows that we do in fact have the doca-openvswitch package installed.

sh-5.1# rpm -qa|grep openvswitch openvswitch-selinux-extra-policy-1.0-39.el9fdp.noarch doca-openvswitch-3.3.0040-1.el9.x86_64

Next we can use the ovs-vsctl command check further if the running openvswitch is in fact doca version. Below in the output we can see the datapath_types have doca listed and when we look at the dpdk_version we can see it references doca as well.

sh-5.1# ovs-vsctl list open_vswitch _uuid : bb0f2e33-856e-49f3-b063-a25084cb7894 bridges : [7d465f8c-2332-4cd8-9115-f5f53161d838, e137b176-e5ee-4df8-8c79-60b364c2c368] cur_cfg : 703 datapath_types : [doca, netdev, system] datapaths : {system=748f4bb7-442e-479a-9923-811ee91408cc} db_version : "8.5.1" doca_initialized : false doca_version : "3.3.0109" dpdk_initialized : false dpdk_version : "DPDK 25.11.0+doca2601" external_ids : {hostname=nvd-srv-26.nvidia.eng.rdu2.dc.redhat.com, ovn-bridge-mappings="physnet:br-ex", ovn-bridge-remote-probe-interval="0", ovn-enable-lflow-cache="true", ovn-encap-ip="10.6.135.5", ovn-encap-type=geneve, ovn-is-interconn="true", ovn-memlimit-lflow-cache-kb="1048576", ovn-monitor-all="true", ovn-ofctrl-wait-before-clear="0", ovn-remote="unix:/var/run/ovn/ovnsb_db.sock", ovn-remote-probe-interval="180000", ovn-set-local-ip="true", rundir="/var/run/openvswitch", system-id="01916ed0-7268-43dd-8355-68fef87a1761"} iface_types : [bareudp, erspan, geneve, gre, gtpu, internal, ip6erspan, ip6gre, lisp, patch, srv6, stt, system, tap, vxlan] manager_options : [] next_cfg : 703 other_config : {bundle-idle-timeout="0", ovn-chassis-idx-01916ed0-7268-43dd-8355-68fef87a1761="", vlan-limit="0"} ovs_version : "3.3.0040" ssl : [] statistics : {} system_type : rhel system_version : "9.6"

Now I noticed above doca and dpdk were not initialized. Let's see if we can get them going with the following. Then restart openvswitch on the node.

sh-5.1# ovs-vsctl --no-wait set Open_vSwitch . other_config:doca-init=true sh-5.1# systemctl restart openvswitch

And now if we look at the open_vswitch values again we can see they are now true.

sh-5.1# ovs-vsctl list open_vswitch _uuid : bb0f2e33-856e-49f3-b063-a25084cb7894 bridges : [7b6aaed2-2e60-45bf-b98c-90e6806482dc, 7d465f8c-2332-4cd8-9115-f5f53161d838] cur_cfg : 898 datapath_types : [doca, netdev, system] datapaths : {system=748f4bb7-442e-479a-9923-811ee91408cc} db_version : "8.5.1" doca_initialized : true doca_version : "3.3.0109" dpdk_initialized : true dpdk_version : "DPDK 25.11.0+doca2601" external_ids : {hostname=nvd-srv-26.nvidia.eng.rdu2.dc.redhat.com, ovn-bridge-mappings="physnet:br-ex", ovn-bridge-remote-probe-interval="0", ovn-enable-lflow-cache="true", ovn-encap-ip="10.6.135.5", ovn-encap-type=geneve, ovn-is-interconn="true", ovn-memlimit-lflow-cache-kb="1048576", ovn-monitor-all="true", ovn-ofctrl-wait-before-clear="0", ovn-remote="unix:/var/run/ovn/ovnsb_db.sock", ovn-remote-probe-interval="180000", ovn-set-local-ip="true", rundir="/var/run/openvswitch", system-id="01916ed0-7268-43dd-8355-68fef87a1761"} iface_types : [bareudp, doca, docavdpa, erspan, geneve, gre, gtpu, internal, ip6erspan, ip6gre, lisp, patch, srv6, stt, system, tap, vxlan] manager_options : [] next_cfg : 898 other_config : {bundle-idle-timeout="0", doca-init="true", ovn-chassis-idx-01916ed0-7268-43dd-8355-68fef87a1761="", vlan-limit="0"} ovs_version : "3.3.0040" ssl : [] statistics : {} system_type : rhel system_version : "9.6"

One other thing I will note here is that in this SNO environment I did go ahead and upgrade it from the starting 4.20.15 version to 4.21.11 with the OVS-DOCA RHCOS on-cluster image in place. After getting to 4.21.11 the OVS-DOCA openvswitch was still in place and running appropriately.  OpenShift showed no issues with the swap out replacement and/or the upgrade process.

Hopefully this provided a good example of how to build and apply a on-cluster OVS-DOCA RHCOS layer for experimental purposes.