wernicke IT
Kubernetes CAPI CAPO OpenStack Private Cloud

Kubernetes auf OpenStack: Warum Cluster API der richtige Ansatz ist

Wer Kubernetes auf eigener OpenStack-Infrastruktur betreiben will, hat im Grunde drei Optionen: OpenStack Magnum, ein manuelles Setup mit kubeadm oder Cluster API mit dem OpenStack-Provider CAPO. Alle drei funktionieren technisch. Aber nur eine davon hält, was man von einer modernen Infrastruktur erwartet.

Was an den anderen Ansätzen nicht stimmt

OpenStack Magnum gibt es seit Jahren. Das Projekt ist in die Jahre gekommen, die Entwicklung bewegt sich langsam, und wer jemals versucht hat, ein Magnum-Problem zu debuggen, weiß, wie undurchsichtig das System unter der Haube ist. Cluster-Upgrades sind mühsam, die Flexibilität begrenzt, und tiefe Integration mit GitOps-Workflows ist kaum möglich.

Manuelle kubeadm-Setups sind das andere Extrem. Man hat volle Kontrolle, zahlt dafür aber mit erheblichem Aufwand. Upgrades müssen Node für Node geplant und ausgeführt werden. Wer drei oder vier Cluster betreibt, verbringt mehr Zeit mit Clusterverwaltung als mit der eigentlichen Arbeit.

Was Cluster API anders macht

Cluster API ist ein CNCF-Projekt, das Kubernetes-Cluster über die gleichen Mechanismen verwaltet, die Kubernetes für alles andere nutzt: Deklarative Ressourcen, Controller, Reconciliation-Loops. Man beschreibt den gewünschten Zustand eines Clusters in YAML und das System sorgt dafür, dass die Realität diesem Zustand entspricht.

CAPO ist der Provider, der Cluster API mit OpenStack verbindet. Er nutzt die OpenStack-APIs, um Maschinen zu provisionieren, Netzwerke einzurichten und den Cluster-Lifecycle zu steuern. Neuinstallation, Upgrade, Scale-out: alles läuft über dieselbe API, alles ist versionierbar und reproduzierbar.

Das Ergebnis ist, dass sich Kubernetes-Cluster auf OpenStack genauso verwalten lassen wie andere Kubernetes-Ressourcen. Man kann sie in Git ablegen, sie mit Flux oder ArgoCD reconcilen lassen, und man hat eine nachvollziehbare Historie aller Änderungen.

Jede OpenStack-Umgebung ist ein bisschen anders

CAPO ist flexibel, aber keine Black Box, die sich selbst konfiguriert. Netzwerk-Topologien, Flavor-Konfigurationen, der Umgang mit Floating IPs oder Security Groups, das variiert von Umgebung zu Umgebung. Wer Cluster-API-Manifeste einfach aus einem Tutorial übernimmt, wird fast immer irgendwo anecken.

Ein gut strukturiertes Cluster-Template hilft dabei, die Konfiguration nachvollziehbar und reproduzierbar zu halten. Hier ein minimales Beispiel für CAPO v1beta2 mit drei Control-Plane-Nodes und drei Workern. Der erste Block ist das Secret mit den OpenStack-Zugangsdaten, das CAPO über identityRef referenziert:

apiVersion: v1
kind: Secret
metadata:
  name: mein-cluster-cloud-config
  namespace: default
stringData:
  clouds.yaml: |
    clouds:
      openstack:
        auth:
          auth_url: https://keystone.example.com/v3
          username: "mein-benutzer"
          password: "mein-passwort"
          project_name: "mein-projekt"
          user_domain_name: "Default"
          project_domain_name: "Default"
        region_name: "RegionOne"
        interface: "public"
        identity_api_version: 3
---
apiVersion: cluster.x-k8s.io/v1beta1
kind: Cluster
metadata:
  name: mein-cluster
  namespace: default
spec:
  clusterNetwork:
    pods:
      cidrBlocks: ["10.244.0.0/16"]
    services:
      cidrBlocks: ["10.96.0.0/12"]
    serviceDomain: cluster.local
  controlPlaneRef:
    apiVersion: controlplane.cluster.x-k8s.io/v1beta1
    kind: KubeadmControlPlane
    name: mein-cluster-control-plane
  infrastructureRef:
    apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
    kind: OpenStackCluster
    name: mein-cluster
---
apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
kind: OpenStackCluster
metadata:
  name: mein-cluster
spec:
  identityRef:
    cloudName: openstack
    name: mein-cluster-cloud-config
  apiServerLoadBalancer:
    enabled: true
  externalNetwork:
    id: <external-network-id>
  managedSecurityGroups:
    allowAllInClusterTraffic: true
  managedSubnets:
    - cidr: "10.6.0.0/24"
      dnsNameservers:
        - "1.1.1.1"
  controlPlaneAvailabilityZones:
    - nova
---
apiVersion: controlplane.cluster.x-k8s.io/v1beta1
kind: KubeadmControlPlane
metadata:
  name: mein-cluster-control-plane
