secure k8 s

OpenBao HA auf k3s — 3 stacked Nodes auf Ubuntu 24.04

Summary: End-to-End-Anleitung von drei nackten Ubuntu-24.04-Servern bis zum funktionsfähigen OpenBao-HA-Cluster auf k3s. Topologie: drei stacked Nodes, jeder gleichzeitig Control-Plane (mit embedded etcd) und Worker. Inkl. kube-vip für die API-VIP, k3s’ eingebautem local-path-Provisioner als StorageClass, cert-manager für die TLS-PKI und der OpenBao-Helm-Installation mit Raft, Service-Registration und Pod-Anti-Affinity. Jeder Befehl 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. k3s- und kube-vip-Schritte folgen den jeweiligen Upstream-Docs (docs.k3s.io, kube-vip.io) — diese liegen nicht im raw/-Ordner.

Last updated: 2026-05-20


0 — Warum k3s und für wen ist diese Anleitung

k3s ist die Light-Variante von Rancher/SUSE: ein einziges Go-Binary unter 70 MB, mit containerd, Flannel, kube-proxy, local-path-provisioner, Traefik und CoreDNS eingebaut. Im Vergleich:

Aspektkubeadmk3sRKE2
Footprintmittelkleinmittel
Container-Runtimemanuellembeddedembedded
CNI defaultkeinerFlannelCanal/Cilium
Storage defaultkeinerlocal-pathkeiner
Ingress defaultkeinerTraefiknginx
etcd defaultexternsqlite (HA: embedded etcd)embedded etcd
ZielgruppeDIY/lernenEdge, kleine Cluster, LabEnterprise, regulated
Anleitungk8s-ha-from-scratchdiese Seiterke2-ha-setup

Diese Seite ist die richtige Wahl, wenn:

  • Ein kleines, kompaktes HA-Cluster auf 3 VMs reicht.
  • Ressourcen knapp sind (z. B. 4 GB RAM pro Node, Edge-Hardware).
  • Eine schnelle Bring-Up-Zeit wichtiger ist als Enterprise-Hardening.

Wenn Pod-Trennung in dedizierte Master/Worker-Nodes gewünscht ist (Compliance, Lasttrennung): rke2-ha-setup nehmen.

1 — Architektur

Drei Ubuntu-24.04-Server, jeder zugleich k3s-Server (Control-Plane + embedded etcd) und Workload-Host. Eine virtuelle IP via kube-vip sorgt dafür, dass der API-Endpoint trotz HA einen einzigen Namen hat.

                          ┌──────────────────────┐
                          │   VIP 10.0.1.10      │
                          │   (kube-vip,         │
                          │    port 6443)        │
                          └──────────┬───────────┘

              ┌──────────────────────┼──────────────────────┐
              │                      │                      │
       ┌──────▼──────┐        ┌──────▼──────┐        ┌──────▼──────┐
       │   node-1    │        │   node-2    │        │   node-3    │
       │ 10.0.1.11   │        │ 10.0.1.12   │        │ 10.0.1.13   │
       │             │        │             │        │             │
       │ k3s server  │        │ k3s server  │        │ k3s server  │
       │ etcd        │◀───────│ etcd        │───────▶│ etcd        │
       │ apiserver   │  raft  │ apiserver   │  raft  │ apiserver   │
       │ kubelet     │        │ kubelet     │        │ kubelet     │
       │ flannel     │        │ flannel     │        │ flannel     │
       │ kube-vip    │        │ kube-vip    │        │ kube-vip    │
       │ openbao-0   │        │ openbao-1   │        │ openbao-2   │
       └─────────────┘        └─────────────┘        └─────────────┘

Warum genau drei Nodes (analog zu allen anderen HA-Setups): etcd und OpenBao-Raft brauchen Quorum-Mehrheit. Mit drei Nodes überlebt der Cluster den Ausfall genau eines Nodes. Hintergrund: raft § Quorum.

Hardware-Empfehlung:

