Part 1 of 2: From bare Ubuntu servers to a running MySQL cluster

OpenEverest is an open source platform for running databases such as MySQL, PostgreSQL and MongoDB on Kubernetes. You get a web console and an API, and Kubernetes operators handle provisioning, scaling, backups and failover behind the scenes.

This post builds a small but realistic lab from scratch: a 3-node K3s Kubernetes cluster on Ubuntu 26.04, OpenEverest on top of it, and a 3-node Percona XtraDB Cluster (PXC) for MySQL that you can connect to from your laptop. Every step explains what the command does and why it is needed.

Part 2 of this series looks inside the MySQL cluster: how to read Galera’s health indicators, and a live failover test where we kill the primary database pod while writes keep running.

What You Will Build

  • K3s: a lightweight, CNCF-certified Kubernetes distribution. A single binary includes containerd (the container runtime), Flannel (pod networking), CoreDNS (service discovery) and the local-path storage provisioner.
  • Helm: the Kubernetes package manager, used to install OpenEverest.
  • OpenEverest: the control plane (everest-server for the UI/API, everest-operator for reconciling database clusters).
  • Percona XtraDB Cluster: a 3-node, synchronously replicated MySQL cluster with HAProxy in front of it, created from the OpenEverest UI.

Server Inventory

Each node needs a unique hostname and a unique IP address. Kubernetes identifies nodes by hostname, so two VMs cloned from the same image with the same hostname will conflict.

Role Hostname IP address
Control plane arunopeneverestcontrolplanenode 10.50.55.155
Worker node 1 arunjithopeneverestworkernode1 10.50.55.156
Worker node 2 arunjithopeneverestworkernode2 10.50.55.157

The nodes must be able to reach each other on these ports:

Port Protocol Purpose
6443 TCP Kubernetes API server (workers to control plane)
8472 UDP Flannel VXLAN overlay: pod-to-pod traffic between nodes
10250 TCP Kubelet API (logs, exec, metrics)

Phase 1: Prepare All Three Nodes

Where to run: all three servers. Open three terminal windows and SSH into each one.

Step 1.1: Connect to Each Server

Open one SSH session per server so you can run the preparation commands side by side.

# Terminal 1 - Control plane ssh <USER>@<CONTROL_PLANE_IP> # Terminal 2 - Worker node 1 ssh <USER>@<WORKER1_IP> # Terminal 3 - Worker node 2 ssh <USER>@<WORKER2_IP>

Step 1.2: Install Packages and Disable Swap

This refreshes the package index and installs the tools the rest of the guide needs:

  • curl and wget download the K3s and Helm installers.
  • open-iscsi and nfs-common are not needed by local-path storage, but they are prerequisites for replicated storage such as Longhorn or NFS volumes. Installing them now makes it easy to switch storage later.

Swap is turned off because the kubelet expects it to be disabled by default. With swap on, memory limits on database pods become unpredictable. The sed command comments out the swap line in /etc/fstab so swap stays off after a reboot.

$ Command (all three nodes)

sudo apt-get update && sudo apt-get install -y curl wget apt-transport-https open-iscsi nfs-common sudo swapoff -a sudo sed -i '/ swap / s/^/#/g' /etc/fstab

Step 1.3: Enable Kernel Modules and IP Forwarding

Kubernetes networking relies on two kernel modules and three kernel settings:

  • overlay: the filesystem driver containerd uses to stack container image layers.
  • br_netfilter: lets iptables see traffic crossing the Linux bridge, which is how Kubernetes Services and network policies work.
  • net.bridge.bridge-nf-call-iptables and ip6tables: send bridged IPv4/IPv6 traffic through iptables.
  • net.ipv4.ip_forward: allows the node to route packets between pods on different nodes.

modprobe loads the modules now. The file in /etc/modules-load.d/ loads them again on every boot, and the file in /etc/sysctl.d/ does the same for the kernel settings.

$ Command (all three nodes)