spec:
  replicas: 3
  version: v1.35.3
  machineTemplate:
    infrastructureRef:
      apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
      kind: OpenStackMachineTemplate
      name: mein-cluster-control-plane
  kubeadmConfigSpec:
    clusterConfiguration:
      controllerManager:
        extraArgs:
          - name: cloud-provider
            value: external
    initConfiguration:
      nodeRegistration:
        name: "{{ local_hostname }}"
        kubeletExtraArgs:
          - name: cloud-provider
            value: external
          - name: provider-id
            value: "openstack:///{{ instance_id }}"
    joinConfiguration:
      nodeRegistration:
        name: "{{ local_hostname }}"
        kubeletExtraArgs:
          - name: cloud-provider
            value: external
          - name: provider-id
            value: "openstack:///{{ instance_id }}"
---
apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
kind: OpenStackMachineTemplate
metadata:
  name: mein-cluster-control-plane
spec:
  template:
    spec:
      flavor: SCS-2V-4
      rootVolume:
        sizeGiB: 50
        type: ceph-ssd
      image:
        filter:
          name: ubuntu-2404-kube-v1.35.3
      sshKeyName: mein-ssh-key
---
apiVersion: cluster.x-k8s.io/v1beta1
kind: MachineDeployment
metadata:
  name: mein-cluster-worker
spec:
  clusterName: mein-cluster
  replicas: 3
  selector:
    matchLabels: {}
  template:
    spec:
      clusterName: mein-cluster
      version: v1.35.3
      failureDomain: nova
      bootstrap:
        configRef:
          apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
          kind: KubeadmConfigTemplate
          name: mein-cluster-worker
      infrastructureRef:
        apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
        kind: OpenStackMachineTemplate
        name: mein-cluster-worker
---
apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
kind: KubeadmConfigTemplate
metadata:
  name: mein-cluster-worker
spec:
  template:
    spec:
      joinConfiguration:
        nodeRegistration:
          name: "{{ local_hostname }}"
          kubeletExtraArgs:
            - name: cloud-provider
              value: external
            - name: provider-id
              value: "openstack:///{{ instance_id }}"
---
apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
kind: OpenStackMachineTemplate
metadata:
  name: mein-cluster-worker
spec:
  template:
    spec:
      flavor: SCS-4V-8
      rootVolume:
        sizeGiB: 50
        type: ceph-ssd
      image:
        filter:
          name: ubuntu-2404-kube-v1.35.3
      sshKeyName: mein-ssh-key

Ein paar Felder verdienen eine kurze Erklärung:

rootVolume ist notwendig, wenn der Flavor keinen lokalen Speicher mitbringt. SCS-Flavors ohne Storage-Suffix wie SCS-2V-4 oder SCS-4V-8 sind diskless, CAPO legt dann ein Cinder-Volume als Bootdisk an. Der Volume-Typ ist umgebungsabhängig und muss an die verfügbaren Cinder-Backends angepasst werden.

provider-id in den kubeletExtraArgs verknüpft jeden Kubernetes-Node mit der entsprechenden OpenStack-Instanz. Ohne diesen Parameter kann der Cloud Controller Manager Nodes nicht korrekt zuordnen. Der Wert wird zur Laufzeit automatisch aus dem cloud-init-Kontext der VM bezogen.

apiServerLoadBalancer und externalNetwork steuern den vorgelagerten Load Balancer für den Kubernetes API-Server. Die externalNetwork-ID ist die UUID des externen Netzwerks in OpenStack, über das der Load Balancer eine Floating IP erhält.

controlPlaneAvailabilityZones und failureDomain legen fest, in welchen OpenStack Availability Zones Control-Plane-Nodes und Worker gestartet werden.

Pivot: Den Management-Cluster auf den Workload-Cluster verschieben

Cluster API wird typischerweise auf einem temporären Bootstrap-Cluster gestartet, oft einem lokalen kind-Cluster. Sobald der eigentliche Workload-Cluster stabil läuft, lässt sich die CAPI-Steuerungsebene dorthin verschieben. Das nennt sich Pivot.

Der Befehl dafür ist clusterctl move. Er überträgt alle CAPI-Objekte vom Bootstrap-Cluster in den Zielcluster:

# CAPI und CAPO im Zielcluster installieren
clusterctl init \
  --kubeconfig=<zielcluster-kubeconfig> \
  --infrastructure openstack

# Pivot durchführen
clusterctl move \
  --to-kubeconfig=<zielcluster-kubeconfig>

Nach dem Pivot verwaltet der Workload-Cluster sich selbst. Der Bootstrap-Cluster kann danach abgebaut werden. Was vorher ein Hilfsmittel war, ist jetzt nicht mehr Teil der Infrastruktur.

Kubeconfig-Management nach dem Pivot