RollevCPURAMDiskHinweis
Node (×3)24 GB50 GB SSDk3s + OpenBao + Reserve. Bei stärkerer Workload: 4 vCPU / 8 GB.

Netzwerk: alle drei Nodes im selben L2 (sonst funktioniert kube-vip im ARP-Mode nicht). Inter-Node-Ports: 6443 (apiserver), 2379–2380 (etcd), 10250 (kubelet), 8472/UDP (Flannel VXLAN), 51820/UDP optional (wenn Flannel mit WireGuard).

2 — Voraussetzungen vor dem ersten Befehl

VariableBeispielBedeutung
VIP10.0.1.10Freie VIP im selben Subnet wie die Nodes
VIP_DNSk3s-api.example.comDNS-Name auf VIP (empfohlen — sonst nur IP in TLS-SANs)
NODE_IP10.0.1.11/12/13LAN-IPs der drei Nodes
K3S_TOKENZufalls-StringShared Secret für Cluster-Join
K3S_VERSIONv1.32.2+k3s1Konkrete Version pinnen

Token einmal generieren und in Passwortmanager ablegen:

openssl rand -hex 32

/etc/hosts auf allen drei Nodes:

sudo tee -a /etc/hosts <<EOF
10.0.1.10 k3s-api.example.com
10.0.1.11 node-1
10.0.1.12 node-2
10.0.1.13 node-3
EOF

3 — Basis-Prep auf allen drei Nodes

k3s bringt fast alles mit — der Pre-Flight ist deutlich kürzer als bei kubeadm.

3.1 — System aktualisieren

sudo apt-get update
sudo apt-get -y upgrade
sudo apt-get -y install curl ca-certificates iptables vim

3.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 kubeadm und RKE2.

3.3 — ufw / Firewall

sudo systemctl disable --now ufw || true
sudo apt-get -y remove ufw || true

Warum: k3s setzt seine iptables-Regeln selbst; ufw würde sie überschreiben oder filtern. Wenn ufw zwingend bleiben muss (Compliance), alle Ports aus § 1 explizit freigeben.

3.4 — Hostnames

# auf node-1:
sudo hostnamectl set-hostname node-1
# analog für node-2 und node-3

3.5 — IPv4-Forwarding (sicherheitshalber)

k3s setzt das normalerweise selbst, aber explizit schadet nicht:

echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/k3s.conf
sudo sysctl --system

4 — kube-vip für die API-VIP vorbereiten

Wir legen das kube-vip-Manifest in das Auto-Apply-Verzeichnis, das k3s direkt nach dem Start einliest. Auf allen drei Nodes:

sudo mkdir -p /var/lib/rancher/k3s/server/manifests

VIP=10.0.1.10
INTERFACE=$(ip route | awk '/default/ {print $5; exit}')
KUBE_VIP_VERSION=v0.8.2

sudo tee /var/lib/rancher/k3s/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_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:

  • /var/lib/rancher/k3s/server/manifests/ ist k3s’ Auto-Apply-Verzeichnis. Jede YAML-Datei darin wird beim Start gegen die API geapplied — saubere Möglichkeit, „Bootstrap-Workloads” Teil der Installation zu machen.
  • vip_arp: "true": kube-vip announct die VIP per Gratuitous-ARP. Funktioniert ohne BGP/Router-Config, aber nur innerhalb desselben L2-Segments.
  • vip_leaderelection: "true": nur einer der drei kube-vip-Instanzen hält die VIP zu jeder Zeit; bei Ausfall springt sie in 5–10 Sekunden weiter.
  • nodeSelector: control-plane: in k3s sind alle drei Server gleichzeitig Control-Plane — alle drei sind kube-vip-Kandidaten.

Alternative ohne VIP (nicht empfohlen, aber funktioniert für PoC): direkt die IP von node-1 als API-Endpoint nehmen. Bei Ausfall von node-1 ist dann die K8s-API offline, obwohl der Cluster eigentlich weiterläuft. Für Production daher kube-vip nehmen.

