secure k8 s

OpenBao HA auf Kubernetes — End-to-End auf Ubuntu 24.04

Summary: Komplette Anleitung von drei nackten Ubuntu-24.04-Servern bis zu einer funktionsfähigen 3-Node-OpenBao-HA-Installation mit Raft, TLS und Service-Registration. Jeder Befehl ist ausgeschrieben; jeder Schritt erklärt was gemacht wird und warum. Endet bei einem produktionsnahen Setup, das noch durch Auto-Unseal, Audit und Snapshots gehärtet werden kann.

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. K8s- und Ubuntu-spezifische Schritte folgen den jeweiligen Upstream-Docs (kubernetes.io, cilium.io, cert-manager.io) — diese liegen nicht im raw/-Ordner.

Last updated: 2026-05-20


0 — Architektur und Ziel

Drei Ubuntu-24.04-Server, jeder zugleich Kubernetes-Control-Plane- und Worker-Node (Stacked-etcd-HA). Auf jedem Node läuft genau ein OpenBao-Pod, alle drei bilden einen Raft-Cluster.

┌─────────────────────────────────────────────────────────────────┐
│                       3 × Ubuntu 24.04                          │
│   ┌──────────────┐    ┌──────────────┐    ┌──────────────┐      │
│   │  node-1      │    │  node-2      │    │  node-3      │      │
│   │              │    │              │    │              │      │
│   │  kubelet     │    │  kubelet     │    │  kubelet     │      │
│   │  containerd  │    │  containerd  │    │  containerd  │      │
│   │  kube-apisrv │    │  kube-apisrv │    │  kube-apisrv │      │
│   │  etcd        │    │  etcd        │    │  etcd        │      │
│   │  Cilium agent│    │  Cilium agent│    │  Cilium agent│      │
│   │  openbao-0   │    │  openbao-1   │    │  openbao-2   │      │
│   └──────────────┘    └──────────────┘    └──────────────┘      │
│         │                    │                    │             │
│         └──── Raft (8201) ───┴──── Raft (8201) ───┘             │
└─────────────────────────────────────────────────────────────────┘

Warum genau drei Nodes? Raft braucht für Quorum eine Mehrheit der Voter. Mit drei Knoten überlebt der Cluster den Ausfall eines Knotens — ein Knoten allein wäre Standalone, zwei Knoten wären „Split-Brain-anfällig”, weil 1/2 kein Quorum bildet. Hintergrund: raft § Quorum. Fünf Knoten wäre die nächste sinnvolle Stufe; vier sind nie sinnvoll, weil sie nur einen Ausfall verkraften wie drei, aber doppelt so viele Schreib-Replikationen brauchen.

Hardware-Empfehlung pro Node:

  • 2 vCPU, 4 GB RAM, 30 GB Disk minimum für den PoC.
  • 4 vCPU, 8 GB RAM, 100 GB SSD für Production (etcd + OpenBao + Workload-Reserve).
  • Netzwerk: alle drei Nodes erreichen sich auf TCP 6443 (kube-apiserver), 2379–2380 (etcd), 10250 (kubelet), 8200/8201 (OpenBao) sowie UDP 8472 oder TCP 4240 (Cilium-VXLAN bzw. Health).

1 — Voraussetzungen vor dem ersten Befehl

Auf allen drei Nodes:

  • Frisches Ubuntu 24.04 LTS Server.
  • Root- oder sudo-Zugriff.
  • Statische IPs.
  • Eindeutige Hostnames (node-1, node-2, node-3 im Folgenden).
  • Eine Methode, dass alle Nodes sich namentlich erreichen — DNS oder /etc/hosts.

Was du dir vor dem ersten sudo notieren solltest:

VariableBeispielwertErklärung
NODE1_IP, NODE2_IP, NODE3_IP10.0.1.11, 10.0.1.12, 10.0.1.13LAN-IPs der drei Nodes
CONTROL_PLANE_ENDPOINTk8s-api.example.com:6443 oder 10.0.1.11:6443DNS-Name oder IP unter dem alle Nodes die K8s-API erreichen. Wenn DNS verfügbar: lieber DNS, weil sonst der Hostname mit der IP eines spezifischen Nodes verknüpft ist — ein Cluster auf nur einer einzigen IP ist nicht HA.
POD_CIDR10.244.0.0/16CIDR für Pod-Netz, darf sich nicht mit dem Host-Netz überschneiden
SVC_CIDR10.96.0.0/12CIDR für Service-IPs (kubeadm-Default)

2 — Basis-Prep auf allen drei Nodes

Diese Schritte auf node-1, node-2 und node-3 in dieser Reihenfolge ausführen. Sie bereiten den Kernel und das Filesystem so vor, dass kubelet startet — fehlt einer der Schritte, scheitert später kubeadm init mit einer kryptischen Fehlermeldung.

2.1 — System aktualisieren

sudo apt-get update
sudo apt-get -y upgrade
sudo apt-get -y install ca-certificates curl gnupg apt-transport-https lsb-release

Warum: Kernel-Updates können Module umbenennen; Apt-Tools brauchen wir gleich für die Kubernetes-Repos.

2.2 — Swap deaktivieren

sudo swapoff -a
sudo sed -i.bak '/ swap / s/^/#/' /etc/fstab

Warum: kubelet weigert sich per Default zu starten, wenn Swap aktiv ist — der Scheduler kann sonst nicht zuverlässig RAM-Reservierungen einhalten, weil Pods in den Swap rutschen können. Es gibt einen --fail-swap-on=false-Flag, aber das ist in Produktion nicht empfehlenswert.

2.3 — Kernel-Module laden

cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

sudo modprobe overlay
sudo modprobe br_netfilter

Warum: overlay ist der Backing-Filesystem-Driver für containerd, br_netfilter verbindet iptables mit dem Linux-Bridge-Subsystem — beide werden für Pod-zu-Pod-Traffic und für kube-proxy/CNI gebraucht. Die modules-load.d-Datei sorgt dafür, dass sie auch nach Reboots geladen sind.

2.4 — sysctl-Parameter setzen

cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF

sudo sysctl --system

Warum: Damit Pakete zwischen Pods auf demselben Host und zwischen Hosts geroutet werden, muss IP-Forwarding an sein. Die Bridge-Hooks sorgen dafür, dass Pakete, die über eine Linux-Bridge laufen, auch durch iptables/nftables und damit durch kube-proxy gesehen werden.

2.5 — Hostname und /etc/hosts

Auf node-1:

sudo hostnamectl set-hostname node-1

Analog node-2 auf node-2, node-3 auf node-3.

Auf allen drei Nodes:

sudo tee -a /etc/hosts <<EOF
10.0.1.11 node-1
10.0.1.12 node-2
10.0.1.13 node-3
EOF

(IPs durch eure echten ersetzen.)

Warum: kubeadm und etcd nutzen Hostnames für die TLS-Zertifikate ihrer Cluster-Member. Ohne stabile Namens-zu-IP-Auflösung führt jeder Restart zu Cert-Mismatches.

3 — Container Runtime: containerd

Kubernetes spricht nicht direkt mit Docker — der Container-Runtime-Layer ist seit K8s 1.24 standardmäßig containerd (oder CRI-O). Wir nehmen containerd, weil es auf Ubuntu im offiziellen Repo liegt und am wenigsten Wartung braucht.

3.1 — Installation

Auf allen Nodes:

sudo apt-get update
sudo apt-get -y install containerd

3.2 — Default-Config schreiben

sudo mkdir -p /etc/containerd
sudo containerd config default | sudo tee /etc/containerd/config.toml >/dev/null

Warum: Frisches containerd hat keine config.toml. Die default-Config ist die Basis, in die wir gleich eine einzige Änderung eintragen.

3.3 — Systemd-Cgroup-Driver aktivieren

sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd
sudo systemctl enable containerd