sudo modprobe overlay sudo modprobe br_netfilter sudo tee /etc/modules-load.d/k8s.conf <<EOF overlay br_netfilter EOF sudo tee /etc/sysctl.d/kubernetes.conf <<EOF net.bridge.bridge-nf-call-iptables  = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward                 = 1 EOF sudo sysctl --system

Phase 2: Install the K3s Control Plane

Where to run: control plane only (10.50.55.155).

Step 2.1: Install the K3s Server

The official install script downloads K3s, installs it as a systemd service named k3s, and starts the Kubernetes control plane. INSTALL_K3S_EXEC passes extra flags to the server:

  • –disable traefik: K3s ships with the Traefik ingress controller. This lab doesn’t use ingress, so we skip it to save resources.
  • –node-ip 10.50.55.155: pins the address Kubernetes advertises for this node. This matters on servers with more than one network interface.
  • –flannel-backend=vxlan: pod traffic between nodes is wrapped in VXLAN over UDP 8472. This is already the default; it is set explicitly for clarity.
$ Command (control plane)

curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable traefik --node-ip 10.50.55.155 --flannel-backend=vxlan" sh -

Step 2.2: Let Your User Run kubectl

K3s writes its admin kubeconfig to /etc/rancher/k3s/k3s.yaml, which only root can read. These commands copy it to your home directory, make you the owner, and point KUBECONFIG at it, now and in every future shell.

$ Command (control plane)

mkdir -p $HOME/.kube sudo cp /etc/rancher/k3s/k3s.yaml $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config export KUBECONFIG=$HOME/.kube/config echo "export KUBECONFIG=$HOME/.kube/config" >> ~/.bashrc

📝 Note: This is the only server where kubectl is configured. All later kubectl and helm commands in this series run on the control plane.

Step 2.3: Get the Node Join Token

Worker nodes need a shared secret to join the cluster. K3s stores it in node-token.

$ Command (control plane)

sudo cat /var/lib/rancher/k3s/server/node-token

Output (format)

K10<ca-hash>::server:<secret>

The token has two parts. K10<ca-hash> lets the joining node verify the server’s TLS certificate. The part after ::server: is the actual password.

Security: Treat the whole token as a credential. Never publish the part after ::server:. Anyone who has it can join a machine to your cluster.

Phase 3: Join the Worker Nodes

Where to run: each worker, in its own terminal. Replace <TOKEN> with the full token from Step 2.3.

When K3S_URL is set, the same install script runs in agent mode. The node connects to the API server at https://10.50.55.155:6443, authenticates with the token, and starts running pods. –node-ip again pins the address each node advertises.

Step 3.1: Join Worker Node 1

$ Command (worker node 1: 10.50.55.156)

curl -sfL https://get.k3s.io | K3S_URL=https://10.50.55.155:6443 \   K3S_TOKEN="<TOKEN>" \   INSTALL_K3S_EXEC="--node-ip=10.50.55.156" sh -

Step 3.2: Join Worker Node 2

$ Command (worker node 2: 10.50.55.157)

curl -sfL https://get.k3s.io | K3S_URL=https://10.50.55.155:6443 \   K3S_TOKEN="<TOKEN>" \   INSTALL_K3S_EXEC="--node-ip=10.50.55.157" sh -

Phase 4: Install OpenEverest

Where to run: control plane only (10.50.55.155).

Step 4.1: Verify All Nodes Are Ready

Ready means the kubelet on that node is healthy and has working pod networking. Workers show <none> under ROLES; that is normal for K3s agents.

$ Command (control plane)

kubectl get nodes

Output

NAME                              STATUS   ROLES           AGE     VERSION arunjithopeneverestworkernode1    Ready    <none>          4m6s    v1.36.5+k3s1 arunjithopeneverestworkernode2    Ready    <none>          3m18s   v1.36.5+k3s1 arunopeneverestcontrolplanenode   Ready    control-plane   9m3s    v1.36.5+k3s1

📝 Note: In K3s, the control plane node also runs normal pods, including one of the database pods. This is fine for a test lab. In production, it is better to run database pods only on worker nodes.