5 — k3s auf node-1 (Cluster-Init)

Auf node-1 — und nur dort — die config.yaml mit cluster-init: true:

sudo mkdir -p /etc/rancher/k3s

sudo tee /etc/rancher/k3s/config.yaml >/dev/null <<EOF
write-kubeconfig-mode: "0640"
token: ${K3S_TOKEN}
tls-san:
  - k3s-api.example.com
  - 10.0.1.10
  - node-1
  - node-2
  - node-3
cluster-init: true
disable:
  - traefik
EOF

Begründungen der Optionen:

  • token: das in Schritt 2 erzeugte Shared Secret. Muss auf allen drei Nodes identisch sein.
  • tls-san: zusätzliche DNS-Namen und IPs im API-Server-Cert. Ohne diese Liste meckert jeder kubectl-Call über die VIP mit x509: certificate is valid for …, not k3s-api.example.com.
  • cluster-init: true: macht diesen Node zum etcd-Bootstrap-Seed. Nur auf einem einzigen Node setzen — sonst wird ein zweiter, paralleler Cluster gebootet. Implizit aktiviert das den embedded-etcd-Backend (statt sqlite); für HA zwingend.
  • disable: traefik: spart Ressourcen, weil wir keinen Ingress brauchen (Clients gehen direkt auf den OpenBao-Service). Wer Traefik will, weglassen.

Installieren:

curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.32.2+k3s1 sh -

get.k3s.io schreibt automatisch eine Unit k3s.service und startet sie. Status prüfen:

sudo systemctl status k3s
sudo journalctl -u k3s -f

Sobald „k3s is up and running” oder ähnlich erscheint, mit Ctrl-C raus. Dann kubectl bedienen können:

mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
sed -i "s/127.0.0.1/k3s-api.example.com/" ~/.kube/config

# k3s bringt kubectl als symlink mit
kubectl get nodes

Erwartet: node-1 mit Status Ready und Rollen control-plane,etcd,master.

6 — node-2 und node-3 joinen

Auf node-2 (und analog node-3):

sudo mkdir -p /etc/rancher/k3s

sudo tee /etc/rancher/k3s/config.yaml >/dev/null <<EOF
write-kubeconfig-mode: "0640"
token: ${K3S_TOKEN}
server: https://k3s-api.example.com:6443
tls-san:
  - k3s-api.example.com
  - 10.0.1.10
  - node-1
  - node-2
  - node-3
disable:
  - traefik
EOF

curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.32.2+k3s1 sh -

Unterschiede zu node-1:

  • Kein cluster-init: true — sonst startet ein zweiter Cluster.
  • server: https://k3s-api.example.com:6443 — über die VIP joinen, nicht über die IP von node-1. Wenn node-1 später ausfällt, schadet das dem Join-Vorgang neuer Nodes nicht.

Nach 1–2 Minuten auf node-1:

kubectl get nodes

Erwartet: drei Nodes, alle Ready, alle mit Rollen control-plane,etcd,master.

7 — Cluster-Verifikation

kubectl get nodes -o wide
kubectl get pods -A
kubectl describe node node-1 | grep -A3 Taints

Erwartet bei describe node:

Taints: <none>

k3s tainted Server-Nodes nicht — sie laufen sofort auch als Worker. Das ist genau, was wir bei drei stacked Nodes wollen.

VIP-Failover testen (empfohlen):

# auf einer Workstation:
ping -c 5 k3s-api.example.com
# danach: k3s auf dem aktuell VIP-haltenden Node stoppen
sudo systemctl stop k3s
# nach ~5–10 s muss die VIP auf einen anderen Node gewandert sein
ping -c 5 k3s-api.example.com
# k3s wieder starten
sudo systemctl start k3s

8 — StorageClass prüfen

k3s bringt local-path als Default-StorageClass mit — kein Helm-Install nötig:

kubectl get storageclass

