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.
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) |
Where to run: all three servers. Open three terminal windows and SSH into each one.
Open one SSH session per server so you can run the preparation commands side by side.
|
1 2 3 4 5 6 7 8 |
# 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> |
This refreshes the package index and installs the tools the rest of the guide needs:
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.
|
1 2 3 |
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 |
Kubernetes networking relies on two kernel modules and three kernel settings:
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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 |
Where to run: control plane only (10.50.55.155).
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:
|
1 |
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable traefik --node-ip 10.50.55.155 --flannel-backend=vxlan" sh - |
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.
|
1 2 3 4 5 |
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 |
kubectl is configured. All later kubectl and helm commands in this series run on the control plane.Worker nodes need a shared secret to join the cluster. K3s stores it in node-token.
|
1 |
sudo cat /var/lib/rancher/k3s/server/node-token |
Output (format)
|
1 |
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.
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.
|
1 2 3 |
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 - |
|
1 2 3 |
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 - |
Where to run: control plane only (10.50.55.155).
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.
|
1 |
kubectl get nodes |
Output
|
1 2 3 4 |
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 |
|
1 2 3 4 |
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> |
Helm installs applications from versioned packages called charts. This script detects your OS and architecture and installs the helm binary to /usr/local/bin.
|
1 |
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash |
Databases need persistent volumes. When OpenEverest creates a database, it requests storage from the default StorageClass, which on K3s is local-path.
|
1 |
kubectl get storageclass |
Output
|
1 2 |
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE local-path (default) rancher.io/local-path Delete WaitForFirstConsumer false 11m |
What these columns mean for a database:
If local-path is not marked (default), set it:
|
1 |
kubectl annotate storageclass local-path storageclass.kubernetes.io/is-default-class=true --overwrite |
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.
|
1 2 3 4 5 6 |
helm repo add openeverest https://openeverest.github.io/helm-charts/ helm repo update helm install everest openeverest/openeverest \ --namespace everest-system \ --create-namespace |
Two pods should reach Running within 1 to 2 minutes:
|
1 |
kubectl get pods -n everest-system |
Output
|
1 2 3 |
NAME READY STATUS RESTARTS AGE everest-operator-589d68c4cf-4hbss 1/1 Running 0 115s everest-server-846b99fcd-2jtd9 1/1 Running 0 115s |
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.
|
1 2 |
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 |
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.
-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.
|
1 |
ssh -L 8080:127.0.0.1:8080 arunjith.aravindan@10.50.55.155 |
In the SSH session you just opened, forward local port 8080 on the control plane to the everest Service. Leave this running.
|
1 |
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.
|
1 |
Open http://localhost:8080 in your browser and log in as admin with the password from Step 4.6. |
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.
Before connecting, look at what OpenEverest created. Each Service is a stable network endpoint in front of a group of pods.
|
1 |
kubectl get svc -n everest | grep mysql-vjg |
Output
|
1 2 3 4 |
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.
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.
|
1 2 3 |
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 |
This forwards port 3306 on the control plane’s loopback interface to the HAProxy Service. Keep this terminal open.
|
1 |
kubectl port-forward svc/mysql-vjg-haproxy 3306:3306 -n everest |
Output
|
1 2 3 |
Forwarding from 127.0.0.1:3306 -> 3306 Forwarding from [::1]:3306 -> 3306 Handling connection for 3306 |
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.
|
1 |
ssh -L 3307:127.0.0.1:3306 arunjith.aravindan@10.50.55.155 |
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.
|
1 |
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.
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
|
1 2 3 |
SHOW STATUS LIKE '%wsrep%stat%'; SHOW STATUS LIKE '%wsrep%siz%'; |
Output
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
+------------------------------+--------------------------------------+ | 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 | +------------------------------+--------------------------------------+ |
|
1 2 3 4 5 6 7 |
+------------------------+-------+ | Variable_name | Value | +------------------------+-------+ | wsrep_cert_index_size | 1 | | wsrep_gcache_pool_size | 15624 | | wsrep_cluster_size | 3 | +------------------------+-------+ |
@@hostname and @@wsrep_node_name show which PXC pod HAProxy picked for this connection.
SQL
|
1 |
SELECT @@hostname, @@wsrep_node_name, @@read_only; |
Output
|
1 2 3 4 5 |
+-----------------+-------------------+-------------+ | @@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.
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.
|
1 |
percona@arunopeneverestcontrolplanenode:~$ kubectl exec -it mysql-vjg-pxc-2 -n everest -c pxc -- mysql -u root -p |
SQL
|
1 |
SELECT @@hostname, @@wsrep_node_name, @@read_only; |
Output
|
1 2 3 4 5 |
+-----------------+-------------------+-------------+ | @@hostname | @@wsrep_node_name | @@read_only | +-----------------+-------------------+-------------+ | mysql-vjg-pxc-2 | mysql-vjg-pxc-2 | 0 | +-----------------+-------------------+-------------+ |
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.
Resources
RELATED POSTS