kubectl get pods -n everest -o wide | grep pxc mysql-vjg-pxc-0                                    1/1     Running   0               75m   10.42.2.17   arunjithopeneverestworkernode2    <none>           <none> mysql-vjg-pxc-1                                    1/1     Running   0               10d   10.42.1.7    arunjithopeneverestworkernode1    <none>           <none> mysql-vjg-pxc-2                                    1/1     Running   0               10d   10.42.0.10   arunopeneverestcontrolplanenode   <none>           <none>

Step 4.2: Install Helm

Helm installs applications from versioned packages called charts. This script detects your OS and architecture and installs the helm binary to /usr/local/bin.

$ Command (control plane)

curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

📝 Note: Piping a script from the internet into bash is convenient for a lab. For repeatable builds, pin a specific Helm release instead.

Step 4.3: Confirm the Default StorageClass

Databases need persistent volumes. When OpenEverest creates a database, it requests storage from the default StorageClass, which on K3s is local-path.

$ Command (control plane)

kubectl get storageclass

Output

NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  11m

What these columns mean for a database:

  • WaitForFirstConsumer: the volume is created only after the pod is scheduled, so the data directory lands on the same node as the pod.
  • Node-local data: each volume is a directory on one node’s disk. The pod is pinned to that node for its whole life. If the node is lost, the pod cannot move; Galera rebuilds the data on a new node from the surviving replicas.
  • ALLOWVOLUMEEXPANSION false: you cannot grow these volumes later, so size them generously.
  • RECLAIMPOLICY Delete: deleting the volume claim deletes the data.

If local-path is not marked (default), set it:

$ Command (control plane)

kubectl annotate storageclass local-path storageclass.kubernetes.io/is-default-class=true --overwrite

Step 4.4: Install OpenEverest with Helm

helm repo add registers the OpenEverest chart repository, and helm repo update downloads its index. helm install creates a release named everest in a new namespace, everest-system, which holds the OpenEverest control plane.

$ Command (control plane)

helm repo add openeverest https://openeverest.github.io/helm-charts/ helm repo update helm install everest openeverest/openeverest \   --namespace everest-system \   --create-namespace

Step 4.5: Verify the OpenEverest Pods

Two pods should reach Running within 1 to 2 minutes:

  • everest-server: serves the web console and REST API.
  • everest-operator: watches OpenEverest resources and turns them into database clusters by driving the Percona operators.
$ Command (control plane)

kubectl get pods -n everest-system

Output

NAME                                READY   STATUS    RESTARTS   AGE everest-operator-589d68c4cf-4hbss   1/1     Running   0          115s everest-server-846b99fcd-2jtd9      1/1     Running   0          115s

Step 4.6: Retrieve the Admin Password

OpenEverest stores its user accounts in a Kubernetes Secret. Secret values are base64-encoded, so the command extracts the users.yaml key and decodes it. The output contains the admin user’s initial password.

$ Command (control plane)

kubectl get secret everest-accounts -n everest-system -o jsonpath='{.data.users\.yaml}' | baseCommand (on your laptop) ssh -L 8080:127.0.0.1:8080 arunjith.aravindan@10.50.55.155

Phase 5: Open the OpenEverest Web Console

The everest Service is only reachable inside the cluster. To use it from your laptop, chain two forwards: an SSH tunnel from your laptop to the control plane, and a kubectl port-forward from the control plane into the cluster.

Step 5.1: Open an SSH Tunnel from Your Laptop

-L 8080:127.0.0.1:8080 means: anything that connects to port 8080 on my laptop is sent through SSH to port 8080 on the control plane’s loopback interface.

$ Command (on your laptop)

ssh -L 8080:127.0.0.1:8080 arunjith.aravindan@10.50.55.155

Step 5.2: Port-Forward the UI Service

In the SSH session you just opened, forward local port 8080 on the control plane to the everest Service. Leave this running.

$ Command (control plane, inside the tunnel session)

kubectl port-forward svc/everest 8080:8080 -n everest-system