Erwartet: local-path (default).

Caveat: local-path ist Node-lokal. Fällt der Node weg, ist auch das Volume weg. Für 3-Node-OpenBao mit Raft ist das OK — Raft repliziert die Daten ohnehin auf allen drei Pods, jede Pod-PVC liegt auf einem anderen Node (durchgesetzt durch Pod-Anti-Affinity in § 11). Bei Verlust eines Nodes übernimmt einer der überlebenden zwei. Für andere Workloads ohne eigene Replikation wäre Longhorn die bessere Wahl — siehe Production-Hardening in § 15.

9 — Helm und cert-manager installieren

9.1 — Helm

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

9.2 — cert-manager

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 spricht TLS auf dem Client-Port 8200 und mTLS auf dem Raft-Inter-Node-Port 8201. Beide decken wir mit einem Server-Cert ab, das alle nötigen DNS-Namen als SANs trägt (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, kann selbst aber keine regulären Server-Certs ausstellen. Den eigentlichen Server-Cert signiert ein zweiter Issuer vom Typ ca, der den frischen privaten Schlüssel der CA verwendet.

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 kubernetes-service-registration.
  • 127.0.0.1 — damit bao lokal im Pod via https://127.0.0.1:8200 funktioniert (Liveness-Probes etc.).

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

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:

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

  # 1 Pod pro Node — bei drei Nodes und drei Replikas: 1:1
  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: local-path

  extraEnvironmentVars:
    BAO_CACERT: /openbao/userconfig/openbao-server-tls/ca.crt

  serviceAccount:
    create: true

ui:
  enabled: true
  serviceType: ClusterIP
EOF

Die wichtigen Punkte:

  • ha.replicas: 3 — drei Pods, einer pro Node.
  • Drei explizite retry_join-Blöcke mit leader_tls_servername="openbao": jeder Pod versucht beim Start alle drei möglichen Leader. Der TLS-Servername-Override zwingt den TLS-Code, gegen den CN openbao zu verifizieren, nicht gegen den per-Pod-DNS 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 erfüllt das Chart selbst. Hintergrund: kubernetes-service-registration. Falls das Setup hängt: service-registration-reactivation.
  • podAntiAffinity: required: höchstens ein OpenBao-Pod pro Node. Mit drei Replikas auf drei Nodes: 1:1. Ein vierter Pod (wenn jemand replicas: 4 setzt) bleibt für immer Pending — bewusst, weil 4 Voter keine sinnvolle Raft-Größe sind (raft § Quorum).
  • storageClass: local-path: nutzt k3s’ eingebauten Provisioner. Volume liegt auf dem jeweiligen Node, OpenBao-Raft repliziert die Daten zwischen den Pods.

12 — Installieren

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

kubectl -n openbao get pods -o wide -w

Erwartet: drei Pods openbao-0, openbao-1, openbao-2, einer pro Node. Status anfangs Running 0/1 (laufen, aber sealed → nicht ready). Ctrl-C raus, sobald alle drei laufen.

13 — Cluster initialisieren und unsealen

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

Output sofort wegspeichern: 5 Unseal-Keys + 1 Initial Root Token. Verlust = Cluster für immer tot.

Pod-für-Pod unsealen (drei 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 retry_join-Blöcken verbinden sich openbao-1 und openbao-2 automatisch mit openbao-0, sobald sie unsealed sind. Kein manueller bao operator raft join.

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 hat openbao-active=true, alle drei openbao-sealed=false.

Active-Service-Endpoint:

kubectl -n openbao get endpoints openbao-active

Erwartet: eine Pod-IP — die des Leaders.

Externer Smoketest:

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.

Failover-Test:

LEADER_POD=$(kubectl -n openbao get pods -l openbao-active=true -o jsonpath='{.items[0].metadata.name}')
kubectl -n openbao delete pod ${LEADER_POD}
sleep 15
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

  • Auto-Unseal statt manuellem Shamir: Cloud-KMS oder Transit-Seal eines zweiten OpenBao-Clusters. Sonst bleibt jeder Pod nach Restart sealed. Siehe seal-unseal.
  • Audit-Devices: mindestens zwei mit verschiedenen Sinks. Ein blockierendes einzelnes Audit-Device stoppt OpenBao komplett. Siehe audit.
  • Snapshot-CronJob: Raft-Snapshots automatisch nach extern wegschreiben. Siehe backups.
  • NetworkPolicies: in k3s ist Flannel ohne NetworkPolicy-Support — entweder Calico-NetworkPolicies separat installieren oder die Default-CNI auf Cilium tauschen (k3s-Option --flannel-backend=none --disable-network-policy + Cilium-Helm-Install). Wichtig: Egress zur K8s-API erlauben, sonst hängt service_registration — siehe service-registration-reactivation § 2.
  • etcd-Backup: k3s schreibt etcd-Snapshots automatisch nach /var/lib/rancher/k3s/server/db/snapshots/ (alle 12 h default). Diese auf extern wegspiegeln. Siehe k3s-Upstream-Doku.
  • Persistenz upgraden: für Workloads ohne eigene Replikation Longhorn nachinstallieren. OpenBao selbst kommt mit local-path aus, weil Raft die Replikation erledigt.
  • kube-vip BGP-Mode: bei Multi-Subnet-Setup ARP durch BGP ersetzen.
  • Resource-Requests/Limits für OpenBao setzen: server.resources.requests.{cpu,memory} — sonst kann ein Memory-Spike den ganzen Node ausknocken (k3s-Server, Workload und kube-vip teilen sich denselben Node).

16 — Stolperfallen

  • cluster-init: true auf mehreren Nodes: Fataler Fehler. Zwei parallele Cluster, beide denken sie sind allein, etcd findet sich nie. Lösung: alle k3s-Installationen deinstallieren (k3s-uninstall.sh), cluster-init: true nur auf einem Node, neu beginnen.
  • node-2 startet, bleibt aber NotReady: meist falscher Token in /etc/rancher/k3s/config.yaml oder VIP noch nicht stabil. sudo journalctl -u k3s | tail -50 zeigt den eigentlichen Fehler.
  • kube-vip-Pod startet nicht mit „interface not found”: INTERFACE in Schritt 4 zeigt auf das falsche Interface. ip a auf dem Node prüfen.
  • OpenBao-Pods Pending mit „node(s) had volume node affinity conflict”: bei local-path ist das Volume an einen spezifischen Node gebunden. Wenn ein Pod gerade auf einen anderen Node verschoben werden soll (z. B. nach Node-Restart), aber das alte PVC noch an den toten Node gepinnt ist, hängt der Pod. Lösung: PVC löschen, Pod neu schedulen, neue PVC entsteht. Funktioniert, weil Raft die Daten ohnehin auf den anderen zwei Pods hat.
  • x509: certificate signed by unknown authority beim Raft-Join: leader_tls_servername fehlt oder passt nicht zum commonName aus Schritt 10.3. CN muss exakt openbao sein.
  • k3s-Upgrade via apt: k3s wird nicht über apt aktualisiert — es ist gar nicht über apt installiert. Stattdessen erneut curl get.k3s.io | INSTALL_K3S_VERSION=… sh mit der höheren Version auf jedem Node einzeln.
  • Traefik kommt zurück nach Helm-Update: wenn disable: traefik vergessen wird, deployt k3s den Traefik-Manifest aus /var/lib/rancher/k3s/server/manifests/traefik.yaml automatisch wieder. Ggf. zusätzlich die Datei löschen.
  • VAULT-/BAO-Adressvariablen vermischen: BAO_ADDR ist OpenBao-nativ. Wer aus dem Vault-Ökosystem kommt: nicht VAULT_ADDR setzen — viele OpenBao-Releases akzeptieren beides, aber die offizielle Variable ist BAO_*. Siehe commands-cli.