secure k8 s

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-apiserver bereit. Ohne VIP müsste jeder Client (kubectl, Agent-Join, externe Tools) auf einen spezifischen Server zeigen — der wird zum SPOF.

Hardware-Empfehlung:

RollevCPURAMDiskHinweis
Server (×3)48 GB50 GB SSDetcd braucht fsync-fähige SSD
Worker (×3)48 GB200 GB SSD100 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

VariableBeispielBedeutung
VIP10.0.1.10Virtuelle IP, die kube-vip auf den Server-Nodes hosten wird. Muss frei sein und im selben Subnet liegen wie die Server-Nodes.
VIP_DNSrke2-api.example.com(Optional) DNS-Name, der auf VIP zeigt. Empfohlen — sonst sind die TLS-SANs an die IP gebunden.
SERVER_NODESserver-1/2/3 → 10.0.1.11–.13Die drei Control-Plane-Nodes.
WORKER_NODESworker-1/2/3 → 10.0.1.21–.23Die drei Worker-Nodes.
RKE2_TOKENlanger Zufalls-StringShared Secret für Cluster-Join. Generieren mit openssl rand -hex 32.
RKE2_VERSIONv1.32.2+rke2r1Konkrete 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 mit x509: 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 die service_registration "kubernetes"-Konstellation relevant (Egress zur K8s-API per kube-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 über server: 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 den nodeSelector der 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: die longhorn-StorageClass wird zum Cluster-Default. PVCs ohne explizite storageClassName bekommen 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 — damit bao lokal im Pod via https://127.0.0.1:8200 funktioniert (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: requiredDuringSchedulingIgnoredDuringExecution mit topologyKey: 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 jemand replicas: 4 setzt) bliebe für immer Pending — bewusst, weil 4 Voter eine schlechte Raft-Größe sind (raft § Quorum).
  • storageClass: longhorn: PVCs landen auf der gerade installierten StorageClass. Mit defaultReplicaCount=3 repliziert Longhorn jedes OpenBao-Volume auf alle drei Worker — Volume-HA als zweite Schicht unter Raft-HA.
  • Drei retry_join-Blöcke mit leader_tls_servername="openbao": jeder Pod versucht beim Start alle drei möglichen Leader. Der leader_tls_servername-Override zwingt den TLS-Code dazu, gegen den CN openbao zu verifizieren statt gegen den per-Pod-DNS-Namen aus leader_api_addr (sonst x509-Mismatch, source: raw/docs/platform/k8s/helm/examples/ha-tls.md).
  • service_registration "kubernetes" {}: Pod-Labels werden gepflegt, die Selector-Services openbao-active/openbao-standby bekommen 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”: INTERFACE in Schritt 3 zeigt auf das falsche Interface. Mit ip a auf 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-agent auf dem betroffenen Worker hilft fast immer.
  • OpenBao-Pods bleiben Pending: kubectl describe pod openbao-0 -n openbao zeigt typisch „0/6 nodes are available: 3 had untolerated taint, 3 didn’t match Pod’s node affinity”. Das heißt: die Worker tragen das node-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 authority beim Raft-Join: leader_tls_servername fehlt oder passt nicht zu commonName aus Schritt 10.3. CN muss exakt openbao sein.
  • Longhorn-Volumes bleiben Degraded: alle drei Worker müssen Schedulable sein. kubectl get nodes.longhorn.io -n longhorn-system prüfen — wenn ein Worker Down oder Disabled ist, 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, iscsid aktiv).
  • VIP-Failover dauert >30 s: vip_leaseduration und vip_renewdeadline in 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 erneutes curl get.rke2.io | sh mit höherem INSTALL_RKE2_VERSION auf jedem Node einzeln, beginnend mit den Servern.