Warum: Ubuntu 24.04 nutzt systemd als cgroup-Manager. Kubernetes erwartet, dass die Container-Runtime denselben cgroup-Manager nutzt wie das Init-System. Steht hier false (cgroupfs-Driver), gerät der Cluster in „inconsistent cgroup driver”-Fehler beim ersten Pod-Start.

4 — Kubernetes-Komponenten installieren

Auf allen Nodes — wir nehmen die offizielle Community-Quelle pkgs.k8s.io, gepinnt auf eine Minor-Version.

K8S_MINOR=v1.32

sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb/Release.key \
  | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb/ /" \
  | sudo tee /etc/apt/sources.list.d/kubernetes.list

sudo apt-get update
sudo apt-get -y install kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl

Warum apt-mark hold: unattended-upgrades würde sonst eine Minor-Version mitziehen, sobald sie verfügbar ist. K8s-Upgrades müssen aber versioniert und gestaffelt über kubeadm upgrade plan laufen, nicht über Apt — sonst geht der Cluster mitten in der Nacht hops.

5 — Cluster-Bootstrap auf node-1

Nur auf node-1:

sudo kubeadm init \
  --pod-network-cidr=10.244.0.0/16 \
  --control-plane-endpoint=node-1:6443 \
  --upload-certs

Was passiert hier:

  • kubeadm legt etcd, kube-apiserver, kube-controller-manager und kube-scheduler als statische Pods unter /etc/kubernetes/manifests/ an — kubelet startet sie nach 10–20 Sekunden.
  • Die TLS-PKI für die Control-Plane wird generiert und unter /etc/kubernetes/pki/ abgelegt.
  • --control-plane-endpoint legt fest, unter welchem DNS-Namen die HA-Control-Plane erreichbar ist — wenn ihr später einen Load Balancer vor die drei API-Server stellt (empfohlen), tragt hier dessen DNS-Namen ein, nicht die IP von node-1. Mit nur einer Eintragung gibt es bei Ausfall von node-1 keine API-Verfügbarkeit.
  • --upload-certs packt die PKI in ein verschlüsseltes Secret (kubeadm-certs) im Cluster, das genau 2 Stunden gültig ist. Mit dem mit ausgegebenen --certificate-key können node-2 und node-3 als zusätzliche Control-Plane joinen ohne manuellen Cert-Transport.

Am Ende gibt kubeadm init zwei wichtige Befehle aus — diese Ausgabe wegspeichern:

You can now join any number of the control-plane node by running:
  kubeadm join node-1:6443 --token … --discovery-token-ca-cert-hash sha256:… \
    --control-plane --certificate-key …

Then you can join any number of worker nodes by running:
  kubeadm join node-1:6443 --token … --discovery-token-ca-cert-hash sha256:…

5.1 — kubectl für den admin-User einrichten

Auf node-1 (oder eurer Workstation, wenn ihr /etc/kubernetes/admin.conf dorthin kopiert):

mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
kubectl get nodes

kubectl get nodes zeigt jetzt node-1 mit Status NotReady — das ist erwartet, weil noch kein CNI installiert ist (siehe Schritt 7).

6 — node-2 und node-3 als Control-Plane joinen

Auf node-2:

sudo kubeadm join node-1:6443 \
  --token <token-aus-init-output> \
  --discovery-token-ca-cert-hash sha256:<hash-aus-init-output> \
  --control-plane \
  --certificate-key <cert-key-aus-init-output>

Dasselbe analog auf node-3.

Wenn der --certificate-key älter als 2 Stunden ist, generiert ihr auf node-1 einen neuen:

sudo kubeadm init phase upload-certs --upload-certs

Anschließend auf node-1:

kubectl get nodes

Erwartet: drei Nodes, alle NotReady mit Rolle control-plane.

6.1 — Taints entfernen, damit Workloads auf Control-Plane-Nodes laufen

In einem reinen Drei-Node-Setup sind alle Knoten gleichzeitig Worker. Per Default verbietet ein Taint das Schedulen normaler Pods auf Control-Plane-Nodes — der muss runter:

kubectl taint nodes --all node-role.kubernetes.io/control-plane-

