This is the deployment manual for a three-node RKE2 control plane. It has three deliberate design choices worth stating up front, because they drive almost every command below:
- A control-plane-only VIP. The virtual IP is used for the API server and nothing else. Application traffic is kept completely separate.
- Cilium in native routing mode. Packets are routed directly between nodes instead of being wrapped in VXLAN, so there is no per-packet encapsulation overhead.
-
A custom Pod CIDR. The Pod network is moved off the RKE2 default
to
10.142.0.0/16.
Target systems: Ubuntu 24.04 or 22.04, layer 2 networking (all nodes in the same broadcast domain).
Node and IP planning
| Role | Hostname | Physical IP | Notes |
|---|---|---|---|
| VIP | — | 10.2.0.130 | API server HA only |
| Master 1 | k8s-m1 | 10.2.0.129 | Initial bootstrap node |
| Master 2 | k8s-m2 | 10.2.0.128 | HA node |
| Master 3 | k8s-m3 | 10.2.0.127 | HA node |
- Network interface:
ens192(adjust to match your hardware) - Pod CIDR:
10.142.0.0/16(custom) - Service CIDR:
10.143.0.0/16
The Service CIDR is not a cosmetic setting. Once the cluster is up you cannot change it without rebuilding, so pick it once and keep every node consistent.
1. Base environment (all nodes)
Run every step in this section on all three machines — 10.2.0.129, 10.2.0.128, and 10.2.0.127.
1.1 System cleanup and kernel parameters
Cilium native routing depends on a clean network stack. Leftover iptables rules from a previous Kubernetes install will silently interfere with eBPF programs, so clear them out first.
# 1. Turn off the host firewall
systemctl disable --now ufw
# 2. Flush all iptables rules (stale rules break eBPF)
iptables -F && iptables -t nat -F && iptables -t mangle -F && iptables -X
iptables -P INPUT ACCEPT
iptables -P FORWARD ACCEPT
iptables -P OUTPUT ACCEPT
# On Ubuntu 24+, reset nftables as well
nft flush ruleset
# 3. Enable IP forwarding (required by native routing)
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
sysctl -p
1.2 Add a hosts entry to prevent a deadlock
This one is easy to skip and painful to debug. Kube-VIP resolves the
kubernetes name during startup; if DNS is not up yet, it crashes and
the VIP never appears.
echo "127.0.0.1 kubernetes" >> /etc/hosts
1.3 Point crictl at the bundled containerd
cat <<EOF > /etc/crictl.yaml
runtime-endpoint: unix:///run/k3s/containerd/containerd.sock
image-endpoint: unix:///run/k3s/containerd/containerd.sock
timeout: 10
debug: false
EOF
2. Deploy the first master (10.2.0.129 only)
2.1 Create the RKE2 config file
mkdir -p /etc/rancher/rke2/
cat <<EOF > /etc/rancher/rke2/config.yaml
tls-san:
- "10.2.0.130"
cni: none # disable the default CNI
disable-kube-proxy: true # disable kube-proxy (Cilium takes over)
etcd-expose-metrics: true
cluster-cidr: "10.142.0.0/16" # custom Pod network
service-cidr: "10.143.0.0/16" # custom Service network
EOF
2.2 Deploy the Kube-VIP static pod
Note the absence of svc_enable. That omission is intentional: it keeps
the VIP bound to the control plane only, so no application traffic ever lands on it.
mkdir -p /var/lib/rancher/rke2/agent/pod-manifests/
cat <<EOF > /var/lib/rancher/rke2/agent/pod-manifests/kube-vip.yaml
apiVersion: v1
kind: Pod
metadata:
name: kube-vip
namespace: kube-system
spec:
containers:
- name: kube-vip
image: ghcr.io/kube-vip/kube-vip:v1.0.3
imagePullPolicy: IfNotPresent
args:
- manager
- --vipSubnet
- "32"
env:
- name: vip_arp
value: "true"
- name: vip_interface
value: "ens192" # confirm your interface name
- name: vip_address
value: "10.2.0.130" # control plane VIP
- name: cp_enable
value: "true"
- name: cp_namespace
value: "kube-system"
- name: vip_leaderelection
value: "true"
- name: KUBECONFIG
value: "/etc/kubernetes/admin.conf"
securityContext:
capabilities:
add:
- NET_ADMIN
- NET_RAW
volumeMounts:
- mountPath: /etc/kubernetes/admin.conf
name: kubeconfig
hostNetwork: true
volumes:
- hostPath:
path: /etc/rancher/rke2/rke2.yaml
name: kubeconfig
EOF
2.3 Start RKE2 and install kubectl
# Install and start
curl -sfL https://get.rke2.io | sh -
systemctl enable rke2-server
systemctl start rke2-server
# Configure kubectl
mkdir -p ~/.kube
cp /etc/rancher/rke2/rke2.yaml ~/.kube/config
chmod 600 ~/.kube/config
export KUBECONFIG=~/.kube/config
ln -s /var/lib/rancher/rke2/bin/kubectl /usr/local/bin/kubectl
RKE2 generates its kubeconfig at /etc/rancher/rke2/rke2.yaml, not the
more familiar /etc/kubernetes/admin.conf. The Kube-VIP manifest above
mounts the RKE2 path onto the kubeadm path inside the container, because Kube-VIP
hard-codes the latter.
3. Install Cilium
Run on 10.2.0.129 only, after the VIP responds. Confirm it does
before continuing — installing Cilium with k8sServiceHost
pointing at an address that is not yet up will leave the CNI unable to bootstrap.
3.1 Install Helm
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm repo add cilium https://helm.cilium.io/
helm repo update
3.2 Deploy Cilium in native routing mode
The critical detail is ipv4NativeRoutingCIDR. It must match the
cluster-cidr you gave RKE2, otherwise the two components will disagree
about which addresses are local Pods and traffic will be misrouted.
helm install cilium cilium/cilium \
--namespace kube-system \
--set kubeProxyReplacement=true \
--set k8sServiceHost=10.2.0.130 \
--set k8sServicePort=6443 \
--set cni.exclusive=false \
--set routingMode=native \
--set autoDirectNodeRoutes=true \
--set ipv4NativeRoutingCIDR=10.142.0.0/16 \
--set bpf.masquerade=true \
--set hubble.relay.enabled=true \
--set hubble.ui.enabled=true
-
kubeProxyReplacement=trueworks together withdisable-kube-proxy: truein the RKE2 config. Turn on one without the other and Service routing breaks. -
cni.exclusive=falseis required on RKE2, which already ships its own CNI configuration files. -
bpf.masquerade=truemoves SNAT from iptables into eBPF, which is the main throughput win over the default setup.
4. Join the HA nodes
4.1 Get the node token (on master 1)
cat /var/lib/rancher/rke2/server/node-token
# copy the output
4.2 Configure the joining nodes (128 and 127)
mkdir -p /etc/rancher/rke2/
# replace <TOKEN> with the value from the previous step
cat <<EOF > /etc/rancher/rke2/config.yaml
server: https://10.2.0.130:9345 # point at the VIP
token: <TOKEN>
tls-san:
- "10.2.0.130"
cni: none
disable-kube-proxy: true
cluster-cidr: "10.142.0.0/16" # must match master 1
EOF
Note port 9345, not 6443. That is the RKE2 supervisor API used for node registration; 6443 is the Kubernetes API itself.
4.3 Preload Kube-VIP (128 and 127)
This step is mandatory. Copy
/var/lib/rancher/rke2/agent/pod-manifests/kube-vip.yaml from master 1
to the exact same path on each joining node, before starting RKE2. Without it the
new nodes cannot take part in the VIP leader election.
4.4 Start RKE2
curl -sfL https://get.rke2.io | sh -
systemctl enable rke2-server
systemctl start rke2-server
5. Verification
5.1 Cluster state
kubectl get nodes -o wide
# expect: all nodes Ready
5.2 Confirm the routing mode
kubectl -n kube-system exec -ti ds/cilium -- cilium status | grep -E "Routing|Masquerading"
# expected:
# Routing: Network: Native Host: BPF
# Masquerading: BPF [ens192] 10.142.0.0/16
5.3 Deploy a test Pod to validate the new CIDR
kubectl create deployment nginx-test --image=nginx:alpine --replicas=2
kubectl get pods -l app=nginx-test -o wide
# expected IP: 10.142.x.x
6. Optional: direct Pod access from your workstation
With native routing there is no encapsulation, so a Pod IP is a real routable address on the LAN. If your workstation is on the same layer 2 network, you can teach it the route and then reach Pods directly — useful for debugging a service without going through a Service or Ingress.
# On macOS (needs sudo)
# Send anything destined for 10.142.x.x to the VIP
sudo route -n add -net 10.142.0.0/16 10.2.0.130
ping 10.142.x.x
This is a convenience for a lab or a trusted office network, not something to do
from an untrusted machine. On Linux the equivalent is
ip route add 10.142.0.0/16 via 10.2.0.130; on Windows,
route add 10.142.0.0 mask 255.255.0.0 10.2.0.130.