Security: kubectl port-forward listens on 127.0.0.1 by default, which is exactly where the SSH tunnel delivers traffic. Avoid –address 0.0.0.0: it exposes the UI to every host that can reach the control plane.

Step 5.3: Log In

Open http://localhost:8080 in your browser and log in as admin with the password from Step 4.6.

Step 5.4: Create a MySQL Database

In the console, create a new MySQL database with 3 nodes. OpenEverest creates a Percona XtraDB Cluster with three PXC pods and HAProxy in front of them, in the everest namespace. It generates a name for the cluster; in this lab it is mysql-vjg, so substitute your own cluster name in the commands that follow.

Phase 6: Connect to MySQL

Step 6.1: Understand the Kubernetes Services

Before connecting, look at what OpenEverest created. Each Service is a stable network endpoint in front of a group of pods.

$ Command (control plane)

kubectl get svc -n everest | grep mysql-vjg

Output

mysql-vjg-haproxy            ClusterIP   10.43.69.73   <none>   3306/TCP,3309/TCP,33062/TCP,33060/TCP,8404/TCP   44m mysql-vjg-haproxy-replicas   ClusterIP   10.43.2.25    <none>   3306/TCP                                         44m mysql-vjg-pxc                ClusterIP   None          <none>   3306/TCP,33062/TCP,33060/TCP                     44m mysql-vjg-pxc-unready        ClusterIP   None          <none>   3306/TCP,33062/TCP,33060/TCP                     44m

Service What it is for
mysql-vjg-haproxy The main application endpoint. Port 3306 sends every connection to a single writer node. 3309 is the same routing with the PROXY protocol (preserves the client IP). 33062 is the MySQL admin port, 33060 is the X Protocol, and 8404 serves HAProxy statistics.
mysql-vjg-haproxy-replicas Port 3306 round-robins connections across all healthy nodes. Use it to spread read traffic.
mysql-vjg-pxc A headless Service (ClusterIP: None). Instead of one virtual IP, it gives every pod a stable DNS name such as mysql-vjg-pxc-0.mysql-vjg-pxc.everest.svc.cluster.local.
mysql-vjg-pxc-unready Also headless, but it publishes pods before they pass their readiness check. Galera nodes use it to find each other while the cluster is still forming.

Inside the cluster, CoreDNS resolves mysql-vjg-haproxy (or the full form mysql-vjg-haproxy.everest.svc.cluster.local) to the Service. That name does not resolve from your laptop, which is why the next steps use port forwarding.

Step 6.2: Get the MySQL Root Password

OpenEverest stores the database credentials in a Secret named everest-secrets-<cluster-name>. This command decodes the root key into a shell variable, so the password never appears on screen or in your shell history.

$ Command (control plane)

export ROOT_PW=$(kubectl get secret everest-secrets-mysql-vjg -n everest -o jsonpath='{.data.root}' | base64 --decode) echo "$ROOT_PW"   # print it once if you need to type it on your laptop

Step 6.3: Port-Forward HAProxy on the Control Plane

This forwards port 3306 on the control plane’s loopback interface to the HAProxy Service. Keep this terminal open.

$ Command (control plane)

kubectl port-forward svc/mysql-vjg-haproxy 3306:3306 -n everest

Output

Forwarding from 127.0.0.1:3306 -> 3306 Forwarding from [::1]:3306 -> 3306 Handling connection for 3306

Step 6.4: Tunnel from Your Laptop

Port 3307 here is just a local port on your laptop, chosen so it doesn’t clash with a MySQL server you may already run on 3306. The tunnel sends it to port 3306 on the control plane, where the port-forward from Step 6.3 picks it up.

$ Command (on your laptop)

ssh -L 3307:127.0.0.1:3306 arunjith.aravindan@10.50.55.155

Step 6.5: Connect with the MySQL Client

In another laptop terminal, connect to the local end of the tunnel. -p with no value makes the client prompt for the password, so it is not saved in your shell history.

$ Command (on your laptop)