Hier liegt eine der häufig übersehenen Herausforderungen: Die Kubeconfig für den Management-Cluster muss langfristig gültig bleiben. kubeadm stellt Zertifikate standardmäßig mit einer Laufzeit von einem Jahr aus. Das gilt auch für die eingebetteten Zugangsdaten in der Kubeconfig. Wer das nicht im Blick hat, verliert eines Tages den Zugriff auf seinen eigenen Management-Cluster.

Zwei Wege, das zu lösen:

Option 1: Jährliche Erneuerung mit kubeadm certs renew all. Funktioniert zuverlässig, setzt aber einen automatisierten Prozess voraus, damit die Erneuerung nicht vergessen wird.

Option 2: Service Account mit dauerhaft gültigem Token. Seit Kubernetes 1.24 werden keine automatischen Token-Secrets für Service Accounts mehr angelegt. Man legt das Secret explizit an:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: capi-admin
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: capi-admin-binding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
- kind: ServiceAccount
  name: capi-admin
  namespace: kube-system
---
apiVersion: v1
kind: Secret
metadata:
  name: capi-admin-token
  namespace: kube-system
  annotations:
    kubernetes.io/service-account.name: capi-admin
type: kubernetes.io/service-account-token

Das Token aus diesem Secret läuft nicht ab, solange Secret und Service Account existieren. Es sollte geschützt aufbewahrt und nur für den Zugriff auf den Management-Cluster verwendet werden. Wer auf Nummer sicher gehen will: Zertifikatserneuerung automatisieren und das Service-Account-Token als robusteren Fallback bereithalten.

Air-gapped Deployments als Sonderfall

Eine der interessanteren Herausforderungen in letzter Zeit war ein Deployment in einer Umgebung ohne regulären Internetzugang. Kein direktes Pulling von Container-Images, keine externe Registry, kein einfacher Weg nach draußen.

Das ist lösbar, aber es erfordert Vorbereitung. Man braucht eine interne Registry, alle benötigten Images müssen vorher gespiegelt werden, und containerd muss so konfiguriert sein, dass es ausschließlich diese interne Quelle nutzt. CAPO ermöglicht es, diese Konfiguration direkt über KubeadmConfigTemplate auf jeden Node zu legen:

apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
kind: KubeadmConfigTemplate
metadata:
  name: mein-cluster-worker
spec:
  template:
    spec:
      files:
        - path: /etc/containerd/certs.d/docker.io/hosts.toml
          owner: root:root
          permissions: "0644"
          content: |
            server = "https://docker.io"

            [host."https://registry-mirror.example.com"]
              capabilities = ["pull", "resolve"]
        - path: /etc/containerd/certs.d/registry.k8s.io/hosts.toml
          owner: root:root
          permissions: "0644"
          content: |
            server = "https://registry.k8s.io"

            [host."https://registry-mirror.example.com"]
              capabilities = ["pull", "resolve"]
        - path: /etc/containerd/certs.d/ghcr.io/hosts.toml
          owner: root:root
          permissions: "0644"
          content: |
            server = "https://ghcr.io"

            [host."https://registry-mirror.example.com"]
              capabilities = ["pull", "resolve"]
        - path: /etc/containerd/certs.d/quay.io/hosts.toml
          owner: root:root
          permissions: "0644"
          content: |
            server = "https://quay.io"

            [host."https://registry-mirror.example.com"]
              capabilities = ["pull", "resolve"]
      joinConfiguration:
        nodeRegistration:
          name: "{{ local_hostname }}"
          kubeletExtraArgs:
            - name: cloud-provider
              value: external
            - name: provider-id
              value: "openstack:///{{ instance_id }}"

Das gleiche files-Array gehört auch in den KubeadmControlPlane-Block unter kubeadmConfigSpec, damit die Control-Plane-Nodes dieselbe Mirror-Konfiguration erhalten. Wer das nicht von Anfang an plant, stößt spätestens beim ersten Node-Upgrade auf ein Problem.

Solche Umgebungen sind bei Behörden und in sicherheitskritischen Bereichen keine Seltenheit. Sie brauchen keine Internetzugang-Ausnahme, sie brauchen ein Deployment, das von vornherein ohne ihn auskommt.

Für wen das relevant ist

CAPO eignet sich überall dort, wo man Kubernetes auf eigener OpenStack-Infrastruktur produktiv betreiben will und dabei Wert auf wartbare, reproduzierbare Setups legt. Das gilt für größere Unternehmen mit eigenem Rechenzentrum genauso wie für Behörden, die auf souveräne Infrastruktur setzen, oder für KMUs, die aus Datenschutzgründen keine Public Cloud nutzen wollen.

Was man mitbringen sollte: solide Kubernetes-Kenntnisse und ein gutes Verständnis der eigenen OpenStack-Umgebung. CAPO nimmt einem die operative Arbeit ab, aber die initiale Konfiguration erfordert, dass man versteht, was man tut. Mit der richtigen Vorbereitung zahlt sich das schnell aus.


Sie betreiben OpenStack und überlegen, wie Sie Kubernetes sauber darauf aufsetzen? Sprechen Sie mich gerne an.