Warum: ohne diesen Befehl bleibt der OpenBao-StatefulSet hängen, weil kein Worker-Knoten zum Schedulen existiert. In größeren Clustern (≥5 Nodes) lässt man den Taint stehen und stellt die OpenBao-Pods bewusst nur auf dedizierte Worker.

7 — CNI: Cilium installieren

Ohne CNI bleibt jeder Pod isoliert. Wir nehmen Cilium, weil es eBPF-basiert ist, NetworkPolicies sauber kann (relevant für service-registration-reactivation später) und mit kubeadm gut zusammenspielt.

7.1 — Helm installieren

Falls Helm noch nicht da ist (auf node-1 oder eurer 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

7.2 — Cilium installieren

helm repo add cilium https://helm.cilium.io/
helm repo update

helm install cilium cilium/cilium \
  --namespace kube-system \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=node-1 \
  --set k8sServicePort=6443 \
  --set ipam.mode=kubernetes \
  --set ipam.operator.clusterPoolIPv4PodCIDRList='{10.244.0.0/16}'

Was hier passiert:

  • kubeProxyReplacement=true ersetzt kube-proxy durch Ciliums eBPF-Lösung — weniger iptables-Last und sauberere NetworkPolicy-Semantik. Wer das nicht will, lässt den Schalter auf false; dann muss kube-proxy als DaemonSet weiterlaufen.
  • ipam.mode=kubernetes liest Pod-CIDRs aus den Node-Spec-Feldern (spec.podCIDR), die kubeadm init mit dem Flag aus Schritt 5 gefüllt hat.

Verifikation:

kubectl -n kube-system get pods -l k8s-app=cilium
kubectl get nodes

Erwartet: drei Cilium-Pods im Status Running, alle drei Nodes auf Ready.

8 — Storage-Provisioner: local-path-provisioner

OpenBao mit Raft braucht persistente Storage pro Pod (server.dataStorage). Auf einem Standalone-Cluster ohne externes Storage-System ist local-path-provisioner von Rancher die einfachste Lösung — er macht aus einem Verzeichnis auf dem Node ein dynamisches PV.

kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml
kubectl annotate storageclass local-path storageclass.kubernetes.io/is-default-class=true

Verifikation:

kubectl get storageclass

Erwartet: local-path (default).

Caveat: Bei local-path lebt das Volume auf dem Node — fällt der Node weg, ist auch das Volume weg. Für PoC und Lab in Ordnung, weil Raft die Daten ohnehin auf den anderen zwei Pods replikiert. Für ernsthaften Betrieb stattdessen Longhorn oder ein externes Storage-Backend nehmen. Hintergrund-Diskussion: storage und backups.

9 — cert-manager installieren

cert-manager managt X.509-Zertifikate als K8s-Ressourcen — wir erzeugen damit gleich eine eigene CA und ein Server-Cert für OpenBao.

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

Verifikation:

kubectl -n cert-manager get pods

Erwartet: drei Pods (cert-manager, cert-manager-cainjector, cert-manager-webhook), alle Running.

10 — TLS-PKI für OpenBao mit cert-manager

OpenBao hat zwei TLS-Pfade:

  1. Client-API (Port 8200) — alle Clients sprechen TLS.
  2. Inter-Node-Raft (Port 8201) — die drei Pods sprechen mTLS untereinander, sonst scheitert bao operator raft join an einem CN-Mismatch (source: raw/docs/platform/k8s/helm/examples/ha-tls.md).

Beide Pfade decken wir mit einem Server-Cert ab, das alle nötigen DNS-Namen als SANs trägt.

Namespace anlegen:

kubectl create namespace openbao

10.1 — Selbst-signierte Root-CA erzeugen

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   # 10 Jahre
  secretName: openbao-ca-secret
  privateKey:
    algorithm: ECDSA
    size: 256
  issuerRef:
    name: selfsigned-bootstrap
    kind: Issuer
EOF

Was hier passiert:

  • Der Bootstrap-Issuer ist ein “selfSigned”-Issuer — der signiert die nächste Stufe (die CA) mit dem privaten Schlüssel des Subjekts selbst.
  • Die CA-Certificate-Ressource erzeugt ein CA-Schlüsselpaar; cert-manager legt das Ergebnis als Secret openbao-ca-secret ab. Dieses Secret enthält tls.crt (= CA-Cert), tls.key (= CA-Schlüssel) und ca.crt.

10.2 — Issuer für Server-Zertifikate

Aus dem CA-Secret bauen wir einen Issuer vom Typ ca — der signiert die eigentlichen Server-Certs für OpenBao:

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

Warum zwei Issuer? Der selfSigned-Issuer kann keine separaten Server-Certs ausstellen — er ist dafür gedacht, eine CA zu bootstrappen. Die signierten Server-Certs kommen vom ca-Issuer, der den privaten Schlüssel der CA hat.

10.3 — Server-Certificate für OpenBao

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   # 1 Jahr
  renewBefore: 720h # 30 Tage vor Ablauf neu ausstellen
  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 (source: raw/docs/platform/k8s/helm/examples/standalone-tls.md für die SAN-Konvention):

  • openbao* — der reguläre ClusterIP-Service.
  • openbao-internal* — der Headless-Service, über den die Pods sich gegenseitig per DNS sehen (openbao-0.openbao-internal.openbao.svc.cluster.local). Das Wildcard *.openbao-internal* deckt diese Pod-DNS-Namen ab.
  • openbao-active / openbao-standby — die Selector-Services aus der Service Registration.
  • 127.0.0.1 — damit bao aus dem Pod heraus via https://127.0.0.1:8200 funktioniert (z. B. für Liveness-Probes).

cert-manager rotiert das Cert automatisch 30 Tage vor Ablauf (renewBefore). 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 — Server-Cert.
  • tls.key — privater Schlüssel.
  • ca.crt — die Root-CA aus Schritt 10.1.

Genau diese drei Dateien hängt der Helm-Chart gleich als Volume in die OpenBao-Pods.

11 — OpenBao Helm-Repo und Values

helm repo add openbao https://openbao.github.io/openbao-helm
helm repo update
helm search repo openbao/openbao

(source: raw/docs/platform/k8s/helm/terraform.md)

Jetzt die values-ha.yaml schreiben. Diese Datei ist das Herzstück — alle Stellschrauben für HA, TLS und Service-Registration leben hier:

cat > values-ha.yaml <<'EOF'
global:
  tlsDisable: false

server:
  image:
    repository: "openbao/openbao"
    tag: "2.0.1"

  # 3-Pod-Setup mit Pod-Anti-Affinity:
  # je ein OpenBao-Pod pro Node, sonst gehen bei Node-Ausfall
  # potentiell zwei Pods gleichzeitig down.
  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" {}

  # Cert-Secret als Volume mounten (cert-manager hat es gerade angelegt)
  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

  # Pod-Anti-Affinity: höchstens ein OpenBao-Pod pro Node
  affinity: |
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels:
              app.kubernetes.io/name: {{ template "openbao.name" . }}
              app.kubernetes.io/instance: "{{ .Release.Name }}"
              component: server
          topologyKey: kubernetes.io/hostname

  # Persistenz für Raft
  dataStorage:
    enabled: true
    size: 10Gi
    storageClass: local-path

  # bao binary findet die CA aus dem gemounteten Secret
  extraEnvironmentVars:
    BAO_CACERT: /openbao/userconfig/openbao-server-tls/ca.crt

  # ServiceAccount mit Patch-Rechten auf Pods (für service_registration)
  serviceAccount:
    create: true

ui:
  enabled: true
  serviceType: ClusterIP
EOF

Die wichtigsten Punkte zur Begründung:

  • Drei explizite retry_join-Blöcke: Jeder Pod versucht beim Start alle drei möglichen Leader anzusprechen — solange einer erreichbar ist, joint der Pod. Das Setup mit drei Blöcken folgt Solution 1 aus der TLS-Doku (source: raw/docs/platform/k8s/helm/examples/ha-tls.md); siehe k8s-ha-setup § Production hardening.
  • leader_tls_servername = "openbao" zwingt den TLS-Client-Code dazu, gegen den CN des Certs (openbao) zu verifizieren statt gegen den DNS-Namen aus dem leader_api_addr (z. B. openbao-1.openbao-internal) — sonst x509-Mismatch, wie in der ha-tls-Quelle beschrieben.
  • service_registration "kubernetes" {} ist aktiv: Pods werden mit openbao-active/openbao-sealed/openbao-version gelabelt; die Selector-Services bekommen Endpoints. Voraussetzungen (RBAC, Downward API) erfüllt das Chart selbst. Hintergrund: kubernetes-service-registration. Falls das Setup später hängt, siehe service-registration-reactivation.
  • podAntiAffinity required…: schedult nie zwei OpenBao-Pods auf denselben Node. Ohne diese Regel könnten bei drei Replikas und drei Nodes zwei Pods auf node-1 landen — Quorum-Verlust bei einem einzigen Node-Ausfall.
  • server.image.tag = "2.0.1": explizit pinnen, nicht latest. Upgrade siehe upgrading.

12 — Installieren

helm install openbao openbao/openbao \
  --namespace openbao \
  --values values-ha.yaml

Sofort danach beobachten:

kubectl -n openbao get pods -w

Erwartet: drei Pods openbao-0, openbao-1, openbao-2. Status anfangs Running aber 0/1 Ready — das ist erwartet: die Pods laufen, sind aber sealed und damit nicht „bereit”. Erst nach bao operator init + unseal werden sie ready.

13 — Cluster initialisieren und unsealen

bao operator init darf nur einmal in der Lebenszeit eines Clusters ausgeführt werden (source: seal-unseal). Wir machen das auf openbao-0:

kubectl -n openbao exec -ti openbao-0 -- bao operator init \
  -key-shares=5 \
  -key-threshold=3 \
  -tls-skip-verify

Was die Optionen tun:

  • -key-shares=5 -key-threshold=3 — Shamir teilt den Master-Key in 5 Anteile auf; 3 davon genügen zum Unseal. Default ist 5/3, aber explizit zu setzen ist sauberer.
  • -tls-skip-verify — erlaubt den lokalen TLS-Call ohne die CA mitzugeben. Alternativ kann man BAO_CACERT setzen (haben wir oben gemacht) und das Flag weglassen.

Die Ausgabe enthält fünf Unseal-Keys (Base64) und einen Initial Root Token — sofort wegspeichern (Password-Manager oder verschlüsselte Datei), sonst ist der Cluster für immer verloren.

13.1 — Pods unsealen

Drei der fünf Unseal-Keys nacheinander durch bao operator unseal schicken — auf jedem Pod einzeln:

# openbao-0
kubectl -n openbao exec -ti openbao-0 -- bao operator unseal <UNSEAL_KEY_1>
kubectl -n openbao exec -ti openbao-0 -- bao operator unseal <UNSEAL_KEY_2>
kubectl -n openbao exec -ti openbao-0 -- bao operator unseal <UNSEAL_KEY_3>

# openbao-1
kubectl -n openbao exec -ti openbao-1 -- bao operator unseal <UNSEAL_KEY_1>
kubectl -n openbao exec -ti openbao-1 -- bao operator unseal <UNSEAL_KEY_2>
kubectl -n openbao exec -ti openbao-1 -- bao operator unseal <UNSEAL_KEY_3>

# openbao-2
kubectl -n openbao exec -ti openbao-2 -- bao operator unseal <UNSEAL_KEY_1>
kubectl -n openbao exec -ti openbao-2 -- bao operator unseal <UNSEAL_KEY_2>
kubectl -n openbao exec -ti openbao-2 -- bao operator unseal <UNSEAL_KEY_3>

Warum jeder Pod separat: Der Unseal-Status wird nicht über Raft repliziert — er ist ein Pod-lokaler Zustand. Das ist by design, damit ein kompromittierter Knoten nicht durch das Storage-Replikat automatisch unsealed wird. Mehr dazu in seal-unseal.

Mit retry_join aus values-ha.yaml verbinden sich openbao-1 und openbao-2 nach dem Unseal automatisch mit openbao-0 — kein manueller bao operator raft join nötig (das wäre nur bei Setup ohne retry_join nötig, wie in der Minimalanleitung k8s-ha-setup beschrieben).

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

Sind alle drei Pods Voter mit State leader/follower, ist Raft synchron.

Status-Labels von Service Registration prüfen:

kubectl -n openbao get pods -L openbao-active,openbao-sealed,openbao-version

Erwartet: genau ein Pod hat openbao-active=true, alle haben openbao-sealed=false, eine gesetzte Versions-Spalte. Hintergrund und Failover-Test: kubernetes-service-registration § 9.

Active-Service-Endpoint prüfen:

kubectl -n openbao get endpoints openbao-active

Erwartet: eine Pod-IP — die des aktuellen Leaders.

Von einer Workstation aus mit dem CA-Cert:

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

Sealed: false, HA Mode: active — der Cluster lebt.

15 — Production-Hardening (Kurzliste)

Das Setup oben ist funktionsfähig, aber noch nicht produktionsreif. Nächste Schritte, jeweils mit eigener Wiki-Seite:

  • Auto-Unseal statt manuellem Shamir-Unseal beim Pod-Restart. Cloud-KMS (AWS/GCP/Azure) oder Transit-Seal eines zweiten OpenBao-Clusters. Siehe seal-unseal.
  • Audit-Devices aktivieren — mindestens zwei mit verschiedenen Sinks; ein einzelnes blockierendes Audit Device legt den Cluster lahm. Siehe audit.
  • Snapshots als CronJob automatisieren. Siehe backups.
  • NetworkPolicies für Egress/Ingress zwischen Pods. Cilium kann das nativ. Wichtig: Egress zur K8s-API erlauben, sonst hängt service_registration — siehe service-registration-reactivation § 2.
  • Persistenz auf echtes Storage umziehen — local-path ist Node-lokal. Longhorn oder externes CSI. Siehe storage.
  • HA für die Control-Plane mit echtem LB. Der --control-plane-endpoint=node-1:6443 aus Schritt 5 ist Single-Point-of-Failure für die K8s-API; in echt einen kube-vip / HAProxy / Cloud-LB davorstellen.
  • VAULT_ADDR / BAO_ADDR der Clients konsequent auf openbao-active:8200 zeigen, nicht auf den Round-Robin-Service — Hintergrund: kubernetes-service-registration § 7.

16 — Typische Stolperfallen

  • kubeadm init hängt bei „[apiclient] Created API client”: meist swap nicht aus, oder cgroup-Driver-Mismatch zwischen kubelet und containerd. Beide aus Schritt 2.2 und 3.3 prüfen.
  • Pods bleiben 0/1 Ready nach Unseal: Active-/Standby-Service-Endpoints prüfen. Wenn leer, ist meist service_registration deaktiviert oder die NetworkPolicy blockt den K8s-API-Egress — siehe service-registration-reactivation.
  • x509: certificate signed by unknown authority beim bao operator raft join: leader_tls_servername fehlt oder passt nicht zum CN aus Schritt 10.3. Der CN im Server-Cert ist openbao; genau diesen String braucht jeder retry_join-Block.
  • openbao-0 startet, openbao-1 und openbao-2 aber nicht: meist Anti-Affinity zu strikt und zu wenige Nodes. Vor dem helm install prüfen, dass alle drei Nodes Ready sind und gleichwertig sind (kein Taint, der die anderen ausschließt).
  • CA-Cert läuft nach 10 Jahren ab — und niemand denkt rechtzeitig dran: renewBefore der CA selbst auf z. B. 8000h setzen, sodass cert-manager rechtzeitig blökt. Oder die CA halt manuell rotieren, was sich durch alle Server-Certs durchzieht.
  • Initial Root Token verloren: mit dem bao operator generate-root-Verfahren neu erzeugbar, solange ihr noch das Unseal-Key-Threshold habt. Siehe tokens.