mysql -h 127.0.0.1 -P 3307 -u root -p

The full path of the connection is: laptop port 3307, then SSH, then control plane port 3306, then kubectl port-forward, then HAProxy, then the current writer pod.

Step 6.6: Check the Cluster Status

These queries show Galera’s replication status. Part 2 explains every value; for now, look for Synced, Primary and a cluster size of 3.

SQL

SHOW STATUS LIKE '%wsrep%stat%'; SHOW STATUS LIKE '%wsrep%siz%';

Output

+------------------------------+--------------------------------------+ | Variable_name                | Value                                | +------------------------------+--------------------------------------+ | wsrep_local_state_uuid       | c1319eb6-d354-11f1-8a79-0ff80b37e565 | | wsrep_monitor_status (L/A/C) | [ (20, 20), (44, 44), (44, 44) ]     | | wsrep_flow_control_status    | OFF                                  | | wsrep_local_state            | 4                                    | | wsrep_local_state_comment    | Synced                               | | wsrep_ist_receive_status     |                                      | | wsrep_evs_state              | OPERATIONAL                          | | wsrep_cluster_state_uuid     | c1319eb6-d354-11f1-8a79-0ff80b37e565 | | wsrep_cluster_status         | Primary                              | +------------------------------+--------------------------------------+

+------------------------+-------+ | Variable_name          | Value | +------------------------+-------+ | wsrep_cert_index_size  | 1     | | wsrep_gcache_pool_size | 15624 | | wsrep_cluster_size     | 3     | +------------------------+-------+

Step 6.7: See Which Node You Are Connected To

@@hostname and @@wsrep_node_name show which PXC pod HAProxy picked for this connection.

SQL

SELECT @@hostname, @@wsrep_node_name, @@read_only;

Output

+-----------------+-------------------+-------------+ | @@hostname      | @@wsrep_node_name | @@read_only | +-----------------+-------------------+-------------+ | mysql-vjg-pxc-0 | mysql-vjg-pxc-0   |           0 | +-----------------+-------------------+-------------+

Notice @@read_only = 0. In Percona XtraDB Cluster every node accepts writes; there are no read-only replicas as in asynchronous replication. So why does HAProxy send all writes to one node?

Galera checks each transaction for conflicts at commit time (certification). If two nodes change the same row at the same moment, one of the transactions fails certification and the application gets ERROR 1213 (40001): Deadlock found when trying to get lock, even though no real lock wait happened. Sending all writes to a single node avoids these conflicts. The -replicas endpoint is meant for reads only by convention, not because the nodes are read-only.

Step 6.8: Open a MySQL Shell Inside a Specific Pod

To talk to one particular node without HAProxy, run the MySQL client inside that pod. A PXC pod has more than one container, so -c pxc selects the database container. -it gives you an interactive terminal, and the client prompts for the root password.

$ Command (control plane)

percona@arunopeneverestcontrolplanenode:~$ kubectl exec -it mysql-vjg-pxc-2 -n everest -c pxc -- mysql -u root -p

SQL

SELECT @@hostname, @@wsrep_node_name, @@read_only;

Output

+-----------------+-------------------+-------------+ | @@hostname      | @@wsrep_node_name | @@read_only | +-----------------+-------------------+-------------+ | mysql-vjg-pxc-2 | mysql-vjg-pxc-2   |           0 | +-----------------+-------------------+-------------+

Summary and What’s Next

You now have a 3-node K3s cluster running OpenEverest and a 3-node Percona XtraDB Cluster behind HAProxy, reachable from your laptop. Along the way you saw how K3s nodes join a cluster, why local-path storage pins database pods to nodes, and how HAProxy, headless Services and CoreDNS fit together.

In Part 2, we read Galera’s health indicators in detail and run a live failover test: we delete the writer pod while a client writes every second, then watch HAProxy switch writers, the pod rejoin with an incremental state transfer, and all three nodes end up with identical data.

The post Deploying OpenEverest on a 3-Node K3s Cluster appeared first on Percona.