This is how I moved my Kubernetes cluster to three RKE2 servers on openSUSE MicroOS — an immutable OS that installs every update into a new snapshot and rolls back on its own if a node comes up broken. The article follows the build from an empty VPS to a dual-stack, highly available cluster with Calico, WireGuard-encrypted pod traffic, Gateway API ingress, coordinated reboots and etcd backups, and it includes the problems I ran into on the real nodes, with the commands to spot them on yours.
Motivation
I already run my projects on a Kubernetes cluster — three control-plane nodes on Ubuntu, built with kubeadm much like I described in Spinning out a High Available Kubernetes cluster with kubeadm. A few things pushed me to rebuild it.
Cost. In four years the price of the existing cluster doubled — for the same hardware, which is already seven years old. Moving to new servers came out cheaper.
Less manual upkeep. With kubeadm, every Kubernetes update is manual work — or automation I would have to build and maintain myself. Ubuntu adds a major release upgrade once in a while on top of that; I wrote about one of them in An upgrade to Jammy Jellyfish.
openSUSE. My workstation runs openSUSE Tumbleweed. It is a rolling release that follows new kernels as they come out: there is no major upgrade every few years, just a steady stream of smaller updates. I wanted the same model on the servers.
Something new. I had never run an immutable OS. openSUSE MicroOS is built from Tumbleweed
and is immutable: a read-only /usr, a Btrfs snapshot for every change, and an automatic rollback
if a node comes up broken after an update.
What this article builds
A three-server, highly available RKE2 cluster on plain KVM VPS instances. The base OS is openSUSE MicroOS, and RKE2 is installed as a proper RPM inside the same transactional update stream as the OS. There are no separate agents: RKE2 servers are schedulable by default, so all three run etcd, the control plane and workloads.
flowchart TB
dns["k8s.example.com<br/>fixed registration address<br/>round-robin DNS: 3× A + 3× AAAA"]
subgraph dc["One datacenter / region"]
n1["k8s-1<br/>rke2-server<br/>etcd + control plane + workloads"]
n2["k8s-2<br/>rke2-server<br/>etcd + control plane + workloads"]
n3["k8s-3<br/>rke2-server<br/>etcd + control plane + workloads"]
end
dns --> n1
dns --> n2
dns --> n3
Target topology: three RKE2 servers behind one DNS name, no load balancer.
Everything below assumes a generic KVM/QEMU VPS provider whose panel gives you a console and the ability to boot a custom ISO (most do). Placeholders used throughout:
| Placeholder | Meaning |
|---|---|
k8s-1, k8s-2, k8s-3 |
hostnames — the node names come from them |
<IP1>, <IP2>, <IP3> |
each node's public IPv4 address |
<IPV6_1>, <IPV6_2>, <IPV6_3> |
each node's public IPv6 address |
k8s.example.com |
the registration address / API DNS name |
k8s-1.example.com … k8s-3.example.com |
one DNS name per node |
I built this in August 2026 on the MicroOS snapshot of that time, with RKE2 v1.35.7+rke2r1 from
the stable channel.
Part 1. Why MicroOS and RKE2
| Property | What you get |
|---|---|
Read-only /usr, writable /etc + /var |
Nothing but transactional-update can mutate the OS |
| Btrfs snapshot per transaction | transactional-update rollback undoes a bad kernel/update |
health-checker |
Automatic rollback if a node fails to come up healthy after an update |
/var is not rolled back |
etcd data, container images and PVs survive an OS rollback |
| SELinux enforcing by default | RKE2 ships a matching policy (rke2-selinux) |
| RKE2 as RPM | Patch upgrades ride along with the normal OS update + reboot cycle |
The last row is the answer to the kubeadm problem from the motivation: Kubernetes patch releases arrive the same way as kernel updates, with nothing extra to run.
The trade-off: you cannot zypper install into the running system, and every OS-level change
needs a reboot. In an HA cluster that is a feature, not a problem — but only if reboots are
serialised, which is what Part 14 is about.
Why exactly three servers
etcd needs a quorum of (n/2)+1 members:
| Servers | Quorum | Can lose |
|---|---|---|
| 2 | 2 | none |
| 3 | 2 | one — exactly what a rolling reboot does |
| 5 | 3 | two, but costs more and makes every write slower |
Three is the number.
Part 2. What you need
Per node (×3):
- CPU/RAM: RKE2's minimum is 2 vCPU / 4 GB; 4 vCPU / 8 GB is comfortable when servers also run workloads (see RKE2 requirements).
- Disk: 40 GB+ SSD/NVMe. etcd is latency-sensitive; spinning or throttled storage shows up
as
etcdserver: request timed outunder load. - Boot mode: the DVD boots on both legacy BIOS and UEFI. Prefer UEFI if the panel offers it.
- Disk device: virtio disks show up as
/dev/vdain the installer, SATA/SCSI as/dev/sda.
Cluster-wide:
- All three in the same datacenter/region. etcd replicates every write synchronously; a
cross-region member turns every API write into a WAN round-trip. Check the RTT between nodes
before committing:
ping -c 20 <IP2>fromk8s-1— single-digit milliseconds is what you want. - Distinct hostnames. The node name comes from the hostname and must be unique.
- A global IPv6 address per node. The cluster is dual-stack, so each node needs working IPv6
(a
/64per VPS is the usual allocation) alongside its IPv4. - DNS: one name for the cluster (
k8s.example.com) with an A and an AAAA record per node — see Part 3. - Time sync active on all three — etcd will not tolerate drift (
timedatectlshows it).
The installer ISO lives at https://download.opensuse.org/tumbleweed/iso/. MicroOS is rolling,
but openSUSE-MicroOS-DVD-x86_64-Current.iso always points at the latest snapshot.
Part 3. One name in front of the cluster — round-robin DNS on Cloudflare
Servers 2 and 3, every agent, and your kubectl all need one stable endpoint in front of the
three servers. RKE2 accepts a load balancer, a virtual IP, or plain round-robin DNS
(HA docs). I use the
last one — there is no infrastructure to run.
Create k8s.example.com in the Cloudflare dashboard as six records under the name k8s:
an A record for each node's IPv4 address (<IP1>, <IP2>, <IP3>) and an AAAA record for each
node's IPv6 address (<IPV6_1>, <IPV6_2>, <IPV6_3>) — the API listens on both families. Set
every one to DNS only (grey cloud) with a TTL of 1 min (or Auto).
Add a per-node name as well — k8s-1, k8s-2, k8s-3, each pointing at one node's A/AAAA
pair, and also DNS only. These go into tls-san (Part 7), so that reaching one specific server
never depends on an address baked into a certificate.
Or with the API — the token needs Zone → DNS → Edit on the zone:
CF_TOKEN=<TOKEN>; ZONE_ID=<ZONE_ID>
add() { # add <A|AAAA> <address>
curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
-d "{\"type\":\"$1\",\"name\":\"k8s\",\"content\":\"$2\",\"ttl\":60,\"proxied\":false}" \
| jq -r '.success, (.errors[]?.message)'
}
for ip in <IP1> <IP2> <IP3>; do add A "$ip"; done
for ip in <IPV6_1> <IPV6_2> <IPV6_3>; do add AAAA "$ip"; done
The one thing that will break this: the orange cloud
The record must be proxied: false / DNS only. Cloudflare's proxy only forwards a fixed set of
HTTP/HTTPS ports — 9345, 6443 and 2379/2380 are not among them — and even on 443 it terminates TLS
at Cloudflare, which is fatal for a Kubernetes API that authenticates clients by certificate. A
proxied record hands out Cloudflare's anycast IPs instead of your nodes, so joins fail with
connection timeouts or x509 errors that look like a certificate problem and are not.
This is per-record, so it does not affect the rest of the zone: application hostnames can stay orange-clouded, and cert-manager's HTTP-01 solver keeps working through the proxy. Only the cluster endpoint is grey.
Verify before installing anything — this is the check that catches the mistake:
dig +short k8s.example.com A @1.1.1.1
dig +short k8s.example.com AAAA @1.1.1.1
Expect exactly your three node IPs in each family. If you see 104.x.x.x or 172.67.x.x, the
record is proxied: switch it to DNS only and re-check. Run it a few times to watch the answer order
rotate.
What round-robin DNS does and does not give you
It has no health checking: a dead node's IP stays in rotation, and roughly one connection in
three to k8s.example.com will hang until it times out. That is acceptable here because of where
the address is actually used:
flowchart LR
laptop["kubectl on your laptop"] -->|"k8s.example.com:6443<br/>retries if it hits a dead node"| rr["k8s.example.com<br/>round-robin, no health checks"]
rr --> servers["k8s-1 · k8s-2 · k8s-3"]
joiner["server that is joining"] -->|"server: k8s-1.example.com:9345<br/>one specific live server"| n1["k8s-1"]
joined["node that has joined"] -->|"rke2 agent load-balances across<br/>the server addresses it learned"| servers
Who uses the round-robin name, and who deliberately does not.
- Joining a node — but point
server:at a specific node instead (Part 8): the round-robin set contains the joining node itself, and a server that asks itself for the etcd member list loops instead of failing over. - Your
kubectl—kubectlretries; if a node is down long enough to annoy you, target a live one directly with--server=https://k8s-2.example.com:6443. - Running cluster traffic — does not use it. Once a node has joined,
rke2 agentload-balances across the real server addresses it learned from the cluster, so a dead server does not interrupt anything.
Keep the TTL low so replacing a node propagates quickly, and remember to update the records whenever a node's IP changes — nothing does that for you. (Cloudflare Load Balancing would add health checks and automatic removal, but it is a paid add-on and not needed for this.)
Publishing all three node IPs is inherent to this approach; the firewall rules in Part 6 are what keep the exposed ports down to 22/80/443 plus your admin IPs.
Part 4. Install MicroOS from the ISO — on all three nodes
The panel console is only needed twice per node: for a few seconds to edit the boot entry, and once after the install to put an SSH key in place. Everything else happens over SSH.
sequenceDiagram
actor You
participant Console as Panel console
participant Installer as ISO installer
participant Node as Installed node
You->>Console: attach ISO, boot, edit the Installation entry
Console->>Installer: boot with ssh=1 sshpassword=...
Installer-->>You: linuxrc reports sshd is running
You->>Installer: ssh root@IP1, then yast.ssh
Installer->>Node: install, reboot, remove the ISO
You->>Console: log in once, write authorized_keys, restorecon
You->>Node: ssh root@IP1 with the key
You->>Node: turn password authentication off
Where the console is unavoidable and where SSH takes over.
-
In the panel, attach
openSUSE-MicroOS-DVD-x86_64-Current.iso(upload it or give the URL):https://download.opensuse.org/tumbleweed/iso/openSUSE-MicroOS-DVD-x86_64-Current.iso -
Boot from it and run the installer over SSH instead of the panel's console. In the GRUB menu highlight Installation, press
e, and append to thelinuxline:ssh=1 sshpassword=<PASSWORD>linuxrc(the ISO's pre-installer) then brings up the network, starts sshd and waits. If the VPS has no DHCP, also addifcfg=eth0=<IP1>/24,<GATEWAY>,<DNS>. -
Press
F10/Ctrl-Xto boot, wait for the console to report that sshd is running, then:ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no root@<IP1>You land on a plain shell; start the installer with
yast.ssh. It is the ncurses YaST —Tab/arrows to move,Enterto activate,Alt+the highlighted letter for shortcuts. The throwaway host key is why theknown_hostsflags are there: the installed system will present a different one. -
In the installer: accept the license, then pick the MicroOS system role.
MicroOS Container Hostwould only add Podman, which RKE2 does not need — it ships its own containerd.Accept the proposed partitioning. It puts the read-only Btrfs root and its snapshots on one partition (20 GB on my nodes) and
/varon a separate Btrfs partition with the rest of the disk. Container images, etcd and volumes therefore never compete with the snapshots for space. -
Set the root password, and on the installation summary screen make sure SSH service is enabled and the SSH port is open — this is about the installed system, not the installer's temporary sshd. Getting it wrong means finishing the job over the panel console.
-
Install, reboot, remove the ISO from the virtual drive.
Do this three times, with a different root password on each node.
Getting your SSH key onto the nodes
ssh-copy-id root@<IPn> does not work: openSUSE ships sshd with upstream's
PermitRootLogin prohibit-password, so root may authenticate by key but never by password. (The distro is explicit about it — there is a separate package,
openssh-server-config-rootlogin, whose entire content is a drop-in reading PermitRootLogin yes.)
ssh-copy-id needs a password login to place the key, so the first key has to arrive another way.
Use the panel console once per node — console logins are not affected by that setting — and write
root's authorized_keys by hand:
# on the node's console, as root
mkdir -p /root/.ssh && chmod 700 /root/.ssh
printf '%s\n' 'ssh-ed25519 AAAA...your key... you@laptop' >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
restorecon -R /root/.ssh # SELinux is enforcing; a wrong label fails auth silently
Do not skip restorecon: a file created in the right place with the wrong SELinux label is
invisible to sshd, and the rejection looks exactly like a wrong key.
Check it before you leave the console:
ls -Z /root/.ssh/authorized_keys # expect ...:ssh_home_t:...
Typing a public key into a laggy console is unpleasant. Two ways to make it less so:
- Use a short key —
ssh-keygen -t ed25519produces a one-line key roughly a quarter the length of an RSA one. - Or fetch it instead of typing it, if the key is already published somewhere you trust:
curl -fsSL https://github.com/<user>.keys >> /root/.ssh/authorized_keys(thenchmod/restoreconas above, and read the file back to confirm you got only the keys you expect).
Now confirm from your laptop, before changing anything else:
ssh root@<IP1> hostnamectl
Once key login works, turn passwords off. Not because root is exposed today — prohibit-password
already covers that — but because it makes the safe setting the default for whatever comes later: a
user account added next month, or an openssh-server-config-rootlogin dependency quietly flipping
PermitRootLogin to yes. It also means brute-force bots fail at method negotiation instead of
PAM, which keeps the logs readable.
/etc/ssh/sshd_config.d/*.conf is included by the shipped sshd_config, and /etc is writable on
MicroOS, so this needs no transactional-update:
echo 'PasswordAuthentication no' > /etc/ssh/sshd_config.d/60-no-passwords.conf
sshd -t && systemctl reload sshd
Keep one console session open until you have confirmed a new SSH login works.
Part 5. First boot sanity checks — on every node
hostnamectl set-hostname k8s-1 # k8s-2 / k8s-3 on the others; node name comes from here
getenforce # Enforcing
findmnt -no OPTIONS / # ro,... -> root is read-only, as intended
snapper list # transaction history
timedatectl # NTP must be active; etcd hates clock drift
getenforce should say Enforcing with nothing done to arrange it: the MicroOS installer selects
SELinux in enforcing mode and makes AppArmor unselectable
(control.MicroOS.xml),
and the pre-built images boot with security=selinux selinux=1 and rewrite /etc/selinux/config
to match. The wiki's older transactional-update setup-selinux instruction is a Kubic-era leftover
and is not needed. If a node does report Disabled, stop and fix that first — the restorecon
steps in Part 4 and the rke2-selinux policy both assume it.
Network
Current MicroOS uses NetworkManager. Confirm it before configuring anything, because a wicked-based system needs different commands (and the forwarding sysctls in Part 6 become mandatory rather than merely recommended):
systemctl is-active NetworkManager wicked
nmcli device status # device names: ens3, eth0, ...
nmcli -g NAME,DEVICE connection show # connection names, and which device each one drives
IPv4 on a KVM VPS is normally DHCP and needs nothing.
IPv6 must work on every node — the cluster is dual-stack. Check what the provider already did:
ip -6 addr show ens3 | grep 'scope global' # your NIC name; a global address means SLAAC/DHCPv6 worked
ip -6 route show default # ... and a default route via fe80::1 or similar
ping -6 -c3 2606:4700:4700::1111 # ... and it actually reaches the internet
If all three succeed, NetworkManager has done the work already. Otherwise the provider expects
static configuration — and given a /64 such as 2001:db8:1234:5678::/64, pick an address inside it.
My servers use the :: address of the block itself, and that has worked fine; strictly,
RFC 4291 reserves the all-zeros interface ID as the subnet-router anycast address, so ::1 is
the more conventional choice.
To set it statically, note that nmcli con mod takes a connection name, not the device name —
with a NIC called ens3 the connection is usually called ens3 too, but on a cloud image it is
just as often Wired connection 1. Ask for it instead of guessing:
CON=$(nmcli -g GENERAL.CONNECTION device show ens3); echo "$CON"
nmcli con mod "$CON" \
ipv6.method manual \
ipv6.addresses <IPV6_1>/64 \
ipv6.gateway fe80::1
nmcli con up "$CON"
Confirm the gateway against the panel before applying — fe80::1 is a common convention, not a
rule, and a wrong gateway gives you an address with no route. Then re-run the three checks.
Finally, make sure every node can reach the other two on both families, and that all three
agree on the time (timedatectl from the checks above):
for ip in <IP1> <IP2> <IP3>; do ping -c 3 -q "$ip"; done
for ip in <IPV6_1> <IPV6_2> <IPV6_3>; do ping6 -c 3 -q "$ip"; done
How transactional updates work
Every change to the OS goes into a new snapshot that only becomes active after a reboot. If the
node does not come up healthy, health-checker rolls it back on its own:
flowchart LR
run["Running system<br/>snapshot N, read-only /usr"] -->|"transactional-update<br/>dup / pkg install"| next["Snapshot N+1<br/>prepared, not active yet"]
next -->|reboot| boot["Boot into N+1"]
boot --> hc{"health-checker:<br/>node healthy?"}
hc -->|yes| ok["N+1 is the running system"]
hc -->|no| rb["Automatic rollback to N"]
keep[("/var and /etc changes<br/>are not rolled back")]
One update cycle on MicroOS.
The commands you will use:
transactional-update dup # update the whole system into a new snapshot
transactional-update pkg install <pkg> # install a package into a new snapshot
transactional-update -c pkg install <p> # ... stacked on top of the pending snapshot
transactional-update shell # interactive shell inside a new snapshot
transactional-update apply # activate without rebooting (services only)
transactional-update rollback last # go back
reboot # every change needs one
Part 6. Prepare each node for RKE2
Everything in this part is per-node — run it on all three.
NetworkManager must ignore CNI interfaces
Otherwise it fights Calico over routes:
cat > /etc/NetworkManager/conf.d/rke2-canal.conf <<'EOF'
[keyfile]
unmanaged-devices=interface-name:flannel*;interface-name:cali*;interface-name:tunl*;interface-name:vxlan.calico;interface-name:vxlan-v6.calico;interface-name:wireguard.cali;interface-name:wg-v6.cali
EOF
systemctl reload NetworkManager
Nothing installs this file for you — rpm -ql rke2-common lists no NetworkManager drop-in, and
neither does the install script. The content is copied verbatim from RKE2's
known issues → NetworkManager; the file name
refers to canal, RKE2's default CNI (Flannel for the datapath plus Calico for policy).
With cni: calico there is no Flannel on the node at all — Calico does both jobs — so
interface-name:flannel* matches nothing. Leave it in anyway: it costs nothing and keeps the file
byte-identical to the documented one if you ever switch CNI. The entries doing the real work on a
Calico node are cali*, tunl* (IPIP), vxlan*.calico (VXLAN) and wireguard.cali / wg-v6.cali
(encryption).
Sysctls
Forwarding plus inotify headroom, applied at the node level rather than through a privileged DaemonSet:
cat > /etc/sysctl.d/90-rke2.conf <<'EOF'
net.ipv4.conf.all.forwarding = 1
net.ipv6.conf.all.forwarding = 1
fs.inotify.max_user_instances = 8192
fs.inotify.max_user_watches = 524288
EOF
sysctl --system
One package: semanage
policycoreutils-python-utils provides semanage for the next step. MicroOS does not ship it:
transactional-update pkg install policycoreutils-python-utils
reboot
Label Calico's state directory for SELinux
Calico keeps state in /var/lib/calico, which is outside RKE2's data directory — and
rke2-selinux only labels /var/lib/rancher/rke2, /opt/cni and /etc/cni. Left with the
default var_lib_t, the tigera-operator cannot read the MTU file that calico-node writes there.
calico-node runs privileged and is not affected, but the operator runs as an ordinary container
(container_t) and reads the directory through a hostPath mount, so every reconcile fails:
calico False ... error reading network MTU: open /var/lib/calico/mtu: permission denied
On a fresh install everything downstream waits on it — tigerastatus shows gatewayapi: Waiting
for Installation Variant to be set. On a running cluster the same error can appear days later:
the operator is a single pod that can be rescheduled to any node, and as soon as it lands on one
without the label, calico goes degraded. kubectl -n tigera-operator get pods -o wide shows
where it runs, and that node's /var/log/audit/audit.log has the denial (comm="operator"
name="mtu"). (tiers: Waiting for Tigera API server to be ready is unrelated and expected with
RKE2 — see Part 18.)
Fix it with a policy rule rather than a one-off chcon, so it also survives a full relabel after a
selinux-policy update and the directory being recreated:
mkdir -p /var/lib/calico
semanage fcontext -a -t container_file_t '/var/lib/calico(/.*)?'
restorecon -RFv /var/lib/calico
ls -Zd /var/lib/calico # ...:container_file_t:...
restorecon -nv /var/lib/calico # silence: a future relabel leaves it alone
# on a cluster that is already running: no restart needed, the operator retries every 5 minutes
kubectl get tigerastatus # calico: AVAILABLE True, DEGRADED False
firewalld must not run
It periodically reloads and flushes chains it does not recognise, taking Calico's and kube-proxy's rules with them; RKE2 says to disable it. If it is absent — which it may well be, depending on the system role — that is the finished state, not something to install. The check below only catches a node where something pulled it in:
rpm -q firewalld && systemctl disable --now firewalld
Disabling it does not leave the node unfiltered at the kernel level — Calico and kube-proxy still program iptables/nftables directly. It does mean the ports table below has to be enforced somewhere, in this order of preference:
- The provider's firewall, if it offers one. Filtering before the packet reaches the NIC cannot be flushed by anything running on the node.
- Calico host-endpoint policy —
HostEndpointplusGlobalNetworkPolicy, which is what RKE2's note means by "custom firewall rules can be implemented through Calico resources". Rules live in Git with everything else. Two things to know before treating it as the perimeter: it only filters while Felix is running (so not during boot, a crash, or a Calico upgrade), and Felix's failsafe ports are allowed no matter what your policy says — by defaulttcp:22,udp:68,tcp:179,tcp:2379,tcp:2380,tcp:5473,tcp:6443,tcp:6666,tcp:6667inbound, which leaves etcd and the API reachable from anywhere. NarrowingfailsafeInboundHostPortsis possible, but a mistake there locks you out of all three nodes at once. - Plain nftables in
/etc/nftables.conf— fine, but add your own table and never flush the ruleset, or you reproduce the firewalld problem by hand.
Ports
Three public VPS means the control plane talks over the internet. Everything RKE2 opens between servers is TLS with mutual certificate auth (etcd peers, the supervisor, the apiserver, kubelet), so the risk is exposure and abuse rather than eavesdropping — but the cluster ports still have no business being reachable from anywhere except the other two nodes.
Restrict at the provider's firewall: allow the peer IPs to everything below, the world to nothing but 22/80/443, and your admin IPs to 6443. Write every rule twice — once for IPv4, once for IPv6. The cluster is dual-stack and the API listens on IPv6 too; an IPv4-only rule set filters nothing on IPv6.
| Port | Proto | Source |
|---|---|---|
| 22 | TCP | your admin IPs |
| 80 / 443 | TCP | the internet (your gateway) |
| 6443 | TCP | the other nodes + your admin IPs (Kubernetes API) |
| 9345 | TCP | the other nodes only (RKE2 supervisor / registration) |
| 2379, 2380, 2381 | TCP | the other nodes only (etcd client, peer, metrics) |
| 10250 | TCP | the other nodes only (kubelet) |
| 30000–32767 | TCP | the other nodes only (NodePort range) |
| 4789 | UDP | the other nodes only (Calico VXLAN, both pools) |
| 179 | TCP | the other nodes only (only if you switch Calico to BGP) |
| 51820, 51821 | UDP | the other nodes only (Calico WireGuard v4 / v6, Part 12) |
Source: RKE2 inbound network rules. Pod traffic on 4789 is not encrypted by default — that is what Part 12 fixes.
Part 7. Install RKE2 on the first server
Install the RPM on all three nodes now — only the config files differ.
The repository, not the install script
get.rke2.io is a wrapper around the two supported methods, and on MicroOS it picks the wrong one:
the docs state plainly that "SLE Micro and MicroOS default to a tarball installation". A tarball
lands in /opt/rke2 (because /usr/local is read-only) and sits outside the update stream. So add
the repository directly, exactly as
the MicroOS tab of the install docs
describes:
export RKE2_MINOR=35
cat << EOF > /etc/zypp/repos.d/rancher-rke2-1-${RKE2_MINOR}-stable.repo
[rancher-rke2-common-stable]
name=Rancher RKE2 Common Stable
baseurl=https://rpm.rancher.io/rke2/stable/common/microos/noarch
enabled=1
autorefresh=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://rpm.rancher.io/public.key
[rancher-rke2-1-${RKE2_MINOR}-stable]
name=Rancher RKE2 1.${RKE2_MINOR} Stable
baseurl=https://rpm.rancher.io/rke2/stable/1.${RKE2_MINOR}/microos/x86_64
enabled=1
autorefresh=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://rpm.rancher.io/public.key
EOF
MicroOS is immutable, so the install goes through transactional-update and needs a reboot:
transactional-update --no-selfupdate -d run zypper --gpg-auto-import-keys install -y rke2-server
reboot
(rke2-agent instead of rke2-server on agent nodes — see Part 11.)
Three deliberate differences from the docs' snippet:
stableinstead oflatest. The docs use thelatestchannel;stableis the channel Rancher recommends running in production, and it is whatget.rke2.iodefaults to.- The minor is pinned in the repo URL (
1.35). That is what keepstransactional-update dupon patch releases only — a minor upgrade becomes a deliberate act of editing this file. Use the same minor on all three nodes. autorefresh=1. A.repofile without it defaults to no auto-refresh, andtransactional-update dupdoes not refresh by itself — the node keeps resolving against the metadata it cached at install time and never sees a new patch release, while the OS repos (which do auto-refresh) keep updating and kured keeps rebooting. Nothing looks broken; RKE2 just silently stays on the version you installed.
Verify before moving on — an RPM install answers rpm -q, a tarball install leaves /opt/rke2:
rpm -q rke2-server rke2-common rke2-selinux
zypper lr -u | grep rancher # both repos, pointing at .../microos/...
ls /opt/rke2 2>/dev/null && echo "tarball install — not what we want on MicroOS"
rke2-selinux pulling in as a dependency is the sign the RPM path worked; the tarball never
installs the policy.
The shared token
Generate it once, on your laptop, and use the same value on all three servers:
openssl rand -hex 32
(If you skip token:, RKE2 generates one on the first server. Either way the value in use ends up
in /var/lib/rancher/rke2/server/token, in the secure K10<CA hash>::<credentials> format that lets
a joining node verify the cluster before sending credentials.)
Back it up with your etcd snapshots. The server token doubles as the PBKDF2 passphrase for the bootstrap data in the datastore, so a snapshot can only be restored with the token that was in use when it was taken.
If a node ends up with the wrong one:
- Server 1, already started — nothing is broken; the token is a random secret, not a name. Either
adopt what it generated (
cat /var/lib/rancher/rke2/server/token) and use that on the other two, or rotate to the value you meant:rke2 token rotate --token <OLD> --new-token <NEW>, then updateconfig.yamland restart every server and agent — the old token stops working (docs). -
A joining server — the join simply fails, nothing reached the cluster. Fix
config.yaml, then clear the state it cached before retrying:rke2-killall.sh # stops containers and processes; deletes nothing rm -rf /var/lib/rancher/rke2 # db, certs, token, images — /etc/rancher/rke2 is untouched systemctl start rke2-serverA node that never joined holds no unique state, so the whole data directory can go — but never run this on a surviving etcd member, where that directory is the cluster.
If it got as far as appearing in
kubectl get nodes,kubectl delete node <name>first — the etcd member goes with it.
Config for server 1
mkdir -p /etc/rancher/rke2
cat > /etc/rancher/rke2/config.yaml <<'EOF'
token: <SHARED_TOKEN>
write-kubeconfig-mode: "0600"
# names — not addresses — that clients use to reach the API
tls-san:
- k8s.example.com
- k8s-1.example.com
- k8s-2.example.com
- k8s-3.example.com
# dual-stack: identical on all three servers
cluster-cidr: "10.42.0.0/16,fd00:42::/56"
service-cidr: "10.43.0.0/16,fd00:43::/112"
# this node's own addresses, v4 first
node-ip: "<IP1>,<IPV6_1>"
node-external-ip: "<IP1>,<IPV6_1>"
cni: calico
ingress-controller: none # ingress comes from a Gateway API gateway (Part 13)
selinux: true # containerd labels containers; not set by the RPM
etcd-snapshot-schedule-cron: "0 */6 * * *"
etcd-snapshot-retention: 20
EOF
chmod 600 /etc/rancher/rke2/config.yaml
Notes on the choices:
-
cni: calico— the CNI my previous cluster already used, and it earns its place twice over: NetworkPolicy enforcement (RKE2's defaultcanalgets that from Calico anyway, but Calico alone does routing and policy in one component), and Gateway API — the Tigera operator ships aGatewayAPICR that stands up an Envoy data plane, so the ingress layer comes from the CNI instead of being a separate controller to install and upgrade. Valid values arenone,calico,canal(default),cilium,flannel. -
ingress-controller: none— RKE2 v1.35 still packages ingress-nginx, and v1.36 switches the default to Traefik. Turn it off if you route with Gateway API. -
disable:acceptsrke2-coredns,rke2-metrics-server,rke2-snapshot-controller,rke2-snapshot-controller-crd,rke2-snapshot-validation-webhook. Only disable the snapshot trio if you don't do CSI volume snapshots. -
No image-GC tuning. kubelet's defaults (evict images above 85% of the image filesystem, down to 80%) are fine. Images live under
/var, which is a separate partition from the root and its snapshots (Part 4), so a full image filesystem cannot starvetransactional-update. Check the layout and the usage on your nodes:df -h / /var crictl imagefsinfoIf you do want different thresholds, set them as a config key —
kubelet-arg: ["image-gc-high-threshold=70", "image-gc-low-threshold=60"]— which survives reboots and upgrades, unlike editing/var/lib/kubelet/config.yamlby hand. -
Critical configuration values.
cluster-cidr,service-cidr,cluster-dns,cluster-domain,egress-selector-mode,disable-kube-proxyanddisable-cloud-controllerbecome critical configuration values once you override them, and they must then be identical on all three servers or the join fails withcritical configuration value mismatch. The dual-stack config above overridescluster-cidrandservice-cidr, so copy those two lines byte for byte. -
tls-sanholds names, not IPs. Each server already puts its own addresses in its serving certificate automatically —127.0.0.1,::1,localhost, the node name and short name, everynode-ip, everynode-external-ip, and the apiserver Service IP (k3spkg/cli/server/server.go, the codebase RKE2 shares). Listing node IPs would duplicate that and pin the certificate to addresses you may want to change — see "Replacing a node" in Part 14. -
node-external-ipmirrorsnode-ipbecause these addresses are public. It is what putsExternalIPin the node'sstatus.addresses, and it is the value that stays correct ifnode-ipever moves to a private network.
Dual-stack: pod and Service IPv6
Dual-stack is a create-time decision — RKE2 cannot enable it later on a cluster that started IPv4-only — so it goes in the config now, on all three servers.
cluster-cidr and service-cidr take a comma-separated pair; node-ip and node-external-ip take
each node's own pair:
cluster-cidr: "10.42.0.0/16,fd00:42::/56"
service-cidr: "10.43.0.0/16,fd00:43::/112"
node-ip: "<IP1>,<IPV6_1>"
node-external-ip: "<IP1>,<IPV6_1>"
Do not set bind-address: "::". RKE2 polls its own API server at whatever bind address you
configure, and no certificate can carry the unspecified address as a SAN, so the server never
reaches ready:
Polling for API server readiness: GET /readyz failed: ... x509: certificate is valid for
127.0.0.1, ::1, <IP1>, <IPV6_1>, 10.43.0.1, not ::
Left unset, the poll uses loopback — which is in the cert — and you get the IPv6 listener anyway: both the API and the supervisor come up on dual-stack sockets by default, serving IPv4 and IPv6 from one socket each. Nothing needs configuring for that; see the check after the first start.
The pod and Service ranges are ULA (fd00::/8) with natOutgoing, so they need no
cooperation from the provider; keep the /56 and /112 sizes, which are the shapes RKE2's own
example uses.
Pin VXLAN on both pools — the operator rejects IPIP for IPv6 but accepts VXLAN, and explicit pools beat inherited defaults. Drop this on the first server before its first start. It becomes a cluster object, so servers 2 and 3 join with it already in place and need no copy; keep the file on that one server and make every later change there (Part 12 adds WireGuard to it):
mkdir -p /var/lib/rancher/rke2/server/manifests
cat > /var/lib/rancher/rke2/server/manifests/rke2-calico-config.yaml <<'EOF'
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: rke2-calico
namespace: kube-system
spec:
valuesContent: |-
installation:
calicoNetwork:
bgp: Disabled
ipPools:
- cidr: 10.42.0.0/16
encapsulation: VXLAN
natOutgoing: Enabled
blockSize: 26
- cidr: fd00:42::/56
encapsulation: VXLAN
natOutgoing: Enabled
blockSize: 122
EOF
First start
You should already be rebooted into the snapshot that contains RKE2 (the install step ends with
reboot, and rke2-server must not start before the SELinux policy is loaded). Start the first
server:
systemctl enable --now rke2-server.service
journalctl -u rke2-server -f
The first start takes a few minutes: it extracts the runtime image, brings up etcd, the static
control-plane pods, then Calico and CoreDNS. Wait for the node to go Ready:
export PATH=$PATH:/var/lib/rancher/rke2/bin KUBECONFIG=/etc/rancher/rke2/rke2.yaml
kubectl get nodes
Check what the API is listening on:
ss -lntp '( sport = :6443 or sport = :9345 )'
curl -k https://[<IPV6_1>]:6443/version
Read the address column carefully — it tells you the family without any guessing:
| Shown | Meaning |
|---|---|
0.0.0.0:6443 |
IPv4 only |
*:6443 |
one socket serving both families — what you want, and the default |
[::]:6443 |
IPv6 only |
On my nodes both 6443 and 9345 show up as *.
Check the IP pools before joining anything else — two pools, both VXLAN, and pods with an address from each:
kubectl get ippools.crd.projectcalico.org \
-o custom-columns='NAME:.metadata.name,CIDR:.spec.cidr,VXLAN:.spec.vxlanMode,NAT:.spec.natOutgoing'
kubectl -n kube-system get pods -l k8s-app=kube-dns \
-o custom-columns='NAME:.metadata.name,IPS:.status.podIPs[*].ip'
Only one pool, or a pool with vxlanMode: Never, means the HelmChartConfig did not apply —
kubectl -n kube-system get helmchartconfig rke2-calico should exist, and its name must match the
chart exactly. Fix it and rebuild this node: the CIDRs cannot be changed once the cluster is
running, so this is far cheaper now than after servers 2 and 3 have joined.
Part 8. Join servers 2 and 3
Same repo file, same transactional-update install, same reboot — no Calico pool
manifest, it is already in the cluster — plus one extra line in the config: server:, pointing at a specific running server, not at
k8s.example.com.
The round-robin name resolves to all three nodes, including the one that is joining. If it picks itself, the new server asks itself for the etcd member list, is not a member yet, and loops:
Sending HTTP/1.1 500 response to [<its own address>]: failed to get etcd MemberList: context deadline exceeded
Waiting to retrieve etcd cluster member list: ...
sequenceDiagram
participant J as k8s-2, joining
participant RR as k8s.example.com
participant S1 as k8s-1, running
alt server points at the round-robin name
J->>RR: resolve
RR-->>J: any of the three addresses, sometimes its own
J->>J: ask itself for the etcd member list
J-->>J: 500 failed to get etcd MemberList, retry forever
else server points at k8s-1.example.com
J->>S1: join with the shared token
S1-->>J: etcd member list, k8s-2 becomes a member
end
The self-join trap, and the one-line fix.
Point server: at k8s-1.example.com (or node 1's literal address, which its certificate covers)
and the problem cannot occur. Once a node has joined, server: is ignored on later starts — etcd
data on disk decides — so there is nothing to change back.
Do them one at a time, waiting for each to reach Ready before starting the next — etcd should
only ever add one member at a time.
On k8s-2 (and then k8s-3, with its own IP):
mkdir -p /etc/rancher/rke2
cat > /etc/rancher/rke2/config.yaml <<'EOF'
server: https://k8s-1.example.com:9345 # a specific live server, NOT the round-robin name
token: <SHARED_TOKEN>
write-kubeconfig-mode: "0600"
tls-san:
- k8s.example.com
- k8s-1.example.com
- k8s-2.example.com
- k8s-3.example.com
cluster-cidr: "10.42.0.0/16,fd00:42::/56"
service-cidr: "10.43.0.0/16,fd00:43::/112"
node-ip: "<IP2>,<IPV6_2>"
node-external-ip: "<IP2>,<IPV6_2>"
cni: calico
ingress-controller: none
selinux: true
etcd-snapshot-schedule-cron: "0 */6 * * *"
etcd-snapshot-retention: 20
EOF
chmod 600 /etc/rancher/rke2/config.yaml
systemctl enable --now rke2-server.service
journalctl -u rke2-server -f
Everything except server:, node-ip and node-external-ip is identical across the three files —
cluster-cidr and service-cidr especially, since a mismatch there is what produces
critical configuration value mismatch. Diff them if a join misbehaves.
Because server: is ignored once a node has etcd data on disk, re-pointing a node at a different
cluster requires wiping /var/lib/rancher/rke2/server/db.
When a join gets stuck
The join is a sequence, and each step fails differently. Read the joining node's log first, then the server it is joining.
| What the joining node logs | Where it got to |
|---|---|
failed to validate token / 401 |
Rejected at the supervisor. Nothing reached the cluster. |
no bootstrap data found in datastore - check server token value |
Reached the datastore with a token that does not match — usually stale local state from an earlier cluster. |
Waiting to retrieve etcd cluster member list in a loop |
Bootstrap succeeded; it cannot get a member list. |
For the third case, the 500s in the log name the address being asked. Compare it with the node's
own node-ip:
getent hosts k8s.example.com # does the round-robin set include this node itself?
grep ^server: /etc/rancher/rke2/config.yaml
If it is asking itself, that is the round-robin trap above. If it is asking another node, check that node's etcd health and the peer ports:
# on the node being joined
curl -s http://127.0.0.1:2381/metrics | grep '^etcd_server_has_leader'
journalctl -u rke2-server | grep -iE 'member|learner|adding' | tail
No member-add line there means the joining node never asked. Meanwhile, on the joiner, check whether its own etcd was ever started:
export PATH=$PATH:/var/lib/rancher/rke2/bin CRI_CONFIG_FILE=/var/lib/rancher/rke2/agent/etc/crictl.yaml
ls /var/lib/rancher/rke2/agent/pod-manifests/ # etcd.yaml written?
pgrep -af etcd # process running?
crictl logs $(crictl ps -aq --name etcd | head -1) 2>&1 | tail -40
Whatever the cause, the recovery is the same: wipe the node (the commands under "The shared token"
in Part 7) and start again with server: pointing at a member you know is healthy. Delete a
half-registered node object from the cluster first, so its etcd member goes with it.
Part 9. Verify the cluster is actually HA
kubectl get nodes -o wide # three nodes, all Ready
kubectl get nodes -l node-role.kubernetes.io/etcd=true # three etcd members
kubectl get pods -A # nothing stuck outside Running/Completed
etcd health, on each server — RKE2's etcd serves metrics on port 2381, so no client certs and no
etcdctl are needed:
curl -s http://127.0.0.1:2381/metrics | grep -E '^etcd_server_has_leader|^etcd_server_leader_changes_seen_total'
etcd_server_has_leader 1 on all three is the answer you want. A node reporting 0, or a
leader-changes counter that keeps climbing, means the members cannot talk to each other reliably —
check the peer port (2380) and the RTT between nodes before going further.
Dual-stack across nodes — the test that actually exercises IPv6 VXLAN, since a single node proves nothing about encapsulation:
kubectl run n1 --image=nicolaka/netshoot --overrides='{"spec":{"nodeName":"k8s-1"}}' -- sleep 3600
kubectl run n2 --image=nicolaka/netshoot --overrides='{"spec":{"nodeName":"k8s-2"}}' -- sleep 3600
kubectl exec n1 -- ping6 -c3 "$(kubectl get pod n2 -o jsonpath='{.status.podIPs[1].ip}')"
kubectl delete pod n1 n2
The real test is a failure test, and it is worth doing before you put anything important on the cluster. Reboot one node and watch from another:
# on k8s-3
kubectl drain k8s-3 --ignore-daemonsets --delete-emptydir-data
reboot
# from k8s-1, meanwhile:
kubectl get nodes -w # k8s-3 goes NotReady, the API keeps answering
# once it is back:
kubectl uncordon k8s-3
If the API stops answering while one node is down, you do not have an HA cluster — most likely your kubeconfig or registration address points at that single node.
Part 10. Access the cluster
On the nodes
RKE2 extracts kubectl, crictl and ctr into /var/lib/rancher/rke2/bin and puts its kubeconfig
in /etc/rancher/rke2/rke2.yaml, so none of them work out of the box on a node. crictl
additionally needs CRI_CONFIG_FILE to find RKE2's containerd socket. One drop-in on each node fixes all three for login shells:
cat > /etc/profile.d/rke2.sh <<'EOF'
export PATH=$PATH:/var/lib/rancher/rke2/bin
export KUBECONFIG=/etc/rancher/rke2/rke2.yaml
export CRI_CONFIG_FILE=/var/lib/rancher/rke2/agent/etc/crictl.yaml
EOF
From your laptop
Point the kubeconfig at the registration address, not at a single node — otherwise losing that one node locks you out of a perfectly healthy cluster:
ssh root@<IP1> cat /etc/rancher/rke2/rke2.yaml > ~/.kube/prod.yaml
sed -i 's|127.0.0.1|k8s.example.com|' ~/.kube/prod.yaml
KUBECONFIG=~/.kube/prod.yaml kubectl get nodes
That works because k8s.example.com is in tls-san. If you would rather not expose 6443 to the
internet at all, drop it from the provider's firewall and tunnel through one of the nodes:
ssh -L 6443:127.0.0.1:6443 root@<IP1>.
Part 11. Optional: agent (worker) nodes
Three servers already run workloads, so agents are only about capacity or isolation. I added one later. Same OS build, same Part 6 prep, then:
# same repo file as Part 7, then:
transactional-update --no-selfupdate -d run zypper --gpg-auto-import-keys install -y rke2-agent
reboot
mkdir -p /etc/rancher/rke2
cat > /etc/rancher/rke2/config.yaml <<'EOF'
server: https://k8s.example.com:9345
token: <SHARED_TOKEN>
node-ip: "<IP4>,<IPV6_4>"
node-external-ip: "<IP4>,<IPV6_4>"
selinux: true
EOF
chmod 600 /etc/rancher/rke2/config.yaml
systemctl enable --now rke2-agent.service # note: agent, not server
"Same Part 6 prep" means all of it — including the semanage label on /var/lib/calico. calico-node
runs on agents too, and the tigera-operator can be scheduled onto an agent like any other pod;
an unlabelled directory there puts calico into the degraded permission denied state from
Part 6. That is exactly what happened on my agent: the label was missed, nothing showed until the
operator pod was rescheduled onto it, and from then on calico stayed degraded for eight days.
Everything node-level applies to agents as well: REBOOT_METHOD=kured (Part 14), and the Longhorn
packages (Part 16) if volumes will land there. The unprivileged-port sysctl (Part 13) is needed only
where Envoy runs; with the node affinity shown in Part 13, that is the three servers. Add the agent's
addresses, both families, to the node-to-node firewall rules, and to the app hostnames' A/AAAA
records if it serves traffic.
The round-robin name is fine for server: here: an agent is not behind that name and never asks for
the etcd member list, so the self-join loop from Part 8 cannot happen.
If you later want servers to stop running workloads, taint all three with
node-taint: ["CriticalAddonsOnly=true:NoExecute"] — but only after agents exist to take over.
Part 12. Encrypt pod traffic between nodes
Control-plane traffic between servers is already mutually authenticated TLS. Pod traffic is not: with VXLAN it is plain UDP 4789 across the public internet. Calico closes that with WireGuard — below, with the evidence at each step.
flowchart LR
subgraph before["Before"]
a1["pod n1 on k8s-1"] -->|"VXLAN, UDP 4789<br/>payload readable on the wire"| a2["pod n2 on k8s-2"]
end
subgraph after["After wireguardEnabled + wireguardEnabledV6"]
b1["pod n1 on k8s-1"] -->|"WireGuard, UDP 51820 v4 / 51821 v6<br/>nothing readable"| b2["pod n2 on k8s-2"]
end
Pod-to-pod traffic between nodes, before and after enabling WireGuard in Calico.
Tools first; MicroOS ships neither:
transactional-update pkg install tcpdump wireguard-tools && reboot # on every node
Show that pod traffic is readable
Two pods on different nodes, and a payload you can grep for. ping -p takes pad bytes in hex and
repeats them to fill the ICMP payload, so feed it the ASCII bytes of a word — 574754455354 is
W G T E S T (printf 'WGTEST' | xxd -p). The packet body becomes WGTESTWGTEST…, which
tcpdump -A prints as text and grep can find:
kubectl run n1 --image=nicolaka/netshoot --overrides='{"spec":{"nodeName":"k8s-1"}}' -- sleep 3600
kubectl run n2 --image=nicolaka/netshoot --overrides='{"spec":{"nodeName":"k8s-2"}}' -- sleep 3600
kubectl wait --for=condition=Ready pod/n1 pod/n2
P4=$(kubectl get pod n2 -o jsonpath='{.status.podIPs[0].ip}')
P6=$(kubectl get pod n2 -o jsonpath='{.status.podIPs[1].ip}')
Capture on k8s-1 into files, bounded by time rather than packet count — tcpdump -c N waits for N
matching packets and a pipe buffers, so a live | grep usually just appears to hang:
# on k8s-1 — both captures run for 30s in the background
timeout 30 tcpdump -ni ens3 -A -l 'ip and udp port 4789' >/tmp/v4.txt 2>/dev/null &
timeout 30 tcpdump -ni ens3 -A -l 'ip6 and udp port 4789' >/tmp/v6.txt 2>/dev/null &
# from your laptop, immediately
kubectl exec n1 -- ping -c 20 -p 574754455354 "$P4"
kubectl exec n1 -- ping6 -c 20 -p 574754455354 "$P6"
# on k8s-1, once the captures exit
grep -c WGTEST /tmp/v4.txt /tmp/v6.txt
Both counts are non-zero: the marker is coming straight out of the VXLAN payload, on both families, on a public network.
Zero counts mean the experiment is not measuring what you think. Check, in order: that any VXLAN is
flowing at all (timeout 15 tcpdump -ni ens3 -c 5 'udp port 4789' while pinging), that WireGuard is
not already enabled
(kubectl get felixconfiguration default -o jsonpath='{.spec.wireguardEnabled}'), and that the two
pods really landed on different nodes (kubectl get pod n1 n2 -o wide).
Enable WireGuard
Open UDP 51820 (v4) and 51821 (v6) between all nodes at the provider's firewall first — Calico
uses one port per family — and remember the v6 rules, so the tunnels can come up as soon as the keys
appear. wireguard.cali and wg-v6.cali are already in the NetworkManager unmanaged-devices list
from Part 6.
modinfo wireguard >/dev/null && echo ok # in-tree since 5.6; MicroOS is well past that
Turn it on through the same HelmChartConfig that pins the IP pools (Part 7) — on k8s-1, where that
file lives — and not with kubectl patch:
# on k8s-1
cat > /var/lib/rancher/rke2/server/manifests/rke2-calico-config.yaml <<'EOF'
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: rke2-calico
namespace: kube-system
spec:
valuesContent: |-
felixConfiguration:
wireguardEnabled: true
wireguardEnabledV6: true
installation:
calicoNetwork:
bgp: Disabled
ipPools:
- cidr: 10.42.0.0/16
encapsulation: VXLAN
natOutgoing: Enabled
blockSize: 26
- cidr: fd00:42::/56
encapsulation: VXLAN
natOutgoing: Enabled
blockSize: 122
EOF
kubectl get felixconfiguration default \
-o jsonpath='v4={.spec.wireguardEnabled} v6={.spec.wireguardEnabledV6}{"\n"}' # v4=true v6=true
The reason for the detour: the rke2-calico chart renders its own FelixConfiguration default, and
its values set wireguardEnabled: false. On every RKE2 upgrade Helm re-applies the chart and takes
that field back, so a kubectl patch lasts only until the next upgrade. Values under
felixConfiguration in the HelmChartConfig go into that same rendered object, so the chart itself
keeps WireGuard on. The managed fields show who owns what:
kubectl get felixconfiguration default -o yaml --show-managed-fields | sed -n '/managedFields/,/^ name:/p'
I learned this the hard way: I had enabled WireGuard with kubectl patch, and the next RKE2 upgrade
quietly set wireguardEnabled back to false. Nothing broke and nothing warned — IPv4 pod traffic
simply went back to crossing the internet unencrypted. IPv6 stays encrypted only because the
chart does not set wireguardEnabledV6, which makes the half-off state even easier to miss.
Each node should now publish a key per family:
kubectl get nodes -o custom-columns=\
'NAME:.metadata.name,WG-V4:.metadata.annotations.projectcalico\.org/WireguardPublicKey,WG-V6:.metadata.annotations.projectcalico\.org/WireguardPublicKeyV6'
Same commands, different result
Repeat the capture exactly — same pods, same pings, same filters:
# on k8s-1 — same two captures as before, plus one on the tunnel ports
timeout 30 tcpdump -ni ens3 -A -l 'ip and udp port 4789' >/tmp/v4-after.txt 2>/dev/null &
timeout 30 tcpdump -ni ens3 -A -l 'ip6 and udp port 4789' >/tmp/v6-after.txt 2>/dev/null &
timeout 30 tcpdump -ni ens3 -l 'udp port 51820 or udp port 51821' >/tmp/wg.txt 2>/dev/null &
# from your laptop, immediately
kubectl exec n1 -- ping -c 20 -p 574754455354 "$P4"
kubectl exec n1 -- ping6 -c 20 -p 574754455354 "$P6"
# on k8s-1, once the captures exit
grep -c WGTEST /tmp/v4-after.txt /tmp/v6-after.txt # both 0
wc -l /tmp/wg.txt # the traffic moved here
| v4 pod traffic | v6 pod traffic | |
|---|---|---|
| Before | VXLAN on ip and udp 4789, payload greppable |
VXLAN on ip6 and udp 4789, payload greppable |
| After | udp 51820, nothing greppable |
udp 51821, nothing greppable |
Two more confirmations, from a node:
ip route get "$P4" # dev wireguard.cali
ip -6 route get "$P6" # dev wg-v6.cali
wg show wireguard.cali # the other nodes as peers, recent handshake, non-zero transfer
wg show wg-v6.cali
A peer with no handshake means 51820/51821 is blocked between those two nodes. Clean up the test pods
with kubectl delete pod n1 n2.
What this does not cover: wireguardEnabled protects pod traffic. Node-to-node control-plane
traffic — etcd, the supervisor, kubelet — keeps its own mutual TLS and does not ride the tunnel.
If your provider offers a private network between instances, using it for node-ip is the other
valid answer, and cheaper on CPU. It is also a bigger change: every node-ip/tls-san value and
firewall rule moves to the private range, and the registration address has to stay reachable.
Part 13. Ingress: the Calico Gateway API data plane
There is no cloud load balancer here, so traffic has to arrive on the nodes themselves. The shape I
use — and the reason ingress-controller: none is in the server config — is Calico's Gateway API
support running Envoy as a host-network DaemonSet that binds 80/443 on each server. With three
nodes that is also an HA win: any of them can serve any hostname.
flowchart LR
client["Client"] --> cf["Cloudflare proxy<br/>app hostnames, orange cloud"]
cf -->|"A + AAAA records<br/>for all three nodes"| e1["k8s-1<br/>Envoy, hostNetwork :80 / :443"]
cf --> e2["k8s-2<br/>Envoy, hostNetwork :80 / :443"]
cf --> e3["k8s-3<br/>Envoy, hostNetwork :80 / :443"]
e1 --> pods["Service and pods<br/>on any node"]
e2 --> pods
e3 --> pods
Request path: any node that receives the request has an Envoy listening.
The bundled Calico is new enough to do this without installing Envoy Gateway yourself. RKE2 v1.35
ships Calico v3.32 (charts/chart_versions.yaml), and the v3.32 operator includes the
gatewayapis.operator.tigera.io CRD:
kubectl get crd gatewayapis.operator.tigera.io
Let non-root Envoy bind 80/443 — on every node that runs it
Envoy runs as uid 65532 with all capabilities dropped, so binding privileged ports needs a node sysctl rather than a capability grant:
cat > /etc/sysctl.d/99-unprivileged-ports.conf <<'EOF'
net.ipv4.ip_unprivileged_port_start=0
EOF
sysctl --system
sysctl net.ipv4.ip_unprivileged_port_start # expect: ... = 0
Despite the ipv4 in its name, this one setting covers IPv6 sockets too — there is no net.ipv6
counterpart. sysctl net.ipv6.ip_unprivileged_port_start answers cannot stat
/proc/sys/net/ipv6/ip_unprivileged_port_start: No such file or directory, and a line for it in the
file only makes sysctl report that error. With just the IPv4 line, Envoy on my nodes listens on
*:443, which is both families.
Also make sure nothing else already owns those ports — an old ingress-nginx, or a stray web server:
ss -lntp '( sport = :80 or sport = :443 )' # expect no output before the gateway exists
If something holds them, the Envoy pods CrashLoop with address already in use.
Enable Gateway API in the operator
Two steps, in this order. The first creates the tigera-gateway namespace and installs the Gateway
API and Envoy Gateway CRDs; only then does the EnvoyProxy kind exist for the second step:
kubectl apply -f - <<'EOF'
apiVersion: operator.tigera.io/v1
kind: GatewayAPI
metadata:
name: default
EOF
kubectl wait --for=condition=established --timeout=120s \
crd/envoyproxies.gateway.envoyproxy.io crd/gatewayclasses.gateway.networking.k8s.io
kubectl get ns tigera-gateway
Apply the host-network data plane
gateway.yaml holds both pieces: an EnvoyProxy that turns the deployment into a host-network
DaemonSet, plus the GatewayAPI update that points tigera-gateway-class at it:
# gateway.yaml
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
name: host-network
namespace: tigera-gateway
spec:
# Without this every listener binds 0.0.0.0 only, and IPv6 clients get "Connection refused".
ipFamily: DualStack
provider:
type: Kubernetes
kubernetes:
# Bind 80/443 as the container port instead of the default offset (80 -> 10080).
useListenerPortAsContainerPort: true
# Run Envoy as a DaemonSet, one per node.
envoyDaemonSet:
# No native hostNetwork/dnsPolicy fields exist on EnvoyProxy, so merge them
# straight into the generated DaemonSet pod template.
patch:
type: StrategicMerge
value:
spec:
template:
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
# Envoy stays non-root (uid 65532, no capabilities); the
# ip_unprivileged_port_start sysctl is what lets it bind 80/443.
# Run Envoy only on the nodes that are in the app hostnames' DNS.
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: ["k8s-1", "k8s-2", "k8s-3"]
# No cloud LB; hostNetwork carries the traffic.
envoyService:
type: ClusterIP
---
# Updates the existing GatewayAPI CR named `default`: render tigera-gateway-class
# as a DaemonSet with the EnvoyProxy customization above.
apiVersion: operator.tigera.io/v1
kind: GatewayAPI
metadata:
name: default
spec:
gatewayClasses:
- name: tigera-gateway-class
gatewayKind: DaemonSet
envoyProxyRef:
name: host-network
namespace: tigera-gateway
What the pieces do:
hostNetwork: trueanddnsPolicy: ClusterFirstWithHostNetput Envoy on the node's own network while keeping cluster DNS.useListenerPortAsContainerPort: true— otherwise Envoy listens on 10080/10443.ipFamily: DualStack— without it, every listener silently binds0.0.0.0only (the CRD's own default), no error anywhere, and IPv6 clients get a cleanConnection refused. Easy to miss:ss -lntpon the node looks completely normal, just IPv4-only, and the failure only shows up when someone actually testscurl -6.- The node affinity keeps Envoy on the nodes that are in the application DNS records. Other nodes, such as an agent, still run workloads; the Envoys reach their pods over the cluster network. A new node gets no Envoy until it is added both here and to DNS.
kubectl apply -f gateway.yaml
Verify what exists at this stage — the controller and the class, not a data plane:
kubectl get tigerastatus
kubectl get gatewayclass tigera-gateway-class # ACCEPTED = True
kubectl -n tigera-gateway get envoyproxy # host-network, from the file above
kubectl -n tigera-gateway get pods # envoy-gateway-... Running (the controller)
There is no DaemonSet yet, and nothing is listening on 80/443. Envoy Gateway creates a proxy
DaemonSet per Gateway resource, so the data plane appears only once a Gateway using
tigera-gateway-class exists — which is the next step.
The Gateway resource
Only the operator-level pieces above are applied by hand. The Gateway itself — listeners,
hostnames, TLS secrets — lives in Git and arrives with everything else once Flux takes over
(Part 17). Trimmed to one application, it looks like this:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: main
namespace: tigera-gateway
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
gatewayClassName: tigera-gateway-class
listeners:
- name: http # plain HTTP and ACME HTTP-01 challenges
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
- name: app # one HTTPS listener per hostname
hostname: app.example.com
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: app-tls
allowedRoutes:
namespaces:
from: All
Once it exists, the data plane shows up and the host ports are bound:
kubectl -n tigera-gateway get ds -o wide # envoy-tigera-gateway-<name>-..., DESIRED = 3
kubectl -n tigera-gateway get pods -o wide # one per node
ss -lntp '( sport = :80 or sport = :443 )' # envoy, on each node
kubectl -n tigera-gateway get gateway main # PROGRAMMED = True
Certificates are minted by cert-manager's gateway-shim from the cert-manager.io/cluster-issuer
annotation on the Gateway, solving HTTP-01 over the same port-80 listener.
DNS for the hostnames you serve
Unlike the cluster endpoint in Part 3, application hostnames are plain HTTP/HTTPS, so Cloudflare's proxy is fine here — orange cloud, WAF and caching included. Point each hostname at all three nodes, A and AAAA: whichever node receives the request has an Envoy listening, and ACME HTTP-01 challenges resolve the same way.
The caveat is the same one round-robin DNS always has, and it applies to proxied records too: neither plain DNS nor the Cloudflare proxy health-checks your origins, so while a node is down a share of requests get connection errors or 5xx. Cloudflare Load Balancing (paid) adds origin health checks; without it, remove a node's A record while it is out for maintenance if you care.
Part 14. Day 2: updates and coordinated reboots
MicroOS runs transactional-update.timer daily: it builds a new snapshot and then wants a reboot.
Because the RKE2 RPM repo is enabled, RKE2 patch releases come with the same cycle — a node
reboot is also an RKE2 upgrade.
In a three-node cluster the danger is obvious: if all three reboot at 03:30, quorum is gone, so the reboots have to take turns. I use kured for that. Another option, with no cluster component, is rebootmgr with a different maintenance window on each node — but it does not drain, so pods are killed rather than evicted.
kured
transactional-update has a built-in reboot method for exactly this. Setting REBOOT_METHOD=kured
makes it touch /var/run/reboot-required instead of rebooting, which is kured's
default sentinel file — kured then takes a cluster-wide lock, cordons and drains the node, reboots
it, and only releases the lock when the node is back.
sequenceDiagram
participant TU as transactional-update
participant Node as k8s-2
participant Kured as kured
participant Lock as cluster-wide lock
TU->>Node: build new snapshot
TU->>Node: touch /var/run/reboot-required
Kured->>Node: notice the sentinel
Kured->>Lock: acquire, one node at a time
Kured->>Node: cordon and drain
Kured->>Node: reboot into the new snapshot
Node->>Node: health-checker, roll back if broken
Node-->>Kured: back and Ready
Kured->>Node: uncordon
Kured->>Lock: release
One coordinated reboot. The other nodes wait for the lock.
On every node — and not by editing the file you would expect. The shipped config lives in
/usr/etc/transactional-update.conf, openSUSE's vendor-config location on read-only /usr;
/etc/transactional-update.conf is the admin override and does not exist until you create it. So
write it rather than sed it:
echo 'REBOOT_METHOD=kured' > /etc/transactional-update.conf
systemctl disable --now rebootmgr.service # otherwise two things want to reboot the node
One line is enough: the vendor file is sourced first and yours second, so every other default
survives. One further trap — only /usr/etc/transactional-update.conf.d/ is scanned for drop-ins,
not the /etc equivalent, so a drop-in under /etc is silently ignored
(sbin/transactional-update.in).
Valid values are auto, rebootmgr, notify, systemd, kured and none; kexec is
deprecated.
Then deploy kured into the cluster. I run it as a Flux HelmRelease from the
https://kubereboot.github.io/charts repository. The defaults already match MicroOS — sentinel
/var/run/reboot-required, one node at a time — and these are the values I set on top:
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: kured
namespace: kured
spec:
chart:
spec:
chart: kured
sourceRef:
kind: HelmRepository
name: kubereboot
namespace: flux-system
version: '>=6.0.0 <7.0.0'
interval: 1h0m0s
targetNamespace: kured
values:
configuration:
# Default sentinel; matches what transactional-update's kured method writes.
rebootSentinel: /var/run/reboot-required
period: 30m
# Release the lock only after the node is back and Ready, and never let a
# stuck node hold it forever.
lockTtl: 60m
lockReleaseDelay: 5m
# Give workloads (and Longhorn replicas) time to move, but do not hang.
drainTimeout: 10m
drainGracePeriod: "60"
# Do not reboot past a drain failure - an unevictable pod is a decision for
# a human, not for 03:00.
forceReboot: false
# Stop other rebooting nodes from scheduling onto this one mid-cycle.
preferNoScheduleTaint: weave.works/kured-node-reboot
annotateNodes: true
Verify the wiring end to end without waiting for an update: touch /var/run/reboot-required on one
node, then watch kubectl get nodes -w — the node should be cordoned, drained and rebooted, and
nothing else should follow it.
Check that every node is still updating
A node whose nightly update fails does not complain anywhere you would see it: it keeps running, it just stops getting OS and RKE2 updates. Compare versions across nodes now and then:
kubectl get nodes -o wide # same RKE2 version and kernel on every node?
# on a node that lags behind:
journalctl -u transactional-update.service --since -2d | tail -3
snapper list | tail -3 # the newest snapshot should be recent
My agent fell behind exactly this way: from October 2 its nightly update failed every day on
Signature verification failed for repomd.xml (Part 18), and it stayed on an older RKE2 and kernel
for twelve days before I noticed.
Manual control
transactional-update dup && reboot # update one node, wait for Ready, then the next
zypper addlock rke2-server # freeze RKE2 while still updating the OS
transactional-update rollback last && reboot
snapper list
A rollback reverts /usr (kernel, RKE2 binary, SELinux policy) but not /var — etcd data,
container images and Longhorn volumes stay as they are, and /etc changes are kept. That is what you
want for a bad kernel. It also means an RKE2 minor downgrade is not a supported path: etcd schema
migrations are one-way.
RKE2 minor upgrades
Minor upgrades are deliberate, and done one node at a time, waiting for Ready in between.
Point the repo file at the next minor and let dup do the rest:
rm /etc/zypp/repos.d/rancher-rke2-1-35-stable.repo
# re-run the repo heredoc from Part 7 with RKE2_MINOR=36, then:
transactional-update dup && reboot
Check the release notes for skipped minors first — Kubernetes does not support jumping two.
Snapshot disk usage
Keep an eye on how much space the snapshots take, and cap how many are kept if it gets tight:
snapper -c root list
btrfs filesystem usage /
sed -i 's/^NUMBER_LIMIT=.*/NUMBER_LIMIT="4-6"/' /etc/snapper/configs/root
Replacing a node
Keeping addresses out of tls-san is what makes a node swap cheap. Each node has a stable name
(k8s-1.example.com …) alongside the round-robin k8s.example.com (Part 3), so the certificate
does not care which IP is behind the name.
flowchart TB
a["kubectl drain k8s-3<br/>kubectl delete node k8s-3"] --> b["etcd member goes with the node<br/>quorum 3 → 2, still a quorum"]
b --> c["DNS: new address for k8s-3.example.com<br/>and for k8s-3's records in k8s.example.com"]
c --> d["Build the new VPS through Parts 4–8<br/>same hostname, own node-ip, same tls-san"]
d --> e["It joins: quorum back to 3<br/>no changes on the two survivors"]
Swapping k8s-3 for a new VPS.
Swapping k8s-3 for a new VPS then means:
- Remove the old member cleanly —
kubectl drain k8s-3 --ignore-daemonsets --delete-emptydir-datathenkubectl delete node k8s-3. RKE2's etcd controller removes the etcd member with the node, so quorum goes 3 → 2 (still a quorum) and back to 3 when the replacement joins. - Update DNS: the A/AAAA pair for
k8s-3.example.com, and the node's records insidek8s.example.com. Nothing else references the old address. - Build the replacement through Parts 4–8 with the same hostname, its own
node-ippair, and an unchangedtls-san. No certificate work, no edits on the surviving servers.
The certificates on the two survivors never mention k8s-3's addresses, so there is nothing stale to
clean up. Had you listed all six IPs in tls-san, this step would instead mean editing three config
files and restarting all three servers.
If you do need to add a SAN later, edit tls-san on every server and restart rke2-server; force it
with rke2 certificate rotate if the serving cert does not pick the change up.
Part 15. Backups and quorum loss
Every server writes its own etcd snapshots to /var/lib/rancher/rke2/server/db/snapshots on the
schedule set in config.yaml — every six hours, keeping 20. Three copies on three nodes is not a
backup; push them off-box.
flowchart LR
snap["etcd snapshot on each server<br/>every 6 hours"] -->|"etcd-s3: true<br/>credentials from a Secret"| s3[("S3-compatible bucket")]
keep["Kept outside the cluster:<br/>server token + S3 credentials"]
s3 --> restore["cluster-reset on the survivor"]
keep -->|"--etcd-s3-* flags on the command line"| restore
What a restore needs, and where each piece lives.
Keep the S3 credentials out of config.yaml — RKE2 reads them from a Secret instead, which means
they can live in Git as a SealedSecret next to everything else Flux manages, and be rotated
without restarting anything. Two lines on each server, and only these two: any other etcd-s3-*
flag makes RKE2 ignore the Secret.
# /etc/rancher/rke2/config.yaml, on all three servers
etcd-s3: true
etcd-s3-config-secret: rke2-etcd-snapshot-s3-config
# sealed into Git; the Secret need not exist when RKE2 starts
apiVersion: v1
kind: Secret
metadata:
name: rke2-etcd-snapshot-s3-config
namespace: kube-system
type: etcd.k3s.cattle.io/s3-config-secret
stringData:
etcd-s3-endpoint: <S3_ENDPOINT>
etcd-s3-access-key: <KEY>
etcd-s3-secret-key: <SECRET>
etcd-s3-bucket: <BUCKET>
etcd-s3-folder: rke2
etcd-s3-region: <REGION>
Any S3-compatible target works. Cloudflare R2 fits
this cluster well — 10 GB and zero egress on the free tier, against snapshots of tens of MB — and
takes etcd-s3-endpoint: <ACCOUNT_ID>.r2.cloudflarestorage.com (host only) with
etcd-s3-region: auto, authenticated by an R2 API token scoped to the bucket. Backblaze B2, Storj
and Scaleway have comparable free tiers if you would rather not keep DNS and backups with one vendor.
The R2 dashboard may show the endpoint with the bucket path appended
(https://<ACCOUNT_ID>.r2.cloudflarestorage.com/<bucket>). Pasted as is into etcd-s3-endpoint,
it fails only at snapshot time:
FATA[0000] Error: see server log for details: failed to initialize S3 client: Endpoint url cannot have fully qualified paths.
The fix is to strip everything from the bucket name onward — scheme and path both — leaving just the
bare host; the bucket belongs solely in etcd-s3-bucket.
Keep a copy of those credentials outside the cluster, next to the server token. The Secret is
read through the apiserver, which does not exist during a restore — RKE2 says so explicitly — so
restoring from S3 means passing --etcd-s3-access-key and friends on the command line. A backup
whose credentials only exist inside the cluster you are restoring is not a backup.
Check the snapshots with kubectl get etcdsnapshotfile (RKE2 tracks snapshots as cluster resources)
or rke2 etcd-snapshot list. Don't wait out etcd-snapshot-schedule-cron to find out whether the S3
upload actually works — rke2 etcd-snapshot save --name manual-test runs (and uploads) immediately,
on demand. And don't stop at the CRD/CLI output either: both report what RKE2 attempted, not
confirmation the object exists — check the bucket itself (R2 dashboard, rclone, aws s3 ls
pointed at the R2 endpoint) at least once.
Losing quorum
If you lose two nodes at once, the survivor cannot elect a leader and the API goes read-only-ish until you rebuild the cluster from one member — the documented procedure in short:
# on all three: systemctl stop rke2-server
# on the survivor:
rke2 server --cluster-reset --cluster-reset-restore-path=<SNAPSHOT> # omit the path to keep current data
systemctl start rke2-server
# on the other two: rm -rf /var/lib/rancher/rke2/server/db && systemctl start rke2-server
Practise this once on throwaway nodes. The first time should not be during an outage.
Part 16. Optional: prerequisites for Longhorn
Longhorn needs an iSCSI initiator on every node, plus an NFS client for ReadWriteMany volumes.
Neither is in the MicroOS image — and with three nodes, Longhorn's default 3 replicas finally have
somewhere to live:
transactional-update pkg install open-iscsi nfs-client
reboot
systemctl enable --now iscsid.service
If any StorageClass sets encrypted: "true", every node also needs cryptsetup — Longhorn
nsenters into the host mount namespace to run it directly, so it must exist on the host, not just
inside the longhorn-manager container. Missing it doesn't fail fast: the PVC provisions, but the
replica sits in attaching/unknown forever, and the only clue is
FailedStarting … cryptsetup --version … exit status 127 in
kubectl -n longhorn-system get events.
transactional-update pkg install cryptsetup
reboot
Longhorn stores data in /var/lib/longhorn (writable, and outside the snapshot/rollback set). It
also needs to know the kubelet root directory; RKE2 uses the default, which you can confirm with
ps -ef | grep '[k]ubelet' | tr ' ' '\n' | grep root-dir — no output means /var/lib/kubelet and no
Helm override is needed. If volumes fail to attach with permission errors, check
journalctl -t audit before reaching for permissive mode.
Longhorn and kured interact: a drained node's replicas rebuild afterwards. Keep kured's lock TTL
(lockTtl in Part 14) longer than a replica rebuild, or Longhorn will still be catching up when the
next node goes down.
Part 17. Next: hand the cluster to Flux
Once all three nodes are Ready, bootstrap GitOps as usual:
flux bootstrap github \
--components-extra=image-reflector-controller,image-automation-controller \
--owner=<github-user> \
--repository=<repository> \
--branch=main \
--path=clusters/<cluster> \
--read-write-key \
--personal
From here on, the Gateway (Part 13), kured (Part 14), the etcd S3 Secret (Part 15) and Longhorn (Part 16) come from Git.
Part 18. Troubleshooting
| Symptom | Cause / fix |
|---|---|
zypper install says the system is read-only |
Correct. Use transactional-update pkg install. |
| Installed a package but it isn't there | You didn't reboot into the new snapshot. |
rke2 ended up in /opt/rke2 |
You ran get.rke2.io without INSTALL_RKE2_METHOD=rpm; it defaults to the tarball on MicroOS. Uninstall (/opt/rke2/bin/rke2-uninstall.sh) and use the repo method in Part 7. |
critical configuration value mismatch on join |
A critical value differs between servers. Diff the three config.yaml. |
critical configuration value mismatch mentioning a CIDR |
The dual-stack cluster-cidr/service-cidr pair differs between servers — they must be byte-identical. |
Polling for API server readiness ... certificate is valid for ..., not :: |
bind-address: "::" is set. Remove it and restart rke2-server — the default listener is dual-stack already. |
| Join rejected on the token (CA hash or credentials) | Fix config.yaml, then wipe /var/lib/rancher/rke2 on that node before restarting — see Part 7. |
Joining node loops on failed to get etcd MemberList: context deadline exceeded, and the 500s name its own address |
server: points at the round-robin name and it resolved to the joining node. Wipe the node and rejoin with server: set to a specific running server. |
| Same loop, but the 500s name another node | That node's etcd is unhealthy. Check etcd_server_has_leader on it, and 2379/2380 between the nodes on both families. |
Join hangs on waiting for server to become available |
9345 blocked between nodes, or the registration address resolves to a node that isn't up yet. |
Join fails with a timeout or an x509 error, dig returns 104.x/172.67.x |
The Cloudflare record is proxied. Set it to DNS only (Part 3). |
Second server started but kubectl get nodes on server 1 shows only one node |
Every server's API reads the same etcd, so a joined server shows up everywhere. It did not join — usually server: is missing from its config.yaml and it bootstrapped a one-node cluster of its own. Fix the config, wipe it and rejoin (Part 7). |
| Pods can't reach each other, routes vanish | NetworkManager is managing cali*/vxlan.calico. Add rke2-canal.conf (Part 6) and reboot. |
plugin type="portmap" failed (add): failed to open iptables: ... not found in $PATH |
A pod declares a hostPort, and the portmap CNI plugin needs the host iptables binary, which MicroOS does not ship. Each retry leaks a pod IP, so fix it promptly: transactional-update pkg install iptables && reboot. Calico itself does not need it — calico-node and kube-proxy carry their own binaries. |
| Pods get only an IPv4 address | The IPv6 pool is missing or the CIDR pair never reached this server. kubectl get ippools.crd.projectcalico.org -o wide should show two pools. |
| IPv6 pod traffic works within a node, not across nodes | The v6 IPPool is not VXLAN-encapsulated, or UDP 4789 is blocked on IPv6. Check .spec.vxlanMode on both pools and the v6 firewall rules. |
IPv6 clients get Connection refused on 80/443, IPv4 works |
The EnvoyProxy has no spec.ipFamily: DualStack, so Envoy bound 0.0.0.0 only (Part 13). |
WireGuard for IPv4 is off again after an RKE2 upgrade: wireguardEnabled: false, no WireguardPublicKey annotation on the nodes |
It was set with kubectl patch. The rke2-calico chart sets wireguardEnabled: false and Helm re-applies it on every upgrade. Set it under felixConfiguration in the HelmChartConfig instead (Part 12). |
| A port you firewalled is still reachable | v6 rules missing — IPv4 rules do not filter IPv6. Check with nft list ruleset or the provider panel. |
| SELinux denials right after install | Reboot the node — the RKE2 policy must be loaded before rke2-server first starts. |
tigerastatus shows calico degraded: error reading network MTU: open /var/lib/calico/mtu: permission denied |
The node running tigera-operator (kubectl -n tigera-operator get pods -o wide) has /var/lib/calico labelled var_lib_t instead of container_file_t; rke2-selinux does not cover that path. Label it (Part 6); the operator retries every 5 minutes. |
gatewayapi: Waiting for Installation Variant to be set |
Not a gateway problem — the Calico Installation is not available yet. Fix tigerastatus first. |
tiers: Waiting for Tigera API server to be ready, and it never clears |
Expected with RKE2: the rke2-calico chart sets apiServer.enabled: false, so the operator's tiers controller waits for an API server that is never installed. The default tiers exist as crd.projectcalico.org resources. Nothing to fix. |
Nightly update fails: Signature verification failed for repomd.xml, Repository 'Rancher RKE2 1.35 Stable' is invalid |
The node does not trust Rancher's signing key, so the non-interactive transactional-update rejects it and aborts the whole update, OS included. Run zypper interactively inside a transaction (transactional-update shell, then zypper ref), check that the fingerprint is C8CF F216 4551 26E9 B9C9 18BE 925E A29A E257 814A (the key at https://rpm.rancher.io/public.key), answer a, exit and reboot. rpm -q gpg-pubkey-e257814a-5e6817fa confirms it. |
too many open files / failed to create fsnotify watcher |
inotify limits — see the sysctls in Part 6. |
| Node unreachable after an update | It probably rolled back on its own (health-checker). Check snapper list and journalctl -b -1. |
etcdserver: request timed out |
Disk or network latency between members. Check ping RTT and disk fsync times in the etcd pod logs. |
| All three rebooted at once | REBOOT_METHOD is still auto/rebootmgr with the same window everywhere — see Part 14. |
| Need to nuke the runtime on one node | rke2-killall.sh, then systemctl start rke2-server. rke2-uninstall.sh removes everything. |
Results
The cluster has been running since August 23 — about 50 days at the time of writing — and it has kept itself up to date the whole time:
- Nightly updates with reboots that take turns. MicroOS builds a new snapshot every night and kured reboots the nodes one at a time. Each server has booted more than 40 times since it was installed; on October 13, for example, the three servers rebooted at 00:31, 02:01 and 02:36.
- Kubernetes upgrades itself. RKE2 arrived as v1.35.7 and moved to v1.35.8 on September 19 and to v1.35.9 on October 2, both times in the middle of the night, through the same update cycle as the OS. The kubeadm-era chore from the motivation is gone.
- The bill is smaller than for the old cluster.
Automatic does not mean unattended, though. Three things failed silently along the way, and each is covered above: the missing SELinux label on the agent (Part 11), the agent's nightly update blocked by an untrusted repository key (Part 14), and WireGuard for IPv4 reverted by an RKE2 upgrade (Part 12). None of them took anything down; all of them needed someone to look.
Next time I would not install each node from the ISO by hand. MicroOS can configure a node on its first boot with Ignition or Combustion, and that is how I would provision the nodes in the future.