OpenBao HA auf RKE2 — 3 Master + 3 Worker auf Ubuntu 24.04
Summary: Komplette End-to-End-Anleitung von sechs nackten Ubuntu-24.04-Servern bis zu einem produktionsnahen OpenBao-HA-Cluster auf RKE2 mit dedizierter 3+3-Topologie (3 dedizierte Control-Plane-/etcd-Nodes, 3 dedizierte Worker-Nodes). Inklusive kube-vip für die API-VIP, Longhorn als Storage, cert-manager für die TLS-PKI und Pod-Scheduling exklusiv auf Workern. Jeder Befehl ist ausgeschrieben mit „was und warum”.
Sources: raw/docs/platform/k8s/helm/examples/ha-with-raft.md, raw/docs/platform/k8s/helm/examples/ha-tls.md, raw/docs/platform/k8s/helm/examples/standalone-tls.md, raw/docs/platform/k8s/helm/configuration.md, raw/docs/platform/k8s/helm/run.md, raw/docs/configuration/storage/raft.md, raw/docs/configuration/service-registration/kubernetes.md. RKE2-, kube-vip- und Longhorn-Schritte folgen den jeweiligen Upstream-Docs (docs.rke2.io, kube-vip.io, longhorn.io) — diese liegen nicht im raw/-Ordner.
Last updated: 2026-05-20
0 — Architektur und Ziel
Sechs Ubuntu-24.04-Server, klar getrennt nach Rolle:
┌──────────────────────┐
│ VIP 10.0.1.10 │
│ (kube-vip, port │
│ 6443 + 9345) │
└──────────┬───────────┘
│
┌────────────────────────────┼────────────────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ server-1 │ │ server-2 │ │ server-3 │
│ 10.0.1.11 │ │ 10.0.1.12 │ │ 10.0.1.13 │
│ │ │ │ │ │
│ rke2-server │ │ rke2-server │ │ rke2-server │
│ etcd │◀───────────│ etcd │───────────▶│ etcd │
│ apiserver │ raft │ apiserver │ raft │ apiserver │
│ scheduler │ │ scheduler │ │ scheduler │
│ kube-vip │ │ kube-vip │ │ kube-vip │
│ taint: NoExec │ │ taint: NoExec │ │ taint: NoExec │
└───────────────┘ └───────────────┘ └───────────────┘
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ worker-1 │ │ worker-2 │ │ worker-3 │
│ 10.0.1.21 │ │ 10.0.1.22 │ │ 10.0.1.23 │
│ │ │ │ │ │
│ rke2-agent │ │ rke2-agent │ │ rke2-agent │
│ kubelet │ │ kubelet │ │ kubelet │
│ Longhorn │ │ Longhorn │ │ Longhorn │
│ openbao-0 │ │ openbao-1 │ │ openbao-2 │
└───────────────┘ └───────────────┘ └───────────────┘
Warum diese Topologie:
- 3 Server-Nodes bilden ein etcd-Quorum (Raft-Mehrheit: 2/3) — Control-Plane überlebt einen Ausfall. Drei ist auch hier das Minimum für echte HA; einer wäre Standalone, zwei wären Split-Brain-anfällig. Hintergrund analog zu OpenBao Raft: raft § Quorum.
- 3 Worker-Nodes geben Platz für die drei OpenBao-Pods (1:1, durchgesetzt durch
podAntiAffinity: required) plus Longhorn-Replicas (auch 3 Replicas pro Volume). Ein Worker-Ausfall lässt sowohl die OpenBao-Raft als auch die Longhorn-Volumes weiterlaufen. - Trennung Server/Worker: Server-Nodes tragen Taint
CriticalAddonsOnly=true:NoExecute— Workloads landen ausschließlich auf Workern. Damit kämpfen Application-Pods und Control-Plane nicht um CPU/RAM, und etcd kann auf den Servern dedizierte SSDs nutzen (etcd ist disk-fsync-empfindlich). - kube-vip auf den Servern stellt eine virtuelle IP für
kube-apiserverbereit. Ohne VIP müsste jeder Client (kubectl, Agent-Join, externe Tools) auf einen spezifischen Server zeigen — der wird zum SPOF.
Hardware-Empfehlung:
| Rolle | vCPU | RAM | Disk | Hinweis |
|---|---|---|---|---|
| Server (×3) | 4 | 8 GB | 50 GB SSD | etcd braucht fsync-fähige SSD |
| Worker (×3) | 4 | 8 GB | 200 GB SSD | 100 GB für Longhorn-Volumes |
Netzwerk: alle sechs Nodes im selben L2-Segment, sonst funktioniert kube-vip ARP-Mode nicht. TCP-Ports zwischen Nodes: 6443 (apiserver), 9345 (RKE2-Supervisor), 2379–2380 (etcd intern), 10250 (kubelet), 4240 (Cilium Health), VXLAN 8472/UDP, sowie 80/443 (Longhorn UI optional).
1 — Voraussetzungen vor dem ersten Befehl
| Variable | Beispiel | Bedeutung |
|---|---|---|
VIP | 10.0.1.10 | Virtuelle IP, die kube-vip auf den Server-Nodes hosten wird. Muss frei sein und im selben Subnet liegen wie die Server-Nodes. |
VIP_DNS | rke2-api.example.com | (Optional) DNS-Name, der auf VIP zeigt. Empfohlen — sonst sind die TLS-SANs an die IP gebunden. |
SERVER_NODES | server-1/2/3 → 10.0.1.11–.13 | Die drei Control-Plane-Nodes. |
WORKER_NODES | worker-1/2/3 → 10.0.1.21–.23 | Die drei Worker-Nodes. |
RKE2_TOKEN | langer Zufalls-String | Shared Secret für Cluster-Join. Generieren mit openssl rand -hex 32. |
RKE2_VERSION | v1.32.2+rke2r1 | Konkrete Channel-Pin. |
RKE2_TOKEN jetzt einmal generieren und sicher ablegen — wir brauchen ihn auf jedem Node:
openssl rand -hex 32
# Ausgabe in Passwortmanager o. ä. speichern, im Folgenden als $RKE2_TOKEN
/etc/hosts auf allen sechs Nodes ergänzen — solange kein interner DNS:
sudo tee -a /etc/hosts <<EOF
10.0.1.10 rke2-api.example.com
10.0.1.11 server-1
10.0.1.12 server-2
10.0.1.13 server-3
10.0.1.21 worker-1
10.0.1.22 worker-2
10.0.1.23 worker-3
EOF
2 — Basis-Prep auf allen sechs Nodes
RKE2 packt containerd, CNI (default: Canal; wir nehmen Cilium), kube-proxy und CoreDNS in einen einzigen Installer — vieles aus dem klassischen kubeadm-Pre-Flight entfällt. Nötig bleibt:
2.1 — System aktualisieren
sudo apt-get update
sudo apt-get -y upgrade
sudo apt-get -y install curl ca-certificates iptables vim
Warum iptables: RKE2 fällt im Default auf nftables zurück, viele Diagnose-Tools (z. B. iptables -L) werden aber implizit erwartet — minimal lassen, statt komplett deinstallieren.
2.2 — Swap deaktivieren
sudo swapoff -a
sudo sed -i.bak '/ swap / s/^/#/' /etc/fstab
Warum: kubelet weigert sich per Default bei aktivem Swap zu starten. Gleicher Grund wie bei jeder K8s-Distribution.
2.3 — ufw / Firewall
RKE2-Installer setzt die nötigen iptables-/nftables-Regeln selbst. Aber ufw ist auf Ubuntu Server 24.04 oft preinstalled und blockt sonst RKE2:
sudo systemctl disable --now ufw || true
sudo apt-get -y remove ufw || true
Wenn ufw zwingend bleiben muss (Compliance), müssen alle Inter-Node-Ports explizit aufgemacht werden — eine vollständige Port-Liste steht in den RKE2-Upstream-Docs unter „Requirements”. Empfehlung: stattdessen Cilium-NetworkPolicies nutzen (siehe Production-Hardening unten).
2.4 — NetworkManager / DNS-Konflikte vermeiden
Auf Ubuntu Server 24.04 kommt systemd-resolved mit. Das ist OK. Aber wenn auf einer Maschine zusätzlich NetworkManager läuft, sollte er die CNI-Interfaces nicht verwalten:
if [ -d /etc/NetworkManager/conf.d ]; then
sudo tee /etc/NetworkManager/conf.d/rke2-canal.conf <<'EOF'
[keyfile]
unmanaged-devices=interface-name:cali*;interface-name:flannel*;interface-name:cilium*;interface-name:lxc*
EOF
sudo systemctl reload NetworkManager || true
fi
Warum: NetworkManager räumt sonst gelegentlich CNI-Interfaces ab → Pods verlieren das Netz nach Reboot.
2.5 — Hostnames
Auf jedem Node den Hostnamen passend setzen:
# auf server-1:
sudo hostnamectl set-hostname server-1
# analog auf den anderen fünf
3 — kube-vip für die API-VIP vorbereiten
Bevor RKE2 startet, legen wir auf den drei Server-Nodes das statische Pod-Manifest für kube-vip an. RKE2 startet alle Pods aus /var/lib/rancher/rke2/server/manifests/ automatisch — das ist der Ort für „mitgelieferte” Workloads.
Auf allen drei Server-Nodes (server-1, server-2, server-3):
sudo mkdir -p /var/lib/rancher/rke2/server/manifests
VIP=10.0.1.10
INTERFACE=$(ip route | awk '/default/ {print $5; exit}')
KUBE_VIP_VERSION=v0.8.2
sudo ctr image pull ghcr.io/kube-vip/kube-vip:${KUBE_VIP_VERSION} 2>/dev/null || true
sudo tee /var/lib/rancher/rke2/server/manifests/kube-vip.yaml >/dev/null <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: kube-vip
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: system:kube-vip-role
rules:
- apiGroups: [""]
resources: ["services", "services/status", "nodes", "endpoints"]
verbs: ["list", "get", "watch", "update"]
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["list", "get", "watch", "update", "create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: system:kube-vip-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:kube-vip-role
subjects:
- kind: ServiceAccount
name: kube-vip
namespace: kube-system
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: kube-vip-ds
namespace: kube-system
spec:
selector:
matchLabels:
name: kube-vip-ds
template:
metadata:
labels:
name: kube-vip-ds
spec:
hostNetwork: true
serviceAccountName: kube-vip
tolerations:
- effect: NoSchedule
operator: Exists
- effect: NoExecute
operator: Exists
nodeSelector:
node-role.kubernetes.io/control-plane: "true"
containers:
- name: kube-vip
image: ghcr.io/kube-vip/kube-vip:${KUBE_VIP_VERSION}
imagePullPolicy: IfNotPresent
securityContext:
capabilities:
add: ["NET_ADMIN", "NET_RAW"]
args: ["manager"]
env:
- name: vip_arp
value: "true"
- name: port
value: "6443"
- name: vip_cidr
value: "32"
- name: cp_enable
value: "true"
- name: cp_namespace
value: kube-system
- name: vip_ddns
value: "false"
- name: vip_leaderelection
value: "true"
- name: vip_leaseduration
value: "5"
- name: vip_renewdeadline
value: "3"
- name: vip_retryperiod
value: "1"
- name: address
value: "${VIP}"
- name: vip_interface
value: "${INTERFACE}"
EOF
Was hier passiert:
- Static-Pod-Manifest-Pfad: Das
manifests/-Verzeichnis ist RKE2-spezifisch — alles, was hier liegt, behandelt RKE2 wie eine “Mit-installierte” Workload und applied es bei jedem Start. Damit ist kube-vip Teil des Cluster-Bootstraps, nicht eine separate Helm-Installation. nodeSelector: node-role.kubernetes.io/control-plane: "true": kube-vip läuft nur auf den drei Servern.vip_arp: "true": kube-vip nutzt Gratuitous-ARP, um die VIP im L2 zu announcen. Funktioniert ohne BGP, ohne externen Router — aber nur innerhalb desselben Subnets.vip_leaderelection: "true": nur einer der drei kube-vip-Instanzen besitzt zu jeder Zeit die VIP; bei Ausfall failt die VIP in 5–10 Sekunden auf einen anderen Server.
4 — RKE2 Server auf server-1 (Cluster-Init)
Auf server-1 — und nur dort — schreiben wir die config.yaml mit cluster-init: true:
sudo mkdir -p /etc/rancher/rke2
sudo tee /etc/rancher/rke2/config.yaml >/dev/null <<EOF
write-kubeconfig-mode: "0640"
token: ${RKE2_TOKEN}
tls-san:
- rke2-api.example.com
- 10.0.1.10
- server-1
- server-2
- server-3
cni: cilium
cluster-init: true
node-taint:
- "CriticalAddonsOnly=true:NoExecute"
disable:
- rke2-ingress-nginx
EOF
Begründungen:
token: das in Schritt 1 generierte Shared Secret. Identisch auf allen anderen Server- und Worker-Nodes.tls-san: zusätzliche SANs im selbst-generierten API-Server-Cert. Ohne diese Liste meckert jeder kubectl-Aufruf über die VIP-IP mitx509: certificate is valid for …, not rke2-api.example.com.cni: cilium: ersetzt das Default (Canal). Cilium ist eBPF-basiert, kann NetworkPolicies sauberer und ist für dieservice_registration "kubernetes"-Konstellation relevant (Egress zur K8s-API perkube-apiserver-Entity policen — siehe service-registration-reactivation).cluster-init: true: macht diesen Node zum etcd-Bootstrap-Seed. Nur auf einem einzigen Node setzen. Die anderen beiden Server joinen später überserver: https://….node-taint: CriticalAddonsOnly=true:NoExecute: schützt die Server-Nodes vor Application-Pods. Workloads ohne diese spezielle Toleration werden hier weder geplant noch laufen sie weiter.disable: rke2-ingress-nginx: spart Ressourcen, weil wir keinen Ingress brauchen (Clients gehen direkt auf den OpenBao-Service). Wenn doch gewünscht, weglassen.
Installieren:
curl -sfL https://get.rke2.io | INSTALL_RKE2_VERSION=v1.32.2+rke2r1 sh -
sudo systemctl enable --now rke2-server.service
Was INSTALL_RKE2_VERSION macht: pinnt explizit eine Version, statt „latest stable” zu nehmen. Bei einem 3+3-Cluster wollt ihr unbedingt deterministische Versionen — alle sechs Nodes müssen dieselbe Minor laufen.
Den ersten Start dauert 60–120 Sekunden (Image-Pulls, etcd-Init). Verifikation:
sudo journalctl -u rke2-server -f
Sobald „Cluster reset complete” oder „k3s is up and running”-ähnliche Meldungen kommen: Ctrl-C. Dann den kubectl-Pfad einrichten:
sudo mkdir -p /root/.kube
sudo cp /etc/rancher/rke2/rke2.yaml /root/.kube/config
sudo sed -i "s/127.0.0.1/rke2-api.example.com/" /root/.kube/config
# RKE2 packt kubectl unter /var/lib/rancher/rke2/bin/
sudo ln -sf /var/lib/rancher/rke2/bin/kubectl /usr/local/bin/kubectl
sudo -i kubectl get nodes
Erwartet: server-1 mit Status Ready, Rolle control-plane,etcd,master.
5 — Server-2 und Server-3 joinen
Auf server-2 (und analog server-3):
sudo mkdir -p /etc/rancher/rke2
sudo tee /etc/rancher/rke2/config.yaml >/dev/null <<EOF
write-kubeconfig-mode: "0640"
token: ${RKE2_TOKEN}
server: https://rke2-api.example.com:9345
tls-san:
- rke2-api.example.com
- 10.0.1.10
- server-1
- server-2
- server-3
cni: cilium
node-taint:
- "CriticalAddonsOnly=true:NoExecute"
disable:
- rke2-ingress-nginx
EOF
curl -sfL https://get.rke2.io | INSTALL_RKE2_VERSION=v1.32.2+rke2r1 sh -
sudo systemctl enable --now rke2-server.service
Unterschied zu server-1:
- Kein
cluster-init: true— sonst würde ein zweiter, paralleler Cluster gebootet. server: https://rke2-api.example.com:9345— Port 9345 ist der RKE2-Supervisor-Port (nicht der kube-apiserver-Port 6443). Über ihn registriert sich der neue Server-Node und holt die etcd-Member-Konfig.
Nach 1–2 Minuten auf server-1 prüfen:
sudo -i kubectl get nodes
Erwartet: drei Ready-Nodes mit der Rolle control-plane,etcd,master.
6 — Agent-Nodes (worker-1, -2, -3) hinzufügen
Auf jedem der drei Worker:
sudo mkdir -p /etc/rancher/rke2
sudo tee /etc/rancher/rke2/config.yaml >/dev/null <<EOF
token: ${RKE2_TOKEN}
server: https://rke2-api.example.com:9345
node-label:
- "node-role.kubernetes.io/worker=true"
EOF
curl -sfL https://get.rke2.io | INSTALL_RKE2_TYPE=agent INSTALL_RKE2_VERSION=v1.32.2+rke2r1 sh -
sudo systemctl enable --now rke2-agent.service
Unterschiede:
INSTALL_RKE2_TYPE=agent: installiert nur kubelet + containerd + Cilium-Agent, ohne kube-apiserver/etcd/scheduler.node-label: node-role.kubernetes.io/worker=true: macht die Worker-Rolle explizit. Brauchen wir gleich für dennodeSelectorder OpenBao-Pods.- Keine
cni-Konfig: die wird vom Server zentral gepflegt.
Nach 1–2 Minuten auf server-1:
sudo -i kubectl get nodes -o wide
Erwartet: sechs Nodes, davon drei mit control-plane,etcd,master und drei mit worker. Alle Ready.
7 — Cluster-Verifikation
Vor dem nächsten Schritt eine Vollkontrolle:
sudo -i kubectl get nodes
sudo -i kubectl get pods -A
sudo -i kubectl describe node server-1 | grep -A3 Taints
Erwartet bei describe node:
Taints: CriticalAddonsOnly=true:NoExecute
node-role.kubernetes.io/control-plane:NoSchedule
node-role.kubernetes.io/etcd:NoExecute
Damit landet keine OpenBao-Pod ohne entsprechende Tolerations auf den Servern. Genau das wollen wir.
VIP-Failover testen (optional, aber empfohlen vor Production):
# auf irgendeinem Worker oder einer Workstation:
ping -c 5 rke2-api.example.com
# danach: rke2-server auf dem aktuell vip-haltenden server stoppen
# und prüfen, dass die VIP nach ~5–10 s auf einen anderen server springt
8 — Storage: Longhorn
Longhorn ist die Default-Wahl für RKE2 auf Bare Metal/VMs — es liefert Distributed Block Storage mit eingebauter 3-fach-Replikation, Snapshots und Backup-zu-S3.
Vor dem Install eine Voraussetzung erfüllen — Longhorn braucht iscsi-initiator-utils:
# auf ALLEN drei Workern:
sudo apt-get -y install open-iscsi nfs-common
sudo systemctl enable --now iscsid
Warum: Longhorn präsentiert seine Volumes über iSCSI an die Pods. Ohne iscsid schlägt jeder PVC mit „connect to iSCSI target failed” fehl.
Helm und Longhorn:
# Helm auf server-1 installieren (oder auf der Workstation)
curl https://baltocdn.com/helm/signing.asc | gpg --dearmor | sudo tee /usr/share/keyrings/helm.gpg >/dev/null
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/helm.gpg] https://baltocdn.com/helm/stable/debian/ all main" \
| sudo tee /etc/apt/sources.list.d/helm-stable-debian.list
sudo apt-get update
sudo apt-get -y install helm
# kubeconfig für Helm
export KUBECONFIG=/etc/rancher/rke2/rke2.yaml
helm repo add longhorn https://charts.longhorn.io
helm repo update
helm install longhorn longhorn/longhorn \
--namespace longhorn-system \
--create-namespace \
--version 1.7.2 \
--set defaultSettings.defaultReplicaCount=3 \
--set persistence.defaultClassReplicaCount=3 \
--set persistence.defaultClass=true
Begründungen:
defaultReplicaCount=3: jedes Volume wird auf alle drei Worker repliziert. Bei drei Workern ergibt das den maximal möglichen Replikationsgrad. Mit weniger als 3 Replicas und Worker-Ausfall: Volume-Read-Only.persistence.defaultClass=true: dielonghorn-StorageClass wird zum Cluster-Default. PVCs ohne explizitestorageClassNamebekommen automatisch Longhorn.
Verifikation:
kubectl -n longhorn-system get pods
kubectl get storageclass
Erwartet: alle Longhorn-Pods Running, longhorn (default) als StorageClass.
9 — cert-manager installieren
cert-manager bietet uns gleich die TLS-PKI für OpenBao als K8s-native Ressourcen.
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--version v1.15.3 \
--set crds.enabled=true
kubectl -n cert-manager get pods
Erwartet: drei Pods Running (cert-manager, cainjector, webhook).
10 — TLS-PKI für OpenBao via cert-manager
OpenBao hat zwei TLS-Pfade: Client-API (8200) und Inter-Node-Raft (8201). Beide decken wir mit einem Server-Cert ab, dessen SANs alle nötigen DNS-Namen abdecken (source: raw/docs/platform/k8s/helm/examples/ha-tls.md, raw/docs/platform/k8s/helm/examples/standalone-tls.md).
Namespace anlegen:
kubectl create namespace openbao
10.1 — Selbst-signierte Root-CA
cat <<'EOF' | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: selfsigned-bootstrap
namespace: openbao
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: openbao-ca
namespace: openbao
spec:
isCA: true
commonName: openbao-ca
duration: 87600h
secretName: openbao-ca-secret
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: selfsigned-bootstrap
kind: Issuer
EOF
Warum zwei Issuer: Der selfSigned-Issuer bootstrapt die CA, signiert aber selbst keine regulären Server-Certs. Erst ein zweiter Issuer vom Typ ca, der den privaten Schlüssel der frischen CA hat, kann das.
10.2 — CA-Issuer
cat <<'EOF' | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: openbao-ca-issuer
namespace: openbao
spec:
ca:
secretName: openbao-ca-secret
EOF
10.3 — Server-Cert mit allen SANs
cat <<'EOF' | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: openbao-server-tls
namespace: openbao
spec:
secretName: openbao-server-tls
duration: 8760h
renewBefore: 720h
commonName: openbao
dnsNames:
- openbao
- openbao.openbao
- openbao.openbao.svc
- openbao.openbao.svc.cluster.local
- openbao-internal
- openbao-internal.openbao
- openbao-internal.openbao.svc
- openbao-internal.openbao.svc.cluster.local
- "*.openbao-internal"
- "*.openbao-internal.openbao"
- "*.openbao-internal.openbao.svc"
- "*.openbao-internal.openbao.svc.cluster.local"
- openbao-active
- openbao-active.openbao.svc.cluster.local
- openbao-standby
- openbao-standby.openbao.svc.cluster.local
ipAddresses:
- 127.0.0.1
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: openbao-ca-issuer
kind: Issuer
EOF
Was die SAN-Liste abdeckt:
openbao*— der reguläre ClusterIP-Service.openbao-internal*und*.openbao-internal*— der Headless-Service plus die per-Pod-DNS-Namen (openbao-0.openbao-internal.openbao.svc.cluster.local), die Raft beim Join braucht.openbao-active/openbao-standby— die Selector-Services aus der Service-Registration (kubernetes-service-registration).127.0.0.1— damitbaolokal im Pod viahttps://127.0.0.1:8200funktioniert (z. B. für Liveness-Probes).
Verifikation:
kubectl -n openbao get certificate openbao-server-tls
kubectl -n openbao describe certificate openbao-server-tls | grep -A2 Status
Erwartet: Ready: True. Das Secret openbao-server-tls enthält jetzt tls.crt, tls.key und ca.crt.
11 — OpenBao Helm-Values mit Worker-Pinning
helm repo add openbao https://openbao.github.io/openbao-helm
helm repo update
(source: raw/docs/platform/k8s/helm/terraform.md)
values-ha.yaml schreiben — die Worker-Only-Logik ist der zentrale Unterschied gegenüber einem flachen Cluster:
cat > values-ha.yaml <<'EOF'
global:
tlsDisable: false
server:
image:
repository: "openbao/openbao"
tag: "2.0.1"
ha:
enabled: true
replicas: 3
raft:
enabled: true
setNodeId: true
config: |
ui = true
listener "tcp" {
address = "[::]:8200"
cluster_address = "[::]:8201"
tls_cert_file = "/openbao/userconfig/openbao-server-tls/tls.crt"
tls_key_file = "/openbao/userconfig/openbao-server-tls/tls.key"
tls_client_ca_file = "/openbao/userconfig/openbao-server-tls/ca.crt"
}
storage "raft" {
path = "/openbao/data"
retry_join {
leader_api_addr = "https://openbao-0.openbao-internal:8200"
leader_tls_servername = "openbao"
leader_ca_cert_file = "/openbao/userconfig/openbao-server-tls/ca.crt"
leader_client_cert_file = "/openbao/userconfig/openbao-server-tls/tls.crt"
leader_client_key_file = "/openbao/userconfig/openbao-server-tls/tls.key"
}
retry_join {
leader_api_addr = "https://openbao-1.openbao-internal:8200"
leader_tls_servername = "openbao"
leader_ca_cert_file = "/openbao/userconfig/openbao-server-tls/ca.crt"
leader_client_cert_file = "/openbao/userconfig/openbao-server-tls/tls.crt"
leader_client_key_file = "/openbao/userconfig/openbao-server-tls/tls.key"
}
retry_join {
leader_api_addr = "https://openbao-2.openbao-internal:8200"
leader_tls_servername = "openbao"
leader_ca_cert_file = "/openbao/userconfig/openbao-server-tls/ca.crt"
leader_client_cert_file = "/openbao/userconfig/openbao-server-tls/tls.crt"
leader_client_key_file = "/openbao/userconfig/openbao-server-tls/tls.key"
}
}
service_registration "kubernetes" {}
volumes:
- name: userconfig-openbao-server-tls
secret:
secretName: openbao-server-tls
volumeMounts:
- mountPath: /openbao/userconfig/openbao-server-tls
name: userconfig-openbao-server-tls
readOnly: true
# Worker-Pinning: nur auf Worker-Nodes schedulen
nodeSelector:
node-role.kubernetes.io/worker: "true"
# Keine Tolerations für den CriticalAddonsOnly-Taint setzen —
# damit landen die Pods garantiert NICHT auf den Server-Nodes.
affinity: |
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: {{ template "openbao.name" . }}
app.kubernetes.io/instance: "{{ .Release.Name }}"
component: server
topologyKey: kubernetes.io/hostname
dataStorage:
enabled: true
size: 10Gi
storageClass: longhorn
extraEnvironmentVars:
BAO_CACERT: /openbao/userconfig/openbao-server-tls/ca.crt
serviceAccount:
create: true
ui:
enabled: true
serviceType: ClusterIP
EOF
Die Punkte, die spezifisch für die 3+3-Topologie sind:
nodeSelector: node-role.kubernetes.io/worker: "true": die OpenBao-Pods landen nur auf den drei Workern, weil nur diese das Label tragen (aus Schritt 6). Server-Nodes haben das Label nicht.- Bewusst keine Tolerations: die Server-Nodes haben Taint
CriticalAddonsOnly=true:NoExecute. Ohne entsprechende Toleration kommt OpenBao dort nicht hin — was wir wollen. Wenn man später eine Toleration ergänzt (etwa für einen Notfall-Schedule), kippt diese Trennung. podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecutionmittopologyKey: kubernetes.io/hostname: höchstens ein OpenBao-Pod pro Worker. Mit drei Replikas auf drei Workern: 1:1-Mapping erzwungen. Ein vierter Pod (z. B. wenn jemandreplicas: 4setzt) bliebe für immerPending— bewusst, weil 4 Voter eine schlechte Raft-Größe sind (raft § Quorum).storageClass: longhorn: PVCs landen auf der gerade installierten StorageClass. MitdefaultReplicaCount=3repliziert Longhorn jedes OpenBao-Volume auf alle drei Worker — Volume-HA als zweite Schicht unter Raft-HA.- Drei
retry_join-Blöcke mitleader_tls_servername="openbao": jeder Pod versucht beim Start alle drei möglichen Leader. Derleader_tls_servername-Override zwingt den TLS-Code dazu, gegen den CNopenbaozu verifizieren statt gegen den per-Pod-DNS-Namen ausleader_api_addr(sonst x509-Mismatch, source:raw/docs/platform/k8s/helm/examples/ha-tls.md). service_registration "kubernetes" {}: Pod-Labels werden gepflegt, die Selector-Servicesopenbao-active/openbao-standbybekommen Endpoints. Voraussetzungen (RBAC, Downward API) erfüllt das Chart selbst. Hintergrund: kubernetes-service-registration; falls der Start hängt: service-registration-reactivation für den Egress-zur-K8s-API-Fix.
12 — Installieren
helm install openbao openbao/openbao \
--namespace openbao \
--values values-ha.yaml
kubectl -n openbao get pods -w
Erwartet: drei Pods openbao-0, openbao-1, openbao-2, je einer auf einem der drei Worker. Status anfangs Running 0/1 (Pod läuft, ist aber sealed).
Pod-Verteilung prüfen:
kubectl -n openbao get pods -o wide
Jeder Pod auf einem anderen Worker — wenn nicht, schlägt anti-affinity zu (z. B. wegen replicas: 4 oder weil ein Worker NotReady ist).
13 — Cluster initialisieren und unsealen
kubectl -n openbao exec -ti openbao-0 -- bao operator init \
-key-shares=5 \
-key-threshold=3 \
-tls-skip-verify
Ausgabe sofort wegspeichern: 5 Unseal-Keys, 1 Initial Root Token. Verliert ihr die, ist der Cluster für immer verloren.
Dann jeden Pod einzeln unsealen — drei der fünf Keys pro Pod:
for POD in openbao-0 openbao-1 openbao-2; do
for KEY in <UNSEAL_KEY_1> <UNSEAL_KEY_2> <UNSEAL_KEY_3>; do
kubectl -n openbao exec -ti ${POD} -- bao operator unseal ${KEY}
done
done
Warum jeder Pod separat: Unseal-Status wird nicht über Raft repliziert — by design, damit ein kompromittierter Knoten nicht automatisch über das Storage-Replikat geunsealed wird. Mehr in seal-unseal.
Mit den drei retry_join-Blöcken aus der values-ha.yaml verbinden sich openbao-1 und openbao-2 direkt nach dem Unseal automatisch mit openbao-0. Kein manueller bao operator raft join nötig.
14 — Verifikation
kubectl -n openbao exec -ti openbao-0 -- bao login <ROOT_TOKEN>
kubectl -n openbao exec -ti openbao-0 -- bao operator raft list-peers
Erwartet (source: raw/docs/platform/k8s/helm/examples/ha-with-raft.md):
Node Address State Voter
---- ------- ----- -----
openbao-0 openbao-0.openbao-internal:8201 leader true
openbao-1 openbao-1.openbao-internal:8201 follower true
openbao-2 openbao-2.openbao-internal:8201 follower true
Service-Registration-Labels:
kubectl -n openbao get pods -L openbao-active,openbao-sealed,openbao-version -o wide
Erwartet: genau ein Pod mit openbao-active=true, alle drei openbao-sealed=false, jeder auf einem anderen Worker.
Active-Service-Endpoint:
kubectl -n openbao get endpoints openbao-active
Erwartet: eine Pod-IP — die des aktuellen Leaders.
Externer Smoketest (von der Workstation):
kubectl -n openbao get secret openbao-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > openbao-ca.crt
kubectl -n openbao port-forward svc/openbao-active 8200:8200 &
BAO_ADDR=https://127.0.0.1:8200 BAO_CACERT=./openbao-ca.crt bao status
Erwartet: Sealed: false, HA Mode: active, Raft Applied Index-Zähler steigt.
Failover-Test:
# aktuellen Leader-Pod ermitteln:
LEADER_POD=$(kubectl -n openbao get pods -l openbao-active=true -o jsonpath='{.items[0].metadata.name}')
kubectl -n openbao delete pod ${LEADER_POD}
# ~15 Sekunden warten, dann:
kubectl -n openbao get pods -L openbao-active
kubectl -n openbao get endpoints openbao-active
Erwartet: ein anderer Pod ist jetzt openbao-active=true, das openbao-active-Endpoint zeigt auf diesen.
15 — Production-Hardening
Das Setup steht und ist HA. Nächste Stufen, jeweils mit eigener Wiki-Seite:
- Auto-Unseal statt manuellem Shamir: Cloud-KMS (AWS/GCP/Azure) oder Transit-Seal eines zweiten OpenBao-Clusters. Bei jedem Pod-Restart bleibt OpenBao sonst sealed und braucht manuellen Eingriff. Siehe seal-unseal.
- Audit-Devices: mindestens zwei mit verschiedenen Sinks. Ein blockierendes einziges Audit-Device stoppt OpenBao komplett. Siehe audit.
- Snapshot-CronJob: Raft-Snapshots automatisch nach S3-kompatiblen Storage (z. B. via Longhorn-Backup-Target). Siehe backups.
- Cilium-NetworkPolicies: Default-Deny im
openbao-Namespace, gezielt Egress zur K8s-API erlauben (sonst hängt service_registration — service-registration-reactivation § 2), Inter-Pod-Traffic auf 8200/8201 auf den Namespace beschränken, Ingress nur von berechtigten Clients. - etcd-Backup auf den Servern: RKE2 schreibt etcd-Snapshots automatisch nach
/var/lib/rancher/rke2/server/db/snapshots/(alle 12 h, 5 Snapshots Retention default). Diese auf S3/extern wegspiegeln. - kube-vip BGP statt ARP: wenn der Cluster über mehrere L2-Segmente verteilt ist oder ARP-Spoofing-Schutz aktiv ist, BGP-Mode konfigurieren (siehe kube-vip-Upstream).
- OpenBao-Telemetrie: Prometheus-Scrape der Raft-Metriken. Siehe internals.
16 — Stolperfallen
- kube-vip-Pod startet nicht mit „interface not found”:
INTERFACEin Schritt 3 zeigt auf das falsche Interface. Mitip aauf dem Server prüfen und das richtige NIC-Device eintragen. - server-2 kommt nicht ins etcd-Quorum mit „failed to dial”: häufigste Ursache ist, dass die VIP-IP noch nicht steht (kube-vip Race beim ersten Start). Lösung: server-1 stabil laufen lassen, dann erst server-2, dann server-3 — nicht parallel installieren.
- Worker bleiben „NotReady”: meist Cilium-Init-Fehler durch alten conntrack-Zustand.
sudo systemctl restart rke2-agentauf dem betroffenen Worker hilft fast immer. - OpenBao-Pods bleiben
Pending:kubectl describe pod openbao-0 -n openbaozeigt typisch „0/6 nodes are available: 3 had untolerated taint, 3 didn’t match Pod’s node affinity”. Das heißt: die Worker tragen dasnode-role.kubernetes.io/worker=true-Label nicht. Schritt 6 wiederholen (kubectl label node worker-N node-role.kubernetes.io/worker=true). x509: certificate signed by unknown authoritybeim Raft-Join:leader_tls_servernamefehlt oder passt nicht zucommonNameaus Schritt 10.3. CN muss exaktopenbaosein.- Longhorn-Volumes bleiben
Degraded: alle drei Worker müssenSchedulablesein.kubectl get nodes.longhorn.io -n longhorn-systemprüfen — wenn ein WorkerDownoderDisabledist, kein dritter Replica möglich. - iSCSI fehlt (häufig bei minimal-Ubuntu): jeder Longhorn-PVC scheitert mit „iscsi: failed to login”. Schritt 8 prüfen (
open-iscsi,iscsidaktiv). - VIP-Failover dauert >30 s:
vip_leasedurationundvip_renewdeadlinein kube-vip stehen zu hoch. Default war hier 5/3 — okay. Bei Werten >10 wird’s spürbar langsam. - RKE2-Upgrade mit
apt: RKE2 wird nicht über Apt aktualisiert. Stattdessen System-Upgrade Controller (SUC) oder erneutescurl get.rke2.io | shmit höheremINSTALL_RKE2_VERSIONauf jedem Node einzeln, beginnend mit den Servern.
Related pages
- k8s-ha-setup — die kompakte Helm-Variante ohne Cluster-Bring-up
- k8s-ha-from-scratch — End-to-End-Variante mit
kubeadmstatt RKE2 (Vergleichsfall) - k3s-ha-setup — End-to-End-Variante mit k3s, 3 stacked Nodes (Light-Variante, Edge-tauglich)
- kubernetes-platform — Helm-Chart, Injector, VSO, CSI im Überblick
- kubernetes-service-registration — Pod-Labels, RBAC, Active-Service
- service-registration-reactivation — wenn die Stanza wegen TLS-Hang ausgeschaltet werden musste
- high-availability — Leader-/Standby-Mechanik, Request-Forwarding
- raft — Raft-Konsens, Quorum, Recovery
- seal-unseal — Shamir vs. Auto-Unseal
- backups — Snapshot-Strategie für Raft + Longhorn-Volumes
- upgrading — HA-Reihenfolge beim Upgrade
- deployment-vm-vs-k8s — Entscheidungshilfe Kubernetes vs. VM-HA